diff --git a/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
new file mode 100644
index 0000000..cc3b2c2
--- /dev/null
+++ b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
@@ -0,0 +1,206 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using SIGCM2.Api.Authorization;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Common;
+using SIGCM2.Application.PuntosDeVenta.Create;
+using SIGCM2.Application.PuntosDeVenta.Deactivate;
+using SIGCM2.Application.PuntosDeVenta.GetById;
+using SIGCM2.Application.PuntosDeVenta.List;
+using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
+using SIGCM2.Application.PuntosDeVenta.Reactivate;
+using SIGCM2.Application.PuntosDeVenta.Reservar;
+using SIGCM2.Application.PuntosDeVenta.Update;
+using SIGCM2.Domain.Enums;
+
+namespace SIGCM2.Api.Controllers;
+
+///
+/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta.
+/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
+///
+[ApiController]
+[Route("api/v1/admin/puntos-de-venta")]
+public sealed class PuntosDeVentaController : ControllerBase
+{
+ private readonly IDispatcher _dispatcher;
+ private readonly IValidator _createValidator;
+ private readonly IValidator _updateValidator;
+
+ public PuntosDeVentaController(
+ IDispatcher dispatcher,
+ IValidator createValidator,
+ IValidator updateValidator)
+ {
+ _dispatcher = dispatcher;
+ _createValidator = createValidator;
+ _updateValidator = updateValidator;
+ }
+
+ /// Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar.
+ [HttpPost]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request)
+ {
+ var command = new CreatePuntoDeVentaCommand(
+ MedioId: request.MedioId ?? 0,
+ NumeroAFIP: request.NumeroAFIP ?? 0,
+ Nombre: request.Nombre ?? string.Empty,
+ Descripcion: request.Descripcion);
+
+ var validation = await _createValidator.ValidateAsync(command);
+ if (!validation.IsValid)
+ {
+ var errors = validation.Errors
+ .GroupBy(e => e.PropertyName)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
+ return BadRequest(new { errors });
+ }
+
+ var result = await _dispatcher.Send(command);
+ return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result);
+ }
+
+ /// Lists puntos de venta with optional filters.
+ [HttpGet]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task ListPuntosDeVenta(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ [FromQuery] int? medioId = null,
+ [FromQuery] bool? activo = null)
+ {
+ if (page < 1) return BadRequest(new { error = "page must be >= 1" });
+ if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
+
+ var query = new ListPuntosDeVentaQuery(page, pageSize, medioId, activo);
+ var result = await _dispatcher.Send>(query);
+ return Ok(result);
+ }
+
+ /// Gets a single punto de venta by id.
+ [HttpGet("{id:int}")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetPuntoDeVentaById([FromRoute] int id)
+ {
+ var query = new GetPuntoDeVentaByIdQuery(id);
+ var result = await _dispatcher.Send(query);
+ return Ok(result);
+ }
+
+ /// Updates a punto de venta's editable fields.
+ [HttpPut("{id:int}")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request)
+ {
+ var command = new UpdatePuntoDeVentaCommand(
+ Id: id,
+ Nombre: request.Nombre ?? string.Empty,
+ NumeroAFIP: request.NumeroAFIP ?? 0,
+ Descripcion: request.Descripcion);
+
+ var validation = await _updateValidator.ValidateAsync(command);
+ if (!validation.IsValid)
+ {
+ var errors = validation.Errors
+ .GroupBy(e => e.PropertyName)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
+ return BadRequest(new { errors });
+ }
+
+ var result = await _dispatcher.Send(command);
+ return Ok(result);
+ }
+
+ /// Deactivates a punto de venta.
+ [HttpPost("{id:int}/deactivate")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task DeactivatePuntoDeVenta([FromRoute] int id)
+ {
+ var command = new DeactivatePuntoDeVentaCommand(id);
+ await _dispatcher.Send(command);
+ return NoContent();
+ }
+
+ /// Reactivates a punto de venta (only if parent Medio is active).
+ [HttpPost("{id:int}/reactivate")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task ReactivatePuntoDeVenta([FromRoute] int id)
+ {
+ var command = new ReactivatePuntoDeVentaCommand(id);
+ await _dispatcher.Send(command);
+ return NoContent();
+ }
+
+ /// Reserves the next sequential number for a given PdV and TipoComprobante.
+ [HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
+ {
+ var command = new ReservarNumeroCommand(id, tipoComprobante);
+ var result = await _dispatcher.Send(command);
+ return Ok(result);
+ }
+
+ /// Returns the next available number (read-only, no reservation).
+ [HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")]
+ [RequirePermission("administracion:puntos_de_venta:gestionar")]
+ [ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
+ {
+ var query = new GetProximoNumeroQuery(id, tipoComprobante);
+ var result = await _dispatcher.Send(query);
+ return Ok(result);
+ }
+}
+
+// ── Request body records ──────────────────────────────────────────────────────
+
+/// ADM-008: Create punto de venta request body.
+public sealed record CreatePuntoDeVentaRequest(
+ int? MedioId,
+ short? NumeroAFIP,
+ string? Nombre,
+ string? Descripcion);
+
+/// ADM-008: Update punto de venta request body.
+public sealed record UpdatePuntoDeVentaRequest(
+ string? Nombre,
+ short? NumeroAFIP,
+ string? Descripcion);
diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
index a9682ff..35fb5be 100644
--- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
+++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
@@ -231,6 +231,43 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
+ // ADM-008: PuntoDeVenta exceptions
+ case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "punto_de_venta_not_found",
+ message = puntoDeVentaNotFoundEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status404NotFound
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case PuntoDeVentaInactivoException puntoDeVentaInactivoEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "punto_de_venta_inactivo",
+ message = puntoDeVentaInactivoEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status409Conflict
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case NumeroAFIPDuplicadoException numeroAFIPDupEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "numero_afip_duplicado",
+ message = numeroAFIPDupEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status409Conflict
+ };
+ context.ExceptionHandled = true;
+ break;
+
// UDT-009: permiso override validation errors
case InvalidPermisoCodesException ipce:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails