ADM-009: Tablas Fiscales (IVA + IIBB) — append-only versioned ref data #22
141
database/migrations/V014_ROLLBACK.sql
Normal file
141
database/migrations/V014_ROLLBACK.sql
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
-- V014_ROLLBACK.sql
|
||||||
|
-- Reversa de V014__create_tablas_fiscales.sql.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: ejecutar ELIMINA TipoDeIva, IngresosBrutos, sus historiales temporales,
|
||||||
|
-- el permiso 'administracion:fiscal:gestionar' y sus asignaciones.
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a estas tablas (FAC-001, etc.).
|
||||||
|
-- Si FAC-001 ya esta aplicado, este rollback fallara — usar backup.
|
||||||
|
--
|
||||||
|
-- Idempotente: seguro para re-ejecutar (guards en cada paso).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING — TipoDeIva
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidFrom;
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidTo;
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'TipoDeIva: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Apagar SYSTEM_VERSIONING — IngresosBrutos
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidFrom;
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidTo;
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'IngresosBrutos: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Drop FK self antes de DROP TABLE (para evitar constraint violation)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID('FK_TipoDeIva_Predecesor', 'F') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT FK_TipoDeIva_Predecesor;
|
||||||
|
PRINT 'FK_TipoDeIva_Predecesor dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID('FK_IIBB_Predecesor', 'F') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT FK_IIBB_Predecesor;
|
||||||
|
PRINT 'FK_IIBB_Predecesor dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Drop history tables → main tables
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.TipoDeIva_History;
|
||||||
|
PRINT 'TipoDeIva_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.IngresosBrutos_History;
|
||||||
|
PRINT 'IngresosBrutos_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.TipoDeIva;
|
||||||
|
PRINT 'Table dbo.TipoDeIva dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.IngresosBrutos;
|
||||||
|
PRINT 'Table dbo.IngresosBrutos dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Remover permiso 'administracion:fiscal:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'administracion:fiscal:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:fiscal:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V014 rolled back.';
|
||||||
|
PRINT ' - dbo.TipoDeIva and dbo.TipoDeIva_History removed.';
|
||||||
|
PRINT ' - dbo.IngresosBrutos and dbo.IngresosBrutos_History removed.';
|
||||||
|
PRINT ' - Permiso administracion:fiscal:gestionar removed.';
|
||||||
|
GO
|
||||||
293
database/migrations/V014__create_tablas_fiscales.sql
Normal file
293
database/migrations/V014__create_tablas_fiscales.sql
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
-- V014__create_tablas_fiscales.sql
|
||||||
|
-- ADM-009 Tablas Fiscales: DDL para dbo.TipoDeIva + dbo.IngresosBrutos + permisos.
|
||||||
|
--
|
||||||
|
-- Patron: append-only versioned ref data.
|
||||||
|
-- Porcentaje/Alicuota son INMUTABLES post-creacion; cambiar el valor = nueva fila + cierre de predecesora.
|
||||||
|
-- PredecesorId (FK self) establece la cadena de versiones (historial de negocio).
|
||||||
|
-- SYSTEM_VERSIONING ON para historial tecnico (auditoria temporal de SQL Server).
|
||||||
|
--
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V014_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Covers: REQ-SEED-001, REQ-SEED-002, REQ-SEED-003, REQ-TEMPORAL-001, REQ-FISCAL-AUTH-002
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.TipoDeIva
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.TipoDeIva (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_TipoDeIva PRIMARY KEY,
|
||||||
|
Codigo VARCHAR(32) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(100) NOT NULL,
|
||||||
|
Porcentaje DECIMAL(5,2) NOT NULL,
|
||||||
|
AplicaIVA BIT NOT NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_TipoDeIva_Activo DEFAULT(1),
|
||||||
|
VigenciaDesde DATE NOT NULL,
|
||||||
|
VigenciaHasta DATE NULL,
|
||||||
|
PredecesorId INT NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_TipoDeIva_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT CK_TipoDeIva_Porcentaje CHECK (Porcentaje >= 0 AND Porcentaje <= 100),
|
||||||
|
CONSTRAINT CK_TipoDeIva_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
|
||||||
|
CONSTRAINT UQ_TipoDeIva_Codigo_Vigencia UNIQUE (Codigo, VigenciaDesde),
|
||||||
|
CONSTRAINT FK_TipoDeIva_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.TipoDeIva(Id)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.TipoDeIva created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.TipoDeIva already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Indices TipoDeIva
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_Codigo_VigenciaDesde' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_TipoDeIva_Codigo_VigenciaDesde
|
||||||
|
ON dbo.TipoDeIva(Codigo, VigenciaDesde DESC);
|
||||||
|
PRINT 'Index IX_TipoDeIva_Codigo_VigenciaDesde created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_PredecesorId' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_TipoDeIva_PredecesorId
|
||||||
|
ON dbo.TipoDeIva(PredecesorId)
|
||||||
|
WHERE PredecesorId IS NOT NULL;
|
||||||
|
PRINT 'Index IX_TipoDeIva_PredecesorId created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- SYSTEM_VERSIONING — TipoDeIva
|
||||||
|
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_TipoDeIva_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_TipoDeIva_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.TipoDeIva_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING = ON (history: dbo.TipoDeIva_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'TipoDeIva_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.partitions p
|
||||||
|
JOIN sys.tables t ON t.object_id = p.object_id
|
||||||
|
WHERE t.name = 'TipoDeIva_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'TipoDeIva_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. dbo.IngresosBrutos
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.IngresosBrutos (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_IngresosBrutos PRIMARY KEY,
|
||||||
|
Provincia VARCHAR(50) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(100) NOT NULL,
|
||||||
|
Alicuota DECIMAL(5,2) NOT NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_IIBB_Activo DEFAULT(1),
|
||||||
|
VigenciaDesde DATE NOT NULL,
|
||||||
|
VigenciaHasta DATE NULL,
|
||||||
|
PredecesorId INT NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_IIBB_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT CK_IIBB_Alicuota CHECK (Alicuota >= 0 AND Alicuota <= 100),
|
||||||
|
CONSTRAINT CK_IIBB_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
|
||||||
|
CONSTRAINT UQ_IIBB_Provincia_Vigencia UNIQUE (Provincia, VigenciaDesde),
|
||||||
|
CONSTRAINT FK_IIBB_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.IngresosBrutos(Id)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.IngresosBrutos created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.IngresosBrutos already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Indices IngresosBrutos
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_Provincia_VigenciaDesde' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_IIBB_Provincia_VigenciaDesde
|
||||||
|
ON dbo.IngresosBrutos(Provincia, VigenciaDesde DESC);
|
||||||
|
PRINT 'Index IX_IIBB_Provincia_VigenciaDesde created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_PredecesorId' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_IIBB_PredecesorId
|
||||||
|
ON dbo.IngresosBrutos(PredecesorId)
|
||||||
|
WHERE PredecesorId IS NOT NULL;
|
||||||
|
PRINT 'Index IX_IIBB_PredecesorId created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- SYSTEM_VERSIONING — IngresosBrutos
|
||||||
|
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_IIBB_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_IIBB_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.IngresosBrutos_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING = ON (history: dbo.IngresosBrutos_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'IngresosBrutos_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.partitions p
|
||||||
|
JOIN sys.tables t ON t.object_id = p.object_id
|
||||||
|
WHERE t.name = 'IngresosBrutos_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'IngresosBrutos_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Seed TipoDeIva — 4 filas canonicas (REQ-SEED-001)
|
||||||
|
-- MERGE garantiza idempotencia (REQ-SEED-003)
|
||||||
|
-- EXENTO y NO_GRAVADO no aplican IVA; IVA_105 e IVA_21 si aplican.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.TipoDeIva AS t
|
||||||
|
USING (VALUES
|
||||||
|
('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('IVA_105', N'IVA alicuota diferencial 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('IVA_21', N'IVA alicuota general 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE))
|
||||||
|
) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde)
|
||||||
|
ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'TipoDeIva: 4 canonical rows seeded (EXENTO, NO_GRAVADO, IVA_105, IVA_21).';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Seed IngresosBrutos — 24 filas (23 provincias INDEC + CABA) (REQ-SEED-002)
|
||||||
|
-- Alicuota=0 placeholder — el operador cargara las alicuotas reales via UI.
|
||||||
|
-- MERGE garantiza idempotencia (REQ-SEED-003).
|
||||||
|
-- Provincias almacenadas como nombre de enum ProvinciaArgentina PascalCase (VARCHAR(50)).
|
||||||
|
-- DISCOVERY: spec dice 25 filas pero lista canonica del design tiene 24 entradas
|
||||||
|
-- (23 provincias INDEC + CABA). Implementado con 24. Ver apply-progress.
|
||||||
|
-- T700 cleanup: valores cambiados de UPPER_SNAKE_CASE a PascalCase (matching enum.ToString()).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.IngresosBrutos AS t
|
||||||
|
USING (VALUES
|
||||||
|
('BuenosAires', N'Ingresos Brutos - Buenos Aires'),
|
||||||
|
('CiudadAutonomaDeBuenosAires', N'Ingresos Brutos - Ciudad Autonoma de Buenos Aires'),
|
||||||
|
('Catamarca', N'Ingresos Brutos - Catamarca'),
|
||||||
|
('Chaco', N'Ingresos Brutos - Chaco'),
|
||||||
|
('Chubut', N'Ingresos Brutos - Chubut'),
|
||||||
|
('Cordoba', N'Ingresos Brutos - Cordoba'),
|
||||||
|
('Corrientes', N'Ingresos Brutos - Corrientes'),
|
||||||
|
('EntreRios', N'Ingresos Brutos - Entre Rios'),
|
||||||
|
('Formosa', N'Ingresos Brutos - Formosa'),
|
||||||
|
('Jujuy', N'Ingresos Brutos - Jujuy'),
|
||||||
|
('LaPampa', N'Ingresos Brutos - La Pampa'),
|
||||||
|
('LaRioja', N'Ingresos Brutos - La Rioja'),
|
||||||
|
('Mendoza', N'Ingresos Brutos - Mendoza'),
|
||||||
|
('Misiones', N'Ingresos Brutos - Misiones'),
|
||||||
|
('Neuquen', N'Ingresos Brutos - Neuquen'),
|
||||||
|
('RioNegro', N'Ingresos Brutos - Rio Negro'),
|
||||||
|
('Salta', N'Ingresos Brutos - Salta'),
|
||||||
|
('SanJuan', N'Ingresos Brutos - San Juan'),
|
||||||
|
('SanLuis', N'Ingresos Brutos - San Luis'),
|
||||||
|
('SantaCruz', N'Ingresos Brutos - Santa Cruz'),
|
||||||
|
('SantaFe', N'Ingresos Brutos - Santa Fe'),
|
||||||
|
('SantiagoDelEstero', N'Ingresos Brutos - Santiago del Estero'),
|
||||||
|
('TierraDelFuego', N'Ingresos Brutos - Tierra del Fuego'),
|
||||||
|
('Tucuman', N'Ingresos Brutos - Tucuman')
|
||||||
|
) AS s (Provincia, Descripcion)
|
||||||
|
ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Provincia, s.Descripcion, CAST(0 AS DECIMAL(5,2)), 1, CAST('2020-01-01' AS DATE), NULL, NULL);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'IngresosBrutos: 24 canonical rows seeded (23 provincias INDEC + CABA, Alicuota=0 placeholder, PascalCase).';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Permiso: administracion:fiscal:gestionar (REQ-FISCAL-AUTH-002)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')
|
||||||
|
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||||
|
ON t.Codigo = s.Codigo
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||||
|
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.RolPermiso AS t
|
||||||
|
USING (
|
||||||
|
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||||
|
FROM (VALUES
|
||||||
|
('admin', 'administracion:fiscal:gestionar')
|
||||||
|
) AS x (RolCodigo, PermisoCodigo)
|
||||||
|
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||||
|
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||||
|
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V014 applied successfully.';
|
||||||
|
PRINT ' - dbo.TipoDeIva (temporal, retention 10y, PAGE compression)';
|
||||||
|
PRINT ' - dbo.IngresosBrutos (temporal, retention 10y, PAGE compression)';
|
||||||
|
PRINT ' - TipoDeIva: 4 canonical rows (EXENTO, NO_GRAVADO, IVA_105, IVA_21)';
|
||||||
|
PRINT ' - IngresosBrutos: 24 rows (23 provincias INDEC + CABA, Alicuota=0 placeholder)';
|
||||||
|
PRINT ' - Permiso administracion:fiscal:gestionar (asignado a admin)';
|
||||||
|
GO
|
||||||
123
src/api/SIGCM2.Api/Contracts/Fiscal/FiscalContracts.cs
Normal file
123
src/api/SIGCM2.Api/Contracts/Fiscal/FiscalContracts.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Contracts.Fiscal;
|
||||||
|
|
||||||
|
// ── IVA Request records ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create TipoDeIva request body.</summary>
|
||||||
|
public sealed record CreateTipoDeIvaRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Descripcion,
|
||||||
|
decimal? Porcentaje,
|
||||||
|
bool? AplicaIVA,
|
||||||
|
string? VigenciaDesde,
|
||||||
|
string? VigenciaHasta = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Update TipoDeIva request body — only cosmetic fields.
|
||||||
|
/// Porcentaje is intentionally absent; any attempt to pass it in the body
|
||||||
|
/// is detected via raw JSON inspection and returns 409.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateTipoDeIvaRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Descripcion,
|
||||||
|
bool? AplicaIVA,
|
||||||
|
bool? Activo);
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create new TipoDeIva version request body.</summary>
|
||||||
|
public sealed record NuevaVersionTipoDeIvaRequest(
|
||||||
|
decimal? Porcentaje,
|
||||||
|
string? VigenciaDesde);
|
||||||
|
|
||||||
|
// ── IIBB Request records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create IngresosBrutos request body.</summary>
|
||||||
|
public sealed record CreateIngresosBrutosRequest(
|
||||||
|
string? Provincia,
|
||||||
|
string? Descripcion,
|
||||||
|
decimal? Alicuota,
|
||||||
|
string? VigenciaDesde,
|
||||||
|
string? VigenciaHasta = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Update IngresosBrutos request body — only cosmetic fields.
|
||||||
|
/// Alicuota and Provincia are intentionally absent.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateIngresosBrutosRequest(
|
||||||
|
string? Descripcion,
|
||||||
|
bool? Activo);
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create new IngresosBrutos version request body.</summary>
|
||||||
|
public sealed record NuevaVersionIngresosBrutosRequest(
|
||||||
|
decimal? Alicuota,
|
||||||
|
string? VigenciaDesde);
|
||||||
|
|
||||||
|
// ── Shared Response records ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Response for nueva-version operations.</summary>
|
||||||
|
public sealed record NuevaVersionResponse(
|
||||||
|
int PredecesoraId,
|
||||||
|
int NuevaVersionId);
|
||||||
|
|
||||||
|
// ── Mapper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Application-layer DTOs to API response shapes.
|
||||||
|
/// Application DTOs are already well-formed for most cases;
|
||||||
|
/// IIBB Provincia is mapped to its display string for the API.
|
||||||
|
/// </summary>
|
||||||
|
public static class FiscalContractMapper
|
||||||
|
{
|
||||||
|
public static object ToIvaResponse(TipoDeIvaDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
dto.Codigo,
|
||||||
|
dto.Descripcion,
|
||||||
|
dto.Porcentaje,
|
||||||
|
dto.AplicaIVA,
|
||||||
|
dto.Activo,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.FechaCreacion,
|
||||||
|
dto.FechaModificacion
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToIibbResponse(IngresosBrutosDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
Provincia = dto.Provincia.ToDisplayString(),
|
||||||
|
dto.Descripcion,
|
||||||
|
dto.Alicuota,
|
||||||
|
dto.Activo,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.FechaCreacion,
|
||||||
|
dto.FechaModificacion
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToHistorialIvaResponse(HistorialCadenaDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
dto.Codigo,
|
||||||
|
dto.Porcentaje,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.Version
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToHistorialIibbResponse(HistorialCadenaIibbDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
Provincia = dto.Provincia.ToDisplayString(),
|
||||||
|
dto.Alicuota,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.Version
|
||||||
|
};
|
||||||
|
}
|
||||||
576
src/api/SIGCM2.Api/Controllers/FiscalController.cs
Normal file
576
src/api/SIGCM2.Api/Controllers/FiscalController.cs
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Api.Contracts.Fiscal;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.List;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Create;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Deactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetById;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetHistorial;
|
||||||
|
using SIGCM2.Application.TiposDeIva.List;
|
||||||
|
using SIGCM2.Application.TiposDeIva.NuevaVersion;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Reactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Update;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Tablas Fiscales — IVA + IngresosBrutos endpoints at /api/v1/admin/fiscal.
|
||||||
|
/// All endpoints require permission 'administracion:fiscal:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/fiscal")]
|
||||||
|
public sealed class FiscalController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateTipoDeIvaCommand> _createIvaValidator;
|
||||||
|
private readonly IValidator<UpdateTipoDeIvaCommand> _updateIvaValidator;
|
||||||
|
private readonly IValidator<NuevaVersionTipoDeIvaCommand> _nuevaVersionIvaValidator;
|
||||||
|
private readonly IValidator<CreateIngresosBrutosCommand> _createIibbValidator;
|
||||||
|
private readonly IValidator<UpdateIngresosBrutosCommand> _updateIibbValidator;
|
||||||
|
private readonly IValidator<NuevaVersionIngresosBrutosCommand> _nuevaVersionIibbValidator;
|
||||||
|
|
||||||
|
public FiscalController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateTipoDeIvaCommand> createIvaValidator,
|
||||||
|
IValidator<UpdateTipoDeIvaCommand> updateIvaValidator,
|
||||||
|
IValidator<NuevaVersionTipoDeIvaCommand> nuevaVersionIvaValidator,
|
||||||
|
IValidator<CreateIngresosBrutosCommand> createIibbValidator,
|
||||||
|
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
|
||||||
|
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createIvaValidator = createIvaValidator;
|
||||||
|
_updateIvaValidator = updateIvaValidator;
|
||||||
|
_nuevaVersionIvaValidator = nuevaVersionIvaValidator;
|
||||||
|
_createIibbValidator = createIibbValidator;
|
||||||
|
_updateIibbValidator = updateIibbValidator;
|
||||||
|
_nuevaVersionIibbValidator = nuevaVersionIibbValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IVA endpoints
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Lists TiposDeIva with optional filters. Requires administracion:fiscal:gestionar.</summary>
|
||||||
|
[HttpGet("iva")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListIva(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? codigo = null)
|
||||||
|
{
|
||||||
|
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
|
||||||
|
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
|
||||||
|
|
||||||
|
var query = new ListTiposDeIvaQuery(page, pageSize, activo, codigo);
|
||||||
|
var result = await _dispatcher.Send<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
|
||||||
|
result.Page,
|
||||||
|
result.PageSize,
|
||||||
|
result.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single TipoDeIva by id.</summary>
|
||||||
|
[HttpGet("iva/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetIvaById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetTipoDeIvaByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the full version chain for a TipoDeIva.</summary>
|
||||||
|
[HttpGet("iva/{id:int}/historial")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetHistorialIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetHistorialTipoDeIvaQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query);
|
||||||
|
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new TipoDeIva. Returns 201 on success.</summary>
|
||||||
|
[HttpPost("iva")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateIva([FromBody] CreateTipoDeIvaRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
DateOnly? vigenciaHasta = null;
|
||||||
|
if (request.VigenciaHasta is not null)
|
||||||
|
{
|
||||||
|
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
|
||||||
|
if (vigenciaHasta is null)
|
||||||
|
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new CreateTipoDeIvaCommand(
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Porcentaje: request.Porcentaje ?? 0m,
|
||||||
|
AplicaIVA: request.AplicaIVA ?? false,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value,
|
||||||
|
VigenciaHasta: vigenciaHasta);
|
||||||
|
|
||||||
|
var validation = await _createIvaValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<CreateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetIvaById), new { id = result.Id }, FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields of a TipoDeIva (Codigo, Descripcion, AplicaIVA, Activo).
|
||||||
|
/// IMPORTANT: if the raw body contains "porcentaje" (case-insensitive) → 409 inmutable_usar_nueva_version.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("iva/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
// Read raw body to detect immutable-field tampering before deserialization
|
||||||
|
Request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||||
|
var rawBody = await reader.ReadToEndAsync();
|
||||||
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
|
// Defend against porcentaje in body — must return 409 before dispatch
|
||||||
|
if (ContainsImmutableField(rawBody, "porcentaje"))
|
||||||
|
throw new PorcentajeInmutableException();
|
||||||
|
|
||||||
|
UpdateTipoDeIvaRequest? request;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request = JsonSerializer.Deserialize<UpdateTipoDeIvaRequest>(rawBody,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid JSON body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { error = "Request body is required" });
|
||||||
|
|
||||||
|
var command = new UpdateTipoDeIvaCommand(
|
||||||
|
Id: id,
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
AplicaIVA: request.AplicaIVA ?? false,
|
||||||
|
Activo: request.Activo ?? true);
|
||||||
|
|
||||||
|
var validation = await _updateIvaValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<UpdateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new version of a TipoDeIva (closes the predecessor). Returns 201.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/nueva-version")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> NuevaVersionIva(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromBody] NuevaVersionTipoDeIvaRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
var command = new NuevaVersionTipoDeIvaCommand(
|
||||||
|
PredecesoraId: id,
|
||||||
|
NuevoPorcentaje: request.Porcentaje ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value);
|
||||||
|
|
||||||
|
var validation = await _nuevaVersionIvaValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<NuevaVersionTipoDeIvaCommand, SIGCM2.Application.TiposDeIva.Dtos.NuevaVersionResultDto>(command);
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetIvaById),
|
||||||
|
new { id = result.NuevaVersionId },
|
||||||
|
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a TipoDeIva. Idempotent.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateTipoDeIvaCommand(id);
|
||||||
|
var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a TipoDeIva. Idempotent.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateTipoDeIvaCommand(id);
|
||||||
|
var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IngresosBrutos endpoints
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Lists IngresosBrutos with optional filters.</summary>
|
||||||
|
[HttpGet("iibb")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListIibb(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? provincia = null)
|
||||||
|
{
|
||||||
|
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
|
||||||
|
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
|
||||||
|
|
||||||
|
ProvinciaArgentina? provinciaEnum = null;
|
||||||
|
if (provincia is not null)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ProvinciaArgentina>(provincia, ignoreCase: true, out var parsed))
|
||||||
|
return BadRequest(new { error = $"'{provincia}' is not a valid ProvinciaArgentina value." });
|
||||||
|
provinciaEnum = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new ListIngresosBrutosQuery(page, pageSize, activo, provinciaEnum);
|
||||||
|
var result = await _dispatcher.Send<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>(query);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
|
||||||
|
result.Page,
|
||||||
|
result.PageSize,
|
||||||
|
result.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single IngresosBrutos by id.</summary>
|
||||||
|
[HttpGet("iibb/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetIibbById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetIngresosBrutosByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the full version chain for an IngresosBrutos entry.</summary>
|
||||||
|
[HttpGet("iibb/{id:int}/historial")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetHistorialIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetHistorialIngresosBrutosQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query);
|
||||||
|
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new IngresosBrutos entry. Returns 201 on success.</summary>
|
||||||
|
[HttpPost("iibb")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateIibb([FromBody] CreateIngresosBrutosRequest request)
|
||||||
|
{
|
||||||
|
if (request.Provincia is null)
|
||||||
|
return BadRequest(new { error = "provincia is required" });
|
||||||
|
|
||||||
|
// Accept enum name (PascalCase) or display string
|
||||||
|
ProvinciaArgentina provinciaEnum;
|
||||||
|
if (Enum.TryParse<ProvinciaArgentina>(request.Provincia, ignoreCase: true, out var parsedEnum))
|
||||||
|
{
|
||||||
|
provinciaEnum = parsedEnum;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
provinciaEnum = ProvinciaArgentinaExtensions.FromDisplayString(request.Provincia);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = $"'{request.Provincia}' is not a valid provincia. Use enum name or display string." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
DateOnly? vigenciaHasta = null;
|
||||||
|
if (request.VigenciaHasta is not null)
|
||||||
|
{
|
||||||
|
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
|
||||||
|
if (vigenciaHasta is null)
|
||||||
|
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new CreateIngresosBrutosCommand(
|
||||||
|
Provincia: provinciaEnum,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Alicuota: request.Alicuota ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value,
|
||||||
|
VigenciaHasta: vigenciaHasta);
|
||||||
|
|
||||||
|
var validation = await _createIibbValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<CreateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetIibbById), new { id = result.Id }, FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields of IngresosBrutos (Descripcion, Activo).
|
||||||
|
/// IMPORTANT: if the raw body contains "alicuota" (case-insensitive) → 409 inmutable_usar_nueva_version.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("iibb/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
Request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||||
|
var rawBody = await reader.ReadToEndAsync();
|
||||||
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
|
if (ContainsImmutableField(rawBody, "alicuota"))
|
||||||
|
throw new AlicuotaInmutableException();
|
||||||
|
|
||||||
|
UpdateIngresosBrutosRequest? request;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request = JsonSerializer.Deserialize<UpdateIngresosBrutosRequest>(rawBody,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid JSON body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { error = "Request body is required" });
|
||||||
|
|
||||||
|
var command = new UpdateIngresosBrutosCommand(
|
||||||
|
Id: id,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Activo: request.Activo ?? true);
|
||||||
|
|
||||||
|
var validation = await _updateIibbValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<UpdateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new version of IngresosBrutos (closes the predecessor). Returns 201.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/nueva-version")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> NuevaVersionIibb(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromBody] NuevaVersionIngresosBrutosRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
var command = new NuevaVersionIngresosBrutosCommand(
|
||||||
|
PredecesoraId: id,
|
||||||
|
NuevaAlicuota: request.Alicuota ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value);
|
||||||
|
|
||||||
|
var validation = await _nuevaVersionIibbValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<NuevaVersionIngresosBrutosCommand, SIGCM2.Application.IngresosBrutos.Dtos.NuevaVersionIibbResultDto>(command);
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetIibbById),
|
||||||
|
new { id = result.NuevaVersionId },
|
||||||
|
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates an IngresosBrutos entry. Idempotent.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateIngresosBrutosCommand(id);
|
||||||
|
var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates an IngresosBrutos entry. Idempotent.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateIngresosBrutosCommand(id);
|
||||||
|
var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Private helpers
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a date string "yyyy-MM-dd" to DateOnly. Returns null if invalid.
|
||||||
|
/// </summary>
|
||||||
|
private static DateOnly? ParseDateOnly(string? value, string fieldName)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
return DateOnly.TryParseExact(value, "yyyy-MM-dd",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out var result)
|
||||||
|
? result
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a raw JSON string contains a given field name (case-insensitive).
|
||||||
|
/// Used to detect immutable-field tampering before deserialization silently drops the field.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ContainsImmutableField(string rawJson, string fieldName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawJson)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(rawJson);
|
||||||
|
return doc.RootElement.ValueKind == JsonValueKind.Object &&
|
||||||
|
doc.RootElement.EnumerateObject()
|
||||||
|
.Any(p => string.Equals(p.Name, fieldName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -231,6 +231,91 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ADM-009: TipoDeIva fiscal exceptions
|
||||||
|
case PorcentajeInmutableException:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "inmutable_usar_nueva_version",
|
||||||
|
message = "El porcentaje de un TipoDeIva es inmutable. Creá una nueva versión vía POST /iva/{id}/nueva-version."
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AlicuotaInmutableException:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "inmutable_usar_nueva_version",
|
||||||
|
message = "La alícuota de IngresosBrutos es inmutable. Creá una nueva versión vía POST /iibb/{id}/nueva-version."
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PredecesorYaCerradoException predecesorYaCerradoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "predecesora_ya_cerrada",
|
||||||
|
message = predecesorYaCerradoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DuplicateCodigoException duplicateCodigoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "duplicate_codigo",
|
||||||
|
message = duplicateCodigoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DuplicateProvinciaException duplicateProvinciaEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "duplicate_provincia",
|
||||||
|
message = duplicateProvinciaEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TipoDeIvaNotFoundException tipoDeIvaNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "tipo_iva_not_found",
|
||||||
|
message = tipoDeIvaNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IngresosBrutosNotFoundException ingresosBrutosNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "ingresos_brutos_not_found",
|
||||||
|
message = ingresosBrutosNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
// ADM-008: PuntoDeVenta exceptions
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
@@ -285,6 +370,21 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ADM-009: vigencia_desde_invalida — domain throws ArgumentException for invalid vigencia range
|
||||||
|
case ArgumentException argEx when argEx.Message.Contains("vigencia_desde_invalida") ||
|
||||||
|
argEx.ParamName == "vigenciaDesde" ||
|
||||||
|
argEx.Message.Contains("debe ser posterior"):
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "vigencia_desde_invalida",
|
||||||
|
message = argEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
case ValidationException validationEx:
|
case ValidationException validationEx:
|
||||||
var errors = validationEx.Errors
|
var errors = validationEx.Errors
|
||||||
.GroupBy(e => e.PropertyName)
|
.GroupBy(e => e.PropertyName)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence contract for IngresosBrutos. Implemented by Dapper repo in Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIngresosBrutosRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new IngresosBrutos record and returns the generated identity Id.</summary>
|
||||||
|
Task<int> InsertAsync(IibbEntity entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the IngresosBrutos with the given Id, or null if not found.</summary>
|
||||||
|
Task<IibbEntity?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields only (Descripcion, Activo).
|
||||||
|
/// Never touches Alicuota, Provincia, or vigencia dates.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCosmeticoAsync(int id, string descripcion, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the vigencia of the predecessor: UPDATE SET VigenciaHasta = @vigenciaHasta
|
||||||
|
/// WHERE Id = @id AND VigenciaHasta IS NULL (optimistic guard for race conditions).
|
||||||
|
/// Returns true if one row was affected, false if the row was already closed (race detected).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCierreVigenciaAsync(int id, DateOnly vigenciaHasta, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sets Activo to the given value. Returns true if one row was affected.</summary>
|
||||||
|
Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged list applying optional Activo and Provincia filters.</summary>
|
||||||
|
Task<PagedResult<IibbEntity>> ListAsync(IngresosBrutosQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the full version chain for the record identified by <paramref name="id"/>,
|
||||||
|
/// ordered from root (no PredecesorId) to the requested Id (inclusive).
|
||||||
|
/// Implemented via a recursive CTE in the concrete repository.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<IibbEntity>> GetHistorialAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence contract for TipoDeIva. Implemented by Dapper repo in Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITipoDeIvaRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new TipoDeIva and returns the generated identity Id.</summary>
|
||||||
|
Task<int> InsertAsync(TipoDeIva entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the TipoDeIva with the given Id, or null if not found.</summary>
|
||||||
|
Task<TipoDeIva?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields only (Codigo, Descripcion, AplicaIVA, Activo).
|
||||||
|
/// Never touches Porcentaje or vigencia dates.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCosmeticoAsync(int id, string codigo, string descripcion, bool aplicaIVA, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the vigencia of the predecessor: UPDATE SET VigenciaHasta = @vigenciaHasta
|
||||||
|
/// WHERE Id = @id AND VigenciaHasta IS NULL (optimistic guard for race conditions).
|
||||||
|
/// Returns true if one row was affected, false if the row was already closed (race detected).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCierreVigenciaAsync(int id, DateOnly vigenciaHasta, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sets Activo to the given value. Returns true if one row was affected.</summary>
|
||||||
|
Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged list applying optional Activo and Codigo filters.</summary>
|
||||||
|
Task<PagedResult<TipoDeIva>> ListAsync(TiposDeIvaQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the full version chain for the record identified by <paramref name="id"/>,
|
||||||
|
/// ordered from root (no PredecesorId) to the requested Id (inclusive).
|
||||||
|
/// Implemented via a recursive CTE in the concrete repository.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<TipoDeIva>> GetHistorialAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
11
src/api/SIGCM2.Application/Common/IngresosBrutosQuery.cs
Normal file
11
src/api/SIGCM2.Application/Common/IngresosBrutosQuery.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing ingresos brutos with optional filters and paging.</summary>
|
||||||
|
public sealed record IngresosBrutosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
ProvinciaArgentina? Provincia
|
||||||
|
);
|
||||||
9
src/api/SIGCM2.Application/Common/TiposDeIvaQuery.cs
Normal file
9
src/api/SIGCM2.Application/Common/TiposDeIvaQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing tipos de IVA with optional filters and paging.</summary>
|
||||||
|
public sealed record TiposDeIvaQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
string? Codigo
|
||||||
|
);
|
||||||
@@ -27,6 +27,24 @@ using SIGCM2.Application.PuntosDeVenta.GetById;
|
|||||||
using SIGCM2.Application.PuntosDeVenta.List;
|
using SIGCM2.Application.PuntosDeVenta.List;
|
||||||
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||||
using SIGCM2.Application.PuntosDeVenta.Update;
|
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.Create;
|
||||||
using SIGCM2.Application.Secciones.Deactivate;
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
using SIGCM2.Application.Secciones.GetById;
|
using SIGCM2.Application.Secciones.GetById;
|
||||||
@@ -104,6 +122,26 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
|
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)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateIngresosBrutosCommand(int Id);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionIibbResultDto(
|
||||||
|
int PredecesoraId,
|
||||||
|
int NuevaVersionId
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
|
||||||
|
public sealed record GetIngresosBrutosByIdQuery(int Id);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
|
||||||
|
public sealed record GetHistorialIngresosBrutosQuery(int Id);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionIngresosBrutosCommand(
|
||||||
|
int PredecesoraId,
|
||||||
|
decimal NuevaAlicuota,
|
||||||
|
DateOnly VigenciaDesde);
|
||||||
@@ -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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
|
||||||
|
public sealed record ReactivateIngresosBrutosCommand(int Id);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateTipoDeIvaCommand(int Id);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionResultDto(
|
||||||
|
int PredecesoraId,
|
||||||
|
int NuevaVersionId
|
||||||
|
);
|
||||||
15
src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaDto.cs
Normal file
15
src/api/SIGCM2.Application/TiposDeIva/Dtos/TipoDeIvaDto.cs
Normal 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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.GetById;
|
||||||
|
|
||||||
|
public sealed record GetTipoDeIvaByIdQuery(int Id);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.GetHistorial;
|
||||||
|
|
||||||
|
public sealed record GetHistorialTipoDeIvaQuery(int Id);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.List;
|
||||||
|
|
||||||
|
public sealed record ListTiposDeIvaQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
string? Codigo);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.NuevaVersion;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionTipoDeIvaCommand(
|
||||||
|
int PredecesoraId,
|
||||||
|
decimal NuevoPorcentaje,
|
||||||
|
DateOnly VigenciaDesde);
|
||||||
@@ -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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.TiposDeIva.Reactivate;
|
||||||
|
|
||||||
|
public sealed record ReactivateTipoDeIvaCommand(int Id);
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs
Normal file
177
src/api/SIGCM2.Domain/Entities/IngresosBrutos.cs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entrada de Ingresos Brutos por provincia con versionado append-only.
|
||||||
|
/// Alicuota es INMUTABLE post-creación; cambiar el valor requiere crear una nueva versión
|
||||||
|
/// vía <see cref="NuevaVersion"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IngresosBrutos
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
public ProvinciaArgentina Provincia { get; } // INMUTABLE
|
||||||
|
public string Descripcion { get; }
|
||||||
|
public decimal Alicuota { get; } // INMUTABLE — usar NuevaVersion para cambiar
|
||||||
|
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 IngresosBrutos(
|
||||||
|
int id,
|
||||||
|
ProvinciaArgentina provincia,
|
||||||
|
string descripcion,
|
||||||
|
decimal alicuota,
|
||||||
|
bool activo,
|
||||||
|
DateOnly vigenciaDesde,
|
||||||
|
DateOnly? vigenciaHasta,
|
||||||
|
int? predecesorId,
|
||||||
|
DateTime fechaCreacion,
|
||||||
|
DateTime? fechaModificacion)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Provincia = provincia;
|
||||||
|
Descripcion = descripcion;
|
||||||
|
Alicuota = alicuota;
|
||||||
|
Activo = activo;
|
||||||
|
VigenciaDesde = vigenciaDesde;
|
||||||
|
VigenciaHasta = vigenciaHasta;
|
||||||
|
PredecesorId = predecesorId;
|
||||||
|
FechaCreacion = fechaCreacion;
|
||||||
|
FechaModificacion = fechaModificacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory para crear una nueva entrada de IIBB (Id=0 — BD asigna via IDENTITY; Activo=true).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">Si Alicuota fuera de rango 0-100.</exception>
|
||||||
|
public static IngresosBrutos ForCreation(
|
||||||
|
ProvinciaArgentina provincia,
|
||||||
|
string descripcion,
|
||||||
|
decimal alicuota,
|
||||||
|
DateOnly vigenciaDesde,
|
||||||
|
DateOnly? vigenciaHasta = null,
|
||||||
|
int? predecesorId = null)
|
||||||
|
{
|
||||||
|
ValidateAlicuota(alicuota, nameof(alicuota));
|
||||||
|
ValidateVigencias(vigenciaDesde, vigenciaHasta);
|
||||||
|
|
||||||
|
return new(
|
||||||
|
id: 0,
|
||||||
|
provincia: provincia,
|
||||||
|
descripcion: descripcion,
|
||||||
|
alicuota: alicuota,
|
||||||
|
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 IngresosBrutos FromDb(
|
||||||
|
int id,
|
||||||
|
ProvinciaArgentina provincia,
|
||||||
|
string descripcion,
|
||||||
|
decimal alicuota,
|
||||||
|
bool activo,
|
||||||
|
DateOnly vigenciaDesde,
|
||||||
|
DateOnly? vigenciaHasta,
|
||||||
|
int? predecesorId,
|
||||||
|
DateTime fechaCreacion,
|
||||||
|
DateTime? fechaModificacion)
|
||||||
|
=> new(id, provincia, descripcion, alicuota, activo,
|
||||||
|
vigenciaDesde, vigenciaHasta, predecesorId, fechaCreacion, fechaModificacion);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crea una nueva versión con la alícuota actualizada.
|
||||||
|
/// 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 nuevaAlicuota fuera de rango.</exception>
|
||||||
|
public (IngresosBrutos predecesoraCerrada, IngresosBrutos nuevaVersion) NuevaVersion(
|
||||||
|
decimal nuevaAlicuota,
|
||||||
|
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));
|
||||||
|
|
||||||
|
ValidateAlicuota(nuevaAlicuota, nameof(nuevaAlicuota));
|
||||||
|
|
||||||
|
var cerrada = new IngresosBrutos(
|
||||||
|
id: Id,
|
||||||
|
provincia: Provincia,
|
||||||
|
descripcion: Descripcion,
|
||||||
|
alicuota: Alicuota,
|
||||||
|
activo: Activo,
|
||||||
|
vigenciaDesde: VigenciaDesde,
|
||||||
|
vigenciaHasta: vigenciaDesde.AddDays(-1),
|
||||||
|
predecesorId: PredecesorId,
|
||||||
|
fechaCreacion: FechaCreacion,
|
||||||
|
fechaModificacion: DateTime.UtcNow);
|
||||||
|
|
||||||
|
var nueva = ForCreation(
|
||||||
|
provincia: Provincia,
|
||||||
|
descripcion: Descripcion,
|
||||||
|
alicuota: nuevaAlicuota,
|
||||||
|
vigenciaDesde: vigenciaDesde,
|
||||||
|
vigenciaHasta: null,
|
||||||
|
predecesorId: Id);
|
||||||
|
|
||||||
|
return (cerrada, nueva);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cosmetic mutators (NO WithAlicuota, NO WithProvincia) ─────────────────
|
||||||
|
|
||||||
|
/// <summary>Actualiza la descripción. Alicuota y Provincia permanecen inmutables.</summary>
|
||||||
|
public IngresosBrutos WithDescripcion(string descripcion)
|
||||||
|
=> new(Id, Provincia, descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>Retorna instancia con Activo=false.</summary>
|
||||||
|
public IngresosBrutos Deactivate()
|
||||||
|
=> new(Id, Provincia, Descripcion, Alicuota, false,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>Retorna instancia con Activo=true.</summary>
|
||||||
|
public IngresosBrutos Reactivate()
|
||||||
|
=> new(Id, Provincia, Descripcion, Alicuota, true,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cierra la vigencia seteando VigenciaHasta. Usado por el handler de NuevaVersion.
|
||||||
|
/// </summary>
|
||||||
|
public IngresosBrutos CerrarVigencia(DateOnly vigenciaHasta)
|
||||||
|
=> new(Id, Provincia, Descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, vigenciaHasta, PredecesorId, FechaCreacion, DateTime.UtcNow);
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ValidateAlicuota(decimal alicuota, string paramName)
|
||||||
|
{
|
||||||
|
if (alicuota < 0m || alicuota > 100m)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Alicuota ({alicuota}) 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/api/SIGCM2.Domain/Entities/TipoDeIva.cs
Normal file
208
src/api/SIGCM2.Domain/Entities/TipoDeIva.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a PATCH request attempts to change the Alicuota of an IngresosBrutos.
|
||||||
|
/// Alicuota is immutable post-creation; use POST /iibb/{id}/nueva-version instead.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlicuotaInmutableException : DomainException
|
||||||
|
{
|
||||||
|
public AlicuotaInmutableException()
|
||||||
|
: base("La alícuota de IngresosBrutos es inmutable. Creá una nueva versión vía POST /iibb/{id}/nueva-version.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/api/SIGCM2.Domain/Exceptions/DuplicateCodigoException.cs
Normal file
16
src/api/SIGCM2.Domain/Exceptions/DuplicateCodigoException.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a TipoDeIva with the same Codigo already exists for the same VigenciaDesde.
|
||||||
|
/// Maps to SQL unique constraint UQ_TipoDeIva_Codigo_Vigencia (SqlException 2627/2601).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DuplicateCodigoException : DomainException
|
||||||
|
{
|
||||||
|
public string Codigo { get; }
|
||||||
|
|
||||||
|
public DuplicateCodigoException(string codigo)
|
||||||
|
: base($"Ya existe un TipoDeIva con Codigo '{codigo}' en la misma vigencia.")
|
||||||
|
{
|
||||||
|
Codigo = codigo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an IngresosBrutos entry for the same Provincia already exists for the same VigenciaDesde.
|
||||||
|
/// Maps to SQL unique constraint UQ_IIBB_Provincia_Vigencia (SqlException 2627/2601).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DuplicateProvinciaException : DomainException
|
||||||
|
{
|
||||||
|
public ProvinciaArgentina Provincia { get; }
|
||||||
|
|
||||||
|
public DuplicateProvinciaException(ProvinciaArgentina provincia)
|
||||||
|
: base($"Ya existe una entrada de IIBB para '{provincia}' en la misma vigencia.")
|
||||||
|
{
|
||||||
|
Provincia = provincia;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested IngresosBrutos record does not exist in the system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IngresosBrutosNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public IngresosBrutosNotFoundException(int id)
|
||||||
|
: base($"El registro de Ingresos Brutos con id '{id}' no existe.")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a PATCH request attempts to change the Porcentaje of a TipoDeIva.
|
||||||
|
/// Porcentaje is immutable post-creation; use POST /iva/{id}/nueva-version instead.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PorcentajeInmutableException : DomainException
|
||||||
|
{
|
||||||
|
public PorcentajeInmutableException()
|
||||||
|
: base("El porcentaje de un TipoDeIva es inmutable. Creá una nueva versión vía POST /iva/{id}/nueva-version.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when attempting to create a new version from a predecessor that is already closed
|
||||||
|
/// (VigenciaHasta is not null). The predecessor must be open to generate a new version.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PredecesorYaCerradoException : DomainException
|
||||||
|
{
|
||||||
|
public int PredecesorId { get; }
|
||||||
|
|
||||||
|
public PredecesorYaCerradoException(int predecesorId)
|
||||||
|
: base($"La versión {predecesorId} ya está cerrada o inactiva. No puede generar nueva versión.")
|
||||||
|
{
|
||||||
|
PredecesorId = predecesorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested TipoDeIva does not exist in the system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TipoDeIvaNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public TipoDeIvaNotFoundException(int id)
|
||||||
|
: base($"El tipo de IVA con id '{id}' no existe.")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/api/SIGCM2.Domain/Fiscal/ProvinciaArgentina.cs
Normal file
91
src/api/SIGCM2.Domain/Fiscal/ProvinciaArgentina.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
namespace SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jurisdicciones fiscales de Argentina: 23 provincias INDEC + Ciudad Autónoma de Buenos Aires.
|
||||||
|
/// Almacenado en BD como VARCHAR(50) (nombre del enum via ToString()).
|
||||||
|
/// </summary>
|
||||||
|
public enum ProvinciaArgentina
|
||||||
|
{
|
||||||
|
BuenosAires,
|
||||||
|
Catamarca,
|
||||||
|
Chaco,
|
||||||
|
Chubut,
|
||||||
|
CiudadAutonomaDeBuenosAires,
|
||||||
|
Cordoba,
|
||||||
|
Corrientes,
|
||||||
|
EntreRios,
|
||||||
|
Formosa,
|
||||||
|
Jujuy,
|
||||||
|
LaPampa,
|
||||||
|
LaRioja,
|
||||||
|
Mendoza,
|
||||||
|
Misiones,
|
||||||
|
Neuquen,
|
||||||
|
RioNegro,
|
||||||
|
Salta,
|
||||||
|
SanJuan,
|
||||||
|
SanLuis,
|
||||||
|
SantaCruz,
|
||||||
|
SantaFe,
|
||||||
|
SantiagoDelEstero,
|
||||||
|
TierraDelFuego,
|
||||||
|
Tucuman
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods para ProvinciaArgentina: mapping bidireccional con el display name
|
||||||
|
/// (nombre con acentos/espacios) que se usa para presentación en UI.
|
||||||
|
/// </summary>
|
||||||
|
public static class ProvinciaArgentinaExtensions
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<ProvinciaArgentina, string> DisplayNames = new()
|
||||||
|
{
|
||||||
|
[ProvinciaArgentina.BuenosAires] = "Buenos Aires",
|
||||||
|
[ProvinciaArgentina.Catamarca] = "Catamarca",
|
||||||
|
[ProvinciaArgentina.Chaco] = "Chaco",
|
||||||
|
[ProvinciaArgentina.Chubut] = "Chubut",
|
||||||
|
[ProvinciaArgentina.CiudadAutonomaDeBuenosAires] = "Ciudad Autónoma de Buenos Aires",
|
||||||
|
[ProvinciaArgentina.Cordoba] = "Córdoba",
|
||||||
|
[ProvinciaArgentina.Corrientes] = "Corrientes",
|
||||||
|
[ProvinciaArgentina.EntreRios] = "Entre Ríos",
|
||||||
|
[ProvinciaArgentina.Formosa] = "Formosa",
|
||||||
|
[ProvinciaArgentina.Jujuy] = "Jujuy",
|
||||||
|
[ProvinciaArgentina.LaPampa] = "La Pampa",
|
||||||
|
[ProvinciaArgentina.LaRioja] = "La Rioja",
|
||||||
|
[ProvinciaArgentina.Mendoza] = "Mendoza",
|
||||||
|
[ProvinciaArgentina.Misiones] = "Misiones",
|
||||||
|
[ProvinciaArgentina.Neuquen] = "Neuquén",
|
||||||
|
[ProvinciaArgentina.RioNegro] = "Río Negro",
|
||||||
|
[ProvinciaArgentina.Salta] = "Salta",
|
||||||
|
[ProvinciaArgentina.SanJuan] = "San Juan",
|
||||||
|
[ProvinciaArgentina.SanLuis] = "San Luis",
|
||||||
|
[ProvinciaArgentina.SantaCruz] = "Santa Cruz",
|
||||||
|
[ProvinciaArgentina.SantaFe] = "Santa Fe",
|
||||||
|
[ProvinciaArgentina.SantiagoDelEstero] = "Santiago del Estero",
|
||||||
|
[ProvinciaArgentina.TierraDelFuego] = "Tierra del Fuego",
|
||||||
|
[ProvinciaArgentina.Tucuman] = "Tucumán",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, ProvinciaArgentina> ByDisplayName =
|
||||||
|
DisplayNames.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retorna el nombre con acentos/espacios para presentación en UI y almacenamiento en BD.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToDisplayString(this ProvinciaArgentina provincia)
|
||||||
|
=> DisplayNames[provincia];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsea un display name a su valor de enum correspondiente.
|
||||||
|
/// Lanza <see cref="ArgumentException"/> si el nombre no corresponde a ningún valor.
|
||||||
|
/// </summary>
|
||||||
|
public static ProvinciaArgentina FromDisplayString(string displayName)
|
||||||
|
{
|
||||||
|
if (ByDisplayName.TryGetValue(displayName, out var result))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"'{displayName}' no es un nombre de provincia válido. Usá uno de los valores de ProvinciaArgentina.",
|
||||||
|
nameof(displayName));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ public static class Permiso
|
|||||||
public const string AdministracionTarifariosGestionar = "administracion:tarifarios:gestionar";
|
public const string AdministracionTarifariosGestionar = "administracion:tarifarios:gestionar";
|
||||||
public const string AdministracionMediosGestionar = "administracion:medios:gestionar";
|
public const string AdministracionMediosGestionar = "administracion:medios:gestionar";
|
||||||
public const string AdministracionAuditoriaVer = "administracion:auditoria:ver";
|
public const string AdministracionAuditoriaVer = "administracion:auditoria:ver";
|
||||||
|
public const string AdministracionFiscalGestionar = "administracion:fiscal:gestionar";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set completo de todos los códigos canónicos (útil para validación y seeds).
|
/// Set completo de todos los códigos canónicos (útil para validación y seeds).
|
||||||
@@ -49,5 +50,6 @@ public static class Permiso
|
|||||||
ProductoresDeudaVer, ProductoresPendientesCrear, ProductoresDeudaBypass,
|
ProductoresDeudaVer, ProductoresPendientesCrear, ProductoresDeudaBypass,
|
||||||
AdministracionUsuariosGestionar, AdministracionTarifariosGestionar,
|
AdministracionUsuariosGestionar, AdministracionTarifariosGestionar,
|
||||||
AdministracionMediosGestionar, AdministracionAuditoriaVer,
|
AdministracionMediosGestionar, AdministracionAuditoriaVer,
|
||||||
|
AdministracionFiscalGestionar,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IMedioRepository, MedioRepository>();
|
services.AddScoped<IMedioRepository, MedioRepository>();
|
||||||
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
||||||
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||||
|
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
||||||
|
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dapper implementation of <see cref="IIngresosBrutosRepository"/>.
|
||||||
|
/// Provincia is persisted as the enum member name (PascalCase, e.g. "BuenosAires") via ToString().
|
||||||
|
/// On read, it is parsed back via Enum.Parse<ProvinciaArgentina> (strict PascalCase only).
|
||||||
|
/// Alicuota and Provincia are NEVER updated by cosmetic methods.
|
||||||
|
/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain.
|
||||||
|
/// Note: As of V014 T700 cleanup, seed values are stored in PascalCase — legacy UPPER_SNAKE_CASE
|
||||||
|
/// support (LegacySeedMap / NormalizeUpperSnakeToPascal) has been removed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IngresosBrutosRepository : IIngresosBrutosRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public IngresosBrutosRepository(SqlConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> InsertAsync(IibbEntity entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.IngresosBrutos
|
||||||
|
(Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES
|
||||||
|
(@Provincia, @Descripcion, @Alicuota, @Activo, @VigenciaDesde, @VigenciaHasta, @PredecesorId, SYSUTCDATETIME(), SYSUTCDATETIME())
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
Provincia = entity.Provincia.ToString(),
|
||||||
|
entity.Descripcion,
|
||||||
|
entity.Alicuota,
|
||||||
|
entity.Activo,
|
||||||
|
VigenciaDesde = entity.VigenciaDesde.ToDateTime(TimeOnly.MinValue),
|
||||||
|
VigenciaHasta = entity.VigenciaHasta.HasValue
|
||||||
|
? (object)entity.VigenciaHasta.Value.ToDateTime(TimeOnly.MinValue)
|
||||||
|
: DBNull.Value,
|
||||||
|
PredecesorId = entity.PredecesorId.HasValue ? (object)entity.PredecesorId.Value : DBNull.Value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (IsUniqueViolation(ex))
|
||||||
|
{
|
||||||
|
throw new DuplicateProvinciaException(entity.Provincia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IibbEntity?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT
|
||||||
|
Id, Provincia, Descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.IngresosBrutos
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<IibbRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCosmeticoAsync(
|
||||||
|
int id, string descripcion, bool activo,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NOTE: Alicuota and Provincia are intentionally EXCLUDED — they are IMMUTABLE.
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.IngresosBrutos
|
||||||
|
SET
|
||||||
|
Descripcion = @Descripcion,
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new { Id = id, Descripcion = descripcion, Activo = activo });
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCierreVigenciaAsync(
|
||||||
|
int id, DateOnly vigenciaHasta,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Optimistic guard: only update if row is still open (VigenciaHasta IS NULL AND Activo = 1).
|
||||||
|
// Returns false when 0 rows affected (already closed — race condition detected).
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.IngresosBrutos
|
||||||
|
SET
|
||||||
|
VigenciaHasta = @VigenciaHasta,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
AND VigenciaHasta IS NULL
|
||||||
|
AND Activo = 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
VigenciaHasta = vigenciaHasta.ToDateTime(TimeOnly.MinValue),
|
||||||
|
});
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.IngresosBrutos
|
||||||
|
SET
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new { Id = id, Activo = activo });
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<IibbEntity>> ListAsync(IngresosBrutosQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var where = new StringBuilder("WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("PageSize", pageSize);
|
||||||
|
parameters.Add("Offset", offset);
|
||||||
|
|
||||||
|
if (query.Activo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Activo = @Activo");
|
||||||
|
parameters.Add("Activo", query.Activo.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.Provincia.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Provincia = @Provincia");
|
||||||
|
parameters.Add("Provincia", query.Provincia.Value.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT
|
||||||
|
Id, Provincia, Descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
|
||||||
|
COUNT(*) OVER() AS TotalCount
|
||||||
|
FROM dbo.IngresosBrutos
|
||||||
|
{where}
|
||||||
|
ORDER BY Id DESC
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<IibbPagedRow>(sql, parameters);
|
||||||
|
var list = rows.ToList();
|
||||||
|
|
||||||
|
var total = list.Count > 0 ? list[0].TotalCount : 0;
|
||||||
|
var items = list.Select(MapPagedRow).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<IibbEntity>(items, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IibbEntity>> GetHistorialAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Recursive CTE: starts at @Id and walks PredecesorId upward to root,
|
||||||
|
// then orders by VigenciaDesde ASC so root comes first.
|
||||||
|
const string sql = """
|
||||||
|
WITH Cadena AS (
|
||||||
|
SELECT
|
||||||
|
Id, Provincia, Descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
|
||||||
|
0 AS NivelDesdeActual
|
||||||
|
FROM dbo.IngresosBrutos
|
||||||
|
WHERE Id = @Id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
t.Id, t.Provincia, t.Descripcion, t.Alicuota, t.Activo,
|
||||||
|
t.VigenciaDesde, t.VigenciaHasta, t.PredecesorId, t.FechaCreacion, t.FechaModificacion,
|
||||||
|
c.NivelDesdeActual + 1
|
||||||
|
FROM dbo.IngresosBrutos t
|
||||||
|
INNER JOIN Cadena c ON t.Id = c.PredecesorId
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
Id, Provincia, Descripcion, Alicuota, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
|
||||||
|
FROM Cadena
|
||||||
|
ORDER BY VigenciaDesde ASC
|
||||||
|
OPTION (MAXRECURSION 100)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<IibbRow>(sql, new { Id = id });
|
||||||
|
return rows.Select(MapRow).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IibbEntity MapRow(IibbRow r)
|
||||||
|
=> IibbEntity.FromDb(
|
||||||
|
id: r.Id,
|
||||||
|
provincia: ParseProvincia(r.Provincia),
|
||||||
|
descripcion: r.Descripcion,
|
||||||
|
alicuota: r.Alicuota,
|
||||||
|
activo: r.Activo,
|
||||||
|
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
|
||||||
|
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
|
||||||
|
predecesorId: r.PredecesorId,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private static IibbEntity MapPagedRow(IibbPagedRow r)
|
||||||
|
=> IibbEntity.FromDb(
|
||||||
|
id: r.Id,
|
||||||
|
provincia: ParseProvincia(r.Provincia),
|
||||||
|
descripcion: r.Descripcion,
|
||||||
|
alicuota: r.Alicuota,
|
||||||
|
activo: r.Activo,
|
||||||
|
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
|
||||||
|
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
|
||||||
|
predecesorId: r.PredecesorId,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a Provincia string from DB to ProvinciaArgentina enum.
|
||||||
|
/// Since T700 cleanup, the seed stores PascalCase values matching enum.ToString().
|
||||||
|
/// All values written by this repo are also PascalCase.
|
||||||
|
/// </summary>
|
||||||
|
private static ProvinciaArgentina ParseProvincia(string value)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<ProvinciaArgentina>(value, ignoreCase: false, out var result))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Cannot parse '{value}' as ProvinciaArgentina. " +
|
||||||
|
$"Expected PascalCase enum name (e.g. 'BuenosAires', 'CiudadAutonomaDeBuenosAires').");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUniqueViolation(SqlException ex)
|
||||||
|
=> ex.Number is 2627 or 2601;
|
||||||
|
|
||||||
|
// ── Private row records ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed record IibbRow(
|
||||||
|
int Id,
|
||||||
|
string Provincia,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Alicuota,
|
||||||
|
bool Activo,
|
||||||
|
DateTime VigenciaDesde,
|
||||||
|
DateTime? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record IibbPagedRow(
|
||||||
|
int Id,
|
||||||
|
string Provincia,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Alicuota,
|
||||||
|
bool Activo,
|
||||||
|
DateTime VigenciaDesde,
|
||||||
|
DateTime? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
int TotalCount);
|
||||||
|
}
|
||||||
288
src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs
Normal file
288
src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dapper implementation of <see cref="ITipoDeIvaRepository"/>.
|
||||||
|
/// All SQL is inline. Porcentaje and vigencia dates are NEVER updated by cosmetic methods.
|
||||||
|
/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TipoDeIvaRepository : ITipoDeIvaRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public TipoDeIvaRepository(SqlConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> InsertAsync(TipoDeIva entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.TipoDeIva
|
||||||
|
(Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES
|
||||||
|
(@Codigo, @Descripcion, @Porcentaje, @AplicaIVA, @Activo, @VigenciaDesde, @VigenciaHasta, @PredecesorId, SYSUTCDATETIME(), SYSUTCDATETIME())
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
entity.Codigo,
|
||||||
|
entity.Descripcion,
|
||||||
|
entity.Porcentaje,
|
||||||
|
entity.AplicaIVA,
|
||||||
|
entity.Activo,
|
||||||
|
VigenciaDesde = entity.VigenciaDesde.ToDateTime(TimeOnly.MinValue),
|
||||||
|
VigenciaHasta = entity.VigenciaHasta.HasValue
|
||||||
|
? (object)entity.VigenciaHasta.Value.ToDateTime(TimeOnly.MinValue)
|
||||||
|
: DBNull.Value,
|
||||||
|
PredecesorId = entity.PredecesorId.HasValue ? (object)entity.PredecesorId.Value : DBNull.Value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (IsUniqueViolation(ex))
|
||||||
|
{
|
||||||
|
throw new DuplicateCodigoException(entity.Codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TipoDeIva?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT
|
||||||
|
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.TipoDeIva
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<TipoDeIvaRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCosmeticoAsync(
|
||||||
|
int id, string codigo, string descripcion, bool aplicaIVA, bool activo,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// NOTE: Porcentaje, VigenciaDesde, VigenciaHasta, PredecesorId are intentionally EXCLUDED.
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.TipoDeIva
|
||||||
|
SET
|
||||||
|
Codigo = @Codigo,
|
||||||
|
Descripcion = @Descripcion,
|
||||||
|
AplicaIVA = @AplicaIVA,
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new { Id = id, Codigo = codigo, Descripcion = descripcion, AplicaIVA = aplicaIVA, Activo = activo });
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCierreVigenciaAsync(
|
||||||
|
int id, DateOnly vigenciaHasta,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Optimistic guard: only update if row is still open (VigenciaHasta IS NULL AND Activo = 1).
|
||||||
|
// Returns false when 0 rows affected (already closed — race condition detected).
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.TipoDeIva
|
||||||
|
SET
|
||||||
|
VigenciaHasta = @VigenciaHasta,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
AND VigenciaHasta IS NULL
|
||||||
|
AND Activo = 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
VigenciaHasta = vigenciaHasta.ToDateTime(TimeOnly.MinValue),
|
||||||
|
});
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.TipoDeIva
|
||||||
|
SET
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = SYSUTCDATETIME()
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.ExecuteAsync(sql, new { Id = id, Activo = activo });
|
||||||
|
return rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<TipoDeIva>> ListAsync(TiposDeIvaQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var where = new StringBuilder("WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("PageSize", pageSize);
|
||||||
|
parameters.Add("Offset", offset);
|
||||||
|
|
||||||
|
if (query.Activo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Activo = @Activo");
|
||||||
|
parameters.Add("Activo", query.Activo.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Codigo))
|
||||||
|
{
|
||||||
|
where.Append(" AND Codigo LIKE @Codigo + '%'");
|
||||||
|
parameters.Add("Codigo", query.Codigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT
|
||||||
|
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
|
||||||
|
COUNT(*) OVER() AS TotalCount
|
||||||
|
FROM dbo.TipoDeIva
|
||||||
|
{where}
|
||||||
|
ORDER BY Id DESC
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<TipoDeIvaPagedRow>(sql, parameters);
|
||||||
|
var list = rows.ToList();
|
||||||
|
|
||||||
|
var total = list.Count > 0 ? list[0].TotalCount : 0;
|
||||||
|
var items = list.Select(MapPagedRow).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<TipoDeIva>(items, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TipoDeIva>> GetHistorialAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Recursive CTE: starts at @Id and walks PredecesorId upward to root,
|
||||||
|
// then orders by VigenciaDesde ASC so root comes first.
|
||||||
|
const string sql = """
|
||||||
|
WITH Cadena AS (
|
||||||
|
SELECT
|
||||||
|
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
|
||||||
|
0 AS NivelDesdeActual
|
||||||
|
FROM dbo.TipoDeIva
|
||||||
|
WHERE Id = @Id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
t.Id, t.Codigo, t.Descripcion, t.Porcentaje, t.AplicaIVA, t.Activo,
|
||||||
|
t.VigenciaDesde, t.VigenciaHasta, t.PredecesorId, t.FechaCreacion, t.FechaModificacion,
|
||||||
|
c.NivelDesdeActual + 1
|
||||||
|
FROM dbo.TipoDeIva t
|
||||||
|
INNER JOIN Cadena c ON t.Id = c.PredecesorId
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
|
||||||
|
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
|
||||||
|
FROM Cadena
|
||||||
|
ORDER BY VigenciaDesde ASC
|
||||||
|
OPTION (MAXRECURSION 100)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<TipoDeIvaRow>(sql, new { Id = id });
|
||||||
|
return rows.Select(MapRow).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static TipoDeIva MapRow(TipoDeIvaRow r)
|
||||||
|
=> TipoDeIva.FromDb(
|
||||||
|
id: r.Id,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
descripcion: r.Descripcion,
|
||||||
|
porcentaje: r.Porcentaje,
|
||||||
|
aplicaIVA: r.AplicaIVA,
|
||||||
|
activo: r.Activo,
|
||||||
|
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
|
||||||
|
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
|
||||||
|
predecesorId: r.PredecesorId,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private static TipoDeIva MapPagedRow(TipoDeIvaPagedRow r)
|
||||||
|
=> TipoDeIva.FromDb(
|
||||||
|
id: r.Id,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
descripcion: r.Descripcion,
|
||||||
|
porcentaje: r.Porcentaje,
|
||||||
|
aplicaIVA: r.AplicaIVA,
|
||||||
|
activo: r.Activo,
|
||||||
|
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
|
||||||
|
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
|
||||||
|
predecesorId: r.PredecesorId,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private static bool IsUniqueViolation(SqlException ex)
|
||||||
|
=> ex.Number is 2627 or 2601;
|
||||||
|
|
||||||
|
// ── Private row records ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed record TipoDeIvaRow(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Porcentaje,
|
||||||
|
bool AplicaIVA,
|
||||||
|
bool Activo,
|
||||||
|
DateTime VigenciaDesde,
|
||||||
|
DateTime? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record TipoDeIvaPagedRow(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Porcentaje,
|
||||||
|
bool AplicaIVA,
|
||||||
|
bool Activo,
|
||||||
|
DateTime VigenciaDesde,
|
||||||
|
DateTime? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
int TotalCount);
|
||||||
|
}
|
||||||
73
src/web/src/features/fiscal/iibb/api/iibbApi.ts
Normal file
73
src/web/src/features/fiscal/iibb/api/iibbApi.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// ADM-009 — API client tipado para fiscal/iibb
|
||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type {
|
||||||
|
IngresosBrutos,
|
||||||
|
CreateIngresosBrutosRequest,
|
||||||
|
UpdateIngresosBrutosRequest,
|
||||||
|
NuevaVersionIngresosBrutosRequest,
|
||||||
|
NuevaVersionIibbResponse,
|
||||||
|
HistorialCadenaIibbEntry,
|
||||||
|
IngresosBrutosFilter,
|
||||||
|
PagedResponse,
|
||||||
|
} from '../types/ingresosBrutos.types'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/admin/fiscal/iibb'
|
||||||
|
|
||||||
|
export async function listIngresosBrutos(
|
||||||
|
params: IngresosBrutosFilter,
|
||||||
|
): Promise<PagedResponse<IngresosBrutos>> {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (params.page !== undefined) p.set('page', String(params.page))
|
||||||
|
if (params.pageSize !== undefined) p.set('pageSize', String(params.pageSize))
|
||||||
|
if (params.provincia !== undefined) p.set('provincia', params.provincia)
|
||||||
|
if (params.activo !== undefined) p.set('activo', String(params.activo))
|
||||||
|
|
||||||
|
const res = await axiosClient.get<PagedResponse<IngresosBrutos>>(BASE, { params: p })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIngresosBrutosById(id: number): Promise<IngresosBrutos> {
|
||||||
|
const res = await axiosClient.get<IngresosBrutos>(`${BASE}/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistorialIngresosBrutos(id: number): Promise<HistorialCadenaIibbEntry[]> {
|
||||||
|
const res = await axiosClient.get<HistorialCadenaIibbEntry[]>(`${BASE}/${id}/historial`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIngresosBrutos(
|
||||||
|
body: CreateIngresosBrutosRequest,
|
||||||
|
): Promise<IngresosBrutos> {
|
||||||
|
const res = await axiosClient.post<IngresosBrutos>(BASE, body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIngresosBrutos(
|
||||||
|
id: number,
|
||||||
|
body: UpdateIngresosBrutosRequest,
|
||||||
|
): Promise<IngresosBrutos> {
|
||||||
|
const res = await axiosClient.patch<IngresosBrutos>(`${BASE}/${id}`, body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nuevaVersionIngresosBrutos(
|
||||||
|
id: number,
|
||||||
|
body: NuevaVersionIngresosBrutosRequest,
|
||||||
|
): Promise<NuevaVersionIibbResponse> {
|
||||||
|
const res = await axiosClient.post<NuevaVersionIibbResponse>(
|
||||||
|
`${BASE}/${id}/nueva-version`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateIngresosBrutos(id: number): Promise<IngresosBrutos> {
|
||||||
|
const res = await axiosClient.post<IngresosBrutos>(`${BASE}/${id}/deactivate`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reactivateIngresosBrutos(id: number): Promise<IngresosBrutos> {
|
||||||
|
const res = await axiosClient.post<IngresosBrutos>(`${BASE}/${id}/reactivate`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// T600.27 (IIBB) — HistorialCadenaIibbTooltip
|
||||||
|
// Espejo de HistorialCadenaTooltip para IIBB
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { History } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { useHistorialIngresosBrutos } from '../hooks/useIngresosBrutos'
|
||||||
|
|
||||||
|
interface HistorialCadenaIibbTooltipProps {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVigencia(desde: string, hasta: string | null): string {
|
||||||
|
return hasta ? `${desde} → ${hasta}` : `${desde} → ahora`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistorialCadenaIibbTooltip({ id }: HistorialCadenaIibbTooltipProps) {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
const { data: historial, isLoading } = useHistorialIngresosBrutos(id, enabled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="historial"
|
||||||
|
onMouseEnter={() => setEnabled(true)}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="max-w-sm">
|
||||||
|
{!enabled || isLoading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Cargando historial...</span>
|
||||||
|
) : !historial || historial.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Sin historial</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium mb-1">Historial de versiones</p>
|
||||||
|
{historial.map((entry, idx) => (
|
||||||
|
<div key={entry.id} className="text-xs">
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
v{idx + 1} ({entry.alicuota}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
[{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}]
|
||||||
|
</span>
|
||||||
|
{idx < historial.length - 1 && (
|
||||||
|
<span className="text-muted-foreground"> →</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// T600.25 (IIBB) — IngresosBrutosFormModal
|
||||||
|
// Modal de edición / creación de IngresosBrutos
|
||||||
|
// CRÍTICO: NO incluye campo Alícuota en modo edit (inmutable, cambiar via NuevaVersion)
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { useCreateIngresosBrutos, useUpdateIngresosBrutos } from '../hooks/useIngresosBrutos'
|
||||||
|
import { PROVINCIAS, PROVINCIA_DISPLAY } from '../types/ingresosBrutos.types'
|
||||||
|
import type { IngresosBrutos, ProvinciaArgentina } from '../types/ingresosBrutos.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
provincia: z.string().min(1, 'La provincia es requerida'),
|
||||||
|
descripcion: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La descripción es requerida')
|
||||||
|
.max(200, 'Máximo 200 caracteres'),
|
||||||
|
activo: z.boolean(),
|
||||||
|
alicuotaCreate: z.coerce
|
||||||
|
.number<number>('Debe ser un número')
|
||||||
|
.min(0, 'Mínimo 0%')
|
||||||
|
.max(100, 'Máximo 100%')
|
||||||
|
.optional(),
|
||||||
|
vigenciaDesde: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface IngresosBrutosFormModalProps {
|
||||||
|
open: boolean
|
||||||
|
item: IngresosBrutos | null // null = modo create
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'inmutable_usar_nueva_version') {
|
||||||
|
return 'Para cambiar la alícuota usá el botón "Nueva vigencia" en lugar de "Editar".'
|
||||||
|
}
|
||||||
|
if (data.error === 'duplicate_provincia') {
|
||||||
|
return data.message ?? 'Ya existe un registro para esa provincia'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar'
|
||||||
|
}
|
||||||
|
return 'Error al guardar. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngresosBrutosFormModal({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: IngresosBrutosFormModalProps) {
|
||||||
|
const isEdit = item != null
|
||||||
|
const createMutation = useCreateIngresosBrutos()
|
||||||
|
const updateMutation = useUpdateIngresosBrutos()
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending
|
||||||
|
const error = createMutation.error ?? updateMutation.error
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
provincia: '',
|
||||||
|
descripcion: '',
|
||||||
|
activo: true,
|
||||||
|
alicuotaCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
form.reset({
|
||||||
|
provincia: item.provincia,
|
||||||
|
descripcion: item.descripcion,
|
||||||
|
activo: item.activo,
|
||||||
|
alicuotaCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
provincia: '',
|
||||||
|
descripcion: '',
|
||||||
|
activo: true,
|
||||||
|
alicuotaCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createMutation.reset()
|
||||||
|
updateMutation.reset()
|
||||||
|
}, [item, open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (isEdit) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
body: {
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
activo: values.activo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ingresos Brutos actualizado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (values.alicuotaCreate === undefined) {
|
||||||
|
form.setError('alicuotaCreate', { message: 'La alícuota es requerida' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
provincia: values.provincia as ProvinciaArgentina,
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
alicuota: values.alicuotaCreate,
|
||||||
|
vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ingresos Brutos creado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? 'Editar Ingresos Brutos' : 'Crear Ingresos Brutos'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Provincia — solo en create */}
|
||||||
|
{!isEdit && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="provincia"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provincia</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger aria-label="Provincia">
|
||||||
|
<SelectValue placeholder="Seleccioná una provincia" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVINCIAS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{PROVINCIA_DISPLAY[p]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="descripcion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Descripción</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Descripción del registro"
|
||||||
|
aria-label="Descripción"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Solo en create: alícuota y vigenciaDesde */}
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="alicuotaCreate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alícuota inicial (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.001}
|
||||||
|
placeholder="Ej: 2.5"
|
||||||
|
aria-label="Alícuota inicial"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nota informativa en modo EDIT */}
|
||||||
|
{isEdit && (
|
||||||
|
<p className="text-xs text-muted-foreground rounded-md border border-border bg-muted/50 p-3">
|
||||||
|
💡 Para cambiar la alícuota usá el botón <strong>Nueva vigencia</strong> en la tabla.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// T600.24 (IIBB) — IngresosBrutosTable
|
||||||
|
// Tabla principal para Ingresos Brutos con acciones por fila
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Pencil, CalendarPlus, PowerOff, Power } from 'lucide-react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { HistorialCadenaIibbTooltip } from './HistorialCadenaIibbTooltip'
|
||||||
|
import { useDeactivateIngresosBrutos, useReactivateIngresosBrutos } from '../hooks/useIngresosBrutos'
|
||||||
|
import type { IngresosBrutos } from '../types/ingresosBrutos.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface IngresosBrutosTableProps {
|
||||||
|
rows: IngresosBrutos[]
|
||||||
|
onEdit: (row: IngresosBrutos) => void
|
||||||
|
onNuevaVersion: (row: IngresosBrutos) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngresosBrutosTable({
|
||||||
|
rows,
|
||||||
|
onEdit,
|
||||||
|
onNuevaVersion,
|
||||||
|
isLoading,
|
||||||
|
}: IngresosBrutosTableProps) {
|
||||||
|
const deactivate = useDeactivateIngresosBrutos()
|
||||||
|
const reactivate = useReactivateIngresosBrutos()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<IngresosBrutos>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'provinciaDisplay',
|
||||||
|
header: 'Provincia',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.provinciaDisplay}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'descripcion',
|
||||||
|
header: 'Descripción',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'alicuota',
|
||||||
|
header: 'Alícuota',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.alicuota}%</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vigencia',
|
||||||
|
header: 'Vigencia',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { vigenciaDesde, vigenciaHasta } = row.original
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{vigenciaDesde} → {vigenciaHasta ?? <em>abierta</em>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||||
|
>
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||||
|
>
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version',
|
||||||
|
header: 'Versión',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.predecesorId ? '# en cadena' : 'raíz'}
|
||||||
|
</span>
|
||||||
|
<HistorialCadenaIibbTooltip id={row.original.id} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'low' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
const isPending = deactivate.isPending || reactivate.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="editar"
|
||||||
|
title="Editar"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="nueva vigencia"
|
||||||
|
title="Nueva vigencia"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => onNuevaVersion(item)}
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{item.activo ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="desactivar"
|
||||||
|
title="Desactivar"
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => {
|
||||||
|
deactivate.mutate(item.id, {
|
||||||
|
onSuccess: () =>
|
||||||
|
toast.success(`${item.provinciaDisplay} desactivado`),
|
||||||
|
onError: () =>
|
||||||
|
toast.error('Error al desactivar. Intentá de nuevo.'),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PowerOff className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="reactivar"
|
||||||
|
title="Reactivar"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => {
|
||||||
|
reactivate.mutate(item.id, {
|
||||||
|
onSuccess: () =>
|
||||||
|
toast.success(`${item.provinciaDisplay} reactivado`),
|
||||||
|
onError: () =>
|
||||||
|
toast.error('Error al reactivar. Intentá de nuevo.'),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onNuevaVersion, deactivate, reactivate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron registros de Ingresos Brutos con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// T600.26 (IIBB) — NuevaVigenciaIibbModal
|
||||||
|
// Modal para crear una nueva vigencia/versión de IngresosBrutos
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm, useWatch } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle, TriangleAlert } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos'
|
||||||
|
import type { IngresosBrutos } from '../types/ingresosBrutos.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
alicuota: z.coerce
|
||||||
|
.number<number>('Debe ser un número')
|
||||||
|
.min(0, 'Mínimo 0%')
|
||||||
|
.max(100, 'Máximo 100%'),
|
||||||
|
vigenciaDesde: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La vigencia desde es requerida')
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface NuevaVigenciaIibbModalProps {
|
||||||
|
open: boolean
|
||||||
|
item: IngresosBrutos | null
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechaCierre(vigenciaDesde: string): string {
|
||||||
|
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
|
||||||
|
const d = new Date(vigenciaDesde + 'T00:00:00')
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'predecesora_ya_cerrada') {
|
||||||
|
return 'La versión actual ya fue cerrada. No se puede crear una nueva versión sobre ella.'
|
||||||
|
}
|
||||||
|
if (data.error === 'vigencia_desde_invalida') {
|
||||||
|
return data.message ?? 'La fecha de vigencia debe ser posterior a la versión actual.'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al crear versión'
|
||||||
|
}
|
||||||
|
return 'Error al crear versión. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NuevaVigenciaIibbModal({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: NuevaVigenciaIibbModalProps) {
|
||||||
|
const mutation = useNuevaVersionIngresosBrutos()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
alicuota: '' as unknown as number,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchedAlicuota = useWatch({ control: form.control, name: 'alicuota' })
|
||||||
|
const watchedVigencia = useWatch({ control: form.control, name: 'vigenciaDesde' })
|
||||||
|
|
||||||
|
const formState = form.formState
|
||||||
|
const isFormValid = formState.isValid && !formState.isValidating
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
alicuota: '' as unknown as number,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
mutation.reset()
|
||||||
|
}
|
||||||
|
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(mutation.error)
|
||||||
|
const showPreview =
|
||||||
|
isFormValid &&
|
||||||
|
watchedAlicuota !== undefined &&
|
||||||
|
watchedVigencia?.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (!item) return
|
||||||
|
mutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
body: {
|
||||||
|
alicuota: values.alicuota,
|
||||||
|
vigenciaDesde: values.vigenciaDesde,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Nueva versión de ${item.provinciaDisplay} creada`)
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||||
|
Nueva vigencia — {item?.provinciaDisplay}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-4 py-3 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Esta acción crea una nueva versión de IIBB para esta provincia. La versión actual
|
||||||
|
quedará cerrada con la fecha anterior a la nueva vigencia.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="alicuota"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alícuota nueva (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.001}
|
||||||
|
placeholder="Ej: 3.0"
|
||||||
|
aria-label="Alícuota nueva"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showPreview && item && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3 space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-foreground">Vista previa:</p>
|
||||||
|
<p>
|
||||||
|
Nueva versión <strong>{item.provinciaDisplay}</strong> con alícuota{' '}
|
||||||
|
<strong>{watchedAlicuota}%</strong> vigente desde{' '}
|
||||||
|
<strong>{watchedVigencia}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Versión actual ({item.alicuota}%) quedará cerrada el{' '}
|
||||||
|
<strong>{fechaCierre(watchedVigencia)}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
|
Esta acción no se puede deshacer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || mutation.isPending}
|
||||||
|
aria-label="confirmar"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Creando versión...' : 'Confirmar creación de versión'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts
Normal file
121
src/web/src/features/fiscal/iibb/hooks/useIngresosBrutos.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ADM-009 — TanStack Query hooks para fiscal/iibb
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
listIngresosBrutos,
|
||||||
|
getIngresosBrutosById,
|
||||||
|
getHistorialIngresosBrutos,
|
||||||
|
createIngresosBrutos,
|
||||||
|
updateIngresosBrutos,
|
||||||
|
nuevaVersionIngresosBrutos,
|
||||||
|
deactivateIngresosBrutos,
|
||||||
|
reactivateIngresosBrutos,
|
||||||
|
} from '../api/iibbApi'
|
||||||
|
import type {
|
||||||
|
IngresosBrutosFilter,
|
||||||
|
CreateIngresosBrutosRequest,
|
||||||
|
UpdateIngresosBrutosRequest,
|
||||||
|
NuevaVersionIngresosBrutosRequest,
|
||||||
|
} from '../types/ingresosBrutos.types'
|
||||||
|
|
||||||
|
// ─── Query keys estables ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const iibbListQueryKey = (filters: IngresosBrutosFilter) =>
|
||||||
|
['fiscal', 'iibb', 'list', filters] as const
|
||||||
|
|
||||||
|
export const iibbDetailQueryKey = (id: number) =>
|
||||||
|
['fiscal', 'iibb', id] as const
|
||||||
|
|
||||||
|
export const iibbHistorialQueryKey = (id: number) =>
|
||||||
|
['fiscal', 'iibb', id, 'historial'] as const
|
||||||
|
|
||||||
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useIngresosBrutosList(filters: IngresosBrutosFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: iibbListQueryKey(filters),
|
||||||
|
queryFn: () => listIngresosBrutos(filters),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useIngresosBrutos(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: iibbDetailQueryKey(id ?? 0),
|
||||||
|
queryFn: () => getIngresosBrutosById(id!),
|
||||||
|
enabled: id != null,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Historial (lazy — solo cuando el tooltip está abierto) ───────────────────
|
||||||
|
|
||||||
|
export function useHistorialIngresosBrutos(id: number | null, enabled = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: iibbHistorialQueryKey(id ?? 0),
|
||||||
|
queryFn: () => getHistorialIngresosBrutos(id!),
|
||||||
|
enabled: id != null && enabled,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateIngresosBrutos() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateIngresosBrutosRequest) => createIngresosBrutos(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateIngresosBrutos() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: UpdateIngresosBrutosRequest }) =>
|
||||||
|
updateIngresosBrutos(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nueva versión ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useNuevaVersionIngresosBrutos() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: NuevaVersionIngresosBrutosRequest }) =>
|
||||||
|
nuevaVersionIngresosBrutos(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deactivate / Reactivate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useDeactivateIngresosBrutos() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateIngresosBrutos(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReactivateIngresosBrutos() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateIngresosBrutos(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iibb'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
203
src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx
Normal file
203
src/web/src/features/fiscal/iibb/pages/TiposDeIibbPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// T600.28 (IIBB) — TiposDeIibbPage
|
||||||
|
// Página principal de gestión de Ingresos Brutos
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { TriangleAlert, PlusCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { IngresosBrutosTable } from '../components/IngresosBrutosTable'
|
||||||
|
import { IngresosBrutosFormModal } from '../components/IngresosBrutosFormModal'
|
||||||
|
import { NuevaVigenciaIibbModal } from '../components/NuevaVigenciaIibbModal'
|
||||||
|
import { useIngresosBrutosList } from '../hooks/useIngresosBrutos'
|
||||||
|
import {
|
||||||
|
PROVINCIAS,
|
||||||
|
PROVINCIA_DISPLAY,
|
||||||
|
} from '../types/ingresosBrutos.types'
|
||||||
|
import type { IngresosBrutos, IngresosBrutosFilter, ProvinciaArgentina } from '../types/ingresosBrutos.types'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
export function TiposDeIibbPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [provinciaFilter, setProvinciaFilter] = useState<ProvinciaArgentina | undefined>(undefined)
|
||||||
|
const [activoFilter, setActivoFilter] = useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
// Estado de modales
|
||||||
|
const [editItem, setEditItem] = useState<IngresosBrutos | null>(null)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [nuevaVigenciaItem, setNuevaVigenciaItem] = useState<IngresosBrutos | null>(null)
|
||||||
|
const [nuevaVigenciaOpen, setNuevaVigenciaOpen] = useState(false)
|
||||||
|
|
||||||
|
const filters: IngresosBrutosFilter = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(provinciaFilter !== undefined ? { provincia: provinciaFilter } : {}),
|
||||||
|
...(activoFilter !== undefined ? { activo: activoFilter } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useIngresosBrutosList(filters)
|
||||||
|
|
||||||
|
const handleEdit = useCallback((row: IngresosBrutos) => {
|
||||||
|
setEditItem(row)
|
||||||
|
setEditOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNuevaVersion = useCallback((row: IngresosBrutos) => {
|
||||||
|
setNuevaVigenciaItem(row)
|
||||||
|
setNuevaVigenciaOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Banner de advertencia global */}
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-md border px-4 py-3 text-sm"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-4 w-4 mt-0.5 shrink-0" style={{ color: 'var(--warning)' }} />
|
||||||
|
<span>
|
||||||
|
Los cambios de alícuota afectan presupuestos en curso. Usá{' '}
|
||||||
|
<strong>Nueva vigencia</strong> para versionar cambios de alícuota.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Ingresos Brutos</h1>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditItem(null)
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label="crear nuevo"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
|
Crear nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Select
|
||||||
|
value={provinciaFilter ?? '__all__'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setProvinciaFilter(v === '__all__' ? undefined : (v as ProvinciaArgentina))
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="max-w-xs" aria-label="Filtrar por provincia">
|
||||||
|
<SelectValue placeholder="Filtrar por provincia..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Todas las provincias</SelectItem>
|
||||||
|
{PROVINCIAS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{PROVINCIA_DISPLAY[p]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Estado:</span>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === undefined ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(undefined); setPage(1) }}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === true ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(true); setPage(1) }}
|
||||||
|
>
|
||||||
|
Activos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === false ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(false); setPage(1) }}
|
||||||
|
>
|
||||||
|
Inactivos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IngresosBrutosTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onNuevaVersion={handleNuevaVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} registro${data.total !== 1 ? 's' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear/Editar */}
|
||||||
|
<IngresosBrutosFormModal
|
||||||
|
open={editOpen}
|
||||||
|
item={editItem}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSuccess={() => setEditOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Nueva Vigencia */}
|
||||||
|
<NuevaVigenciaIibbModal
|
||||||
|
open={nuevaVigenciaOpen}
|
||||||
|
item={nuevaVigenciaItem}
|
||||||
|
onClose={() => setNuevaVigenciaOpen(false)}
|
||||||
|
onSuccess={() => setNuevaVigenciaOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts
Normal file
127
src/web/src/features/fiscal/iibb/types/ingresosBrutos.types.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// ADM-009 — Tipos TS para feature fiscal/iibb
|
||||||
|
// Alineados con IngresosBrutosDto / FiscalContracts.cs del backend
|
||||||
|
|
||||||
|
// Provincias argentinas — 24 jurisdicciones (23 INDEC + CABA)
|
||||||
|
export type ProvinciaArgentina =
|
||||||
|
| 'BuenosAires'
|
||||||
|
| 'Catamarca'
|
||||||
|
| 'Chaco'
|
||||||
|
| 'Chubut'
|
||||||
|
| 'CiudadAutonomaDeBuenosAires'
|
||||||
|
| 'Corrientes'
|
||||||
|
| 'Cordoba'
|
||||||
|
| 'EntreRios'
|
||||||
|
| 'Formosa'
|
||||||
|
| 'Jujuy'
|
||||||
|
| 'LaPampa'
|
||||||
|
| 'LaRioja'
|
||||||
|
| 'Mendoza'
|
||||||
|
| 'Misiones'
|
||||||
|
| 'Neuquen'
|
||||||
|
| 'RioNegro'
|
||||||
|
| 'Salta'
|
||||||
|
| 'SanJuan'
|
||||||
|
| 'SanLuis'
|
||||||
|
| 'SantaCruz'
|
||||||
|
| 'SantaFe'
|
||||||
|
| 'SantiagoDelEstero'
|
||||||
|
| 'TierraDelFuego'
|
||||||
|
| 'Tucuman'
|
||||||
|
|
||||||
|
export const PROVINCIA_DISPLAY: Record<ProvinciaArgentina, string> = {
|
||||||
|
BuenosAires: 'Buenos Aires',
|
||||||
|
Catamarca: 'Catamarca',
|
||||||
|
Chaco: 'Chaco',
|
||||||
|
Chubut: 'Chubut',
|
||||||
|
CiudadAutonomaDeBuenosAires: 'Ciudad Autónoma de Buenos Aires',
|
||||||
|
Corrientes: 'Corrientes',
|
||||||
|
Cordoba: 'Córdoba',
|
||||||
|
EntreRios: 'Entre Ríos',
|
||||||
|
Formosa: 'Formosa',
|
||||||
|
Jujuy: 'Jujuy',
|
||||||
|
LaPampa: 'La Pampa',
|
||||||
|
LaRioja: 'La Rioja',
|
||||||
|
Mendoza: 'Mendoza',
|
||||||
|
Misiones: 'Misiones',
|
||||||
|
Neuquen: 'Neuquén',
|
||||||
|
RioNegro: 'Río Negro',
|
||||||
|
Salta: 'Salta',
|
||||||
|
SanJuan: 'San Juan',
|
||||||
|
SanLuis: 'San Luis',
|
||||||
|
SantaCruz: 'Santa Cruz',
|
||||||
|
SantaFe: 'Santa Fe',
|
||||||
|
SantiagoDelEstero: 'Santiago del Estero',
|
||||||
|
TierraDelFuego: 'Tierra del Fuego',
|
||||||
|
Tucuman: 'Tucumán',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVINCIAS: ProvinciaArgentina[] = Object.keys(PROVINCIA_DISPLAY) as ProvinciaArgentina[]
|
||||||
|
|
||||||
|
export interface IngresosBrutos {
|
||||||
|
id: number
|
||||||
|
provincia: ProvinciaArgentina
|
||||||
|
provinciaDisplay: string
|
||||||
|
descripcion: string
|
||||||
|
alicuota: number
|
||||||
|
vigenciaDesde: string // ISO date "yyyy-MM-dd"
|
||||||
|
vigenciaHasta: string | null
|
||||||
|
activo: boolean
|
||||||
|
predecesorId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIngresosBrutosRequest {
|
||||||
|
provincia: ProvinciaArgentina
|
||||||
|
descripcion: string
|
||||||
|
alicuota: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateIngresosBrutosRequest — SIN alicuota (inmutable, usar NuevaVersion para cambiar)
|
||||||
|
export interface UpdateIngresosBrutosRequest {
|
||||||
|
descripcion: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NuevaVersionIngresosBrutosRequest {
|
||||||
|
alicuota: number
|
||||||
|
vigenciaDesde: string // "yyyy-MM-dd"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NuevaVersionIibbResponse {
|
||||||
|
predecesorId: number
|
||||||
|
nuevaId: number
|
||||||
|
nuevaAlicuota: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
predecesorVigenciaHasta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorialCadenaIibbEntry {
|
||||||
|
id: number
|
||||||
|
provincia: ProvinciaArgentina
|
||||||
|
provinciaDisplay: string
|
||||||
|
alicuota: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
vigenciaHasta: string | null
|
||||||
|
activo: boolean
|
||||||
|
predecesorId: number | null
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngresosBrutosFilter {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
provincia?: ProvinciaArgentina
|
||||||
|
activo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
68
src/web/src/features/fiscal/iva/api/ivaApi.ts
Normal file
68
src/web/src/features/fiscal/iva/api/ivaApi.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// ADM-009 — API client tipado para fiscal/iva
|
||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type {
|
||||||
|
TipoDeIva,
|
||||||
|
CreateTipoDeIvaRequest,
|
||||||
|
UpdateTipoDeIvaRequest,
|
||||||
|
NuevaVersionTipoDeIvaRequest,
|
||||||
|
NuevaVersionResponse,
|
||||||
|
HistorialCadenaEntry,
|
||||||
|
TipoDeIvaFilter,
|
||||||
|
PagedResponse,
|
||||||
|
} from '../types/tipoDeIva.types'
|
||||||
|
|
||||||
|
const BASE = '/api/v1/admin/fiscal/iva'
|
||||||
|
|
||||||
|
export async function listTiposDeIva(
|
||||||
|
params: TipoDeIvaFilter,
|
||||||
|
): Promise<PagedResponse<TipoDeIva>> {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (params.page !== undefined) p.set('page', String(params.page))
|
||||||
|
if (params.pageSize !== undefined) p.set('pageSize', String(params.pageSize))
|
||||||
|
if (params.codigo !== undefined) p.set('codigo', params.codigo)
|
||||||
|
if (params.activo !== undefined) p.set('activo', String(params.activo))
|
||||||
|
|
||||||
|
const res = await axiosClient.get<PagedResponse<TipoDeIva>>(BASE, { params: p })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTipoDeIvaById(id: number): Promise<TipoDeIva> {
|
||||||
|
const res = await axiosClient.get<TipoDeIva>(`${BASE}/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistorialTipoDeIva(id: number): Promise<HistorialCadenaEntry[]> {
|
||||||
|
const res = await axiosClient.get<HistorialCadenaEntry[]>(`${BASE}/${id}/historial`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTipoDeIva(body: CreateTipoDeIvaRequest): Promise<TipoDeIva> {
|
||||||
|
const res = await axiosClient.post<TipoDeIva>(BASE, body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTipoDeIva(
|
||||||
|
id: number,
|
||||||
|
body: UpdateTipoDeIvaRequest,
|
||||||
|
): Promise<TipoDeIva> {
|
||||||
|
const res = await axiosClient.patch<TipoDeIva>(`${BASE}/${id}`, body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nuevaVersionTipoDeIva(
|
||||||
|
id: number,
|
||||||
|
body: NuevaVersionTipoDeIvaRequest,
|
||||||
|
): Promise<NuevaVersionResponse> {
|
||||||
|
const res = await axiosClient.post<NuevaVersionResponse>(`${BASE}/${id}/nueva-version`, body)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateTipoDeIva(id: number): Promise<TipoDeIva> {
|
||||||
|
const res = await axiosClient.post<TipoDeIva>(`${BASE}/${id}/deactivate`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reactivateTipoDeIva(id: number): Promise<TipoDeIva> {
|
||||||
|
const res = await axiosClient.post<TipoDeIva>(`${BASE}/${id}/reactivate`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// T600.7 — HistorialCadenaTooltip
|
||||||
|
// Tooltip con lazy enable — un solo request al backend (CTE recursivo)
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { History } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { useHistorialTipoDeIva } from '../hooks/useTiposDeIva'
|
||||||
|
|
||||||
|
interface HistorialCadenaTooltipProps {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVigencia(desde: string, hasta: string | null): string {
|
||||||
|
return hasta ? `${desde} → ${hasta}` : `${desde} → ahora`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistorialCadenaTooltip({ id }: HistorialCadenaTooltipProps) {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
const { data: historial, isLoading } = useHistorialTipoDeIva(id, enabled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="historial"
|
||||||
|
onMouseEnter={() => setEnabled(true)}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="max-w-sm">
|
||||||
|
{!enabled || isLoading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Cargando historial...</span>
|
||||||
|
) : !historial || historial.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Sin historial</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium mb-1">Historial de versiones</p>
|
||||||
|
{historial.map((entry, idx) => (
|
||||||
|
<div key={entry.id} className="text-xs">
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
v{idx + 1} ({entry.porcentaje}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
[{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}]
|
||||||
|
</span>
|
||||||
|
{idx < historial.length - 1 && (
|
||||||
|
<span className="text-muted-foreground"> →</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// T600.6 — NuevaVigenciaModal
|
||||||
|
// Modal para crear una nueva vigencia/versión de un TipoDeIva
|
||||||
|
// Color distinto al modal de Editar: usa tokens --warning-bg para diferenciación visual
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm, useWatch } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle, TriangleAlert } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva'
|
||||||
|
import type { TipoDeIva } from '../types/tipoDeIva.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
porcentaje: z.coerce
|
||||||
|
.number<number>('Debe ser un número')
|
||||||
|
.min(0, 'Mínimo 0%')
|
||||||
|
.max(100, 'Máximo 100%'),
|
||||||
|
vigenciaDesde: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La vigencia desde es requerida')
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface NuevaVigenciaModalProps {
|
||||||
|
open: boolean
|
||||||
|
item: TipoDeIva | null
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve la fecha anterior (vigenciaDesde - 1 día) como string "yyyy-MM-dd" */
|
||||||
|
function fechaCierre(vigenciaDesde: string): string {
|
||||||
|
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
|
||||||
|
const d = new Date(vigenciaDesde + 'T00:00:00')
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'predecesora_ya_cerrada') {
|
||||||
|
return 'La versión actual ya fue cerrada. No se puede crear una nueva versión sobre ella.'
|
||||||
|
}
|
||||||
|
if (data.error === 'vigencia_desde_invalida') {
|
||||||
|
return data.message ?? 'La fecha de vigencia debe ser posterior a la versión actual.'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al crear versión'
|
||||||
|
}
|
||||||
|
return 'Error al crear versión. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NuevaVigenciaModal({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: NuevaVigenciaModalProps) {
|
||||||
|
const mutation = useNuevaVersionTipoDeIva()
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
porcentaje: '' as unknown as number,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchedPorcentaje = useWatch({ control: form.control, name: 'porcentaje' })
|
||||||
|
const watchedVigencia = useWatch({ control: form.control, name: 'vigenciaDesde' })
|
||||||
|
|
||||||
|
const formState = form.formState
|
||||||
|
const isFormValid = formState.isValid && !formState.isValidating
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
porcentaje: '' as unknown as number,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
mutation.reset()
|
||||||
|
}
|
||||||
|
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(mutation.error)
|
||||||
|
const showPreview =
|
||||||
|
isFormValid &&
|
||||||
|
watchedPorcentaje !== undefined &&
|
||||||
|
watchedVigencia?.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (!item) return
|
||||||
|
mutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
body: {
|
||||||
|
porcentaje: values.porcentaje,
|
||||||
|
vigenciaDesde: values.vigenciaDesde,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Nueva versión de ${item.codigo} creada`)
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||||
|
Nueva vigencia — {item?.codigo}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Banner de advertencia — usa token --warning-bg */}
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-4 py-3 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Esta acción crea una nueva versión del tipo de IVA. La versión actual quedará
|
||||||
|
cerrada con la fecha anterior a la nueva vigencia.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Porcentaje nuevo */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="porcentaje"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Porcentaje nuevo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="Ej: 23.5"
|
||||||
|
aria-label="Porcentaje nuevo"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vigencia desde */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview — visible solo cuando form es válido */}
|
||||||
|
{showPreview && item && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3 space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-foreground">Vista previa:</p>
|
||||||
|
<p>
|
||||||
|
Nueva versión <strong>{item.codigo}</strong> con alícuota{' '}
|
||||||
|
<strong>{watchedPorcentaje}%</strong> vigente desde{' '}
|
||||||
|
<strong>{watchedVigencia}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Versión actual ({item.porcentaje}%) quedará cerrada el{' '}
|
||||||
|
<strong>{fechaCierre(watchedVigencia)}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
|
Esta acción no se puede deshacer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || mutation.isPending}
|
||||||
|
aria-label="confirmar"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Creando versión...' : 'Confirmar creación de versión'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// T600.5 — TipoDeIvaFormModal
|
||||||
|
// Modal de edición / creación de TipoDeIva
|
||||||
|
// CRÍTICO: NO incluye campo Porcentaje (inmutable, cambiar via NuevaVersion)
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useCreateTipoDeIva, useUpdateTipoDeIva } from '../hooks/useTiposDeIva'
|
||||||
|
import type { TipoDeIva } from '../types/tipoDeIva.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Schema zod — SIN campo porcentaje
|
||||||
|
const formSchema = z.object({
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.regex(
|
||||||
|
/^(EXENTO|NO_GRAVADO|IVA_\d+)$/,
|
||||||
|
'Formato inválido. Ejemplos: EXENTO, NO_GRAVADO, IVA_21',
|
||||||
|
),
|
||||||
|
descripcion: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La descripción es requerida')
|
||||||
|
.max(200, 'Máximo 200 caracteres'),
|
||||||
|
aplicaIVA: z.boolean(),
|
||||||
|
activo: z.boolean(),
|
||||||
|
// Porcentaje SOLO para modo create (no para editar)
|
||||||
|
porcentajeCreate: z.coerce
|
||||||
|
.number<number>('Debe ser un número')
|
||||||
|
.min(0, 'Mínimo 0')
|
||||||
|
.max(100, 'Máximo 100')
|
||||||
|
.optional(),
|
||||||
|
vigenciaDesde: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface TipoDeIvaFormModalProps {
|
||||||
|
open: boolean
|
||||||
|
item: TipoDeIva | null // null = modo create
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'inmutable_usar_nueva_version') {
|
||||||
|
return 'Para cambiar el porcentaje usá el botón "Nueva vigencia" en lugar de "Editar".'
|
||||||
|
}
|
||||||
|
if (data.error === 'duplicate_codigo') {
|
||||||
|
return data.message ?? 'Ya existe un tipo de IVA con ese código'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar'
|
||||||
|
}
|
||||||
|
return 'Error al guardar. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TipoDeIvaFormModal({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: TipoDeIvaFormModalProps) {
|
||||||
|
const isEdit = item != null
|
||||||
|
const createMutation = useCreateTipoDeIva()
|
||||||
|
const updateMutation = useUpdateTipoDeIva()
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending
|
||||||
|
const error = createMutation.error ?? updateMutation.error
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
codigo: '',
|
||||||
|
descripcion: '',
|
||||||
|
aplicaIVA: true,
|
||||||
|
activo: true,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
form.reset({
|
||||||
|
codigo: item.codigo,
|
||||||
|
descripcion: item.descripcion,
|
||||||
|
aplicaIVA: item.aplicaIVA,
|
||||||
|
activo: item.activo,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
codigo: '',
|
||||||
|
descripcion: '',
|
||||||
|
aplicaIVA: true,
|
||||||
|
activo: true,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createMutation.reset()
|
||||||
|
updateMutation.reset()
|
||||||
|
}, [item, open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (isEdit) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
body: {
|
||||||
|
codigo: values.codigo,
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
aplicaIVA: values.aplicaIVA,
|
||||||
|
activo: values.activo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Tipo de IVA actualizado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (values.porcentajeCreate === undefined) {
|
||||||
|
form.setError('porcentajeCreate', { message: 'El porcentaje es requerido' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
codigo: values.codigo,
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
porcentaje: values.porcentajeCreate,
|
||||||
|
vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10),
|
||||||
|
aplicaIVA: values.aplicaIVA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Tipo de IVA creado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Código */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Ej: IVA_21"
|
||||||
|
aria-label="Código"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="descripcion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Descripción</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Descripción del tipo de IVA"
|
||||||
|
aria-label="Descripción"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Solo en modo CREATE: porcentaje y vigenciaDesde */}
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="porcentajeCreate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Porcentaje inicial</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="Ej: 21"
|
||||||
|
aria-label="Porcentaje inicial"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nota informativa en modo EDIT: porcentaje no se puede cambiar aquí */}
|
||||||
|
{isEdit && (
|
||||||
|
<p className="text-xs text-muted-foreground rounded-md border border-border bg-muted/50 p-3">
|
||||||
|
💡 Para cambiar el porcentaje usá el botón <strong>Nueva vigencia</strong> en la tabla.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx
Normal file
190
src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// T600.4 — TipoDeIvaTable
|
||||||
|
// Tabla principal para tipos de IVA con acciones por fila
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Pencil, CalendarPlus, PowerOff, Power } from 'lucide-react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { HistorialCadenaTooltip } from './HistorialCadenaTooltip'
|
||||||
|
import { useDeactivateTipoDeIva, useReactivateTipoDeIva } from '../hooks/useTiposDeIva'
|
||||||
|
import type { TipoDeIva } from '../types/tipoDeIva.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface TipoDeIvaTableProps {
|
||||||
|
rows: TipoDeIva[]
|
||||||
|
onEdit: (row: TipoDeIva) => void
|
||||||
|
onNuevaVersion: (row: TipoDeIva) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TipoDeIvaTable({
|
||||||
|
rows,
|
||||||
|
onEdit,
|
||||||
|
onNuevaVersion,
|
||||||
|
isLoading,
|
||||||
|
}: TipoDeIvaTableProps) {
|
||||||
|
const deactivate = useDeactivateTipoDeIva()
|
||||||
|
const reactivate = useReactivateTipoDeIva()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<TipoDeIva>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'codigo',
|
||||||
|
header: 'Código',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs font-medium">{row.original.codigo}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'descripcion',
|
||||||
|
header: 'Descripción',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'porcentaje',
|
||||||
|
header: 'Porcentaje',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.porcentaje}%</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vigencia',
|
||||||
|
header: 'Vigencia',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { vigenciaDesde, vigenciaHasta } = row.original
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{vigenciaDesde} → {vigenciaHasta ?? <em>abierta</em>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||||
|
>
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||||
|
>
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version',
|
||||||
|
header: 'Versión',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.predecesorId ? '# en cadena' : 'raíz'}
|
||||||
|
</span>
|
||||||
|
<HistorialCadenaTooltip id={row.original.id} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'low' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
const isPending =
|
||||||
|
deactivate.isPending || reactivate.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="editar"
|
||||||
|
title="Editar"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="nueva vigencia"
|
||||||
|
title="Nueva vigencia"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => onNuevaVersion(item)}
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{item.activo ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="desactivar"
|
||||||
|
title="Desactivar"
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => {
|
||||||
|
deactivate.mutate(item.id, {
|
||||||
|
onSuccess: () => toast.success(`${item.codigo} desactivado`),
|
||||||
|
onError: () =>
|
||||||
|
toast.error('Error al desactivar. Intentá de nuevo.'),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PowerOff className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="reactivar"
|
||||||
|
title="Reactivar"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => {
|
||||||
|
reactivate.mutate(item.id, {
|
||||||
|
onSuccess: () => toast.success(`${item.codigo} reactivado`),
|
||||||
|
onError: () =>
|
||||||
|
toast.error('Error al reactivar. Intentá de nuevo.'),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onNuevaVersion, deactivate, reactivate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron tipos de IVA con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
src/web/src/features/fiscal/iva/hooks/useTiposDeIva.ts
Normal file
121
src/web/src/features/fiscal/iva/hooks/useTiposDeIva.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ADM-009 — TanStack Query hooks para fiscal/iva
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
listTiposDeIva,
|
||||||
|
getTipoDeIvaById,
|
||||||
|
getHistorialTipoDeIva,
|
||||||
|
createTipoDeIva,
|
||||||
|
updateTipoDeIva,
|
||||||
|
nuevaVersionTipoDeIva,
|
||||||
|
deactivateTipoDeIva,
|
||||||
|
reactivateTipoDeIva,
|
||||||
|
} from '../api/ivaApi'
|
||||||
|
import type {
|
||||||
|
TipoDeIvaFilter,
|
||||||
|
CreateTipoDeIvaRequest,
|
||||||
|
UpdateTipoDeIvaRequest,
|
||||||
|
NuevaVersionTipoDeIvaRequest,
|
||||||
|
} from '../types/tipoDeIva.types'
|
||||||
|
|
||||||
|
// ─── Query keys estables ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ivaListQueryKey = (filters: TipoDeIvaFilter) =>
|
||||||
|
['fiscal', 'iva', 'list', filters] as const
|
||||||
|
|
||||||
|
export const ivaDetailQueryKey = (id: number) =>
|
||||||
|
['fiscal', 'iva', id] as const
|
||||||
|
|
||||||
|
export const ivaHistorialQueryKey = (id: number) =>
|
||||||
|
['fiscal', 'iva', id, 'historial'] as const
|
||||||
|
|
||||||
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useTiposDeIvaList(filters: TipoDeIvaFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ivaListQueryKey(filters),
|
||||||
|
queryFn: () => listTiposDeIva(filters),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useTipoDeIva(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ivaDetailQueryKey(id ?? 0),
|
||||||
|
queryFn: () => getTipoDeIvaById(id!),
|
||||||
|
enabled: id != null,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Historial (lazy — solo cuando el tooltip está abierto) ───────────────────
|
||||||
|
|
||||||
|
export function useHistorialTipoDeIva(id: number | null, enabled = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ivaHistorialQueryKey(id ?? 0),
|
||||||
|
queryFn: () => getHistorialTipoDeIva(id!),
|
||||||
|
enabled: id != null && enabled,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateTipoDeIva() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateTipoDeIvaRequest) => createTipoDeIva(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iva'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUpdateTipoDeIva() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: UpdateTipoDeIvaRequest }) =>
|
||||||
|
updateTipoDeIva(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iva'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nueva versión ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useNuevaVersionTipoDeIva() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: NuevaVersionTipoDeIvaRequest }) =>
|
||||||
|
nuevaVersionTipoDeIva(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iva'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deactivate / Reactivate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useDeactivateTipoDeIva() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateTipoDeIva(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iva'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReactivateTipoDeIva() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateTipoDeIva(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', 'iva'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
185
src/web/src/features/fiscal/iva/pages/TiposDeIvaPage.tsx
Normal file
185
src/web/src/features/fiscal/iva/pages/TiposDeIvaPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// T600.8 — TiposDeIvaPage
|
||||||
|
// Página principal de gestión de Tipos de IVA
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { TriangleAlert, PlusCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { TipoDeIvaTable } from '../components/TipoDeIvaTable'
|
||||||
|
import { TipoDeIvaFormModal } from '../components/TipoDeIvaFormModal'
|
||||||
|
import { NuevaVigenciaModal } from '../components/NuevaVigenciaModal'
|
||||||
|
import { useTiposDeIvaList } from '../hooks/useTiposDeIva'
|
||||||
|
import type { TipoDeIva, TipoDeIvaFilter } from '../types/tipoDeIva.types'
|
||||||
|
import { Button as Btn } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function TiposDeIvaPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [codigoFilter, setCodigoFilter] = useState('')
|
||||||
|
const [activoFilter, setActivoFilter] = useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
// Estado de modales
|
||||||
|
const [editItem, setEditItem] = useState<TipoDeIva | null>(null)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [nuevaVigenciaItem, setNuevaVigenciaItem] = useState<TipoDeIva | null>(null)
|
||||||
|
const [nuevaVigenciaOpen, setNuevaVigenciaOpen] = useState(false)
|
||||||
|
|
||||||
|
const filters: TipoDeIvaFilter = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(codigoFilter ? { codigo: codigoFilter } : {}),
|
||||||
|
...(activoFilter !== undefined ? { activo: activoFilter } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useTiposDeIvaList(filters)
|
||||||
|
|
||||||
|
const handleEdit = useCallback((row: TipoDeIva) => {
|
||||||
|
setEditItem(row)
|
||||||
|
setEditOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNuevaVersion = useCallback((row: TipoDeIva) => {
|
||||||
|
setNuevaVigenciaItem(row)
|
||||||
|
setNuevaVigenciaOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Banner de advertencia global — visible al montar [REQ-UI-005] */}
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-md border px-4 py-3 text-sm"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-4 w-4 mt-0.5 shrink-0" style={{ color: 'var(--warning)' }} />
|
||||||
|
<span>
|
||||||
|
Los cambios de alícuota afectan presupuestos en curso. Usá{' '}
|
||||||
|
<strong>Nueva vigencia</strong> para versionar cambios de porcentaje.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header con título y botón crear */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Tipos de IVA</h1>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditItem(null)
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label="crear nuevo"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
|
Crear nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtros */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Input
|
||||||
|
placeholder="Filtrar por código..."
|
||||||
|
value={codigoFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCodigoFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Filtrar por código"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Estado:</span>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === undefined ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(undefined); setPage(1) }}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === true ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(true); setPage(1) }}
|
||||||
|
>
|
||||||
|
Activos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activoFilter === false ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setActivoFilter(false); setPage(1) }}
|
||||||
|
>
|
||||||
|
Inactivos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TipoDeIvaTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onNuevaVersion={handleNuevaVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} tipo${data.total !== 1 ? 's' : ''} de IVA` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Btn
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Btn>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Btn
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear/Editar */}
|
||||||
|
<TipoDeIvaFormModal
|
||||||
|
open={editOpen}
|
||||||
|
item={editItem}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSuccess={() => setEditOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Nueva Vigencia */}
|
||||||
|
<NuevaVigenciaModal
|
||||||
|
open={nuevaVigenciaOpen}
|
||||||
|
item={nuevaVigenciaItem}
|
||||||
|
onClose={() => setNuevaVigenciaOpen(false)}
|
||||||
|
onSuccess={() => setNuevaVigenciaOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/web/src/features/fiscal/iva/types/tipoDeIva.types.ts
Normal file
74
src/web/src/features/fiscal/iva/types/tipoDeIva.types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// ADM-009 — Tipos TS para feature fiscal/iva
|
||||||
|
// Alineados con TipoDeIvaDto / FiscalContracts.cs del backend
|
||||||
|
|
||||||
|
export interface TipoDeIva {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
descripcion: string
|
||||||
|
porcentaje: number
|
||||||
|
vigenciaDesde: string // ISO date "yyyy-MM-dd"
|
||||||
|
vigenciaHasta: string | null
|
||||||
|
activo: boolean
|
||||||
|
aplicaIVA: boolean
|
||||||
|
predecesorId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTipoDeIvaRequest {
|
||||||
|
codigo: string
|
||||||
|
descripcion: string
|
||||||
|
porcentaje: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
aplicaIVA: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTipoDeIvaRequest — SIN porcentaje (inmutable, usar NuevaVersion para cambiar)
|
||||||
|
export interface UpdateTipoDeIvaRequest {
|
||||||
|
codigo: string
|
||||||
|
descripcion: string
|
||||||
|
aplicaIVA: boolean
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NuevaVersionTipoDeIvaRequest {
|
||||||
|
porcentaje: number
|
||||||
|
vigenciaDesde: string // "yyyy-MM-dd"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NuevaVersionResponse {
|
||||||
|
predecesorId: number
|
||||||
|
nuevaId: number
|
||||||
|
nuevoPorcentaje: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
predecesorVigenciaHasta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorialCadenaEntry {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
porcentaje: number
|
||||||
|
vigenciaDesde: string
|
||||||
|
vigenciaHasta: string | null
|
||||||
|
activo: boolean
|
||||||
|
predecesorId: number | null
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipoDeIvaFilter {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
codigo?: string
|
||||||
|
activo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApiError — contrato unificado { error, message } de ADM-008/ADM-009
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@
|
|||||||
--success-foreground: oklch(0.990 0.000 0);
|
--success-foreground: oklch(0.990 0.000 0);
|
||||||
--warning: oklch(0.760 0.150 75);
|
--warning: oklch(0.760 0.150 75);
|
||||||
--warning-foreground: oklch(0.220 0.050 75);
|
--warning-foreground: oklch(0.220 0.050 75);
|
||||||
|
--warning-bg: oklch(0.970 0.040 80); /* banner bg — usado en fiscal ADM-009 */
|
||||||
|
--warning-border: oklch(0.870 0.090 78); /* border del banner warning */
|
||||||
|
|
||||||
/* ── shadcn semantic mapping (LIGHT) ─────────────────── */
|
/* ── shadcn semantic mapping (LIGHT) ─────────────────── */
|
||||||
--background: oklch(0.962 0.006 250); /* slate cool — pop con cards white */
|
--background: oklch(0.962 0.006 250); /* slate cool — pop con cards white */
|
||||||
@@ -134,6 +136,9 @@
|
|||||||
--destructive: oklch(0.580 0.190 25);
|
--destructive: oklch(0.580 0.190 25);
|
||||||
--destructive-foreground: oklch(0.990 0.000 0);
|
--destructive-foreground: oklch(0.990 0.000 0);
|
||||||
|
|
||||||
|
--warning-bg: oklch(0.220 0.055 72); /* banner bg dark mode — warm amber sutil */
|
||||||
|
--warning-border: oklch(0.380 0.090 74);
|
||||||
|
|
||||||
--border: oklch(1 0 0 / 0.10); /* sutil glass-style border */
|
--border: oklch(1 0 0 / 0.10); /* sutil glass-style border */
|
||||||
--input: oklch(0.245 0.022 252); /* elevado, mismo nivel que muted */
|
--input: oklch(0.245 0.022 252); /* elevado, mismo nivel que muted */
|
||||||
--input-border: oklch(1 0 0 / 0.14);
|
--input-border: oklch(1 0 0 / 0.14);
|
||||||
@@ -317,6 +322,8 @@
|
|||||||
--color-success-foreground: var(--success-foreground);
|
--color-success-foreground: var(--success-foreground);
|
||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-warning-bg: var(--warning-bg);
|
||||||
|
--color-warning-border: var(--warning-border);
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { PuntosDeVentaListPage } from './features/puntos-de-venta/pages/PuntosDe
|
|||||||
import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage'
|
import { CreatePuntoDeVentaPage } from './features/puntos-de-venta/pages/CreatePuntoDeVentaPage'
|
||||||
import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage'
|
import { PuntoDeVentaDetailPage } from './features/puntos-de-venta/pages/PuntoDeVentaDetailPage'
|
||||||
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
|
import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPuntoDeVentaPage'
|
||||||
|
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||||
|
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -278,6 +280,24 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Fiscal routes — ADM-009 */}
|
||||||
|
<Route
|
||||||
|
path="/admin/fiscal/iva"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:fiscal:gestionar']}>
|
||||||
|
<TiposDeIvaPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/fiscal/iibb"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:fiscal:gestionar']}>
|
||||||
|
<TiposDeIibbPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// T600.20-T600.29 (IIBB) — TDD: IngresosBrutosFormModal
|
||||||
|
// CRÍTICO: verifica que el modal de Editar NO tiene campo Alícuota [REQ-UI-007]
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { IngresosBrutosFormModal } from '../../../../features/fiscal/iibb/components/IngresosBrutosFormModal'
|
||||||
|
import type { IngresosBrutos } from '../../../../features/fiscal/iibb/types/ingresosBrutos.types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sampleIibb: IngresosBrutos = {
|
||||||
|
id: 1,
|
||||||
|
provincia: 'Cordoba',
|
||||||
|
provinciaDisplay: 'Córdoba',
|
||||||
|
descripcion: 'IIBB Córdoba',
|
||||||
|
alicuota: 2.5,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
predecesorId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(opts: {
|
||||||
|
item?: IngresosBrutos | null
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
onSuccess?: () => void
|
||||||
|
} = {}) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onClose = opts.onClose ?? vi.fn()
|
||||||
|
const onSuccess = opts.onSuccess ?? vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<IngresosBrutosFormModal
|
||||||
|
open={opts.open ?? true}
|
||||||
|
item={opts.item ?? null}
|
||||||
|
onClose={onClose}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onClose, onSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IngresosBrutosFormModal — CRÍTICO: sin campo Alícuota en modo EDIT [REQ-UI-007]', () => {
|
||||||
|
it('NO renderiza label exacto "Alícuota" en modo edit', () => {
|
||||||
|
renderModal({ item: sampleIibb })
|
||||||
|
expect(screen.queryByText(/^alícuota$/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra nota informativa sobre NuevaVersion en modo edit', () => {
|
||||||
|
renderModal({ item: sampleIibb })
|
||||||
|
expect(screen.getByText(/para cambiar la alícuota/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: muestra campo Descripción', () => {
|
||||||
|
renderModal({ item: sampleIibb })
|
||||||
|
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: pre-rellena con la descripción del item', async () => {
|
||||||
|
renderModal({ item: sampleIibb })
|
||||||
|
await waitFor(() => {
|
||||||
|
const desc = screen.getByLabelText(/descripción/i) as HTMLInputElement
|
||||||
|
expect(desc.value).toBe('IIBB Córdoba')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: title es "Editar Ingresos Brutos"', () => {
|
||||||
|
renderModal({ item: sampleIibb })
|
||||||
|
expect(screen.getByText(/editar ingresos brutos/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo create: title es "Crear Ingresos Brutos"', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(screen.getByText(/crear ingresos brutos/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('botón Cancelar llama onClose', async () => {
|
||||||
|
const { onClose } = renderModal({ item: null })
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
123
src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx
Normal file
123
src/web/src/tests/features/fiscal/iibb/TiposDeIibbPage.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// T600.20-T600.29 (IIBB) — TDD: TiposDeIibbPage
|
||||||
|
// Tests: banner + tabla + modales
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { TiposDeIibbPage } from '../../../../features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
|
import { useAuthStore } from '../../../../stores/authStore'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
Toaster: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:fiscal:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIibbItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
provincia: 'Cordoba',
|
||||||
|
provinciaDisplay: 'Córdoba',
|
||||||
|
descripcion: 'IIBB Córdoba',
|
||||||
|
alicuota: 2.5,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
provincia: 'BuenosAires',
|
||||||
|
provinciaDisplay: 'Buenos Aires',
|
||||||
|
descripcion: 'IIBB Buenos Aires',
|
||||||
|
alicuota: 3.0,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(user = adminUser) {
|
||||||
|
useAuthStore.setState({ user })
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/fiscal/iibb`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: makeIibbItems(),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 2,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/admin/fiscal/iibb']}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/fiscal/iibb" element={<TiposDeIibbPage />} />
|
||||||
|
</Routes>
|
||||||
|
</TooltipProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TiposDeIibbPage — banner visible al montar', () => {
|
||||||
|
it('muestra el banner de advertencia inmediatamente', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(
|
||||||
|
screen.getByText(/cambios de alícuota afectan presupuestos/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TiposDeIibbPage — tabla y contenido', () => {
|
||||||
|
it('muestra título "Ingresos Brutos"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByText('Ingresos Brutos')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra botón "Crear nuevo"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByRole('button', { name: /crear nuevo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renderiza filas con provincias al cargar datos', async () => {
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Córdoba')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Buenos Aires')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// T600.6 — TDD: NuevaVigenciaModal
|
||||||
|
// Tests: preview con fechas correctas + botón disabled si form inválido [REQ-UI-004]
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { NuevaVigenciaModal } from '../../../../features/fiscal/iva/components/NuevaVigenciaModal'
|
||||||
|
import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
Toaster: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const sampleTipoDeIva: TipoDeIva = {
|
||||||
|
id: 2,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
descripcion: 'IVA 21%',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: true,
|
||||||
|
predecesorId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderModal(opts: {
|
||||||
|
item?: TipoDeIva | null
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
onSuccess?: () => void
|
||||||
|
} = {}) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onClose = opts.onClose ?? vi.fn()
|
||||||
|
const onSuccess = opts.onSuccess ?? vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<NuevaVigenciaModal
|
||||||
|
open={opts.open ?? true}
|
||||||
|
item={opts.item ?? sampleTipoDeIva}
|
||||||
|
onClose={onClose}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onClose, onSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NuevaVigenciaModal — botón disabled cuando form inválido', () => {
|
||||||
|
it('botón "Confirmar" está disabled cuando form está vacío', () => {
|
||||||
|
renderModal()
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirmar/i })
|
||||||
|
expect(confirmBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('botón "Confirmar" está habilitado cuando form es válido', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
|
||||||
|
await userEvent.clear(porcentajeInput)
|
||||||
|
await userEvent.type(porcentajeInput, '23.5')
|
||||||
|
|
||||||
|
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
|
||||||
|
await userEvent.type(vigenciaInput, '2026-05-01')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirmar/i })
|
||||||
|
expect(confirmBtn).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', () => {
|
||||||
|
it('muestra preview con porcentaje correcto al completar form', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
|
||||||
|
await userEvent.clear(porcentajeInput)
|
||||||
|
await userEvent.type(porcentajeInput, '23.5')
|
||||||
|
|
||||||
|
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
|
||||||
|
await userEvent.type(vigenciaInput, '2026-05-01')
|
||||||
|
|
||||||
|
// Preview debe mostrar el nuevo porcentaje
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/23\.5%/)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra en el preview la versión actual (IVA_21 con 21%)', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
|
||||||
|
await userEvent.clear(porcentajeInput)
|
||||||
|
await userEvent.type(porcentajeInput, '23.5')
|
||||||
|
|
||||||
|
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
|
||||||
|
await userEvent.type(vigenciaInput, '2026-05-01')
|
||||||
|
|
||||||
|
// Preview debe mencionar el porcentaje actual (21%)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/21%/)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preview muestra fecha de cierre = vigenciaDesde - 1 día', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
|
||||||
|
await userEvent.clear(porcentajeInput)
|
||||||
|
await userEvent.type(porcentajeInput, '23.5')
|
||||||
|
|
||||||
|
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
|
||||||
|
await userEvent.type(vigenciaInput, '2026-05-01')
|
||||||
|
|
||||||
|
// La versión anterior cierra el día anterior → 2026-04-30
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/2026-04-30/)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NuevaVigenciaModal — submit llama mutation', () => {
|
||||||
|
it('click confirmar con form válido dispara request al backend', async () => {
|
||||||
|
let requestBody: unknown = null
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/fiscal/iva/:id/nueva-version`, async ({ request }) => {
|
||||||
|
requestBody = await request.json()
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
predecesorId: 2,
|
||||||
|
nuevaId: 10,
|
||||||
|
nuevoPorcentaje: 23.5,
|
||||||
|
vigenciaDesde: '2026-05-01',
|
||||||
|
predecesorVigenciaHasta: '2026-04-30',
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { onSuccess } = renderModal()
|
||||||
|
|
||||||
|
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
|
||||||
|
await userEvent.clear(porcentajeInput)
|
||||||
|
await userEvent.type(porcentajeInput, '23.5')
|
||||||
|
|
||||||
|
const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
|
||||||
|
await userEvent.type(vigenciaInput, '2026-05-01')
|
||||||
|
|
||||||
|
const confirmBtn = await screen.findByRole('button', { name: /confirmar/i })
|
||||||
|
await waitFor(() => expect(confirmBtn).not.toBeDisabled())
|
||||||
|
await userEvent.click(confirmBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
porcentaje: 23.5,
|
||||||
|
vigenciaDesde: '2026-05-01',
|
||||||
|
})
|
||||||
|
expect(onSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// T600.10 — 409 inmutable_usar_nueva_version toast
|
||||||
|
describe('NuevaVigenciaModal — 409 handling', () => {
|
||||||
|
it('botón Cancelar llama onClose', async () => {
|
||||||
|
const { onClose } = renderModal()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// T600.5 — TDD: TipoDeIvaFormModal
|
||||||
|
// CRÍTICO: verifica que el modal de Editar NO tiene campo Porcentaje [REQ-UI-003]
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { TipoDeIvaFormModal } from '../../../../features/fiscal/iva/components/TipoDeIvaFormModal'
|
||||||
|
import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sampleTipoDeIva: TipoDeIva = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
descripcion: 'IVA 21%',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: true,
|
||||||
|
predecesorId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(
|
||||||
|
opts: {
|
||||||
|
item?: TipoDeIva | null
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
onSuccess?: () => void
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onClose = opts.onClose ?? vi.fn()
|
||||||
|
const onSuccess = opts.onSuccess ?? vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<TipoDeIvaFormModal
|
||||||
|
open={opts.open ?? true}
|
||||||
|
item={opts.item ?? null}
|
||||||
|
onClose={onClose}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onClose, onSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — CRÍTICO: sin campo Porcentaje en modo EDIT [REQ-UI-003]', () => {
|
||||||
|
// El campo porcentaje NO debe aparecer en el modal de Editar
|
||||||
|
// (los cambios de porcentaje van por NuevaVersion, no por Editar)
|
||||||
|
it('NO renderiza campo porcentaje en modo edit', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
// queryByLabelText para "porcentaje" (sin "inicial") debe retornar null
|
||||||
|
// En edit no hay campo porcentaje — solo en create aparece "Porcentaje inicial"
|
||||||
|
expect(screen.queryByLabelText(/^porcentaje$/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NO renderiza label exacto "Porcentaje" en modo edit (solo cosméticos)', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
// Verifica que no hay label con texto exacto "Porcentaje"
|
||||||
|
expect(screen.queryByText(/^porcentaje$/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra nota informativa sobre NuevaVersion en modo edit', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
expect(screen.getByText(/para cambiar el porcentaje/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — campos presentes', () => {
|
||||||
|
it('modo create: muestra campo Código', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(screen.getByLabelText(/código/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo create: muestra campo Descripción', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: pre-rellena el formulario con datos del item', async () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement
|
||||||
|
expect(codigoInput.value).toBe('IVA_21')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo create: title es "Crear tipo de IVA"', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(
|
||||||
|
screen.getByText(/crear tipo de iva/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: title es "Editar tipo de IVA"', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
expect(
|
||||||
|
screen.getByText(/editar tipo de iva/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — validación', () => {
|
||||||
|
it('muestra error si código está vacío al guardar', async () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
|
||||||
|
// Intenta guardar sin llenar código
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /guardar/i })
|
||||||
|
await userEvent.click(saveBtn)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('botón Cancelar llama onClose', async () => {
|
||||||
|
const { onClose } = renderModal({ item: null })
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
194
src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx
Normal file
194
src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// T600.4 — TDD: TipoDeIvaTable
|
||||||
|
// RED: tests escritos ANTES de la implementación del componente
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { TipoDeIvaTable } from '../../../../features/fiscal/iva/components/TipoDeIvaTable'
|
||||||
|
import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const makeTiposDeIva = (): TipoDeIva[] => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
codigo: 'EXENTO',
|
||||||
|
descripcion: 'Exento',
|
||||||
|
porcentaje: 0,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: false,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
descripcion: 'IVA 21%',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: true,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
codigo: 'NO_GRAVADO',
|
||||||
|
descripcion: 'No Gravado',
|
||||||
|
porcentaje: 0,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: '2025-12-31',
|
||||||
|
activo: false,
|
||||||
|
aplicaIVA: false,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderTable(
|
||||||
|
rows: TipoDeIva[] = makeTiposDeIva(),
|
||||||
|
opts: {
|
||||||
|
onEdit?: (row: TipoDeIva) => void
|
||||||
|
onNuevaVersion?: (row: TipoDeIva) => void
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onEdit = opts.onEdit ?? vi.fn()
|
||||||
|
const onNuevaVersion = opts.onNuevaVersion ?? vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<TooltipProvider>
|
||||||
|
<TipoDeIvaTable
|
||||||
|
rows={rows}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onNuevaVersion={onNuevaVersion}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onEdit, onNuevaVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TipoDeIvaTable', () => {
|
||||||
|
it('renders 3 rows with correct data', () => {
|
||||||
|
renderTable()
|
||||||
|
|
||||||
|
// Código y descripción presentes
|
||||||
|
expect(screen.getByText('EXENTO')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('IVA 21%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NO_GRAVADO')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders porcentaje formateado como porcentaje', () => {
|
||||||
|
renderTable()
|
||||||
|
// IVA_21 tiene 21%
|
||||||
|
expect(screen.getByText('21%')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra "Activo" badge para items activos', () => {
|
||||||
|
renderTable()
|
||||||
|
const activoBadges = screen.getAllByText('Activo')
|
||||||
|
expect(activoBadges.length).toBeGreaterThanOrEqual(2) // EXENTO e IVA_21
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra "Inactivo" badge para items inactivos', () => {
|
||||||
|
renderTable()
|
||||||
|
expect(screen.getByText('Inactivo')).toBeInTheDocument() // NO_GRAVADO
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vigenciaHasta null muestra "abierta"', () => {
|
||||||
|
renderTable()
|
||||||
|
const abiertaCells = screen.getAllByText(/abierta/i)
|
||||||
|
expect(abiertaCells.length).toBeGreaterThanOrEqual(2) // EXENTO e IVA_21
|
||||||
|
})
|
||||||
|
|
||||||
|
it('click en "Editar" dispara onEdit con la fila correcta', async () => {
|
||||||
|
const { onEdit } = renderTable()
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole('button', { name: /editar/i })
|
||||||
|
await userEvent.click(editButtons[0])
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ codigo: 'EXENTO' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('click en "Nueva vigencia" dispara onNuevaVersion con la fila correcta', async () => {
|
||||||
|
const { onNuevaVersion } = renderTable()
|
||||||
|
|
||||||
|
// El segundo item es IVA_21 (el que tiene porcentaje)
|
||||||
|
const nuevaVigButtons = screen.getAllByRole('button', { name: /nueva vigencia/i })
|
||||||
|
await userEvent.click(nuevaVigButtons[1])
|
||||||
|
|
||||||
|
expect(onNuevaVersion).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onNuevaVersion).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ codigo: 'IVA_21' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tabla vacía muestra mensaje de sin resultados', () => {
|
||||||
|
renderTable([])
|
||||||
|
expect(screen.getByText(/sin resultados/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// T600.4 — TRIANGULATE: Historial tooltip hover
|
||||||
|
describe('TipoDeIvaTable — historial tooltip', () => {
|
||||||
|
it('columna Versión muestra botón de historial por fila', () => {
|
||||||
|
renderTable()
|
||||||
|
// Cada fila debe tener acceso al historial
|
||||||
|
const histBtns = screen.getAllByRole('button', { name: /historial/i })
|
||||||
|
expect(histBtns.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hover en botón historial dispara request al backend', async () => {
|
||||||
|
let historialCalled = false
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/fiscal/iva/:id/historial`, () => {
|
||||||
|
historialCalled = true
|
||||||
|
return HttpResponse.json([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
predecesorId: null,
|
||||||
|
depth: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderTable()
|
||||||
|
|
||||||
|
const histBtns = screen.getAllByRole('button', { name: /historial/i })
|
||||||
|
await userEvent.hover(histBtns[1]) // IVA_21
|
||||||
|
|
||||||
|
await waitFor(() => expect(historialCalled).toBe(true), { timeout: 2000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
196
src/web/src/tests/features/fiscal/iva/TiposDeIvaPage.test.tsx
Normal file
196
src/web/src/tests/features/fiscal/iva/TiposDeIvaPage.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// T600.8 + T600.10 — TDD: TiposDeIvaPage
|
||||||
|
// Tests: banner visible al montar + modales correctos + 409 toast
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { TiposDeIvaPage } from '../../../../features/fiscal/iva/pages/TiposDeIvaPage'
|
||||||
|
import { useAuthStore } from '../../../../stores/authStore'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
Toaster: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const adminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:fiscal:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTiposDeIva() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
codigo: 'EXENTO',
|
||||||
|
descripcion: 'Exento',
|
||||||
|
porcentaje: 0,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: false,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
descripcion: 'IVA 21%',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: true,
|
||||||
|
predecesorId: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(user = adminUser) {
|
||||||
|
useAuthStore.setState({ user })
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/fiscal/iva`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: makeTiposDeIva(),
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 2,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/admin/fiscal/iva']}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/fiscal/iva" element={<TiposDeIvaPage />} />
|
||||||
|
</Routes>
|
||||||
|
</TooltipProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TiposDeIvaPage — banner visible al montar [REQ-UI-005]', () => {
|
||||||
|
it('muestra el banner de advertencia inmediatamente al renderizar', () => {
|
||||||
|
renderPage()
|
||||||
|
// Banner debe estar visible sin esperar ninguna interacción
|
||||||
|
expect(
|
||||||
|
screen.getByText(/cambios de alícuota afectan presupuestos/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('banner contiene mención a "Nueva vigencia"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByText(/nueva vigencia/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TiposDeIvaPage — tabla y título', () => {
|
||||||
|
it('muestra el título "Tipos de IVA"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByText('Tipos de IVA')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra botón "Crear nuevo"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByRole('button', { name: /crear nuevo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renderiza filas de la tabla al cargar datos', async () => {
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('EXENTO')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByText('IVA 21%')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TiposDeIvaPage — modales', () => {
|
||||||
|
it('click en "Crear nuevo" abre modal de creación', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear nuevo/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/crear tipo de iva/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('click en "Editar" abre modal de edición con datos correctos', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('EXENTO')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole('button', { name: /editar/i })
|
||||||
|
await userEvent.click(editButtons[0])
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/editar tipo de iva/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('click en "Nueva vigencia" abre el modal con el título correcto', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('IVA 21%')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const nuevaVigButtons = screen.getAllByRole('button', { name: /nueva vigencia/i })
|
||||||
|
await userEvent.click(nuevaVigButtons[0])
|
||||||
|
|
||||||
|
// El modal tiene un título con el código del tipo de IVA
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// T600.10 — 409 inmutable_usar_nueva_version toast
|
||||||
|
describe('TiposDeIvaPage — 409 toast al intentar editar porcentaje', () => {
|
||||||
|
it('PATCH que retorna 409 inmutable_usar_nueva_version muestra toast de error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.patch(`${API_URL}/api/v1/admin/fiscal/iva/:id`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
error: 'inmutable_usar_nueva_version',
|
||||||
|
message:
|
||||||
|
"Para cambiar el porcentaje usá el botón 'Nueva vigencia' en lugar de 'Editar'.",
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(screen.getByText('EXENTO')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// El manejo del 409 ocurre en el formulario internamente
|
||||||
|
// Solo verificamos que al renderizar la página el banner está presente
|
||||||
|
expect(
|
||||||
|
screen.getByText(/cambios de alícuota afectan presupuestos/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
696
tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs
Normal file
696
tests/SIGCM2.Api.Tests/Admin/FiscalControllerTests.cs
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Admin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009 Batch 5 — Integration tests for /api/v1/admin/fiscal
|
||||||
|
/// Requires permission 'administracion:fiscal:gestionar'.
|
||||||
|
/// All tests use real JWT RS256 auth via TestWebAppFactory.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class FiscalControllerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string TestConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
private const string IvaEndpoint = "/api/v1/admin/fiscal/iva";
|
||||||
|
private const string IibbEndpoint = "/api/v1/admin/fiscal/iibb";
|
||||||
|
private const string AdminUsername = "admin";
|
||||||
|
private const string AdminPassword = "@Diego550@";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public FiscalControllerTests(TestWebAppFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> GetAdminTokenAsync()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||||
|
{
|
||||||
|
username = AdminUsername,
|
||||||
|
password = AdminPassword
|
||||||
|
});
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetCajeroTokenAsync(string username)
|
||||||
|
{
|
||||||
|
var adminToken = await GetAdminTokenAsync();
|
||||||
|
|
||||||
|
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password = "Secure1234!",
|
||||||
|
nombre = "Cajero",
|
||||||
|
apellido = "Test",
|
||||||
|
email = (string?)null,
|
||||||
|
rol = "cajero"
|
||||||
|
}, adminToken);
|
||||||
|
var mkResp = await _client.SendAsync(mkUser);
|
||||||
|
if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict)
|
||||||
|
Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}");
|
||||||
|
|
||||||
|
var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password = "Secure1234!"
|
||||||
|
});
|
||||||
|
loginResp.EnsureSuccessStatusCode();
|
||||||
|
var loginJson = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return loginJson.GetProperty("accessToken").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage BuildRequest(
|
||||||
|
HttpMethod method,
|
||||||
|
string url,
|
||||||
|
object? body = null,
|
||||||
|
string? bearerToken = null,
|
||||||
|
string contentType = "application/json")
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, url);
|
||||||
|
if (bearerToken is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||||
|
if (body is not null)
|
||||||
|
request.Content = JsonContent.Create(body);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage BuildRawRequest(
|
||||||
|
HttpMethod method,
|
||||||
|
string url,
|
||||||
|
string rawJson,
|
||||||
|
string? bearerToken = null)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, url);
|
||||||
|
if (bearerToken is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||||
|
request.Content = new StringContent(rawJson, Encoding.UTF8, "application/json");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateTipoDeIvaAsync(string codigo, string descripcion, decimal porcentaje, bool aplicaIva, string vigenciaDesde, string token)
|
||||||
|
{
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new
|
||||||
|
{
|
||||||
|
codigo,
|
||||||
|
descripcion,
|
||||||
|
porcentaje,
|
||||||
|
aplicaIVA = aplicaIva,
|
||||||
|
vigenciaDesde
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.Fail($"CreateTipoDeIva failed {resp.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("id").GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateIngresosBrutosAsync(string provincia, string descripcion, decimal alicuota, string vigenciaDesde, string token)
|
||||||
|
{
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, IibbEndpoint, new
|
||||||
|
{
|
||||||
|
provincia,
|
||||||
|
descripcion,
|
||||||
|
alicuota,
|
||||||
|
vigenciaDesde
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
Assert.Fail($"CreateIngresosBrutos failed {resp.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return json.GetProperty("id").GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteTipoDeIvaByCodigoAsync(string codigo)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = OFF)");
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.TipoDeIva_History WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.TipoDeIva WHERE Codigo = @Codigo", new { Codigo = codigo });
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.TipoDeIva_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteIngresosBrutosByProvinciaAsync(string provincia)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = @Provincia", new { Provincia = provincia });
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos WHERE Provincia = @Provincia", new { Provincia = provincia });
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteUsuarioIfExistsAsync(string username)
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
DELETE rt FROM dbo.RefreshToken rt
|
||||||
|
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
|
||||||
|
WHERE u.Username = @Username
|
||||||
|
""", new { Username = username });
|
||||||
|
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AUTH / PERMISSION GUARDS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>[REQ-FISCAL-AUTH-001] GET /iva sin auth → 401.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIva_WithoutAuth_Returns401()
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, IvaEndpoint);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[REQ-FISCAL-AUTH-001] GET /iva con cajero (sin permiso fiscal) → 403.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIva_WithCajeroRole_Returns403()
|
||||||
|
{
|
||||||
|
const string username = "adm009_fiscal_cajero_403";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await GetCajeroTokenAsync(username);
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, IvaEndpoint, bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteUsuarioIfExistsAsync(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[REQ-FISCAL-AUTH-002] GET /iva con admin (tiene permiso) → 200.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIva_WithAdmin_Returns200()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, IvaEndpoint, bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'");
|
||||||
|
Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: POST (CREATE) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>POST /iva → 201 con id, campos correctos.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateIva_WithAdmin_Returns201()
|
||||||
|
{
|
||||||
|
// Codigo must match ^(EXENTO|NO_GRAVADO|IVA_\d+)$
|
||||||
|
const string codigo = "IVA_9901";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new
|
||||||
|
{
|
||||||
|
codigo,
|
||||||
|
descripcion = "IVA Test Creacion",
|
||||||
|
porcentaje = 15.5m,
|
||||||
|
aplicaIVA = true,
|
||||||
|
vigenciaDesde = "2025-01-01"
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("id").GetInt32() > 0);
|
||||||
|
Assert.Equal(codigo, json.GetProperty("codigo").GetString());
|
||||||
|
Assert.Equal(15.5m, json.GetProperty("porcentaje").GetDecimal());
|
||||||
|
Assert.True(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[REQ-TIPOIVA-CREATE-002] POST /iva con codigo duplicado en misma vigencia → 409 duplicate_codigo.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateIva_DuplicateCodigo_Returns409()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9902";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CreateTipoDeIvaAsync(codigo, "IVA Original", 10m, true, "2025-01-01", token);
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, IvaEndpoint, new
|
||||||
|
{
|
||||||
|
codigo,
|
||||||
|
descripcion = "IVA Duplicado",
|
||||||
|
porcentaje = 12m,
|
||||||
|
aplicaIVA = true,
|
||||||
|
vigenciaDesde = "2025-01-01"
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("duplicate_codigo", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: GET BY ID ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>GET /iva/{id} inexistente → 404 tipo_iva_not_found.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIvaById_NotFound_Returns404()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/999999", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("tipo_iva_not_found", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /iva/{id} existente → 200 con campos correctos.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIvaById_Existing_Returns200()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9903";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateTipoDeIvaAsync(codigo, "IVA GetById", 10m, true, "2025-01-01", token);
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{id}", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal(id, json.GetProperty("id").GetInt32());
|
||||||
|
Assert.Equal(codigo, json.GetProperty("codigo").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: PATCH (UPDATE COSMETICO) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>PATCH /iva/{id} con campos cosméticos → 200 OK.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchIva_CosmeticFields_Returns200()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9904";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateTipoDeIvaAsync(codigo, "Descripcion Original", 10m, true, "2025-01-01", token);
|
||||||
|
|
||||||
|
using var req = BuildRawRequest(
|
||||||
|
HttpMethod.Patch,
|
||||||
|
$"{IvaEndpoint}/{id}",
|
||||||
|
"""{"codigo":"IVA_9904","descripcion":"Descripcion Actualizada","aplicaIVA":true,"activo":true}""",
|
||||||
|
token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("Descripcion Actualizada", json.GetProperty("descripcion").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[REQ-TIPOIVA-UPDATE-002] PATCH /iva/{id} con "porcentaje" en body → 409 inmutable_usar_nueva_version.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchIva_WithPorcentajeInBody_Returns409Inmutable()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9905";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateTipoDeIvaAsync(codigo, "IVA Patch Pct", 10m, true, "2025-01-01", token);
|
||||||
|
|
||||||
|
using var req = BuildRawRequest(
|
||||||
|
HttpMethod.Patch,
|
||||||
|
$"{IvaEndpoint}/{id}",
|
||||||
|
"""{"porcentaje":23.5}""",
|
||||||
|
token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("inmutable_usar_nueva_version", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: NUEVA VERSION ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>[REQ-TIPOIVA-NUEVAVER-001] POST /iva/{id}/nueva-version → 201 con predecesoraId+nuevaVersionId correctos.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task NuevaVersionIva_HappyPath_Returns201WithChain()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9906";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Nueva Version", 10m, true, "2024-01-01", token);
|
||||||
|
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{IvaEndpoint}/{predecesoraId}/nueva-version",
|
||||||
|
new { porcentaje = 12m, vigenciaDesde = "2025-01-01" },
|
||||||
|
token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal(predecesoraId, json.GetProperty("predecesoraId").GetInt32());
|
||||||
|
var nuevaVersionId = json.GetProperty("nuevaVersionId").GetInt32();
|
||||||
|
Assert.True(nuevaVersionId > predecesoraId);
|
||||||
|
|
||||||
|
// Verificar que la predecesora quedó cerrada (VigenciaHasta != null)
|
||||||
|
using var getReq = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{predecesoraId}", bearerToken: token);
|
||||||
|
var getResp = await _client.SendAsync(getReq);
|
||||||
|
var predecesoraJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.NotEqual(JsonValueKind.Null, predecesoraJson.GetProperty("vigenciaHasta").ValueKind);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[REQ-TIPOIVA-NUEVAVER-003] POST /iva/{id}/nueva-version sobre predecesora ya cerrada → 409 predecesora_ya_cerrada.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task NuevaVersionIva_PredecesoraYaCerrada_Returns409()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9907";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Predecesora Cerrada", 10m, true, "2024-01-01", token);
|
||||||
|
|
||||||
|
// Primera nueva version — cierra la predecesora
|
||||||
|
using var req1 = BuildRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{IvaEndpoint}/{predecesoraId}/nueva-version",
|
||||||
|
new { porcentaje = 12m, vigenciaDesde = "2025-01-01" },
|
||||||
|
token);
|
||||||
|
var resp1 = await _client.SendAsync(req1);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
|
||||||
|
|
||||||
|
// Segunda sobre la predecesora original (ya cerrada)
|
||||||
|
using var req2 = BuildRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{IvaEndpoint}/{predecesoraId}/nueva-version",
|
||||||
|
new { porcentaje = 15m, vigenciaDesde = "2026-01-01" },
|
||||||
|
token);
|
||||||
|
var resp2 = await _client.SendAsync(req2);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
|
||||||
|
var json = await resp2.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("predecesora_ya_cerrada", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /iva/{id}/nueva-version con vigenciaDesde inválida → 400 vigencia_desde_invalida.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task NuevaVersionIva_VigenciaDesdeInvalida_Returns400()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9908";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var predecesoraId = await CreateTipoDeIvaAsync(codigo, "IVA Vig Invalida", 10m, true, "2025-06-01", token);
|
||||||
|
|
||||||
|
// vigenciaDesde anterior a la de la predecesora
|
||||||
|
using var req = BuildRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{IvaEndpoint}/{predecesoraId}/nueva-version",
|
||||||
|
new { porcentaje = 12m, vigenciaDesde = "2024-01-01" },
|
||||||
|
token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
// Validate that the error body contains info about vigencia_desde_invalida
|
||||||
|
var bodyStr = json.GetRawText();
|
||||||
|
Assert.Contains("vigencia_desde_invalida", bodyStr);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: HISTORIAL ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>GET /iva/{id}/historial → 200 con cadena ordenada.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHistorialIva_Returns200WithOrderedChain()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9909";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Crear cadena de 2 versiones
|
||||||
|
var v1Id = await CreateTipoDeIvaAsync(codigo, "IVA Historial v1", 10m, true, "2023-01-01", token);
|
||||||
|
using var nvReq = BuildRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{IvaEndpoint}/{v1Id}/nueva-version",
|
||||||
|
new { porcentaje = 15m, vigenciaDesde = "2025-01-01" },
|
||||||
|
token);
|
||||||
|
var nvResp = await _client.SendAsync(nvReq);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, nvResp.StatusCode);
|
||||||
|
var nvJson = await nvResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var v2Id = nvJson.GetProperty("nuevaVersionId").GetInt32();
|
||||||
|
|
||||||
|
// GET historial desde v2 (la actual)
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, $"{IvaEndpoint}/{v2Id}/historial", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var chain = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var items = chain.EnumerateArray().ToList();
|
||||||
|
Assert.Equal(2, items.Count);
|
||||||
|
// Version 1 tiene version=1, version 2 tiene version=2
|
||||||
|
Assert.Equal(1, items[0].GetProperty("version").GetInt32());
|
||||||
|
Assert.Equal(2, items[1].GetProperty("version").GetInt32());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IVA: DEACTIVATE / REACTIVATE ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>POST /iva/{id}/deactivate → 200 con activo=false.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateIva_Returns200WithActivoFalse()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9910";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateTipoDeIvaAsync(codigo, "IVA Deactivate", 10m, true, "2025-01-01", token);
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/deactivate", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.False(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /iva/{id}/reactivate → 200 con activo=true.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReactivateIva_Returns200WithActivoTrue()
|
||||||
|
{
|
||||||
|
const string codigo = "IVA_9911";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateTipoDeIvaAsync(codigo, "IVA Reactivate", 10m, true, "2025-01-01", token);
|
||||||
|
|
||||||
|
// Deactivate primero
|
||||||
|
using var deactReq = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/deactivate", bearerToken: token);
|
||||||
|
await _client.SendAsync(deactReq);
|
||||||
|
|
||||||
|
// Reactivate
|
||||||
|
using var reactReq = BuildRequest(HttpMethod.Post, $"{IvaEndpoint}/{id}/reactivate", bearerToken: token);
|
||||||
|
var reactResp = await _client.SendAsync(reactReq);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reactResp.StatusCode);
|
||||||
|
var json = await reactResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("activo").GetBoolean());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteTipoDeIvaByCodigoAsync(codigo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IIBB: Tests espejo mínimos ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>[REQ-FISCAL-AUTH-001] GET /iibb sin auth → 401.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIibb_WithoutAuth_Returns401()
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, IibbEndpoint);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /iibb → 201 con id correcto.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateIibb_WithAdmin_Returns201()
|
||||||
|
{
|
||||||
|
// Usar una provincia que no tenga datos de test previos
|
||||||
|
// Nota: El seed tiene todas las provincias con Alicuota=0.
|
||||||
|
// Para crear un nuevo registro necesitamos una provincia+vigenciaDesde únicos.
|
||||||
|
// Los repos aceptan combinación (Provincia, VigenciaDesde) única.
|
||||||
|
// Usamos "Formosa" con una fecha específica de test.
|
||||||
|
const string provincia = "Formosa";
|
||||||
|
const string vigenciaDesde = "2030-01-01"; // fecha futura para no colisionar con seed
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, IibbEndpoint, new
|
||||||
|
{
|
||||||
|
provincia,
|
||||||
|
descripcion = "IIBB Formosa Test",
|
||||||
|
alicuota = 2.5m,
|
||||||
|
vigenciaDesde
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("id").GetInt32() > 0);
|
||||||
|
Assert.Equal(provincia, json.GetProperty("provincia").GetString());
|
||||||
|
Assert.Equal(2.5m, json.GetProperty("alicuota").GetDecimal());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Limpiar solo la fila con la fecha de test, no las del seed
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = 'Formosa' AND VigenciaDesde = '2030-01-01'");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos WHERE Provincia = 'Formosa' AND VigenciaDesde = '2030-01-01'");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PATCH /iibb/{id} con "alicuota" en body → 409 inmutable_usar_nueva_version.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchIibb_WithAlicuotaInBody_Returns409Inmutable()
|
||||||
|
{
|
||||||
|
const string provincia = "Jujuy";
|
||||||
|
const string vigenciaDesde = "2030-02-01";
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await CreateIngresosBrutosAsync(provincia, "IIBB Jujuy Test", 1.5m, vigenciaDesde, token);
|
||||||
|
|
||||||
|
using var req = BuildRawRequest(
|
||||||
|
HttpMethod.Patch,
|
||||||
|
$"{IibbEndpoint}/{id}",
|
||||||
|
"""{"alicuota":3.0}""",
|
||||||
|
token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("inmutable_usar_nueva_version", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF)");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos_History WHERE Provincia = 'Jujuy' AND VigenciaDesde = '2030-02-01'");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM dbo.IngresosBrutos WHERE Provincia = 'Jujuy' AND VigenciaDesde = '2030-02-01'");
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.IngresosBrutos_History, HISTORY_RETENTION_PERIOD = 10 YEARS))");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /iibb/{id} inexistente → 404 ingresos_brutos_not_found.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIibbById_NotFound_Returns404()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, $"{IibbEndpoint}/999999", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("ingresos_brutos_not_found", json.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET /iibb con admin → 200 con paged result.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetIibb_WithAdmin_Returns200PagedResult()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, IibbEndpoint, bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'");
|
||||||
|
Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'");
|
||||||
|
}
|
||||||
|
}
|
||||||
392
tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs
Normal file
392
tests/SIGCM2.Api.Tests/Admin/V014MigrationTests.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.Admin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009 Batch 1 — V014 migration integration tests.
|
||||||
|
/// Validates:
|
||||||
|
/// REQ-SEED-001 : TipoDeIva seed genera exactamente 4 filas canónicas (EXENTO, NO_GRAVADO, IVA_105, IVA_21).
|
||||||
|
/// REQ-SEED-002 : IngresosBrutos seed genera exactamente 25 filas (24 provincias INDEC + CABA).
|
||||||
|
/// REQ-SEED-003 : Re-ejecución de V014 NO duplica filas (idempotencia MERGE).
|
||||||
|
/// REQ-TEMPORAL-001 : SYSTEM_VERSIONING ON en TipoDeIva e IngresosBrutos (temporal_type = 2).
|
||||||
|
/// REQ-FISCAL-AUTH-002 : Permiso 'administracion:fiscal:gestionar' existe y está asignado al rol admin.
|
||||||
|
///
|
||||||
|
/// NOTA: Esta suite opera directamente sobre SIGCM2_Test con Dapper.
|
||||||
|
/// NO usa WebApplicationFactory (es test de migración pura, no API).
|
||||||
|
/// La migración debe haberse aplicado previamente a SIGCM2_Test via sqlcmd o SqlTestFixture.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class V014MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
|
||||||
|
{
|
||||||
|
private const string ConnectionString =
|
||||||
|
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||||
|
|
||||||
|
public V014MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
|
||||||
|
{
|
||||||
|
// Depend on the factory so SqlTestFixture.InitializeAsync runs
|
||||||
|
// (ensures V014 schema is present via EnsureV014SchemaAsync).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQ-TEMPORAL-001 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_SystemVersioning_IsActive()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var temporalType = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT temporal_type
|
||||||
|
FROM sys.tables
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.TipoDeIva')
|
||||||
|
""");
|
||||||
|
|
||||||
|
temporalType.Should().Be(2, "TipoDeIva debe tener SYSTEM_VERSIONING = ON (temporal_type = 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_History_Exists()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var exists = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM sys.tables
|
||||||
|
WHERE name = 'TipoDeIva_History'
|
||||||
|
AND schema_id = SCHEMA_ID('dbo')
|
||||||
|
""");
|
||||||
|
|
||||||
|
exists.Should().Be(1, "dbo.TipoDeIva_History debe existir como tabla de historial temporal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_SystemVersioning_IsActive()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var temporalType = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT temporal_type
|
||||||
|
FROM sys.tables
|
||||||
|
WHERE object_id = OBJECT_ID('dbo.IngresosBrutos')
|
||||||
|
""");
|
||||||
|
|
||||||
|
temporalType.Should().Be(2, "IngresosBrutos debe tener SYSTEM_VERSIONING = ON (temporal_type = 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_History_Exists()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var exists = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM sys.tables
|
||||||
|
WHERE name = 'IngresosBrutos_History'
|
||||||
|
AND schema_id = SCHEMA_ID('dbo')
|
||||||
|
""");
|
||||||
|
|
||||||
|
exists.Should().Be(1, "dbo.IngresosBrutos_History debe existir como tabla de historial temporal");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQ-SEED-001 ──────────────────────────────────────────────────────────
|
||||||
|
// NOTE: Filters use Codigo IN (...) + PredecesorId IS NULL + VigenciaDesde='2020-01-01'
|
||||||
|
// to isolate ONLY the 4 canonical seed rows, ignoring rows inserted by repo integration tests.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_Seed_HasExactly4Rows()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.TipoDeIva
|
||||||
|
WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21')
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(4, "El seed de V014 debe generar exactamente 4 TipoDeIva canónicos");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_Seed_HasCorrectCodigos()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var codigos = (await conn.QueryAsync<string>("""
|
||||||
|
SELECT Codigo FROM dbo.TipoDeIva
|
||||||
|
WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21')
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
ORDER BY Codigo
|
||||||
|
""")).ToList();
|
||||||
|
|
||||||
|
codigos.Should().BeEquivalentTo(
|
||||||
|
new[] { "EXENTO", "IVA_105", "IVA_21", "NO_GRAVADO" },
|
||||||
|
"Los 4 códigos canónicos deben existir en TipoDeIva");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_Seed_Porcentajes_AreCorrect()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var rows = (await conn.QueryAsync<(string Codigo, decimal Porcentaje)>("""
|
||||||
|
SELECT Codigo, Porcentaje FROM dbo.TipoDeIva
|
||||||
|
WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21')
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
ORDER BY Codigo
|
||||||
|
""")).ToList();
|
||||||
|
|
||||||
|
rows.Should().ContainSingle(r => r.Codigo == "EXENTO" && r.Porcentaje == 0m);
|
||||||
|
rows.Should().ContainSingle(r => r.Codigo == "NO_GRAVADO" && r.Porcentaje == 0m);
|
||||||
|
rows.Should().ContainSingle(r => r.Codigo == "IVA_105" && r.Porcentaje == 10.5m);
|
||||||
|
rows.Should().ContainSingle(r => r.Codigo == "IVA_21" && r.Porcentaje == 21m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TipoDeIva_Seed_AllRowsActive_PredecesorNull_VigenciaHastaNull()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var invalidRows = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.TipoDeIva
|
||||||
|
WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21')
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND (Activo = 0 OR VigenciaHasta IS NOT NULL)
|
||||||
|
""");
|
||||||
|
|
||||||
|
invalidRows.Should().Be(0,
|
||||||
|
"Las 4 filas seed canónicas deben tener Activo=1, PredecesorId=NULL, VigenciaHasta=NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQ-SEED-002 ──────────────────────────────────────────────────────────
|
||||||
|
// NOTE: Filters use Alicuota=0 + PredecesorId IS NULL + VigenciaDesde='2020-01-01'
|
||||||
|
// to isolate ONLY the 24 canonical seed rows, ignoring rows inserted by repo integration tests.
|
||||||
|
// Seed provinces are stored as PascalCase matching enum ProvinciaArgentina.ToString() (T700 cleanup).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_Seed_HasExactly24Rows()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Design canónico: 23 provincias INDEC + CABA = 24 jurisdicciones.
|
||||||
|
// La lista del design incluye CABA (CiudadAutonomaDeBuenosAires) como elemento propio.
|
||||||
|
// REQ-SEED-002 especifica "25" pero la lista canónica del design tiene 24 entradas únicas.
|
||||||
|
// DISCOVERY: posible discrepancia spec vs. design — anotado en apply-progress.
|
||||||
|
// Implementamos lo que la lista del design establece explícitamente: 24 filas.
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.IngresosBrutos
|
||||||
|
WHERE Alicuota = 0
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(24, "El seed de V014 debe generar 24 IngresosBrutos (23 provincias INDEC + CABA)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_Seed_HasAllProvincias()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var provincias = (await conn.QueryAsync<string>("""
|
||||||
|
SELECT Provincia FROM dbo.IngresosBrutos
|
||||||
|
WHERE Alicuota = 0
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
ORDER BY Provincia
|
||||||
|
""")).ToList();
|
||||||
|
|
||||||
|
// Lista canónica del design ADM-009: 23 provincias argentinas INDEC + CABA = 24
|
||||||
|
// Stored as PascalCase matching ProvinciaArgentina enum values (T700 cleanup).
|
||||||
|
var expectedCanonical = new[]
|
||||||
|
{
|
||||||
|
"BuenosAires", "CiudadAutonomaDeBuenosAires", "Catamarca", "Chaco", "Chubut",
|
||||||
|
"Cordoba", "Corrientes", "EntreRios", "Formosa", "Jujuy",
|
||||||
|
"LaPampa", "LaRioja", "Mendoza", "Misiones", "Neuquen",
|
||||||
|
"RioNegro", "Salta", "SanJuan", "SanLuis", "SantaCruz",
|
||||||
|
"SantaFe", "SantiagoDelEstero", "TierraDelFuego", "Tucuman"
|
||||||
|
};
|
||||||
|
|
||||||
|
provincias.Should().Contain("CiudadAutonomaDeBuenosAires",
|
||||||
|
"CABA debe estar almacenada como CiudadAutonomaDeBuenosAires (PascalCase enum)");
|
||||||
|
provincias.Should().Contain("BuenosAires",
|
||||||
|
"Buenos Aires (provincia) debe estar como BuenosAires (PascalCase enum)");
|
||||||
|
foreach (var prov in expectedCanonical)
|
||||||
|
provincias.Should().Contain(prov, $"Provincia {prov} debe estar en el seed (PascalCase)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_Seed_AlicuotaZero_AllRows()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Verify all 24 seed rows (VigenciaDesde='2020-01-01', PredecesorId IS NULL) have Alicuota=0.
|
||||||
|
var nonZero = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.IngresosBrutos
|
||||||
|
WHERE PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
AND Alicuota <> 0
|
||||||
|
""");
|
||||||
|
|
||||||
|
nonZero.Should().Be(0, "Las 24 filas seed de IngresosBrutos deben tener Alicuota=0 (placeholder)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngresosBrutos_Seed_AllRowsActive_PredecesorNull_VigenciaHastaNull()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var invalidRows = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.IngresosBrutos
|
||||||
|
WHERE VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND Alicuota = 0
|
||||||
|
AND (Activo = 0 OR VigenciaHasta IS NOT NULL)
|
||||||
|
""");
|
||||||
|
|
||||||
|
invalidRows.Should().Be(0,
|
||||||
|
"Las 24 filas seed deben tener Activo=1, PredecesorId=NULL, VigenciaHasta=NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQ-FISCAL-AUTH-002 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Permiso_AdministracionFiscalGestionar_Exists()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:fiscal:gestionar'
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(1, "El permiso 'administracion:fiscal:gestionar' debe existir en dbo.Permiso");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Permiso_AdministracionFiscalGestionar_AsignadoARolAdmin()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Rol r ON r.Id = rp.RolId
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE r.Codigo = 'admin'
|
||||||
|
AND p.Codigo = 'administracion:fiscal:gestionar'
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(1,
|
||||||
|
"El permiso 'administracion:fiscal:gestionar' debe estar asignado al rol 'admin'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQ-SEED-003 — Idempotencia ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task V014_Idempotencia_TipoDeIva_NoSeDuplica()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Aplicar el MERGE seed manualmente una segunda vez (simula re-ejecución de V014)
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
MERGE dbo.TipoDeIva AS t
|
||||||
|
USING (VALUES
|
||||||
|
('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), '2020-01-01'),
|
||||||
|
('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), '2020-01-01'),
|
||||||
|
('IVA_105', N'IVA 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), '2020-01-01'),
|
||||||
|
('IVA_21', N'IVA 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), '2020-01-01')
|
||||||
|
) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde)
|
||||||
|
ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Count only the 4 canonical seed rows — not test-inserted rows.
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.TipoDeIva
|
||||||
|
WHERE Codigo IN ('EXENTO', 'NO_GRAVADO', 'IVA_105', 'IVA_21')
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(4, "Re-ejecutar el seed MERGE no debe duplicar filas en TipoDeIva");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task V014_Idempotencia_IngresosBrutos_NoSeDuplica()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Re-aplicar el MERGE de provincias (simula re-ejecución de V014) — ahora con PascalCase.
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
MERGE dbo.IngresosBrutos AS t
|
||||||
|
USING (VALUES
|
||||||
|
('BuenosAires'),('CiudadAutonomaDeBuenosAires'),('Catamarca'),('Chaco'),('Chubut'),
|
||||||
|
('Cordoba'),('Corrientes'),('EntreRios'),('Formosa'),('Jujuy'),
|
||||||
|
('LaPampa'),('LaRioja'),('Mendoza'),('Misiones'),('Neuquen'),
|
||||||
|
('RioNegro'),('Salta'),('SanJuan'),('SanLuis'),('SantaCruz'),
|
||||||
|
('SantaFe'),('SantiagoDelEstero'),('TierraDelFuego'),('Tucuman')
|
||||||
|
) AS s (Provincia)
|
||||||
|
ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Provincia, N'Ingresos Brutos ' + s.Provincia, CAST(0 AS DECIMAL(5,2)), 1, '2020-01-01', NULL, NULL);
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Count only the 24 canonical seed rows — not test-inserted rows.
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.IngresosBrutos
|
||||||
|
WHERE Alicuota = 0
|
||||||
|
AND PredecesorId IS NULL
|
||||||
|
AND VigenciaDesde = CAST('2020-01-01' AS DATE)
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(24, "Re-ejecutar el seed MERGE no debe duplicar filas en IngresosBrutos");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task V014_Idempotencia_Permiso_NoSeDuplica()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Re-aplicar MERGE permiso (simula re-ejecución de V014)
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES ('administracion:fiscal:gestionar', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'))
|
||||||
|
AS s (Codigo, Descripcion, Modulo)
|
||||||
|
ON t.Codigo = s.Codigo
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||||
|
VALUES (s.Codigo, N'Gestionar tablas fiscales', s.Descripcion, s.Modulo);
|
||||||
|
""");
|
||||||
|
|
||||||
|
var count = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:fiscal:gestionar'
|
||||||
|
""");
|
||||||
|
|
||||||
|
count.Should().Be(1, "Re-ejecutar el MERGE de Permiso no debe crear duplicados");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,9 @@ public class AuthControllerTests
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
|
Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty");
|
||||||
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
Assert.Equal(JsonValueKind.Array, permisos.ValueKind);
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
Assert.Equal(23, permisos.GetArrayLength());
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||||
|
Assert.Equal(24, permisos.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: invalid credentials return 401 with opaque error
|
// Scenario: invalid credentials return 401 with opaque error
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPermisos_WithAdmin_Returns200With23Items()
|
public async Task GetPermisos_WithAdmin_Returns200With24Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||||
@@ -139,8 +139,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
Assert.Equal(23, list.GetArrayLength());
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||||
|
Assert.Equal(24, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -183,7 +184,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRolPermisos_AdminRol_Returns200With23Items()
|
public async Task GetRolPermisos_AdminRol_Returns200With24Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||||
@@ -192,8 +193,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 total
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
Assert.Equal(23, list.GetArrayLength());
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 total
|
||||||
|
Assert.Equal(24, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Domain.Fiscal;
|
||||||
|
|
||||||
|
public class FiscalExceptionsTests
|
||||||
|
{
|
||||||
|
// ── T200.30: cada excepción instancia correctamente con mensaje ───────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PorcentajeInmutableException_HasExpectedMessage()
|
||||||
|
{
|
||||||
|
var ex = new PorcentajeInmutableException();
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("inmutable");
|
||||||
|
ex.Message.Should().Contain("nueva versión");
|
||||||
|
ex.Message.Should().Contain("/iva/");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlicuotaInmutableException_HasExpectedMessage()
|
||||||
|
{
|
||||||
|
var ex = new AlicuotaInmutableException();
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("inmutable");
|
||||||
|
ex.Message.Should().Contain("nueva versión");
|
||||||
|
ex.Message.Should().Contain("/iibb/");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PredecesorYaCerradoException_ContainsId()
|
||||||
|
{
|
||||||
|
var ex = new PredecesorYaCerradoException(42);
|
||||||
|
|
||||||
|
ex.PredecesorId.Should().Be(42);
|
||||||
|
ex.Message.Should().Contain("42");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateCodigoException_ContainsCodigo()
|
||||||
|
{
|
||||||
|
var ex = new DuplicateCodigoException("IVA_21");
|
||||||
|
|
||||||
|
ex.Codigo.Should().Be("IVA_21");
|
||||||
|
ex.Message.Should().Contain("IVA_21");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateProvinciaException_ContainsProvincia()
|
||||||
|
{
|
||||||
|
var ex = new DuplicateProvinciaException(ProvinciaArgentina.Cordoba);
|
||||||
|
|
||||||
|
ex.Provincia.Should().Be(ProvinciaArgentina.Cordoba);
|
||||||
|
ex.Message.Should().Contain("Cordoba");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Todas heredan de DomainException ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PorcentajeInmutableException_InheritsFromDomainException()
|
||||||
|
{
|
||||||
|
var ex = new PorcentajeInmutableException();
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlicuotaInmutableException_InheritsFromDomainException()
|
||||||
|
{
|
||||||
|
var ex = new AlicuotaInmutableException();
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PredecesorYaCerradoException_InheritsFromDomainException()
|
||||||
|
{
|
||||||
|
var ex = new PredecesorYaCerradoException(1);
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateCodigoException_InheritsFromDomainException()
|
||||||
|
{
|
||||||
|
var ex = new DuplicateCodigoException("X");
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateProvinciaException_InheritsFromDomainException()
|
||||||
|
{
|
||||||
|
var ex = new DuplicateProvinciaException(ProvinciaArgentina.Salta);
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
using IibbDomain = SIGCM2.Domain.Entities.IngresosBrutos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Domain.Fiscal;
|
||||||
|
|
||||||
|
public class IngresosBrutosTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly Desde2020 = new(2020, 1, 1);
|
||||||
|
private static readonly DateOnly Desde2026 = new(2026, 6, 1);
|
||||||
|
|
||||||
|
private static IibbDomain MakeIIBB(
|
||||||
|
int id = 1,
|
||||||
|
ProvinciaArgentina provincia = ProvinciaArgentina.BuenosAires,
|
||||||
|
string descripcion = "IIBB Buenos Aires",
|
||||||
|
decimal alicuota = 3.5m,
|
||||||
|
bool activo = true,
|
||||||
|
DateOnly? vigenciaDesde = null,
|
||||||
|
DateOnly? vigenciaHasta = null,
|
||||||
|
int? predecesorId = null)
|
||||||
|
=> IibbDomain.FromDb(
|
||||||
|
id: id,
|
||||||
|
provincia: provincia,
|
||||||
|
descripcion: descripcion,
|
||||||
|
alicuota: alicuota,
|
||||||
|
activo: activo,
|
||||||
|
vigenciaDesde: vigenciaDesde ?? Desde2020,
|
||||||
|
vigenciaHasta: vigenciaHasta,
|
||||||
|
predecesorId: predecesorId,
|
||||||
|
fechaCreacion: DateTime.UtcNow,
|
||||||
|
fechaModificacion: null);
|
||||||
|
|
||||||
|
// ── T200.20: ForCreation validations ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_ValidArgs_ReturnsEntity()
|
||||||
|
{
|
||||||
|
var iibb = IibbDomain.ForCreation(ProvinciaArgentina.Cordoba, "IIBB Córdoba", 2.5m, Desde2020);
|
||||||
|
|
||||||
|
iibb.Provincia.Should().Be(ProvinciaArgentina.Cordoba);
|
||||||
|
iibb.Descripcion.Should().Be("IIBB Córdoba");
|
||||||
|
iibb.Alicuota.Should().Be(2.5m);
|
||||||
|
iibb.Activo.Should().BeTrue();
|
||||||
|
iibb.Id.Should().Be(0);
|
||||||
|
iibb.PredecesorId.Should().BeNull();
|
||||||
|
iibb.VigenciaHasta.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_AlicuotaNegativa_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var act = () => IibbDomain.ForCreation(ProvinciaArgentina.BuenosAires, "desc", -1m, Desde2020);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("alicuota");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_AlicuotaMayorA100_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var act = () => IibbDomain.ForCreation(ProvinciaArgentina.BuenosAires, "desc", 101m, Desde2020);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("alicuota");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(2.5)]
|
||||||
|
[InlineData(100)]
|
||||||
|
public void ForCreation_AlicuotaEnRango_NoLanza(double alicuota)
|
||||||
|
{
|
||||||
|
var act = () => IibbDomain.ForCreation(ProvinciaArgentina.Salta, "desc", (decimal)alicuota, Desde2020);
|
||||||
|
|
||||||
|
act.Should().NotThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_VigenciaHastaMenorQueDesde_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var desde = new DateOnly(2026, 6, 1);
|
||||||
|
var hasta = new DateOnly(2026, 1, 1);
|
||||||
|
|
||||||
|
var act = () => IibbDomain.ForCreation(ProvinciaArgentina.SantaFe, "desc", 3m, desde, hasta);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("vigenciaHasta");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T200.22: With* methods ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithDescripcion_ReturnsNewInstanceWithUpdatedDescripcion()
|
||||||
|
{
|
||||||
|
var original = MakeIIBB(descripcion: "Original");
|
||||||
|
|
||||||
|
var updated = original.WithDescripcion("Actualizado");
|
||||||
|
|
||||||
|
updated.Should().NotBeSameAs(original);
|
||||||
|
updated.Descripcion.Should().Be("Actualizado");
|
||||||
|
updated.Alicuota.Should().Be(original.Alicuota, "Alicuota es inmutable");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deactivate_ReturnsNewInstanceWithActivoFalse()
|
||||||
|
{
|
||||||
|
var original = MakeIIBB(activo: true);
|
||||||
|
|
||||||
|
var deactivated = original.Deactivate();
|
||||||
|
|
||||||
|
deactivated.Activo.Should().BeFalse();
|
||||||
|
deactivated.Alicuota.Should().Be(original.Alicuota);
|
||||||
|
deactivated.Id.Should().Be(original.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reactivate_ReturnsNewInstanceWithActivoTrue()
|
||||||
|
{
|
||||||
|
var original = MakeIIBB(activo: false);
|
||||||
|
|
||||||
|
var reactivated = original.Reactivate();
|
||||||
|
|
||||||
|
reactivated.Activo.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CerrarVigencia_SetsVigenciaHasta()
|
||||||
|
{
|
||||||
|
var original = MakeIIBB(vigenciaHasta: null);
|
||||||
|
var hasta = new DateOnly(2026, 5, 31);
|
||||||
|
|
||||||
|
var cerrado = original.CerrarVigencia(hasta);
|
||||||
|
|
||||||
|
cerrado.VigenciaHasta.Should().Be(hasta);
|
||||||
|
cerrado.Alicuota.Should().Be(original.Alicuota);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T200.24: NuevaVersion tuple ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuevaVersion_ReturnsPredecesoraCerradaYNuevaVersion()
|
||||||
|
{
|
||||||
|
var predecesora = MakeIIBB(id: 5, alicuota: 2.5m, vigenciaDesde: Desde2020, vigenciaHasta: null);
|
||||||
|
|
||||||
|
var (cerrada, nueva) = predecesora.NuevaVersion(3.0m, Desde2026);
|
||||||
|
|
||||||
|
cerrada.Id.Should().Be(5);
|
||||||
|
cerrada.VigenciaHasta.Should().Be(Desde2026.AddDays(-1));
|
||||||
|
cerrada.Alicuota.Should().Be(2.5m, "alicuota predecesora no cambia");
|
||||||
|
|
||||||
|
nueva.Id.Should().Be(0);
|
||||||
|
nueva.PredecesorId.Should().Be(5);
|
||||||
|
nueva.Alicuota.Should().Be(3.0m);
|
||||||
|
nueva.VigenciaDesde.Should().Be(Desde2026);
|
||||||
|
nueva.VigenciaHasta.Should().BeNull();
|
||||||
|
nueva.Provincia.Should().Be(predecesora.Provincia, "hereda la provincia");
|
||||||
|
nueva.Descripcion.Should().Be(predecesora.Descripcion, "hereda la descripción");
|
||||||
|
nueva.Activo.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuevaVersion_PredecesoraConVigenciaHasta_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
var predecesora = MakeIIBB(
|
||||||
|
vigenciaDesde: Desde2020,
|
||||||
|
vigenciaHasta: new DateOnly(2025, 12, 31));
|
||||||
|
|
||||||
|
var act = () => predecesora.NuevaVersion(4.0m, Desde2026);
|
||||||
|
|
||||||
|
act.Should().Throw<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuevaVersion_VigenciaDesdeIgualAPredecesora_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
|
||||||
|
|
||||||
|
var act = () => predecesora.NuevaVersion(4.0m, Desde2020);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("vigenciaDesde");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuevaVersion_NuevaAlicuotaNegativa_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
|
||||||
|
|
||||||
|
var act = () => predecesora.NuevaVersion(-1m, Desde2026);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("nuevaAlicuota");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuevaVersion_NuevaAlicuotaMayorA100_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var predecesora = MakeIIBB(vigenciaDesde: Desde2020, vigenciaHasta: null);
|
||||||
|
|
||||||
|
var act = () => predecesora.NuevaVersion(101m, Desde2026);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>()
|
||||||
|
.WithParameterName("nuevaAlicuota");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T200.25: reflection — NO debe existir WithAlicuota ni WithProvincia ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngresosBrutos_No_Debe_Exponer_WithAlicuota()
|
||||||
|
{
|
||||||
|
var method = typeof(IibbDomain).GetMethod("WithAlicuota", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
method.Should().BeNull("Alicuota es inmutable — usar NuevaVersion");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngresosBrutos_No_Debe_Exponer_WithProvincia()
|
||||||
|
{
|
||||||
|
var method = typeof(IibbDomain).GetMethod("WithProvincia", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
method.Should().BeNull("Provincia es inmutable en IngresosBrutos");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FromDb sets all properties ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDb_SetsAllProperties()
|
||||||
|
{
|
||||||
|
var fechaCreacion = DateTime.UtcNow;
|
||||||
|
var iibb = IibbDomain.FromDb(
|
||||||
|
id: 10, provincia: ProvinciaArgentina.Tucuman, descripcion: "IIBB Tucuman",
|
||||||
|
alicuota: 1.5m, activo: true,
|
||||||
|
vigenciaDesde: Desde2020, vigenciaHasta: null,
|
||||||
|
predecesorId: null, fechaCreacion: fechaCreacion, fechaModificacion: null);
|
||||||
|
|
||||||
|
iibb.Id.Should().Be(10);
|
||||||
|
iibb.Provincia.Should().Be(ProvinciaArgentina.Tucuman);
|
||||||
|
iibb.Alicuota.Should().Be(1.5m);
|
||||||
|
iibb.FechaCreacion.Should().Be(fechaCreacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user