feat(adm-009): TipoDeIva + IngresosBrutos handlers, DTOs, DI registration

This commit is contained in:
2026-04-17 18:09:52 -03:00
parent 2cd25e1036
commit bd0c4deea7
47 changed files with 1134 additions and 0 deletions

View File

@@ -27,6 +27,24 @@ using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.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.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.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById;
@@ -104,6 +122,26 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
// Tipos de IVA (ADM-009)
services.AddScoped<ICommandHandler<CreateTipoDeIvaCommand, TipoDeIvaDto>, CreateTipoDeIvaCommandHandler>();
services.AddScoped<ICommandHandler<UpdateTipoDeIvaCommand, TipoDeIvaDto>, UpdateTipoDeIvaCommandHandler>();
services.AddScoped<ICommandHandler<NuevaVersionTipoDeIvaCommand, NuevaVersionResultDto>, NuevaVersionTipoDeIvaCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateTipoDeIvaCommand, TipoDeIvaDto>, DeactivateTipoDeIvaCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateTipoDeIvaCommand, TipoDeIvaDto>, ReactivateTipoDeIvaCommandHandler>();
services.AddScoped<ICommandHandler<GetTipoDeIvaByIdQuery, TipoDeIvaDto>, GetTipoDeIvaByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>, ListTiposDeIvaQueryHandler>();
services.AddScoped<ICommandHandler<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>, GetHistorialTipoDeIvaQueryHandler>();
// Ingresos Brutos (ADM-009)
services.AddScoped<ICommandHandler<CreateIngresosBrutosCommand, IngresosBrutosDto>, CreateIngresosBrutosCommandHandler>();
services.AddScoped<ICommandHandler<UpdateIngresosBrutosCommand, IngresosBrutosDto>, UpdateIngresosBrutosCommandHandler>();
services.AddScoped<ICommandHandler<NuevaVersionIngresosBrutosCommand, NuevaVersionIibbResultDto>, NuevaVersionIngresosBrutosCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateIngresosBrutosCommand, IngresosBrutosDto>, DeactivateIngresosBrutosCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateIngresosBrutosCommand, IngresosBrutosDto>, ReactivateIngresosBrutosCommandHandler>();
services.AddScoped<ICommandHandler<GetIngresosBrutosByIdQuery, IngresosBrutosDto>, GetIngresosBrutosByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,10 @@
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Application.IngresosBrutos.Create;
public sealed record CreateIngresosBrutosCommand(
ProvinciaArgentina Provincia,
string Descripcion,
decimal Alicuota,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta = null);

View File

@@ -0,0 +1,57 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.IngresosBrutos.Dtos;
namespace SIGCM2.Application.IngresosBrutos.Create;
public sealed class CreateIngresosBrutosCommandHandler
: ICommandHandler<CreateIngresosBrutosCommand, IngresosBrutosDto>
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
public CreateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<IngresosBrutosDto> Handle(CreateIngresosBrutosCommand command)
{
var entity = Domain.Entities.IngresosBrutos.ForCreation(
command.Provincia,
command.Descripcion,
command.Alicuota,
command.VigenciaDesde,
command.VigenciaHasta);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.InsertAsync(entity);
await _audit.LogAsync(
action: "ingresos_brutos.create",
targetType: "IngresosBrutos",
targetId: newId.ToString(),
metadata: new { entity.Provincia, entity.Alicuota, entity.VigenciaDesde });
tx.Complete();
return IngresosBrutosMapper.ToDto(Domain.Entities.IngresosBrutos.FromDb(
id: newId,
provincia: entity.Provincia,
descripcion: entity.Descripcion,
alicuota: entity.Alicuota,
activo: entity.Activo,
vigenciaDesde: entity.VigenciaDesde,
vigenciaHasta: entity.VigenciaHasta,
predecesorId: entity.PredecesorId,
fechaCreacion: DateTime.UtcNow,
fechaModificacion: null));
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
namespace SIGCM2.Application.IngresosBrutos.Create;
public sealed class CreateIngresosBrutosCommandValidator : AbstractValidator<CreateIngresosBrutosCommand>
{
private const int DescripcionMaxLength = 255;
public CreateIngresosBrutosCommandValidator()
{
RuleFor(x => x.Descripcion)
.NotEmpty().WithMessage("La descripción es requerida.")
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
RuleFor(x => x.Alicuota)
.InclusiveBetween(0m, 100m)
.WithMessage("La alícuota debe estar entre 0 y 100.");
RuleFor(x => x.VigenciaDesde)
.NotEqual(default(DateOnly))
.WithMessage("La fecha de vigencia desde es requerida.");
RuleFor(x => x.VigenciaHasta)
.GreaterThanOrEqualTo(x => x.VigenciaDesde)
.WithMessage("VigenciaHasta no puede ser anterior a VigenciaDesde.")
.When(x => x.VigenciaHasta.HasValue);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.IngresosBrutos.Deactivate;
public sealed record DeactivateIngresosBrutosCommand(int Id);

View File

@@ -0,0 +1,46 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.IngresosBrutos.Deactivate;
public sealed class DeactivateIngresosBrutosCommandHandler
: ICommandHandler<DeactivateIngresosBrutosCommand, IngresosBrutosDto>
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<IngresosBrutosDto> Handle(DeactivateIngresosBrutosCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new IngresosBrutosNotFoundException(command.Id);
if (!entity.Activo)
return IngresosBrutosMapper.ToDto(entity);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.SetActivoAsync(command.Id, false);
await _audit.LogAsync(
action: "ingresos_brutos.deactivate",
targetType: "IngresosBrutos",
targetId: command.Id.ToString());
tx.Complete();
return IngresosBrutosMapper.ToDto(entity.Deactivate());
}
}

View File

@@ -0,0 +1,14 @@
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Application.IngresosBrutos.Dtos;
public sealed record HistorialCadenaIibbDto(
int Id,
ProvinciaArgentina Provincia,
decimal Alicuota,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta,
int? PredecesorId,
/// <summary>1-based index in the version chain (1 = root, N = current).</summary>
int Version
);

View File

@@ -0,0 +1,16 @@
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Application.IngresosBrutos.Dtos;
public sealed record IngresosBrutosDto(
int Id,
ProvinciaArgentina Provincia,
string Descripcion,
decimal Alicuota,
bool Activo,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion
);

View File

@@ -0,0 +1,38 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.IngresosBrutos.Dtos;
public static class IngresosBrutosMapper
{
public static IngresosBrutosDto ToDto(Domain.Entities.IngresosBrutos entity) => new(
Id: entity.Id,
Provincia: entity.Provincia,
Descripcion: entity.Descripcion,
Alicuota: entity.Alicuota,
Activo: entity.Activo,
VigenciaDesde: entity.VigenciaDesde,
VigenciaHasta: entity.VigenciaHasta,
PredecesorId: entity.PredecesorId,
FechaCreacion: entity.FechaCreacion,
FechaModificacion: entity.FechaModificacion
);
public static IReadOnlyList<HistorialCadenaIibbDto> ToHistorialChain(IReadOnlyList<Domain.Entities.IngresosBrutos> chain)
{
var result = new List<HistorialCadenaIibbDto>(chain.Count);
for (var i = 0; i < chain.Count; i++)
{
var item = chain[i];
result.Add(new HistorialCadenaIibbDto(
Id: item.Id,
Provincia: item.Provincia,
Alicuota: item.Alicuota,
VigenciaDesde: item.VigenciaDesde,
VigenciaHasta: item.VigenciaHasta,
PredecesorId: item.PredecesorId,
Version: i + 1
));
}
return result;
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.IngresosBrutos.Dtos;
public sealed record NuevaVersionIibbResultDto(
int PredecesoraId,
int NuevaVersionId
);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.IngresosBrutos.GetById;
public sealed record GetIngresosBrutosByIdQuery(int Id);

View File

@@ -0,0 +1,25 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.IngresosBrutos.GetById;
public sealed class GetIngresosBrutosByIdQueryHandler
: ICommandHandler<GetIngresosBrutosByIdQuery, IngresosBrutosDto>
{
private readonly IIngresosBrutosRepository _repo;
public GetIngresosBrutosByIdQueryHandler(IIngresosBrutosRepository repo)
{
_repo = repo;
}
public async Task<IngresosBrutosDto> Handle(GetIngresosBrutosByIdQuery query)
{
var entity = await _repo.GetByIdAsync(query.Id)
?? throw new IngresosBrutosNotFoundException(query.Id);
return IngresosBrutosMapper.ToDto(entity);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.IngresosBrutos.GetHistorial;
public sealed record GetHistorialIngresosBrutosQuery(int Id);

View File

@@ -0,0 +1,22 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.IngresosBrutos.Dtos;
namespace SIGCM2.Application.IngresosBrutos.GetHistorial;
public sealed class GetHistorialIngresosBrutosQueryHandler
: ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>
{
private readonly IIngresosBrutosRepository _repo;
public GetHistorialIngresosBrutosQueryHandler(IIngresosBrutosRepository repo)
{
_repo = repo;
}
public async Task<IReadOnlyList<HistorialCadenaIibbDto>> Handle(GetHistorialIngresosBrutosQuery query)
{
var chain = await _repo.GetHistorialAsync(query.Id);
return IngresosBrutosMapper.ToHistorialChain(chain);
}
}

View File

@@ -0,0 +1,9 @@
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Application.IngresosBrutos.List;
public sealed record ListIngresosBrutosQuery(
int Page,
int PageSize,
bool? Activo,
ProvinciaArgentina? Provincia);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.IngresosBrutos.Dtos;
namespace SIGCM2.Application.IngresosBrutos.List;
public sealed class ListIngresosBrutosQueryHandler
: ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>
{
private readonly IIngresosBrutosRepository _repo;
public ListIngresosBrutosQueryHandler(IIngresosBrutosRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<IngresosBrutosDto>> Handle(ListIngresosBrutosQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new IngresosBrutosQuery(page, pageSize, query.Activo, query.Provincia);
var paged = await _repo.ListAsync(repoQuery);
var items = paged.Items.Select(IngresosBrutosMapper.ToDto).ToList();
return new PagedResult<IngresosBrutosDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
public sealed record NuevaVersionIngresosBrutosCommand(
int PredecesoraId,
decimal NuevaAlicuota,
DateOnly VigenciaDesde);

View File

@@ -0,0 +1,71 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
public sealed class NuevaVersionIngresosBrutosCommandHandler
: ICommandHandler<NuevaVersionIngresosBrutosCommand, NuevaVersionIibbResultDto>
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<NuevaVersionIibbResultDto> Handle(NuevaVersionIngresosBrutosCommand command)
{
// Step 1: load predecesora
var predecesora = await _repo.GetByIdAsync(command.PredecesoraId)
?? throw new IngresosBrutosNotFoundException(command.PredecesoraId);
// Step 2: guard — predecesora must be open and active
if (!predecesora.Activo || predecesora.VigenciaHasta is not null)
throw new PredecesorYaCerradoException(command.PredecesoraId);
// Steps 34: domain validation + tuple creation (throws ArgumentException if vigencia invalid)
var (predecesoraCerrada, nuevaVersion) = predecesora.NuevaVersion(
command.NuevaAlicuota,
command.VigenciaDesde);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
// Step 5: optimistic close — race guard
var closed = await _repo.UpdateCierreVigenciaAsync(
command.PredecesoraId,
predecesoraCerrada.VigenciaHasta!.Value);
if (!closed)
throw new PredecesorYaCerradoException(command.PredecesoraId);
// Step 6: insert new version
var nuevoId = await _repo.InsertAsync(nuevaVersion);
// Step 7: audit (fail-closed)
await _audit.LogAsync(
action: "ingresos_brutos.nueva_version",
targetType: "IngresosBrutos",
targetId: nuevoId.ToString(),
metadata: new
{
predecesoraId = command.PredecesoraId,
nuevoId,
alicuotaNueva = command.NuevaAlicuota,
vigenciaDesde = command.VigenciaDesde,
});
// Step 8: commit
tx.Complete();
return new NuevaVersionIibbResultDto(command.PredecesoraId, nuevoId);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
public sealed class NuevaVersionIngresosBrutosCommandValidator : AbstractValidator<NuevaVersionIngresosBrutosCommand>
{
public NuevaVersionIngresosBrutosCommandValidator()
{
RuleFor(x => x.PredecesoraId)
.GreaterThan(0).WithMessage("El id de la predecesora debe ser mayor a 0.");
RuleFor(x => x.NuevaAlicuota)
.InclusiveBetween(0m, 100m)
.WithMessage("La nueva alícuota debe estar entre 0 y 100.");
RuleFor(x => x.VigenciaDesde)
.NotEqual(default(DateOnly))
.WithMessage("La fecha de vigencia desde es requerida.");
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.IngresosBrutos.Reactivate;
public sealed record ReactivateIngresosBrutosCommand(int Id);

View File

@@ -0,0 +1,46 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.IngresosBrutos.Reactivate;
public sealed class ReactivateIngresosBrutosCommandHandler
: ICommandHandler<ReactivateIngresosBrutosCommand, IngresosBrutosDto>
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<IngresosBrutosDto> Handle(ReactivateIngresosBrutosCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new IngresosBrutosNotFoundException(command.Id);
if (entity.Activo)
return IngresosBrutosMapper.ToDto(entity);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.SetActivoAsync(command.Id, true);
await _audit.LogAsync(
action: "ingresos_brutos.reactivate",
targetType: "IngresosBrutos",
targetId: command.Id.ToString());
tx.Complete();
return IngresosBrutosMapper.ToDto(entity.Reactivate());
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.IngresosBrutos.Update;
/// <summary>
/// Updates only cosmetic fields: Descripcion, Activo.
/// Alicuota and Provincia are NOT part of this command — they are immutable.
/// </summary>
public sealed record UpdateIngresosBrutosCommand(
int Id,
string Descripcion,
bool Activo);

View File

@@ -0,0 +1,51 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.IngresosBrutos.Update;
public sealed class UpdateIngresosBrutosCommandHandler
: ICommandHandler<UpdateIngresosBrutosCommand, IngresosBrutosDto>
{
private readonly IIngresosBrutosRepository _repo;
private readonly IAuditLogger _audit;
public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<IngresosBrutosDto> Handle(UpdateIngresosBrutosCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new IngresosBrutosNotFoundException(command.Id);
var updated = entity.WithDescripcion(command.Descripcion);
updated = command.Activo ? updated.Reactivate() : updated.Deactivate();
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateCosmeticoAsync(command.Id, command.Descripcion, command.Activo);
await _audit.LogAsync(
action: "ingresos_brutos.update",
targetType: "IngresosBrutos",
targetId: command.Id.ToString(),
metadata: new
{
before = new { entity.Descripcion, entity.Activo },
after = new { command.Descripcion, command.Activo },
});
tx.Complete();
return IngresosBrutosMapper.ToDto(updated);
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace SIGCM2.Application.IngresosBrutos.Update;
public sealed class UpdateIngresosBrutosCommandValidator : AbstractValidator<UpdateIngresosBrutosCommand>
{
private const int DescripcionMaxLength = 255;
public UpdateIngresosBrutosCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
RuleFor(x => x.Descripcion)
.NotEmpty().WithMessage("La descripción es requerida.")
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.TiposDeIva.Create;
public sealed record CreateTipoDeIvaCommand(
string Codigo,
string Descripcion,
decimal Porcentaje,
bool AplicaIVA,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta = null);

View File

@@ -0,0 +1,61 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.Create;
public sealed class CreateTipoDeIvaCommandHandler : ICommandHandler<CreateTipoDeIvaCommand, TipoDeIvaDto>
{
private readonly ITipoDeIvaRepository _repo;
private readonly IAuditLogger _audit;
public CreateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<TipoDeIvaDto> Handle(CreateTipoDeIvaCommand command)
{
var entity = TipoDeIva.ForCreation(
command.Codigo,
command.Descripcion,
command.Porcentaje,
command.AplicaIVA,
command.VigenciaDesde,
command.VigenciaHasta);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.InsertAsync(entity);
// fail-closed: if LogAsync throws, tx rolls back
await _audit.LogAsync(
action: "tipo_iva.create",
targetType: "TipoDeIva",
targetId: newId.ToString(),
metadata: new { entity.Codigo, entity.Porcentaje, entity.VigenciaDesde });
tx.Complete();
return TipoDeIvaMapper.ToDto(TipoDeIva.FromDb(
id: newId,
codigo: entity.Codigo,
descripcion: entity.Descripcion,
porcentaje: entity.Porcentaje,
aplicaIVA: entity.AplicaIVA,
activo: entity.Activo,
vigenciaDesde: entity.VigenciaDesde,
vigenciaHasta: entity.VigenciaHasta,
predecesorId: entity.PredecesorId,
fechaCreacion: DateTime.UtcNow,
fechaModificacion: null));
}
}

View File

@@ -0,0 +1,34 @@
using FluentValidation;
namespace SIGCM2.Application.TiposDeIva.Create;
public sealed class CreateTipoDeIvaCommandValidator : AbstractValidator<CreateTipoDeIvaCommand>
{
private const int DescripcionMaxLength = 255;
public CreateTipoDeIvaCommandValidator()
{
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.")
.Matches(@"^(EXENTO|NO_GRAVADO|IVA_\d+)$")
.WithMessage("El código debe cumplir el formato EXENTO, NO_GRAVADO o IVA_{número}.");
RuleFor(x => x.Descripcion)
.NotEmpty().WithMessage("La descripción es requerida.")
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
RuleFor(x => x.Porcentaje)
.InclusiveBetween(0m, 100m)
.WithMessage("El porcentaje debe estar entre 0 y 100.");
RuleFor(x => x.VigenciaDesde)
.NotEqual(default(DateOnly))
.WithMessage("La fecha de vigencia desde es requerida.");
RuleFor(x => x.VigenciaHasta)
.GreaterThanOrEqualTo(x => x.VigenciaDesde)
.WithMessage("VigenciaHasta no puede ser anterior a VigenciaDesde.")
.When(x => x.VigenciaHasta.HasValue);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.TiposDeIva.Deactivate;
public sealed record DeactivateTipoDeIvaCommand(int Id);

View File

@@ -0,0 +1,46 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.Deactivate;
public sealed class DeactivateTipoDeIvaCommandHandler : ICommandHandler<DeactivateTipoDeIvaCommand, TipoDeIvaDto>
{
private readonly ITipoDeIvaRepository _repo;
private readonly IAuditLogger _audit;
public DeactivateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<TipoDeIvaDto> Handle(DeactivateTipoDeIvaCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new TipoDeIvaNotFoundException(command.Id);
// Idempotent: already inactive → return as-is without writing an audit event
if (!entity.Activo)
return TipoDeIvaMapper.ToDto(entity);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.SetActivoAsync(command.Id, false);
await _audit.LogAsync(
action: "tipo_iva.deactivate",
targetType: "TipoDeIva",
targetId: command.Id.ToString());
tx.Complete();
return TipoDeIvaMapper.ToDto(entity.Deactivate());
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.TiposDeIva.Dtos;
public sealed record HistorialCadenaDto(
int Id,
string Codigo,
decimal Porcentaje,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta,
int? PredecesorId,
/// <summary>1-based index in the version chain (1 = root, N = current).</summary>
int Version
);

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.TiposDeIva.Dtos;
public sealed record NuevaVersionResultDto(
int PredecesoraId,
int NuevaVersionId
);

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Application.TiposDeIva.Dtos;
public sealed record TipoDeIvaDto(
int Id,
string Codigo,
string Descripcion,
decimal Porcentaje,
bool AplicaIVA,
bool Activo,
DateOnly VigenciaDesde,
DateOnly? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion
);

View File

@@ -0,0 +1,39 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.TiposDeIva.Dtos;
public static class TipoDeIvaMapper
{
public static TipoDeIvaDto ToDto(TipoDeIva entity) => new(
Id: entity.Id,
Codigo: entity.Codigo,
Descripcion: entity.Descripcion,
Porcentaje: entity.Porcentaje,
AplicaIVA: entity.AplicaIVA,
Activo: entity.Activo,
VigenciaDesde: entity.VigenciaDesde,
VigenciaHasta: entity.VigenciaHasta,
PredecesorId: entity.PredecesorId,
FechaCreacion: entity.FechaCreacion,
FechaModificacion: entity.FechaModificacion
);
public static IReadOnlyList<HistorialCadenaDto> ToHistorialChain(IReadOnlyList<TipoDeIva> chain)
{
var result = new List<HistorialCadenaDto>(chain.Count);
for (var i = 0; i < chain.Count; i++)
{
var item = chain[i];
result.Add(new HistorialCadenaDto(
Id: item.Id,
Codigo: item.Codigo,
Porcentaje: item.Porcentaje,
VigenciaDesde: item.VigenciaDesde,
VigenciaHasta: item.VigenciaHasta,
PredecesorId: item.PredecesorId,
Version: i + 1
));
}
return result;
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.TiposDeIva.GetById;
public sealed record GetTipoDeIvaByIdQuery(int Id);

View File

@@ -0,0 +1,24 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.GetById;
public sealed class GetTipoDeIvaByIdQueryHandler : ICommandHandler<GetTipoDeIvaByIdQuery, TipoDeIvaDto>
{
private readonly ITipoDeIvaRepository _repo;
public GetTipoDeIvaByIdQueryHandler(ITipoDeIvaRepository repo)
{
_repo = repo;
}
public async Task<TipoDeIvaDto> Handle(GetTipoDeIvaByIdQuery query)
{
var entity = await _repo.GetByIdAsync(query.Id)
?? throw new TipoDeIvaNotFoundException(query.Id);
return TipoDeIvaMapper.ToDto(entity);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.TiposDeIva.GetHistorial;
public sealed record GetHistorialTipoDeIvaQuery(int Id);

View File

@@ -0,0 +1,22 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.TiposDeIva.Dtos;
namespace SIGCM2.Application.TiposDeIva.GetHistorial;
public sealed class GetHistorialTipoDeIvaQueryHandler
: ICommandHandler<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>
{
private readonly ITipoDeIvaRepository _repo;
public GetHistorialTipoDeIvaQueryHandler(ITipoDeIvaRepository repo)
{
_repo = repo;
}
public async Task<IReadOnlyList<HistorialCadenaDto>> Handle(GetHistorialTipoDeIvaQuery query)
{
var chain = await _repo.GetHistorialAsync(query.Id);
return TipoDeIvaMapper.ToHistorialChain(chain);
}
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.TiposDeIva.List;
public sealed record ListTiposDeIvaQuery(
int Page,
int PageSize,
bool? Activo,
string? Codigo);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.TiposDeIva.Dtos;
namespace SIGCM2.Application.TiposDeIva.List;
public sealed class ListTiposDeIvaQueryHandler
: ICommandHandler<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>
{
private readonly ITipoDeIvaRepository _repo;
public ListTiposDeIvaQueryHandler(ITipoDeIvaRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<TipoDeIvaDto>> Handle(ListTiposDeIvaQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new TiposDeIvaQuery(page, pageSize, query.Activo, query.Codigo);
var paged = await _repo.ListAsync(repoQuery);
var items = paged.Items.Select(TipoDeIvaMapper.ToDto).ToList();
return new PagedResult<TipoDeIvaDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.TiposDeIva.NuevaVersion;
public sealed record NuevaVersionTipoDeIvaCommand(
int PredecesoraId,
decimal NuevoPorcentaje,
DateOnly VigenciaDesde);

View File

@@ -0,0 +1,72 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.NuevaVersion;
public sealed class NuevaVersionTipoDeIvaCommandHandler
: ICommandHandler<NuevaVersionTipoDeIvaCommand, NuevaVersionResultDto>
{
private readonly ITipoDeIvaRepository _repo;
private readonly IAuditLogger _audit;
public NuevaVersionTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<NuevaVersionResultDto> Handle(NuevaVersionTipoDeIvaCommand command)
{
// Step 1: load predecesora
var predecesora = await _repo.GetByIdAsync(command.PredecesoraId)
?? throw new TipoDeIvaNotFoundException(command.PredecesoraId);
// Step 2: guard — predecesora must be open and active
if (!predecesora.Activo || predecesora.VigenciaHasta is not null)
throw new PredecesorYaCerradoException(command.PredecesoraId);
// Steps 34: delegate validation + tuple creation to domain (throws ArgumentException on invalid vigencia)
var (predecesoraCerrada, nuevaVersion) = predecesora.NuevaVersion(
command.NuevoPorcentaje,
command.VigenciaDesde);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
// Step 5: optimistic close — race guard
var closed = await _repo.UpdateCierreVigenciaAsync(
command.PredecesoraId,
predecesoraCerrada.VigenciaHasta!.Value);
if (!closed)
throw new PredecesorYaCerradoException(command.PredecesoraId);
// Step 6: insert new version
var nuevoId = await _repo.InsertAsync(nuevaVersion);
// Step 7: audit (fail-closed — if this throws, tx is NOT completed)
await _audit.LogAsync(
action: "tipo_iva.nueva_version",
targetType: "TipoDeIva",
targetId: nuevoId.ToString(),
metadata: new
{
predecesoraId = command.PredecesoraId,
nuevoId,
porcentajeNuevo = command.NuevoPorcentaje,
vigenciaDesde = command.VigenciaDesde,
});
// Step 8: commit
tx.Complete();
// Step 9: return result
return new NuevaVersionResultDto(command.PredecesoraId, nuevoId);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace SIGCM2.Application.TiposDeIva.NuevaVersion;
public sealed class NuevaVersionTipoDeIvaCommandValidator : AbstractValidator<NuevaVersionTipoDeIvaCommand>
{
public NuevaVersionTipoDeIvaCommandValidator()
{
RuleFor(x => x.PredecesoraId)
.GreaterThan(0).WithMessage("El id de la predecesora debe ser mayor a 0.");
RuleFor(x => x.NuevoPorcentaje)
.InclusiveBetween(0m, 100m)
.WithMessage("El nuevo porcentaje debe estar entre 0 y 100.");
RuleFor(x => x.VigenciaDesde)
.NotEqual(default(DateOnly))
.WithMessage("La fecha de vigencia desde es requerida.");
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.TiposDeIva.Reactivate;
public sealed record ReactivateTipoDeIvaCommand(int Id);

View File

@@ -0,0 +1,46 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.Reactivate;
public sealed class ReactivateTipoDeIvaCommandHandler : ICommandHandler<ReactivateTipoDeIvaCommand, TipoDeIvaDto>
{
private readonly ITipoDeIvaRepository _repo;
private readonly IAuditLogger _audit;
public ReactivateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<TipoDeIvaDto> Handle(ReactivateTipoDeIvaCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new TipoDeIvaNotFoundException(command.Id);
// Idempotent: already active → return as-is without writing an audit event
if (entity.Activo)
return TipoDeIvaMapper.ToDto(entity);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.SetActivoAsync(command.Id, true);
await _audit.LogAsync(
action: "tipo_iva.reactivate",
targetType: "TipoDeIva",
targetId: command.Id.ToString());
tx.Complete();
return TipoDeIvaMapper.ToDto(entity.Reactivate());
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.TiposDeIva.Update;
/// <summary>
/// Updates only cosmetic fields: Codigo, Descripcion, AplicaIVA, Activo.
/// Porcentaje is NOT part of this command — it is immutable and can only change via NuevaVersion.
/// </summary>
public sealed record UpdateTipoDeIvaCommand(
int Id,
string Codigo,
string Descripcion,
bool AplicaIVA,
bool Activo);

View File

@@ -0,0 +1,62 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.TiposDeIva.Update;
public sealed class UpdateTipoDeIvaCommandHandler : ICommandHandler<UpdateTipoDeIvaCommand, TipoDeIvaDto>
{
private readonly ITipoDeIvaRepository _repo;
private readonly IAuditLogger _audit;
public UpdateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<TipoDeIvaDto> Handle(UpdateTipoDeIvaCommand command)
{
var entity = await _repo.GetByIdAsync(command.Id)
?? throw new TipoDeIvaNotFoundException(command.Id);
var updated = entity
.WithCodigo(command.Codigo)
.WithDescripcion(command.Descripcion)
.WithAplicaIVA(command.AplicaIVA);
// Apply Activo change if needed
updated = command.Activo
? updated.Reactivate()
: updated.Deactivate();
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateCosmeticoAsync(
command.Id,
command.Codigo,
command.Descripcion,
command.AplicaIVA,
command.Activo);
await _audit.LogAsync(
action: "tipo_iva.update",
targetType: "TipoDeIva",
targetId: command.Id.ToString(),
metadata: new
{
before = new { entity.Codigo, entity.Descripcion, entity.AplicaIVA, entity.Activo },
after = new { command.Codigo, command.Descripcion, command.AplicaIVA, command.Activo },
});
tx.Complete();
return TipoDeIvaMapper.ToDto(updated);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
namespace SIGCM2.Application.TiposDeIva.Update;
public sealed class UpdateTipoDeIvaCommandValidator : AbstractValidator<UpdateTipoDeIvaCommand>
{
private const int DescripcionMaxLength = 255;
public UpdateTipoDeIvaCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.")
.Matches(@"^(EXENTO|NO_GRAVADO|IVA_\d+)$")
.WithMessage("El código debe cumplir el formato EXENTO, NO_GRAVADO o IVA_{número}.");
RuleFor(x => x.Descripcion)
.NotEmpty().WithMessage("La descripción es requerida.")
.MaximumLength(DescripcionMaxLength)
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
}
}