From f307306f91f46d5a1c73ab8548801035a4ae6242 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 17:49:07 -0300 Subject: [PATCH] feat(adm-009): TipoDeIva sealed entity with factories --- src/api/SIGCM2.Domain/Entities/TipoDeIva.cs | 208 ++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/TipoDeIva.cs diff --git a/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs b/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs new file mode 100644 index 0000000..3514bdc --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoDeIva.cs @@ -0,0 +1,208 @@ +using System.Text.RegularExpressions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Domain.Entities; + +/// +/// Tipo de IVA de referencia con versionado append-only. +/// Porcentaje es INMUTABLE post-creación; cambiar el valor requiere crear una nueva versión +/// vía . +/// +public sealed class TipoDeIva +{ + private static readonly Regex CodigoRegex = + new(@"^(EXENTO|NO_GRAVADO|IVA_\d+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public int Id { get; } + public string Codigo { get; } + public string Descripcion { get; } + public decimal Porcentaje { get; } // INMUTABLE — usar NuevaVersion para cambiar + public bool AplicaIVA { get; } + public bool Activo { get; } + public DateOnly VigenciaDesde { get; } + public DateOnly? VigenciaHasta { get; } + public int? PredecesorId { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + private TipoDeIva( + int id, + string codigo, + string descripcion, + decimal porcentaje, + bool aplicaIVA, + bool activo, + DateOnly vigenciaDesde, + DateOnly? vigenciaHasta, + int? predecesorId, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Codigo = codigo; + Descripcion = descripcion; + Porcentaje = porcentaje; + AplicaIVA = aplicaIVA; + Activo = activo; + VigenciaDesde = vigenciaDesde; + VigenciaHasta = vigenciaHasta; + PredecesorId = predecesorId; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + /// + /// Factory para crear un nuevo TipoDeIva (Id=0 — BD asigna via IDENTITY; Activo=true). + /// + /// Si Codigo no cumple formato o Porcentaje fuera de rango. + public static TipoDeIva ForCreation( + string codigo, + string descripcion, + decimal porcentaje, + bool aplicaIVA, + DateOnly vigenciaDesde, + DateOnly? vigenciaHasta = null, + int? predecesorId = null) + { + ValidateCodigo(codigo); + ValidatePorcentaje(porcentaje, nameof(porcentaje)); + ValidateVigencias(vigenciaDesde, vigenciaHasta); + + return new( + id: 0, + codigo: codigo, + descripcion: descripcion, + porcentaje: porcentaje, + aplicaIVA: aplicaIVA, + activo: true, + vigenciaDesde: vigenciaDesde, + vigenciaHasta: vigenciaHasta, + predecesorId: predecesorId, + fechaCreacion: default, + fechaModificacion: null); + } + + /// + /// Factory para reconstruir desde repositorio (Dapper). No aplica validaciones de dominio. + /// + public static TipoDeIva FromDb( + int id, + string codigo, + string descripcion, + decimal porcentaje, + bool aplicaIVA, + bool activo, + DateOnly vigenciaDesde, + DateOnly? vigenciaHasta, + int? predecesorId, + DateTime fechaCreacion, + DateTime? fechaModificacion) + => new(id, codigo, descripcion, porcentaje, aplicaIVA, activo, + vigenciaDesde, vigenciaHasta, predecesorId, fechaCreacion, fechaModificacion); + + /// + /// Crea una nueva versión con el porcentaje actualizado. + /// Retorna la predecesora cerrada y la nueva versión. + /// + /// Si la predecesora ya está cerrada (VigenciaHasta != null). + /// Si vigenciaDesde no es posterior a la predecesora, o nuevoPorcentaje fuera de rango. + public (TipoDeIva predecesoraCerrada, TipoDeIva nuevaVersion) NuevaVersion( + decimal nuevoPorcentaje, + DateOnly vigenciaDesde) + { + if (VigenciaHasta is not null) + throw new InvalidOperationException( + $"La versión {Id} ya está cerrada (VigenciaHasta={VigenciaHasta}). No puede generar nueva versión."); + + if (vigenciaDesde <= VigenciaDesde) + throw new ArgumentException( + $"vigenciaDesde ({vigenciaDesde}) debe ser posterior a VigenciaDesde de la predecesora ({VigenciaDesde}).", + nameof(vigenciaDesde)); + + ValidatePorcentaje(nuevoPorcentaje, nameof(nuevoPorcentaje)); + + var cerrada = new TipoDeIva( + id: Id, + codigo: Codigo, + descripcion: Descripcion, + porcentaje: Porcentaje, + aplicaIVA: AplicaIVA, + activo: Activo, + vigenciaDesde: VigenciaDesde, + vigenciaHasta: vigenciaDesde.AddDays(-1), + predecesorId: PredecesorId, + fechaCreacion: FechaCreacion, + fechaModificacion: DateTime.UtcNow); + + var nueva = ForCreation( + codigo: Codigo, + descripcion: Descripcion, + porcentaje: nuevoPorcentaje, + aplicaIVA: AplicaIVA, + vigenciaDesde: vigenciaDesde, + vigenciaHasta: null, + predecesorId: Id); + + return (cerrada, nueva); + } + + // ── Cosmetic mutators (sealed With* — NOT WithPorcentaje) ───────────────── + + /// Actualiza la descripción. Porcentaje y vigencias permanecen inmutables. + public TipoDeIva WithDescripcion(string descripcion) + => new(Id, Codigo, descripcion, Porcentaje, AplicaIVA, Activo, + VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + /// Actualiza el código. Porcentaje y vigencias permanecen inmutables. + public TipoDeIva WithCodigo(string codigo) + => new(Id, codigo, Descripcion, Porcentaje, AplicaIVA, Activo, + VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + /// Actualiza la bandera AplicaIVA. Porcentaje permanece inmutable. + public TipoDeIva WithAplicaIVA(bool aplicaIVA) + => new(Id, Codigo, Descripcion, Porcentaje, aplicaIVA, Activo, + VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + /// Retorna instancia con Activo=false. + public TipoDeIva Deactivate() + => new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, false, + VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + /// Retorna instancia con Activo=true. + public TipoDeIva Reactivate() + => new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, true, + VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + /// + /// Cierra la vigencia seteando VigenciaHasta. Usado por el handler de NuevaVersion. + /// + public TipoDeIva CerrarVigencia(DateOnly vigenciaHasta) + => new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, + VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow); + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static void ValidateCodigo(string codigo) + { + if (string.IsNullOrWhiteSpace(codigo) || !CodigoRegex.IsMatch(codigo)) + throw new ArgumentException( + $"Codigo '{codigo}' inválido. Debe cumplir ^(EXENTO|NO_GRAVADO|IVA_\\d+)$.", + nameof(codigo)); + } + + private static void ValidatePorcentaje(decimal porcentaje, string paramName) + { + if (porcentaje < 0m || porcentaje > 100m) + throw new ArgumentException( + $"Porcentaje ({porcentaje}) debe estar entre 0 y 100.", + paramName); + } + + private static void ValidateVigencias(DateOnly desde, DateOnly? hasta) + { + if (hasta.HasValue && hasta.Value < desde) + throw new ArgumentException( + $"VigenciaHasta ({hasta}) no puede ser anterior a VigenciaDesde ({desde}).", + "vigenciaHasta"); + } +}