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).
This commit is contained in:
2026-04-21 10:35:38 -03:00
parent 5175cc1ece
commit 5c1675e59a
8 changed files with 1390 additions and 160 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -8,7 +8,7 @@ using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary>
/// 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<int>("""
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<int>("""
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<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
_productType2Id = await conn.ExecuteScalarAsync<int>("""
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<int>("""
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<int>("""
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 ───────────────────────────────────────────────────────────────
/// <summary>
/// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL.
/// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId.
/// </summary>
private static async Task<long> 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",

View File

@@ -6,14 +6,15 @@ using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// <summary>
/// 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.
/// </summary>
[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<long?>("@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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<int>(@"
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<decimal>(@"
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"));
}
}

View File

@@ -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

View File

@@ -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
}
/// <summary>
/// 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.
/// </summary>
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).
}
/// <summary>
/// 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)
/// </summary>
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<bool>(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);
}
/// <summary>
/// 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).
/// </summary>
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);
}
}