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