diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index cda65d3..c0e29e9 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -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>, ListPuntosDeVentaQueryHandler>(); services.AddScoped, GetPuntoDeVentaByIdQueryHandler>(); + // Tipos de IVA (ADM-009) + services.AddScoped, CreateTipoDeIvaCommandHandler>(); + services.AddScoped, UpdateTipoDeIvaCommandHandler>(); + services.AddScoped, NuevaVersionTipoDeIvaCommandHandler>(); + services.AddScoped, DeactivateTipoDeIvaCommandHandler>(); + services.AddScoped, ReactivateTipoDeIvaCommandHandler>(); + services.AddScoped, GetTipoDeIvaByIdQueryHandler>(); + services.AddScoped>, ListTiposDeIvaQueryHandler>(); + services.AddScoped>, GetHistorialTipoDeIvaQueryHandler>(); + + // Ingresos Brutos (ADM-009) + services.AddScoped, CreateIngresosBrutosCommandHandler>(); + services.AddScoped, UpdateIngresosBrutosCommandHandler>(); + services.AddScoped, NuevaVersionIngresosBrutosCommandHandler>(); + services.AddScoped, DeactivateIngresosBrutosCommandHandler>(); + services.AddScoped, ReactivateIngresosBrutosCommandHandler>(); + services.AddScoped, GetIngresosBrutosByIdQueryHandler>(); + services.AddScoped>, ListIngresosBrutosQueryHandler>(); + services.AddScoped>, GetHistorialIngresosBrutosQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommand.cs b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommand.cs new file mode 100644 index 0000000..54c3ba6 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandHandler.cs new file mode 100644 index 0000000..4cc9814 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + private readonly IAuditLogger _audit; + + public CreateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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)); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandValidator.cs b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandValidator.cs new file mode 100644 index 0000000..b7b6b4d --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Create/CreateIngresosBrutosCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.IngresosBrutos.Create; + +public sealed class CreateIngresosBrutosCommandValidator : AbstractValidator +{ + 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); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommand.cs b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommand.cs new file mode 100644 index 0000000..3e76c89 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.IngresosBrutos.Deactivate; + +public sealed record DeactivateIngresosBrutosCommand(int Id); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs new file mode 100644 index 0000000..d78b769 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Deactivate/DeactivateIngresosBrutosCommandHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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()); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Dtos/HistorialCadenaIibbDto.cs b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/HistorialCadenaIibbDto.cs new file mode 100644 index 0000000..c6ef13f --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/HistorialCadenaIibbDto.cs @@ -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, + /// 1-based index in the version chain (1 = root, N = current). + int Version +); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosDto.cs b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosDto.cs new file mode 100644 index 0000000..2d39656 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosDto.cs @@ -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 +); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosMapper.cs b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosMapper.cs new file mode 100644 index 0000000..190e0c8 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/IngresosBrutosMapper.cs @@ -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 ToHistorialChain(IReadOnlyList chain) + { + var result = new List(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; + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Dtos/NuevaVersionIibbResultDto.cs b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/NuevaVersionIibbResultDto.cs new file mode 100644 index 0000000..76d5b71 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Dtos/NuevaVersionIibbResultDto.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.IngresosBrutos.Dtos; + +public sealed record NuevaVersionIibbResultDto( + int PredecesoraId, + int NuevaVersionId +); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQuery.cs b/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQuery.cs new file mode 100644 index 0000000..f85aca9 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.IngresosBrutos.GetById; + +public sealed record GetIngresosBrutosByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQueryHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQueryHandler.cs new file mode 100644 index 0000000..b1f3d55 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/GetById/GetIngresosBrutosByIdQueryHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + + public GetIngresosBrutosByIdQueryHandler(IIngresosBrutosRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetIngresosBrutosByIdQuery query) + { + var entity = await _repo.GetByIdAsync(query.Id) + ?? throw new IngresosBrutosNotFoundException(query.Id); + + return IngresosBrutosMapper.ToDto(entity); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQuery.cs b/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQuery.cs new file mode 100644 index 0000000..22f237f --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.IngresosBrutos.GetHistorial; + +public sealed record GetHistorialIngresosBrutosQuery(int Id); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQueryHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQueryHandler.cs new file mode 100644 index 0000000..2bc141f --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/GetHistorial/GetHistorialIngresosBrutosQueryHandler.cs @@ -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> +{ + private readonly IIngresosBrutosRepository _repo; + + public GetHistorialIngresosBrutosQueryHandler(IIngresosBrutosRepository repo) + { + _repo = repo; + } + + public async Task> Handle(GetHistorialIngresosBrutosQuery query) + { + var chain = await _repo.GetHistorialAsync(query.Id); + return IngresosBrutosMapper.ToHistorialChain(chain); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQuery.cs b/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQuery.cs new file mode 100644 index 0000000..ece1997 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQuery.cs @@ -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); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQueryHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQueryHandler.cs new file mode 100644 index 0000000..89b8529 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/List/ListIngresosBrutosQueryHandler.cs @@ -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> +{ + private readonly IIngresosBrutosRepository _repo; + + public ListIngresosBrutosQueryHandler(IIngresosBrutosRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommand.cs b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommand.cs new file mode 100644 index 0000000..6d379bb --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.IngresosBrutos.NuevaVersion; + +public sealed record NuevaVersionIngresosBrutosCommand( + int PredecesoraId, + decimal NuevaAlicuota, + DateOnly VigenciaDesde); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs new file mode 100644 index 0000000..741b93b --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + private readonly IAuditLogger _audit; + + public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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 3–4: 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); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandValidator.cs b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandValidator.cs new file mode 100644 index 0000000..be595a8 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/NuevaVersion/NuevaVersionIngresosBrutosCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace SIGCM2.Application.IngresosBrutos.NuevaVersion; + +public sealed class NuevaVersionIngresosBrutosCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommand.cs b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommand.cs new file mode 100644 index 0000000..01e8462 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.IngresosBrutos.Reactivate; + +public sealed record ReactivateIngresosBrutosCommand(int Id); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs new file mode 100644 index 0000000..4b96bd3 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Reactivate/ReactivateIngresosBrutosCommandHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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()); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommand.cs b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommand.cs new file mode 100644 index 0000000..0ef8c36 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.IngresosBrutos.Update; + +/// +/// Updates only cosmetic fields: Descripcion, Activo. +/// Alicuota and Provincia are NOT part of this command — they are immutable. +/// +public sealed record UpdateIngresosBrutosCommand( + int Id, + string Descripcion, + bool Activo); diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs new file mode 100644 index 0000000..d87e4c3 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandHandler.cs @@ -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 +{ + private readonly IIngresosBrutosRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandValidator.cs b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandValidator.cs new file mode 100644 index 0000000..e3be054 --- /dev/null +++ b/src/api/SIGCM2.Application/IngresosBrutos/Update/UpdateIngresosBrutosCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace SIGCM2.Application.IngresosBrutos.Update; + +public sealed class UpdateIngresosBrutosCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommand.cs b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommand.cs new file mode 100644 index 0000000..4679fe1 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandHandler.cs new file mode 100644 index 0000000..3adfc11 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + private readonly IAuditLogger _audit; + + public CreateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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)); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandValidator.cs b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandValidator.cs new file mode 100644 index 0000000..c90b6ed --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Create/CreateTipoDeIvaCommandValidator.cs @@ -0,0 +1,34 @@ +using FluentValidation; + +namespace SIGCM2.Application.TiposDeIva.Create; + +public sealed class CreateTipoDeIvaCommandValidator : AbstractValidator +{ + 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); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommand.cs b/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommand.cs new file mode 100644 index 0000000..a50ba5d --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.TiposDeIva.Deactivate; + +public sealed record DeactivateTipoDeIvaCommand(int Id); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandler.cs new file mode 100644 index 0000000..5759f31 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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()); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Dtos/HistorialCadenaDto.cs b/src/api/SIGCM2.Application/TiposDeIva/Dtos/HistorialCadenaDto.cs new file mode 100644 index 0000000..ed9134f --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Dtos/HistorialCadenaDto.cs @@ -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, + /// 1-based index in the version chain (1 = root, N = current). + int Version +); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Dtos/NuevaVersionResultDto.cs b/src/api/SIGCM2.Application/TiposDeIva/Dtos/NuevaVersionResultDto.cs new file mode 100644 index 0000000..0d9d48a --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Dtos/NuevaVersionResultDto.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.TiposDeIva.Dtos; + +public sealed record NuevaVersionResultDto( + int PredecesoraId, + int NuevaVersionId +); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaDto.cs b/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaDto.cs new file mode 100644 index 0000000..cee104a --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaDto.cs @@ -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 +); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaMapper.cs b/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaMapper.cs new file mode 100644 index 0000000..1192ebb --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaMapper.cs @@ -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 ToHistorialChain(IReadOnlyList chain) + { + var result = new List(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; + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQuery.cs b/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQuery.cs new file mode 100644 index 0000000..a00d193 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.TiposDeIva.GetById; + +public sealed record GetTipoDeIvaByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandler.cs new file mode 100644 index 0000000..3c253cc --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + + public GetTipoDeIvaByIdQueryHandler(ITipoDeIvaRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetTipoDeIvaByIdQuery query) + { + var entity = await _repo.GetByIdAsync(query.Id) + ?? throw new TipoDeIvaNotFoundException(query.Id); + + return TipoDeIvaMapper.ToDto(entity); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQuery.cs b/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQuery.cs new file mode 100644 index 0000000..28bf4ec --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.TiposDeIva.GetHistorial; + +public sealed record GetHistorialTipoDeIvaQuery(int Id); diff --git a/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandler.cs new file mode 100644 index 0000000..bbcb6e1 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandler.cs @@ -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> +{ + private readonly ITipoDeIvaRepository _repo; + + public GetHistorialTipoDeIvaQueryHandler(ITipoDeIvaRepository repo) + { + _repo = repo; + } + + public async Task> Handle(GetHistorialTipoDeIvaQuery query) + { + var chain = await _repo.GetHistorialAsync(query.Id); + return TipoDeIvaMapper.ToHistorialChain(chain); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQuery.cs b/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQuery.cs new file mode 100644 index 0000000..e57b6a1 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQuery.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.TiposDeIva.List; + +public sealed record ListTiposDeIvaQuery( + int Page, + int PageSize, + bool? Activo, + string? Codigo); diff --git a/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQueryHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQueryHandler.cs new file mode 100644 index 0000000..fc1cbd6 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/List/ListTiposDeIvaQueryHandler.cs @@ -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> +{ + private readonly ITipoDeIvaRepository _repo; + + public ListTiposDeIvaQueryHandler(ITipoDeIvaRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommand.cs b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommand.cs new file mode 100644 index 0000000..0e36c9f --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.TiposDeIva.NuevaVersion; + +public sealed record NuevaVersionTipoDeIvaCommand( + int PredecesoraId, + decimal NuevoPorcentaje, + DateOnly VigenciaDesde); diff --git a/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandler.cs new file mode 100644 index 0000000..5ea4034 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + private readonly IAuditLogger _audit; + + public NuevaVersionTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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 3–4: 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); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandValidator.cs b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandValidator.cs new file mode 100644 index 0000000..8a4e661 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace SIGCM2.Application.TiposDeIva.NuevaVersion; + +public sealed class NuevaVersionTipoDeIvaCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommand.cs b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommand.cs new file mode 100644 index 0000000..ee3ebc4 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.TiposDeIva.Reactivate; + +public sealed record ReactivateTipoDeIvaCommand(int Id); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs new file mode 100644 index 0000000..2a2ab9a --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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()); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommand.cs b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommand.cs new file mode 100644 index 0000000..f2448a4 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommand.cs @@ -0,0 +1,12 @@ +namespace SIGCM2.Application.TiposDeIva.Update; + +/// +/// Updates only cosmetic fields: Codigo, Descripcion, AplicaIVA, Activo. +/// Porcentaje is NOT part of this command — it is immutable and can only change via NuevaVersion. +/// +public sealed record UpdateTipoDeIvaCommand( + int Id, + string Codigo, + string Descripcion, + bool AplicaIVA, + bool Activo); diff --git a/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandHandler.cs b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandHandler.cs new file mode 100644 index 0000000..d264293 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandHandler.cs @@ -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 +{ + private readonly ITipoDeIvaRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateTipoDeIvaCommandHandler(ITipoDeIvaRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandValidator.cs b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandValidator.cs new file mode 100644 index 0000000..2da4896 --- /dev/null +++ b/src/api/SIGCM2.Application/TiposDeIva/Update/UpdateTipoDeIvaCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +namespace SIGCM2.Application.TiposDeIva.Update; + +public sealed class UpdateTipoDeIvaCommandValidator : AbstractValidator +{ + 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."); + } +}