From 5c1675e59a01f8b44e828ec37dde1470b4d4d9b5 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 10:35:38 -0300 Subject: [PATCH] refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001) BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet; subsequent commits in this PR restore compilation. Acceptable only because feature/PRC-001 is not yet merged to main. - V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and GetActiveForProductType (renamed from GetActiveForMedio). NEW SP ReactivateWithGuard (A+guard pattern for feature 3 of scope delta). Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing). - V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing). - V023_ROLLBACK + V024_ROLLBACK scripts. - SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed method signature (ProductTypeId=NULL + PricePerUnit=0), history table TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio). - MigrationTests: updated SP existence + column + FK + price assertions. - RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated; C# method/property renames deferred to Agent 2 (backend refactor). --- database/migrations/V023_ROLLBACK.sql | 246 +++++++++++ ...chargeable_char_config_to_product_type.sql | 406 ++++++++++++++++++ database/migrations/V024_ROLLBACK.sql | 22 + .../V024__reseed_global_with_zero_price.sql | 34 ++ .../ChargeableCharConfigHardeningTests.cs | 150 ++++--- .../ChargeableCharConfigMigrationTests.cs | 278 ++++++++---- ...bleCharConfigRepositoryIntegrationTests.cs | 10 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 404 ++++++++++++++++- 8 files changed, 1390 insertions(+), 160 deletions(-) create mode 100644 database/migrations/V023_ROLLBACK.sql create mode 100644 database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql create mode 100644 database/migrations/V024_ROLLBACK.sql create mode 100644 database/migrations/V024__reseed_global_with_zero_price.sql diff --git a/database/migrations/V023_ROLLBACK.sql b/database/migrations/V023_ROLLBACK.sql new file mode 100644 index 0000000..4e9735a --- /dev/null +++ b/database/migrations/V023_ROLLBACK.sql @@ -0,0 +1,246 @@ +-- V023_ROLLBACK.sql +-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql. +-- +-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId. +-- - Todos los datos de ProductTypeId se pierden. +-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL). +-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después. +-- +-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ─── 1. Drop new SPs ──────────────────────────────────────────────────────── + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.'; +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.'; +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; + PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.'; +END +GO + +-- ─── 2. Reverse table alterations if ProductTypeId column exists ───────────── + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') +BEGIN + -- 2a. Turn off SYSTEM_VERSIONING + ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF); + PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.'; + + -- 2b. Drop indexes on ProductTypeId + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.'; + END + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.'; + END + + -- 2c. Drop FK to ProductType + DECLARE @fk_pt sysname; + SELECT @fk_pt = name + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.ProductType'); + IF @fk_pt IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt); + PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.'; + END + + -- 2d. Drop NonNegative price check; restore Positive check + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative; + PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.'; + END + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0); + PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.'; + END + + -- 2e. Drop ProductTypeId column from main + history + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId; + PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.'; + + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'ProductTypeId') + BEGIN + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId; + PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.'; + END + + -- 2f. Restore MedioId column + ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL; + ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL; + PRINT 'V023 ROLLBACK: MedioId restored.'; + + -- 2g. Restore FK to Medio + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_Medio + FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION; + PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.'; + + -- 2h. Restore indexes on MedioId + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (MedioId, Symbol) + WHERE ValidTo IS NULL; + PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).'; + + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).'; + + -- 2i. Restore SYSTEM_VERSIONING + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.'; +END +ELSE + PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.'; +GO + +-- ─── 3. Restore original SPs ──────────────────────────────────────────────── + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); +GO + +ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @MedioId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF @MedioId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId) + BEGIN + ROLLBACK; + THROW 50404, 'Medio not found', 1; + END + + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@MedioId IS NULL AND MedioId IS NULL) + OR (@MedioId IS NOT NULL AND MedioId = @MedioId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ChargeableCharConfig + (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0'); +GO + +ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio + @MedioId INT, + @AsOfDate DATE +AS +BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (MedioId = @MedioId OR MedioId IS NULL) + ) + SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; +END +GO + +PRINT ''; +PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.'; +GO diff --git a/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql b/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql new file mode 100644 index 0000000..5724118 --- /dev/null +++ b/database/migrations/V023__refactor_chargeable_char_config_to_product_type.sql @@ -0,0 +1,406 @@ +-- V023__refactor_chargeable_char_config_to_product_type.sql +-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio). +-- +-- Cambios: +-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian. +-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType. +-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query). +-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId). +-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType. +-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3). +-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing). +-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0). +-- +-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia +-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta. +-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention). +-- Depende de: V017 (dbo.ProductType debe existir). +-- Reversa: V023_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- SDD Design: engram sdd/prc-001-word-counter-spike/design +-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1 + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId +-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo). +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId') +BEGIN + PRINT 'V023: MedioId column found — proceeding with refactor.'; + + -- ─── 1. Turn OFF SYSTEM_VERSIONING ───────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF); + PRINT 'V023: SYSTEM_VERSIONING = OFF.'; + + -- ─── 2. Drop indexes that reference MedioId ──────────────────────── + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.'; + END + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + PRINT 'V023: IX_ChargeableCharConfig_Query dropped.'; + END + + -- ─── 3. Drop FK to Medio ──────────────────────────────────────────── + DECLARE @fk_name sysname; + SELECT @fk_name = name + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.Medio'); + IF @fk_name IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name); + PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.'; + END + + -- ─── 4. Drop MedioId column (drop DF constraint first if present) ─── + DECLARE @df_medio sysname; + SELECT @df_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND c.name = 'MedioId'; + IF @df_medio IS NOT NULL + BEGIN + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio); + PRINT 'V023: Default constraint on MedioId dropped.'; + END + + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId; + PRINT 'V023: MedioId column dropped from ChargeableCharConfig.'; + + -- Drop MedioId from history table if present + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'MedioId') + BEGIN + -- Drop default constraint on history MedioId if any + DECLARE @df_hist_medio sysname; + SELECT @df_hist_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND c.name = 'MedioId'; + IF @df_hist_medio IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio); + + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId; + PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.'; + END + + -- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ── + -- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it. + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive; + PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.'; + END + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + BEGIN + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative + CHECK (PricePerUnit >= 0); + PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).'; + END + + -- ─── 6. Add ProductTypeId column ──────────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + ADD ProductTypeId INT NULL; -- NULL = global fallback + PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.'; + + ALTER TABLE dbo.ChargeableCharConfig_History + ADD ProductTypeId INT NULL; + PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.'; + + -- ─── 7. Add FK to ProductType ──────────────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_ProductType + FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION; + PRINT 'V023: FK_ChargeableCharConfig_ProductType added.'; + + -- ─── 8. Recreate filtered unique index with ProductTypeId ──────────── + -- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback. + -- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global. + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol) + WHERE ValidTo IS NULL; + PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).'; + + -- ─── 9. Recreate cover index with ProductTypeId ────────────────────── + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category); + PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).'; + + -- ─── 10. Turn SYSTEM_VERSIONING back ON ────────────────────────────── + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).'; +END +ELSE +BEGIN + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo')) + PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.'; + ELSE + PRINT 'V023: MedioId column not found — table already refactored, skipping.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId) +-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. +-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity). +-- THROW 50404: ProductType not found. +-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom. +-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol). +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose; +GO + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @ProductTypeId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Validate ProductTypeId only when provided (NULL = global fallback, always valid). + -- FK constraint handles referential integrity; we throw 50404 explicitly for better UX. + IF @ProductTypeId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId) + BEGIN + ROLLBACK; + THROW 50404, 'ProductType not found', 1; + END + + -- Read current vigente with range lock for serialization. + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL) + OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + -- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom. + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + -- Close the current vigente: ValidTo = new ValidFrom - 1 day. + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + -- Insert the new vigente. + INSERT INTO dbo.ChargeableCharConfig + (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_GetActiveForProductType +-- Resolución per-ProductType + global fallback: 1 fila por Symbol. +-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1). +-- @ProductTypeId: the specific product type to resolve for. +-- @AsOfDate: resolve active rows as of this date (for pricing snapshot). +-- ═══════════════════════════════════════════════════════════════════════ + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType + @ProductTypeId INT, + @AsOfDate DATE +AS +BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL) + ) + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta) +-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol). +-- Guards: +-- - Row must exist → THROW 50404 +-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active +-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411 +-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412 +-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo). +-- Preserves forward-only invariant and maintains clean history. +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard; +GO + +CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard + @Id BIGINT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Step 1: Lock + load target row. + DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT; + SELECT @ProductTypeId = ProductTypeId, + @Symbol = Symbol, + @ValidTo = ValidTo, + @IsActive = IsActive + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK) + WHERE Id = @Id; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + THROW 50404, 'ChargeableCharConfig row not found', 1; + END + + -- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0). + -- If it is currently active (ValidTo IS NULL), reactivation is nonsensical. + IF @ValidTo IS NULL + BEGIN + ROLLBACK TRANSACTION; + THROW 50410, 'Row is already active — reactivation not needed', 1; + END + + -- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol). + -- Prevents re-opening a row while another is already vigente. + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidTo IS NULL + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1; + END + + -- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo. + -- Ensures this is the LAST closed row; reactivating an older row would violate + -- forward-only ordering of the temporal chain. + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidFrom > @ValidTo + AND Id <> @Id + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1; + END + + -- Step 5: Literal undo — re-open the row. + UPDATE dbo.ChargeableCharConfig + SET IsActive = 1, + ValidTo = NULL + WHERE Id = @Id; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +PRINT ''; +PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:'; +PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)'; +PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated'; +PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId'; +PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped'; +PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created'; +PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)'; +PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)'; +PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).'; +GO diff --git a/database/migrations/V024_ROLLBACK.sql b/database/migrations/V024_ROLLBACK.sql new file mode 100644 index 0000000..d46fd31 --- /dev/null +++ b/database/migrations/V024_ROLLBACK.sql @@ -0,0 +1,22 @@ +-- V024_ROLLBACK.sql +-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql. +-- +-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022). +-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo. +-- +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + +PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.'; +PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10)); +GO diff --git a/database/migrations/V024__reseed_global_with_zero_price.sql b/database/migrations/V024__reseed_global_with_zero_price.sql new file mode 100644 index 0000000..70f37bf --- /dev/null +++ b/database/migrations/V024__reseed_global_with_zero_price.sql @@ -0,0 +1,34 @@ +-- V024__reseed_global_with_zero_price.sql +-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000. +-- +-- Cambios: +-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000. +-- +-- Decisión: UPDATE directo (no forward-only close+insert) porque: +-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio. +-- - No existe historial de facturación con el valor 1.0000. +-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales. +-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix +-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún). +-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1. +-- +-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL. +-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado). +-- Reversa: V024_ROLLBACK.sql. +-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required). +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + +PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).'; +PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10)); +GO diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs index 2e7cde7..54514d2 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs @@ -8,7 +8,7 @@ using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// -/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig. +/// PRC-001 — Integration hardening tests for ChargeableCharConfig. /// Covers cross-cutting concerns not addressed by individual layer batches: /// /// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on @@ -18,9 +18,11 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state. /// -/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync: -/// - ELDIA override for '$' → per-medio row returned at priority -/// - ELPLATA (no override) → global fallback returned +/// T7.4 Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync: +/// - ProductType1 override for '$' → per-PT row returned at priority +/// - ProductType2 (no override) → global fallback returned +/// +/// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows. /// /// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture). /// Each test seeds its own unique symbols to avoid cross-test interference. @@ -29,8 +31,10 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime { private readonly SqlTestFixture _db; - private int _eldiaId; - private int _elplataId; + // V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs. + // These are ProductType IDs (FK to dbo.ProductType), not Medio IDs. + private int _productType1Id; // has per-PT override (was ELDIA) + private int _productType2Id; // no override, falls back to global (was ELPLATA) public ChargeableCharConfigHardeningTests(SqlTestFixture db) { @@ -44,17 +48,18 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb); await conn.OpenAsync(); - // Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override). - _eldiaId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + // Seed two dedicated ProductTypes for override/fallback resolution tests. + // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id). + _productType1Id = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, IsActive) OUTPUT INSERTED.Id - VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1) + VALUES ('Hardening PT1 (override)', 1) """); - _elplataId = await conn.ExecuteScalarAsync(""" - INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + _productType2Id = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, IsActive) OUTPUT INSERTED.Id - VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1) + VALUES ('Hardening PT2 (fallback)', 1) """); } @@ -126,27 +131,28 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed"); failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException"); - // Verify post-race state: exactly 1 vigente row for (NULL, '¢') + // Verify post-race state: exactly 1 vigente row for (ProductTypeId=NULL, '¢') + // V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb); await verifyConn.OpenAsync(); var vigente = await verifyConn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL + WHERE ProductTypeId IS NULL AND Symbol = @Symbol AND ValidTo IS NULL AND IsActive = 1 """, new { Symbol = symbol }); vigente.Should().Be(1, - "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)"); + "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (ProductTypeId, Symbol)"); // No duplicates: total row count must also be 1 (only the winner was inserted) var total = await verifyConn.ExecuteScalarAsync(""" SELECT COUNT(1) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL + WHERE ProductTypeId IS NULL AND Symbol = @Symbol """, new { Symbol = symbol }); @@ -260,115 +266,127 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime } // ───────────────────────────────────────────────────────────────────────── - // T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync + // T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync + // + // V023 scope delta: MedioId → ProductTypeId in table + SP. // // Scenario: - // - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed) - // - ELDIA-specific '$' at price 5.00 effective from today (per-medio override) - // - ELPLATA has no override for '$' + // - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed) + // - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override) + // - ProductType2 has no override for '$' // - // GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins) - // GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback) + // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins) + // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback) + // + // NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be + // renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2. + // SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already + // updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is. // ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal() + public async Task GetActiveConfigForProductType_Override_WinsOverGlobal() { var asOf = new DateOnly(2026, 6, 1); - // Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01 + // Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01 await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); - await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); + await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1)); - // Build the repository + service (same as application layer usage) + // Build the repository + service (C# method will be renamed in Agent 2) var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf); + var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync - // The per-medio '$' must be returned + // The per-PT '$' must be returned var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); - dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it"); + dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it"); - dollarRow!.MedioId.Should().Be(_eldiaId, - "the per-medio row (MedioId = ELDIA) must take priority over the global row"); + dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId + "the per-PT row (ProductTypeId = PT1) must take priority over the global row"); dollarRow.PricePerUnit.Should().Be(5.0000m, - "ELDIA override has price 5.00, not the global 1.00"); + "ProductType1 override has price 5.00, not the global 0.00"); } [Fact] - public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal() + public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal() { var asOf = new DateOnly(2026, 6, 1); - // ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync - // provides '$' at global price. + // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync + // provides '$' at global price (0.0000 after V024). var repo = BuildRepository(); - var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf); + var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync // Must have at least the global '$' from seed rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01"); var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$"); - dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)"); + dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)"); - dollarRow!.MedioId.Should().BeNull( - "ELPLATA has no override — the returned row must be the global row (MedioId = NULL)"); + dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId + "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)"); } [Fact] - public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly() + public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly() { // End-to-end: IChargeableCharConfigService resolves the final dictionary. - // Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed). + // Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed). var asOf = new DateOnly(2026, 6, 1); await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); await seedConn.OpenAsync(); - await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); + await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1)); // Build the service (wraps repo with priority resolution) + // TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync var service = BuildService(); - var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf); - var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf); + var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2 + var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2 - // ELDIA: '%' must come from per-medio override at 3.00 - eldiaConfig.Should().ContainKey("%", - "ELDIA has a per-medio override for '%'"); - eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m, - "per-medio '%' at 3.00 must override the global 2.00 for ELDIA"); + // ProductType1: '%' must come from per-PT override at 3.00 + pt1Config.Should().ContainKey("%", + "ProductType1 has a per-PT override for '%'"); + pt1Config["%"].PricePerUnit.Should().Be(3.0000m, + "per-PT '%' at 3.00 must override the global 0.00 for ProductType1"); - // ELPLATA: '%' must come from global fallback - // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId). - // We verify via price: global '%' is seeded at 2.00 by V022. - elplataConfig.Should().ContainKey("%", - "global '%' from canonical seed must appear in ELPLATA's resolved config"); - // V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql) - elplataConfig["%"].PricePerUnit.Should().Be(1.0000m, - "ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)"); + // ProductType2: '%' must come from global fallback + // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId). + // We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022). + pt2Config.Should().ContainKey("%", + "global '%' from canonical seed must appear in ProductType2's resolved config"); + // V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced) + pt2Config["%"].PricePerUnit.Should().Be(0.0000m, + "ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)"); } // ── Helpers ─────────────────────────────────────────────────────────────── + /// + /// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL. + /// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId. + /// private static async Task ExecInsertWithCloseAsync( SqlConnection conn, - int? medioId, + int? productTypeId, string symbol, string category, decimal pricePerUnit, DateTime validFrom) { var p = new DynamicParameters(); - p.Add("@MedioId", medioId, System.Data.DbType.Int32); - p.Add("@Symbol", symbol, System.Data.DbType.String); - p.Add("@Category", category, System.Data.DbType.String); - p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ProductTypeId", productTypeId, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", symbol, System.Data.DbType.String); + p.Add("@Category", category, System.Data.DbType.String); + p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", validFrom, System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await conn.ExecuteAsync( "dbo.usp_ChargeableCharConfig_InsertWithClose", diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs index 821662e..2d54482 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs @@ -6,14 +6,15 @@ using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// -/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 migrations. +/// PRC-001 — Integration tests for V020/V021/V022/V023/V024 migrations. /// These tests verify the BD schema applied against SIGCM2_Test_App: /// - V020: permission 'tasacion:caracteres_especiales:gestionar' exists. -/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + filtered UX + SPs. +/// - V021: dbo.ChargeableCharConfig table + SYSTEM_VERSIONING + SPs (initial MedioId shape). /// - V022: 4 global seed rows ($, %, !, ¡) exist and are active. +/// - V023 (scope delta): MedioId → ProductTypeId refactor + ReactivateWithGuard SP. +/// - V024 (scope delta): global seed PricePerUnit reset to 0.0000 (opt-in billing). /// -/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step). -/// After GREEN, all tests in this class should pass. +/// After GREEN all tests pass. SqlTestFixture applies V023+V024 during initialization. /// [Collection("Database")] public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime @@ -104,19 +105,19 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime [Fact] public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull() { - // Cleanup + // Cleanup (use ProductTypeId IS NULL after V023 refactor) await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'@'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'@'"); var p = new DynamicParameters(); - p.Add("@MedioId", null, System.Data.DbType.Int32); - p.Add("@Symbol", "@", System.Data.DbType.String); - p.Add("@Category", "Other",System.Data.DbType.String); - p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, + p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", "@", System.Data.DbType.String); + p.Add("@Category", "Other",System.Data.DbType.String); + p.Add("@PricePerUnit", 1.5m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync( @@ -132,7 +133,7 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND ProductTypeId IS NULL"); } [Fact] @@ -140,17 +141,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime { // Seed primer activo await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'€'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'€'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "€", System.Data.DbType.String); - p1.Add("@Category", "Currency", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "€", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -159,14 +160,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Insertar segundo (debe cerrar el primero) var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "€", System.Data.DbType.String); - p2.Add("@Category", "Currency", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "€", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 2, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, @@ -187,24 +188,24 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND ProductTypeId IS NULL"); } [Fact] public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409() { await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'£'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'£'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "£", System.Data.DbType.String); - p1.Add("@Category", "Currency", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "£", System.Data.DbType.String); + p1.Add("@Category", "Currency", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -212,14 +213,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Intento con ValidFrom <= activo.ValidFrom → debe lanzar 50409 var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "£", System.Data.DbType.String); - p2.Add("@Category", "Currency", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "£", System.Data.DbType.String); + p2.Add("@Category", "Currency", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 3, 1), System.Data.DbType.Date); // igual → viola forward-only + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); var act = async () => await _connection.ExecuteAsync( @@ -232,52 +233,53 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND ProductTypeId IS NULL"); } [Fact] - public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works() + public async Task usp_InsertWithClose_ProductTypeNull_GlobalFallback_Works() { + // V023: global fallback is now ProductTypeId IS NULL (was MedioId IS NULL) await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'¥'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'¥'"); var p = new DynamicParameters(); - p.Add("@MedioId", null, System.Data.DbType.Int32); - p.Add("@Symbol", "¥", System.Data.DbType.String); - p.Add("@Category", "Currency", System.Data.DbType.String); - p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p.Add("@NewId", dbType: System.Data.DbType.Int64, + p.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p.Add("@Symbol", "¥", System.Data.DbType.String); + p.Add("@Category", "Currency", System.Data.DbType.String); + p.Add("@PricePerUnit", 1.25m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p, commandType: System.Data.CommandType.StoredProcedure); var newId = p.Get("@NewId"); - newId.Should().BeGreaterThan(0, "global insert (MedioId NULL) debe funcionar"); + newId.Should().BeGreaterThan(0, "global insert (ProductTypeId NULL) debe funcionar"); // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND ProductTypeId IS NULL"); } [Fact] public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow() { await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId IS NULL AND Symbol = N'#'"); + "DELETE FROM dbo.ChargeableCharConfig WHERE ProductTypeId IS NULL AND Symbol = N'#'"); var p1 = new DynamicParameters(); - p1.Add("@MedioId", null, System.Data.DbType.Int32); - p1.Add("@Symbol", "#", System.Data.DbType.String); - p1.Add("@Category", "Other", System.Data.DbType.String); - p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); - p1.Add("@NewId", dbType: System.Data.DbType.Int64, + p1.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p1.Add("@Symbol", "#", System.Data.DbType.String); + p1.Add("@Category", "Other", System.Data.DbType.String); + p1.Add("@PricePerUnit", 1.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p1.Add("@ValidFrom", new DateTime(2026, 1, 1), System.Data.DbType.Date); + p1.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p1.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1, @@ -286,14 +288,14 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Close it by inserting a newer version var p2 = new DynamicParameters(); - p2.Add("@MedioId", null, System.Data.DbType.Int32); - p2.Add("@Symbol", "#", System.Data.DbType.String); - p2.Add("@Category", "Other", System.Data.DbType.String); - p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); - p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); - p2.Add("@NewId", dbType: System.Data.DbType.Int64, + p2.Add("@ProductTypeId", null, System.Data.DbType.Int32); // V023: was @MedioId + p2.Add("@Symbol", "#", System.Data.DbType.String); + p2.Add("@Category", "Other", System.Data.DbType.String); + p2.Add("@PricePerUnit", 2.0m, System.Data.DbType.Decimal, precision: 18, scale: 4); + p2.Add("@ValidFrom", new DateTime(2026, 6, 1), System.Data.DbType.Date); + p2.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); - p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, + p2.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output); await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2, @@ -309,13 +311,25 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime // Cleanup await _connection.ExecuteAsync( - "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL"); + "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND ProductTypeId IS NULL"); } - // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ──────────── + // ── V023 scope delta: SP — usp_ChargeableCharConfig_GetActiveForProductType ──────────── [Fact] - public async Task V021_SP_GetActiveForMedio_Exists() + public async Task V023_SP_GetActiveForProductType_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType') + AND type = 'P'"); + + exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_GetActiveForProductType (renamed from GetActiveForMedio)"); + } + + [Fact] + public async Task V023_SP_GetActiveForMedio_NoLongerExists() { var exists = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) @@ -323,7 +337,81 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio') AND type = 'P'"); - exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir"); + exists.Should().Be(0, "V023 debe eliminar usp_ChargeableCharConfig_GetActiveForMedio (renamed to GetActiveForProductType)"); + } + + [Fact] + public async Task V023_SP_ReactivateWithGuard_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.objects + WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard') + AND type = 'P'"); + + exists.Should().Be(1, "V023 debe crear usp_ChargeableCharConfig_ReactivateWithGuard (new SP for feature 3)"); + } + + // ── V023 scope delta: column ProductTypeId replaces MedioId ───────── + + [Fact] + public async Task V023_Column_ProductTypeId_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId'"); + + exists.Should().Be(1, "V023 debe agregar columna ProductTypeId a dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V023_Column_MedioId_NoLongerExists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId'"); + + exists.Should().Be(0, "V023 debe eliminar columna MedioId de dbo.ChargeableCharConfig"); + } + + [Fact] + public async Task V023_FK_ProductType_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.ProductType')"); + + exists.Should().Be(1, "V023 debe crear FK de ChargeableCharConfig a ProductType"); + } + + [Fact] + public async Task V023_Check_Price_NonNegative_Exists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(1, "V023 debe crear CK_ChargeableCharConfig_Price_NonNegative (>= 0 para opt-in billing)"); + } + + [Fact] + public async Task V023_Check_Price_Positive_NoLongerExists() + { + var exists = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) + FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')"); + + exists.Should().Be(0, "V023 debe eliminar CK_ChargeableCharConfig_Price_Positive (reemplazado por NonNegative)"); } // ── V020: Permission existence ──────────────────────────────────────── @@ -339,17 +427,17 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'"); } - // ── V022: Seed rows ─────────────────────────────────────────────────── + // ── V022: Seed rows (after V023 refactor: use ProductTypeId IS NULL) ───── [Fact] public async Task V022_Seeds_AtLeastFourGlobalRows() { var count = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND ValidTo IS NULL AND IsActive = 1"); - count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡"); + count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡ (global = ProductTypeId IS NULL tras V023)"); } [Fact] @@ -357,9 +445,39 @@ public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime { var inactiveCount = await _connection.ExecuteScalarAsync(@" SELECT COUNT(*) FROM dbo.ChargeableCharConfig - WHERE MedioId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') + WHERE ProductTypeId IS NULL AND Symbol IN (N'$', N'%', N'!', N'¡') AND IsActive = 0"); inactiveCount.Should().Be(0, "todas las filas de seed deben tener IsActive = 1"); } + + // ── V024 scope delta: global seed prices = 0.0000 (opt-in billing) ─────── + + [Fact] + public async Task V024_GlobalSeedRows_HaveZeroPrice() + { + var nonZeroCount = await _connection.ExecuteScalarAsync(@" + SELECT COUNT(*) FROM dbo.ChargeableCharConfig + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL + AND PricePerUnit <> 0.0000"); + + nonZeroCount.Should().Be(0, + "V024 debe resetear todas las filas de seed global a PricePerUnit = 0.0000 (opt-in billing)"); + } + + [Fact] + public async Task V024_GlobalSeedRows_AllHaveZeroPriceExact() + { + var rows = await _connection.QueryAsync(@" + SELECT PricePerUnit FROM dbo.ChargeableCharConfig + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL"); + + rows.Should().AllSatisfy(price => + price.Should().Be(0.0000m, + "V024 seed: cada fila global debe tener PricePerUnit = 0.0000 exacto")); + } } diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs index 63a4a6d..16acd8b 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs @@ -14,15 +14,19 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing; /// /// All tests run against the real DB via SqlTestFixture (Database collection). /// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync(). -/// Tests that mutate specific (MedioId, Symbol) pairs clean their own state before mutating. +/// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating. +/// +/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync +/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor). +/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected. /// /// Spec coverage: /// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id /// T4.2 InsertWithCloseAsync — with existing vigente → closes previous, inserts new /// T4.3 InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException /// T4.4 InsertWithCloseAsync — system versioning captures history row after mutation -/// T4.5 GetActiveForMedioAsync — medio has override → returns both medio and global rows -/// T4.6 GetActiveForMedioAsync — no medio override → returns only global rows +/// T4.5 GetActiveForProductTypeAsync — PT has override → returns both PT and global rows +/// T4.6 GetActiveForProductTypeAsync — no PT override → returns only global rows /// T4.7 ListAsync — paginates (skip/take) /// T4.8 CountAsync — filters by activeOnly /// T4.9 GetByIdAsync — missing → returns null diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 775cdaa..4b07492 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -72,6 +72,12 @@ public sealed class SqlTestFixture : IAsyncLifetime // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed. await EnsureV021SchemaAsync(); + // V023 (PRC-001 scope delta): refactor ChargeableCharConfig — MedioId → ProductTypeId + ReactivateWithGuard SP. + await EnsureV023SchemaAsync(); + + // V024 (PRC-001 scope delta): reseed global rows with PricePerUnit = 0.0000 (opt-in billing). + await EnsureV024SeedAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -127,6 +133,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); + // PRC-001 scope delta: ChargeableCharConfig re-seeded with ProductTypeId-based canonical seed. await SeedChargeableCharConfigCanonicalAsync(); } @@ -574,28 +581,34 @@ public sealed class SqlTestFixture : IAsyncLifetime } /// - /// PRC-001 (V022): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. - /// Mirrors V022__seed_chargeable_char_config.sql (MERGE idempotente). - /// The table itself is never added to TablesToIgnore because per-medio test rows + /// PRC-001 scope delta (V022+V024): re-seeds the 4 global ChargeableCharConfig defaults after each Respawn. + /// Uses ProductTypeId NULL (global fallback) and PricePerUnit = 0.0000 (opt-in billing — V024 decision). + /// Mirrors V022 MERGE pattern, adapted for ProductTypeId column (V023 refactor). + /// The table itself is never added to TablesToIgnore because per-productType test rows /// must be reset between test classes — only the 4 global defaults are reseeded. + /// NOTE: seed price is 0.0000 (not 1.0000). Tests asserting price must use 0.0000 unless + /// they have explicitly seeded their own rows at a different price. /// private async Task SeedChargeableCharConfigCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') BEGIN MERGE dbo.ChargeableCharConfig AS t USING (VALUES - (NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), - (NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) - ) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom) - ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) + (NULL, N'$', N'Currency', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'%', N'Percentage', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'!', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)), + (NULL, N'¡', N'Exclamation', CAST(0.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)) + ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom) + ON (t.ProductTypeId IS NULL AND s.ProductTypeId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL) WHEN NOT MATCHED THEN - INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) - VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); + INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1); END """; await _connection.ExecuteAsync(sql); @@ -1517,4 +1530,373 @@ public sealed class SqlTestFixture : IAsyncLifetime // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). } + + /// + /// PRC-001 scope delta (V023): refactors dbo.ChargeableCharConfig from MedioId to ProductTypeId. + /// Mirrors V023__refactor_chargeable_char_config_to_product_type.sql (idempotente). + /// + /// Steps (only run if MedioId column still exists — guard for idempotence): + /// 1. SYSTEM_VERSIONING OFF + /// 2. Drop UX_Vigente + IX_Query (MedioId-based) + /// 3. Drop FK_ChargeableCharConfig_Medio + /// 4. Drop MedioId column from main + history + /// 5. Drop CK_Price_Positive; add CK_Price_NonNegative (>= 0 for opt-in billing) + /// 6. Add ProductTypeId column (nullable) to main + history + /// 7. Add FK_ChargeableCharConfig_ProductType + /// 8. Recreate UX_Vigente + IX_Query (ProductTypeId-based) + /// 9. SYSTEM_VERSIONING ON + /// 10. Drop+Create usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId) + /// 11. Drop usp_ChargeableCharConfig_GetActiveForMedio + /// 12. Create usp_ChargeableCharConfig_GetActiveForProductType + /// 13. Create usp_ChargeableCharConfig_ReactivateWithGuard (NEW) + /// + private async Task EnsureV023SchemaAsync() + { + // ── Guard: only run the ALTER block if MedioId still exists ────────── + // SPs are always idempotently recreated (create-if-not-exists + alter pattern). + const string checkMedioId = """ + SELECT CAST( + CASE WHEN EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'MedioId' + ) THEN 1 ELSE 0 END + AS BIT) + """; + + var hasMedioId = await _connection.ExecuteScalarAsync(checkMedioId); + + if (hasMedioId) + { + // ── 1. SYSTEM_VERSIONING OFF ────────────────────────────────────── + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF)"); + + // ── 2. Drop MedioId-based indexes ───────────────────────────────── + const string dropIndexes = """ + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig; + + IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' + AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig; + """; + await _connection.ExecuteAsync(dropIndexes); + + // ── 3. Drop FK to Medio ──────────────────────────────────────────── + const string dropFkMedio = """ + DECLARE @fk_name sysname; + SELECT @fk_name = name FROM sys.foreign_keys + WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND referenced_object_id = OBJECT_ID('dbo.Medio'); + IF @fk_name IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name); + """; + await _connection.ExecuteAsync(dropFkMedio); + + // ── 4. Drop MedioId from main + history ──────────────────────────── + const string dropMedioIdMain = """ + DECLARE @df_medio sysname; + SELECT @df_medio = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND c.name = 'MedioId'; + IF @df_medio IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio); + ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId; + """; + await _connection.ExecuteAsync(dropMedioIdMain); + + const string dropMedioIdHistory = """ + IF EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND name = 'MedioId') + BEGIN + DECLARE @df_hist sysname; + SELECT @df_hist = dc.name + FROM sys.default_constraints dc + JOIN sys.columns c ON c.default_object_id = dc.object_id + WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History') + AND c.name = 'MedioId'; + IF @df_hist IS NOT NULL + EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist); + ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId; + END + """; + await _connection.ExecuteAsync(dropMedioIdHistory); + + // ── 5. Replace price check constraint ──────────────────────────────── + const string replacePriceCheck = """ + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_Positive' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive; + + IF NOT EXISTS (SELECT 1 FROM sys.check_constraints + WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative' + AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')) + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative CHECK (PricePerUnit >= 0); + """; + await _connection.ExecuteAsync(replacePriceCheck); + + // ── 6. Add ProductTypeId to main + history ───────────────────────── + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig ADD ProductTypeId INT NULL"); + await _connection.ExecuteAsync( + "ALTER TABLE dbo.ChargeableCharConfig_History ADD ProductTypeId INT NULL"); + + // ── 7. Add FK to ProductType ─────────────────────────────────────── + await _connection.ExecuteAsync(""" + ALTER TABLE dbo.ChargeableCharConfig + ADD CONSTRAINT FK_ChargeableCharConfig_ProductType + FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION + """); + + // ── 8. Recreate ProductTypeId-based indexes ──────────────────────── + await _connection.ExecuteAsync(""" + CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol) + WHERE ValidTo IS NULL + """); + + await _connection.ExecuteAsync(""" + CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query + ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo) + INCLUDE (PricePerUnit, IsActive, Category) + """); + + // ── 9. SYSTEM_VERSIONING ON ──────────────────────────────────────── + await _connection.ExecuteAsync(""" + ALTER TABLE dbo.ChargeableCharConfig + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ChargeableCharConfig_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )) + """); + } + + // ── 10. Recreate InsertWithClose SP (always: idempotent via drop+create) ── + const string createInsertSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0'); + """; + + // Only ALTER if ProductTypeId column exists (meaning table was already refactored or we just did it) + const string alterInsertSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose + @ProductTypeId INT = NULL, + @Symbol NVARCHAR(4), + @Category NVARCHAR(32), + @PricePerUnit DECIMAL(18,4), + @ValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF @ProductTypeId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId) + BEGIN + ROLLBACK; + THROW 50404, 'ProductType not found', 1; + END + + DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActiveValidFrom = ValidFrom + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL) + OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId)) + AND Symbol = @Symbol + AND ValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom + BEGIN + ROLLBACK; + THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ChargeableCharConfig + SET ValidTo = DATEADD(DAY, -1, @ValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ChargeableCharConfig + (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive) + VALUES + (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + await _connection.ExecuteAsync(createInsertSp); + await _connection.ExecuteAsync(alterInsertSp); + + // ── 11. Drop GetActiveForMedio SP ────────────────────────────────────── + await _connection.ExecuteAsync(""" + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL + DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio; + """); + + // ── 12. Create GetActiveForProductType SP ────────────────────────────── + const string createGetForPtSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForProductType', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType AS RETURN 0'); + """; + + const string alterGetForPtSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType + @ProductTypeId INT, + @AsOfDate DATE + AS + BEGIN + SET NOCOUNT ON; + WITH Candidates AS ( + SELECT + Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, + ROW_NUMBER() OVER ( + PARTITION BY Symbol + ORDER BY + CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, + ValidFrom DESC + ) AS rn + FROM dbo.ChargeableCharConfig + WHERE IsActive = 1 + AND ValidFrom <= @AsOfDate + AND (ValidTo IS NULL OR ValidTo >= @AsOfDate) + AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL) + ) + SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive + FROM Candidates + WHERE rn = 1; + END + """; + + await _connection.ExecuteAsync(createGetForPtSp); + await _connection.ExecuteAsync(alterGetForPtSp); + + // ── 13. Create ReactivateWithGuard SP (NEW) ──────────────────────────── + const string createReactivateSp = """ + IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_ReactivateWithGuard', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard AS RETURN 0'); + """; + + const string alterReactivateSp = """ + ALTER PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard + @Id BIGINT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT; + SELECT @ProductTypeId = ProductTypeId, + @Symbol = Symbol, + @ValidTo = ValidTo, + @IsActive = IsActive + FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK) + WHERE Id = @Id; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + THROW 50404, 'ChargeableCharConfig row not found', 1; + END + + IF @ValidTo IS NULL + BEGIN + ROLLBACK TRANSACTION; + THROW 50410, 'Row is already active — reactivation not needed', 1; + END + + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidTo IS NULL + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1; + END + + IF EXISTS ( + SELECT 1 FROM dbo.ChargeableCharConfig + WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL)) + AND Symbol = @Symbol + AND ValidFrom > @ValidTo + AND Id <> @Id + ) + BEGIN + ROLLBACK TRANSACTION; + THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1; + END + + UPDATE dbo.ChargeableCharConfig + SET IsActive = 1, + ValidTo = NULL + WHERE Id = @Id; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + await _connection.ExecuteAsync(createReactivateSp); + await _connection.ExecuteAsync(alterReactivateSp); + } + + /// + /// PRC-001 scope delta (V024): reseeds global ChargeableCharConfig rows to PricePerUnit = 0.0000. + /// Direct UPDATE — V022 seed price 1.0000 was always a placeholder, no business history exists. + /// Safe to re-run: already-zero rows are unchanged. + /// Requires ProductTypeId column to exist (V023 must have run). + /// + private async Task EnsureV024SeedAsync() + { + const string sql = """ + IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') + AND name = 'ProductTypeId') + BEGIN + UPDATE dbo.ChargeableCharConfig + SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4)) + WHERE ProductTypeId IS NULL + AND Symbol IN (N'$', N'%', N'!', N'¡') + AND ValidTo IS NULL; + END + """; + await _connection.ExecuteAsync(sql); + } }