diff --git a/src/api/SIGCM2.Api/Controllers/FiscalController.cs b/src/api/SIGCM2.Api/Controllers/FiscalController.cs new file mode 100644 index 0000000..7fba9cb --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/FiscalController.cs @@ -0,0 +1,576 @@ +using System.Text.Json; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Api.Contracts.Fiscal; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.IngresosBrutos.Create; +using SIGCM2.Application.IngresosBrutos.Deactivate; +using SIGCM2.Application.IngresosBrutos.Dtos; +using SIGCM2.Application.IngresosBrutos.GetById; +using SIGCM2.Application.IngresosBrutos.GetHistorial; +using SIGCM2.Application.IngresosBrutos.List; +using SIGCM2.Application.IngresosBrutos.NuevaVersion; +using SIGCM2.Application.IngresosBrutos.Reactivate; +using SIGCM2.Application.IngresosBrutos.Update; +using SIGCM2.Application.TiposDeIva.Create; +using SIGCM2.Application.TiposDeIva.Deactivate; +using SIGCM2.Application.TiposDeIva.Dtos; +using SIGCM2.Application.TiposDeIva.GetById; +using SIGCM2.Application.TiposDeIva.GetHistorial; +using SIGCM2.Application.TiposDeIva.List; +using SIGCM2.Application.TiposDeIva.NuevaVersion; +using SIGCM2.Application.TiposDeIva.Reactivate; +using SIGCM2.Application.TiposDeIva.Update; +using SIGCM2.Domain.Exceptions; +using SIGCM2.Domain.Fiscal; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-009: Tablas Fiscales — IVA + IngresosBrutos endpoints at /api/v1/admin/fiscal. +/// All endpoints require permission 'administracion:fiscal:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/fiscal")] +public sealed class FiscalController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createIvaValidator; + private readonly IValidator _updateIvaValidator; + private readonly IValidator _nuevaVersionIvaValidator; + private readonly IValidator _createIibbValidator; + private readonly IValidator _updateIibbValidator; + private readonly IValidator _nuevaVersionIibbValidator; + + public FiscalController( + IDispatcher dispatcher, + IValidator createIvaValidator, + IValidator updateIvaValidator, + IValidator nuevaVersionIvaValidator, + IValidator createIibbValidator, + IValidator updateIibbValidator, + IValidator nuevaVersionIibbValidator) + { + _dispatcher = dispatcher; + _createIvaValidator = createIvaValidator; + _updateIvaValidator = updateIvaValidator; + _nuevaVersionIvaValidator = nuevaVersionIvaValidator; + _createIibbValidator = createIibbValidator; + _updateIibbValidator = updateIibbValidator; + _nuevaVersionIibbValidator = nuevaVersionIibbValidator; + } + + // ══════════════════════════════════════════════════════════════════════════ + // IVA endpoints + // ══════════════════════════════════════════════════════════════════════════ + + /// Lists TiposDeIva with optional filters. Requires administracion:fiscal:gestionar. + [HttpGet("iva")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListIva( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = null, + [FromQuery] string? codigo = 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 ListTiposDeIvaQuery(page, pageSize, activo, codigo); + var result = await _dispatcher.Send>(query); + + return Ok(new + { + Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(), + result.Page, + result.PageSize, + result.Total + }); + } + + /// Gets a single TipoDeIva by id. + [HttpGet("iva/{id:int}")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetIvaById([FromRoute] int id) + { + var query = new GetTipoDeIvaByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(FiscalContractMapper.ToIvaResponse(result)); + } + + /// Gets the full version chain for a TipoDeIva. + [HttpGet("iva/{id:int}/historial")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetHistorialIva([FromRoute] int id) + { + var query = new GetHistorialTipoDeIvaQuery(id); + var result = await _dispatcher.Send>(query); + return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList()); + } + + /// Creates a new TipoDeIva. Returns 201 on success. + [HttpPost("iva")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateIva([FromBody] CreateTipoDeIvaRequest request) + { + var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde"); + if (vigenciaDesde is null) + return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" }); + + DateOnly? vigenciaHasta = null; + if (request.VigenciaHasta is not null) + { + vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta"); + if (vigenciaHasta is null) + return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" }); + } + + var command = new CreateTipoDeIvaCommand( + Codigo: request.Codigo ?? string.Empty, + Descripcion: request.Descripcion ?? string.Empty, + Porcentaje: request.Porcentaje ?? 0m, + AplicaIVA: request.AplicaIVA ?? false, + VigenciaDesde: vigenciaDesde.Value, + VigenciaHasta: vigenciaHasta); + + var validation = await _createIvaValidator.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(GetIvaById), new { id = result.Id }, FiscalContractMapper.ToIvaResponse(result)); + } + + /// + /// Updates cosmetic fields of a TipoDeIva (Codigo, Descripcion, AplicaIVA, Activo). + /// IMPORTANT: if the raw body contains "porcentaje" (case-insensitive) → 409 inmutable_usar_nueva_version. + /// + [HttpPatch("iva/{id:int}")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdateIva([FromRoute] int id) + { + // Read raw body to detect immutable-field tampering before deserialization + Request.EnableBuffering(); + using var reader = new StreamReader(Request.Body, leaveOpen: true); + var rawBody = await reader.ReadToEndAsync(); + Request.Body.Position = 0; + + // Defend against porcentaje in body — must return 409 before dispatch + if (ContainsImmutableField(rawBody, "porcentaje")) + throw new PorcentajeInmutableException(); + + UpdateTipoDeIvaRequest? request; + try + { + request = JsonSerializer.Deserialize(rawBody, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + return BadRequest(new { error = "Invalid JSON body" }); + } + + if (request is null) + return BadRequest(new { error = "Request body is required" }); + + var command = new UpdateTipoDeIvaCommand( + Id: id, + Codigo: request.Codigo ?? string.Empty, + Descripcion: request.Descripcion ?? string.Empty, + AplicaIVA: request.AplicaIVA ?? false, + Activo: request.Activo ?? true); + + var validation = await _updateIvaValidator.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(FiscalContractMapper.ToIvaResponse(result)); + } + + /// Creates a new version of a TipoDeIva (closes the predecessor). Returns 201. + [HttpPost("iva/{id:int}/nueva-version")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task NuevaVersionIva( + [FromRoute] int id, + [FromBody] NuevaVersionTipoDeIvaRequest request) + { + var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde"); + if (vigenciaDesde is null) + return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" }); + + var command = new NuevaVersionTipoDeIvaCommand( + PredecesoraId: id, + NuevoPorcentaje: request.Porcentaje ?? 0m, + VigenciaDesde: vigenciaDesde.Value); + + var validation = await _nuevaVersionIvaValidator.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(GetIvaById), + new { id = result.NuevaVersionId }, + new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId)); + } + + /// Deactivates a TipoDeIva. Idempotent. + [HttpPost("iva/{id:int}/deactivate")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateIva([FromRoute] int id) + { + var command = new DeactivateTipoDeIvaCommand(id); + var result = await _dispatcher.Send(command); + return Ok(FiscalContractMapper.ToIvaResponse(result)); + } + + /// Reactivates a TipoDeIva. Idempotent. + [HttpPost("iva/{id:int}/reactivate")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateIva([FromRoute] int id) + { + var command = new ReactivateTipoDeIvaCommand(id); + var result = await _dispatcher.Send(command); + return Ok(FiscalContractMapper.ToIvaResponse(result)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // IngresosBrutos endpoints + // ══════════════════════════════════════════════════════════════════════════ + + /// Lists IngresosBrutos with optional filters. + [HttpGet("iibb")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListIibb( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = null, + [FromQuery] string? provincia = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + ProvinciaArgentina? provinciaEnum = null; + if (provincia is not null) + { + if (!Enum.TryParse(provincia, ignoreCase: true, out var parsed)) + return BadRequest(new { error = $"'{provincia}' is not a valid ProvinciaArgentina value." }); + provinciaEnum = parsed; + } + + var query = new ListIngresosBrutosQuery(page, pageSize, activo, provinciaEnum); + var result = await _dispatcher.Send>(query); + + return Ok(new + { + Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(), + result.Page, + result.PageSize, + result.Total + }); + } + + /// Gets a single IngresosBrutos by id. + [HttpGet("iibb/{id:int}")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetIibbById([FromRoute] int id) + { + var query = new GetIngresosBrutosByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(FiscalContractMapper.ToIibbResponse(result)); + } + + /// Gets the full version chain for an IngresosBrutos entry. + [HttpGet("iibb/{id:int}/historial")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetHistorialIibb([FromRoute] int id) + { + var query = new GetHistorialIngresosBrutosQuery(id); + var result = await _dispatcher.Send>(query); + return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList()); + } + + /// Creates a new IngresosBrutos entry. Returns 201 on success. + [HttpPost("iibb")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateIibb([FromBody] CreateIngresosBrutosRequest request) + { + if (request.Provincia is null) + return BadRequest(new { error = "provincia is required" }); + + // Accept enum name (PascalCase) or display string + ProvinciaArgentina provinciaEnum; + if (Enum.TryParse(request.Provincia, ignoreCase: true, out var parsedEnum)) + { + provinciaEnum = parsedEnum; + } + else + { + try + { + provinciaEnum = ProvinciaArgentinaExtensions.FromDisplayString(request.Provincia); + } + catch (ArgumentException) + { + return BadRequest(new { error = $"'{request.Provincia}' is not a valid provincia. Use enum name or display string." }); + } + } + + var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde"); + if (vigenciaDesde is null) + return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" }); + + DateOnly? vigenciaHasta = null; + if (request.VigenciaHasta is not null) + { + vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta"); + if (vigenciaHasta is null) + return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" }); + } + + var command = new CreateIngresosBrutosCommand( + Provincia: provinciaEnum, + Descripcion: request.Descripcion ?? string.Empty, + Alicuota: request.Alicuota ?? 0m, + VigenciaDesde: vigenciaDesde.Value, + VigenciaHasta: vigenciaHasta); + + var validation = await _createIibbValidator.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(GetIibbById), new { id = result.Id }, FiscalContractMapper.ToIibbResponse(result)); + } + + /// + /// Updates cosmetic fields of IngresosBrutos (Descripcion, Activo). + /// IMPORTANT: if the raw body contains "alicuota" (case-insensitive) → 409 inmutable_usar_nueva_version. + /// + [HttpPatch("iibb/{id:int}")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdateIibb([FromRoute] int id) + { + Request.EnableBuffering(); + using var reader = new StreamReader(Request.Body, leaveOpen: true); + var rawBody = await reader.ReadToEndAsync(); + Request.Body.Position = 0; + + if (ContainsImmutableField(rawBody, "alicuota")) + throw new AlicuotaInmutableException(); + + UpdateIngresosBrutosRequest? request; + try + { + request = JsonSerializer.Deserialize(rawBody, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + return BadRequest(new { error = "Invalid JSON body" }); + } + + if (request is null) + return BadRequest(new { error = "Request body is required" }); + + var command = new UpdateIngresosBrutosCommand( + Id: id, + Descripcion: request.Descripcion ?? string.Empty, + Activo: request.Activo ?? true); + + var validation = await _updateIibbValidator.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(FiscalContractMapper.ToIibbResponse(result)); + } + + /// Creates a new version of IngresosBrutos (closes the predecessor). Returns 201. + [HttpPost("iibb/{id:int}/nueva-version")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task NuevaVersionIibb( + [FromRoute] int id, + [FromBody] NuevaVersionIngresosBrutosRequest request) + { + var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde"); + if (vigenciaDesde is null) + return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" }); + + var command = new NuevaVersionIngresosBrutosCommand( + PredecesoraId: id, + NuevaAlicuota: request.Alicuota ?? 0m, + VigenciaDesde: vigenciaDesde.Value); + + var validation = await _nuevaVersionIibbValidator.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(GetIibbById), + new { id = result.NuevaVersionId }, + new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId)); + } + + /// Deactivates an IngresosBrutos entry. Idempotent. + [HttpPost("iibb/{id:int}/deactivate")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateIibb([FromRoute] int id) + { + var command = new DeactivateIngresosBrutosCommand(id); + var result = await _dispatcher.Send(command); + return Ok(FiscalContractMapper.ToIibbResponse(result)); + } + + /// Reactivates an IngresosBrutos entry. Idempotent. + [HttpPost("iibb/{id:int}/reactivate")] + [RequirePermission("administracion:fiscal:gestionar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateIibb([FromRoute] int id) + { + var command = new ReactivateIngresosBrutosCommand(id); + var result = await _dispatcher.Send(command); + return Ok(FiscalContractMapper.ToIibbResponse(result)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Private helpers + // ══════════════════════════════════════════════════════════════════════════ + + /// + /// Parses a date string "yyyy-MM-dd" to DateOnly. Returns null if invalid. + /// + private static DateOnly? ParseDateOnly(string? value, string fieldName) + { + if (value is null) return null; + return DateOnly.TryParseExact(value, "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var result) + ? result + : null; + } + + /// + /// Checks if a raw JSON string contains a given field name (case-insensitive). + /// Used to detect immutable-field tampering before deserialization silently drops the field. + /// + private static bool ContainsImmutableField(string rawJson, string fieldName) + { + if (string.IsNullOrWhiteSpace(rawJson)) return false; + try + { + using var doc = JsonDocument.Parse(rawJson); + return doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.EnumerateObject() + .Any(p => string.Equals(p.Name, fieldName, StringComparison.OrdinalIgnoreCase)); + } + catch (JsonException) + { + return false; + } + } +}