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; } } }