Files
SIG-CM2.0/src/api/SIGCM2.Api/Controllers/FiscalController.cs
dmolinari 9957724c40 chore(tests): dotnet format sobre archivos pre-existentes (surfaced durante CAT-001)
Fix mecánico de whitespace detectado por dotnet format --verify-no-changes durante la verify phase de CAT-001 (PR #30). Sin cambios funcionales.
2026-04-18 20:56:23 -03:00

577 lines
26 KiB
C#

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;
/// <summary>
/// ADM-009: Tablas Fiscales — IVA + IngresosBrutos endpoints at /api/v1/admin/fiscal.
/// All endpoints require permission 'administracion:fiscal:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/fiscal")]
public sealed class FiscalController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateTipoDeIvaCommand> _createIvaValidator;
private readonly IValidator<UpdateTipoDeIvaCommand> _updateIvaValidator;
private readonly IValidator<NuevaVersionTipoDeIvaCommand> _nuevaVersionIvaValidator;
private readonly IValidator<CreateIngresosBrutosCommand> _createIibbValidator;
private readonly IValidator<UpdateIngresosBrutosCommand> _updateIibbValidator;
private readonly IValidator<NuevaVersionIngresosBrutosCommand> _nuevaVersionIibbValidator;
public FiscalController(
IDispatcher dispatcher,
IValidator<CreateTipoDeIvaCommand> createIvaValidator,
IValidator<UpdateTipoDeIvaCommand> updateIvaValidator,
IValidator<NuevaVersionTipoDeIvaCommand> nuevaVersionIvaValidator,
IValidator<CreateIngresosBrutosCommand> createIibbValidator,
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
{
_dispatcher = dispatcher;
_createIvaValidator = createIvaValidator;
_updateIvaValidator = updateIvaValidator;
_nuevaVersionIvaValidator = nuevaVersionIvaValidator;
_createIibbValidator = createIibbValidator;
_updateIibbValidator = updateIibbValidator;
_nuevaVersionIibbValidator = nuevaVersionIibbValidator;
}
// ══════════════════════════════════════════════════════════════════════════
// IVA endpoints
// ══════════════════════════════════════════════════════════════════════════
/// <summary>Lists TiposDeIva with optional filters. Requires administracion:fiscal:gestionar.</summary>
[HttpGet("iva")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query);
return Ok(new
{
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
result.Page,
result.PageSize,
result.Total
});
}
/// <summary>Gets a single TipoDeIva by id.</summary>
[HttpGet("iva/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIvaById([FromRoute] int id)
{
var query = new GetTipoDeIvaByIdQuery(id);
var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Gets the full version chain for a TipoDeIva.</summary>
[HttpGet("iva/{id:int}/historial")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIva([FromRoute] int id)
{
var query = new GetHistorialTipoDeIvaQuery(id);
var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
}
/// <summary>Creates a new TipoDeIva. Returns 201 on success.</summary>
[HttpPost("iva")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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<CreateTipoDeIvaCommand, TipoDeIvaDto>(command);
return CreatedAtAction(nameof(GetIvaById), new { id = result.Id }, FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>
/// Updates cosmetic fields of a TipoDeIva (Codigo, Descripcion, AplicaIVA, Activo).
/// IMPORTANT: if the raw body contains "porcentaje" (case-insensitive) → 409 inmutable_usar_nueva_version.
/// </summary>
[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<IActionResult> 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<UpdateTipoDeIvaRequest>(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<UpdateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Creates a new version of a TipoDeIva (closes the predecessor). Returns 201.</summary>
[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<IActionResult> 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<NuevaVersionTipoDeIvaCommand, SIGCM2.Application.TiposDeIva.Dtos.NuevaVersionResultDto>(command);
return CreatedAtAction(
nameof(GetIvaById),
new { id = result.NuevaVersionId },
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
}
/// <summary>Deactivates a TipoDeIva. Idempotent.</summary>
[HttpPost("iva/{id:int}/deactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateIva([FromRoute] int id)
{
var command = new DeactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Reactivates a TipoDeIva. Idempotent.</summary>
[HttpPost("iva/{id:int}/reactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateIva([FromRoute] int id)
{
var command = new ReactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
// ══════════════════════════════════════════════════════════════════════════
// IngresosBrutos endpoints
// ══════════════════════════════════════════════════════════════════════════
/// <summary>Lists IngresosBrutos with optional filters.</summary>
[HttpGet("iibb")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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<ProvinciaArgentina>(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<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>(query);
return Ok(new
{
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
result.Page,
result.PageSize,
result.Total
});
}
/// <summary>Gets a single IngresosBrutos by id.</summary>
[HttpGet("iibb/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIibbById([FromRoute] int id)
{
var query = new GetIngresosBrutosByIdQuery(id);
var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Gets the full version chain for an IngresosBrutos entry.</summary>
[HttpGet("iibb/{id:int}/historial")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIibb([FromRoute] int id)
{
var query = new GetHistorialIngresosBrutosQuery(id);
var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
}
/// <summary>Creates a new IngresosBrutos entry. Returns 201 on success.</summary>
[HttpPost("iibb")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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<ProvinciaArgentina>(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<CreateIngresosBrutosCommand, IngresosBrutosDto>(command);
return CreatedAtAction(nameof(GetIibbById), new { id = result.Id }, FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>
/// Updates cosmetic fields of IngresosBrutos (Descripcion, Activo).
/// IMPORTANT: if the raw body contains "alicuota" (case-insensitive) → 409 inmutable_usar_nueva_version.
/// </summary>
[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<IActionResult> 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<UpdateIngresosBrutosRequest>(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<UpdateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Creates a new version of IngresosBrutos (closes the predecessor). Returns 201.</summary>
[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<IActionResult> 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<NuevaVersionIngresosBrutosCommand, SIGCM2.Application.IngresosBrutos.Dtos.NuevaVersionIibbResultDto>(command);
return CreatedAtAction(
nameof(GetIibbById),
new { id = result.NuevaVersionId },
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
}
/// <summary>Deactivates an IngresosBrutos entry. Idempotent.</summary>
[HttpPost("iibb/{id:int}/deactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateIibb([FromRoute] int id)
{
var command = new DeactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Reactivates an IngresosBrutos entry. Idempotent.</summary>
[HttpPost("iibb/{id:int}/reactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateIibb([FromRoute] int id)
{
var command = new ReactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
// ══════════════════════════════════════════════════════════════════════════
// Private helpers
// ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Parses a date string "yyyy-MM-dd" to DateOnly. Returns null if invalid.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}