feat(adm-009): TipoDeIva sealed entity with factories

This commit is contained in:
2026-04-17 17:49:07 -03:00
parent b16dd313ed
commit f307306f91

View File

@@ -0,0 +1,208 @@
using System.Text.RegularExpressions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Entities;
/// <summary>
/// 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 <see cref="NuevaVersion"/>.
/// </summary>
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;
}
/// <summary>
/// Factory para crear un nuevo TipoDeIva (Id=0 — BD asigna via IDENTITY; Activo=true).
/// </summary>
/// <exception cref="ArgumentException">Si Codigo no cumple formato o Porcentaje fuera de rango.</exception>
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);
}
/// <summary>
/// Factory para reconstruir desde repositorio (Dapper). No aplica validaciones de dominio.
/// </summary>
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);
/// <summary>
/// Crea una nueva versión con el porcentaje actualizado.
/// Retorna la predecesora cerrada y la nueva versión.
/// </summary>
/// <exception cref="InvalidOperationException">Si la predecesora ya está cerrada (VigenciaHasta != null).</exception>
/// <exception cref="ArgumentException">Si vigenciaDesde no es posterior a la predecesora, o nuevoPorcentaje fuera de rango.</exception>
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) ─────────────────
/// <summary>Actualiza la descripción. Porcentaje y vigencias permanecen inmutables.</summary>
public TipoDeIva WithDescripcion(string descripcion)
=> new(Id, Codigo, descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
/// <summary>Actualiza el código. Porcentaje y vigencias permanecen inmutables.</summary>
public TipoDeIva WithCodigo(string codigo)
=> new(Id, codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
/// <summary>Actualiza la bandera AplicaIVA. Porcentaje permanece inmutable.</summary>
public TipoDeIva WithAplicaIVA(bool aplicaIVA)
=> new(Id, Codigo, Descripcion, Porcentaje, aplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
/// <summary>Retorna instancia con Activo=false.</summary>
public TipoDeIva Deactivate()
=> new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, false,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
/// <summary>Retorna instancia con Activo=true.</summary>
public TipoDeIva Reactivate()
=> new(Id, Codigo, Descripcion, Porcentaje, AplicaIVA, true,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
/// <summary>
/// Cierra la vigencia seteando VigenciaHasta. Usado por el handler de NuevaVersion.
/// </summary>
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");
}
}