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