577 lines
26 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|