diff --git a/database/migrations/V021_ROLLBACK.sql b/database/migrations/V021_ROLLBACK.sql
new file mode 100644
index 0000000..28a7370
--- /dev/null
+++ b/database/migrations/V021_ROLLBACK.sql
@@ -0,0 +1,79 @@
+-- V021_ROLLBACK.sql
+-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
+--
+-- Pasos:
+-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
+-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
+-- 3. Drop de dbo.ChargeableCharConfig_History.
+-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
+-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
+-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
+--
+-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST.
+-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
+IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
+BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
+ PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
+END
+GO
+
+-- 2. Elimina el PERIOD y las hidden cols.
+IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
+BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig
+ DROP PERIOD FOR SYSTEM_TIME;
+
+ IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
+ ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
+ IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
+ ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
+
+ ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
+ ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
+ PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
+END
+GO
+
+-- 3. Drop de la history table.
+IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
+BEGIN
+ DROP TABLE dbo.ChargeableCharConfig_History;
+ PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
+END
+GO
+
+-- 4. Drop de la tabla principal.
+IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
+BEGIN
+ DROP TABLE dbo.ChargeableCharConfig;
+ PRINT 'Table dbo.ChargeableCharConfig dropped.';
+END
+GO
+
+-- 5. Drop del SP InsertWithClose.
+IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
+BEGIN
+ DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
+ PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
+END
+GO
+
+-- 6. Drop del SP GetActiveForMedio.
+IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
+BEGIN
+ DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
+ PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
+END
+GO
+
+PRINT '';
+PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
+GO
diff --git a/database/migrations/V021__create_chargeable_char_config.sql b/database/migrations/V021__create_chargeable_char_config.sql
new file mode 100644
index 0000000..867d403
--- /dev/null
+++ b/database/migrations/V021__create_chargeable_char_config.sql
@@ -0,0 +1,256 @@
+-- V021__create_chargeable_char_config.sql
+-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
+--
+-- Cambios:
+-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
+-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
+-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
+-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
+--
+-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
+-- Idempotente: seguro para re-ejecutar.
+-- Reversa: V021_ROLLBACK.sql.
+-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
+--
+-- Notas:
+-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
+-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
+-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
+-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
+-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
+-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
+--
+-- SDD Design: engram sdd/prc-001-word-counter-spike/design
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 1. dbo.ChargeableCharConfig
+-- ═══════════════════════════════════════════════════════════════════════
+
+IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.ChargeableCharConfig (
+ Id BIGINT IDENTITY(1,1) NOT NULL
+ CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
+ MedioId INT NULL, -- NULL = global fallback
+ Symbol NVARCHAR(4) NOT NULL,
+ Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
+ PricePerUnit DECIMAL(18,4) NOT NULL,
+ ValidFrom DATE NOT NULL,
+ ValidTo DATE NULL,
+ IsActive BIT NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
+ FechaCreacion DATETIME2(3) NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
+ CONSTRAINT FK_ChargeableCharConfig_Medio
+ FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
+ CONSTRAINT CK_ChargeableCharConfig_Price_Positive
+ CHECK (PricePerUnit > 0),
+ CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
+ CHECK (LEN(Symbol) > 0),
+ CONSTRAINT CK_ChargeableCharConfig_ValidRange
+ CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
+ );
+ PRINT 'Table dbo.ChargeableCharConfig created.';
+END
+ELSE
+ PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
+-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
+-- ═══════════════════════════════════════════════════════════════════════
+
+IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
+BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig
+ ADD
+ SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
+ SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
+ PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
+ PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
+END
+GO
+
+IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
+BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig
+ SET (SYSTEM_VERSIONING = ON (
+ HISTORY_TABLE = dbo.ChargeableCharConfig_History,
+ HISTORY_RETENTION_PERIOD = 10 YEARS
+ ));
+ PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
+END
+ELSE
+ PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
+GO
+
+IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_History' AND schema_id = SCHEMA_ID('dbo'))
+ AND NOT EXISTS (
+ SELECT 1 FROM sys.partitions p
+ JOIN sys.tables t ON t.object_id = p.object_id
+ WHERE t.name = 'ChargeableCharConfig_History' AND p.data_compression = 2
+ )
+BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
+ PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
+END
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 3. Índices
+-- ═══════════════════════════════════════════════════════════════════════
+
+-- Un único vigente por (MedioId, Symbol).
+-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
+-- → enforza exactamente 1 vigente global por símbolo (D7).
+IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'UX_ChargeableCharConfig_Vigente'
+ AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
+)
+BEGIN
+ CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
+ ON dbo.ChargeableCharConfig (MedioId, Symbol)
+ WHERE ValidTo IS NULL;
+ PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
+END
+GO
+
+-- Cover para GetActiveForMedio y List.
+IF NOT EXISTS (
+ SELECT 1 FROM sys.indexes
+ WHERE name = 'IX_ChargeableCharConfig_Query'
+ AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
+)
+BEGIN
+ CREATE INDEX IX_ChargeableCharConfig_Query
+ ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
+ INCLUDE (PricePerUnit, IsActive, Category);
+ PRINT 'Index IX_ChargeableCharConfig_Query created.';
+END
+GO
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
+-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
+-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
+-- THROW 50404: Medio not found.
+-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
+-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
+-- ═══════════════════════════════════════════════════════════════════════
+GO
+CREATE OR 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;
+
+ -- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
+ 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
+
+ -- Lee el vigente actual con bloqueo de rango para serialización.
+ 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;
+
+ -- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
+ IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
+ BEGIN
+ ROLLBACK;
+ THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
+ END
+
+ -- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
+ 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;
+
+ -- Inserta el nuevo vigente.
+ 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
+
+-- ═══════════════════════════════════════════════════════════════════════
+-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
+-- Resolución per-medio + global fallback: 1 fila por Symbol.
+-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
+-- ═══════════════════════════════════════════════════════════════════════
+GO
+CREATE OR 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, -- 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 (MedioId = @MedioId OR MedioId IS NULL)
+ )
+ SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
+ FROM Candidates
+ WHERE rn = 1;
+END
+GO
+
+PRINT '';
+PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
+PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
+GO
diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs
index 7eeb4b8..a7a644b 100644
--- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs
@@ -52,8 +52,9 @@ public class AuthControllerTests
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
- // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
- Assert.Equal(27, permisos.GetArrayLength());
+ // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
+ // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
+ Assert.Equal(28, permisos.GetArrayLength());
}
// Scenario: invalid credentials return 401 with opaque error
diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
index a363606..70d5952 100644
--- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
+++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
@@ -142,8 +142,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
- // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
- Assert.Equal(27, list.GetArrayLength());
+ // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
+ // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
+ Assert.Equal(28, list.GetArrayLength());
}
[Fact]
@@ -199,8 +200,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
- // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
- Assert.Equal(27, list.GetArrayLength());
+ // V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27
+ // V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' → 28 total
+ Assert.Equal(28, list.GetArrayLength());
}
[Fact]
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs
new file mode 100644
index 0000000..821662e
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigMigrationTests.cs
@@ -0,0 +1,365 @@
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Xunit;
+
+namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
+
+///
+/// PRC-001 Batch 1 (RED) — Integration tests for V020/V021/V022 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.
+/// - V022: 4 global seed rows ($, %, !, ¡) exist and are active.
+///
+/// Tests are tagged [RED] until V020+V021+V022 are applied (Batch 1 GREEN step).
+/// After GREEN, all tests in this class should pass.
+///
+[Collection("Database")]
+public sealed class ChargeableCharConfigMigrationTests : IAsyncLifetime
+{
+ private SqlConnection _connection = null!;
+
+ public async Task InitializeAsync()
+ {
+ _connection = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await _connection.OpenAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _connection.CloseAsync();
+ await _connection.DisposeAsync();
+ }
+
+ // ── V021: Table existence ─────────────────────────────────────────────
+
+ [Fact]
+ public async Task V021_Table_ChargeableCharConfig_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
+
+ exists.Should().Be(1, "V021 debe crear dbo.ChargeableCharConfig");
+ }
+
+ [Fact]
+ public async Task V021_Table_ChargeableCharConfig_HasSystemVersioning()
+ {
+ var temporalType = await _connection.ExecuteScalarAsync(@"
+ SELECT temporal_type
+ FROM sys.tables
+ WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
+
+ temporalType.Should().Be(2, "SYSTEM_VERSIONING = ON requiere temporal_type = 2");
+ }
+
+ [Fact]
+ public async Task V021_HistoryTable_ChargeableCharConfigHistory_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')");
+
+ exists.Should().Be(1, "SYSTEM_VERSIONING debe crear dbo.ChargeableCharConfig_History");
+ }
+
+ [Fact]
+ public async Task V021_FilteredUniqueIndex_UX_ChargeableCharConfig_Vigente_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*)
+ FROM sys.indexes
+ WHERE name = 'UX_ChargeableCharConfig_Vigente'
+ AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
+
+ exists.Should().Be(1, "UX_ChargeableCharConfig_Vigente filtered index debe existir");
+ }
+
+ [Fact]
+ public async Task V021_CoverIndex_IX_ChargeableCharConfig_Query_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*)
+ FROM sys.indexes
+ WHERE name = 'IX_ChargeableCharConfig_Query'
+ AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')");
+
+ exists.Should().Be(1, "IX_ChargeableCharConfig_Query covering index debe existir");
+ }
+
+ // ── V021: SP — usp_ChargeableCharConfig_InsertWithClose ──────────────
+
+ [Fact]
+ public async Task V021_SP_InsertWithClose_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*)
+ FROM sys.objects
+ WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose')
+ AND type = 'P'");
+
+ exists.Should().Be(1, "usp_ChargeableCharConfig_InsertWithClose debe existir");
+ }
+
+ [Fact]
+ public async Task usp_InsertWithClose_FirstPrice_ClosedIsNull()
+ {
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId 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,
+ direction: System.Data.ParameterDirection.Output);
+ 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");
+ var closedId = p.Get("@ClosedId");
+
+ newId.Should().BeGreaterThan(0, "primer insert debe devolver NewId > 0");
+ closedId.Should().BeNull("primer insert no cierra nada — ClosedId debe ser NULL");
+
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'@' AND MedioId IS NULL");
+ }
+
+ [Fact]
+ public async Task usp_InsertWithClose_HappyPath_ClosesAndCreates()
+ {
+ // Seed primer activo
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId 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,
+ direction: System.Data.ParameterDirection.Output);
+ p1.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1,
+ commandType: System.Data.CommandType.StoredProcedure);
+ var firstId = p1.Get("@NewId")!.Value;
+
+ // 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,
+ direction: System.Data.ParameterDirection.Output);
+ p2.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ var newId = p2.Get("@NewId")!.Value;
+ var closedId = p2.Get("@ClosedId");
+
+ newId.Should().BeGreaterThan(firstId);
+ closedId.Should().Be(firstId, "el primer activo debe ser cerrado");
+
+ // Verify el activo cerrado tiene ValidTo = 2026-01-31
+ var closedValidTo = await _connection.ExecuteScalarAsync(
+ "SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id",
+ new { Id = firstId });
+ closedValidTo.Should().Be(new DateTime(2026, 1, 31),
+ "ValidTo del cerrado = ValidFrom(nuevo) - 1 día");
+
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'€' AND MedioId IS NULL");
+ }
+
+ [Fact]
+ public async Task usp_InsertWithClose_ForwardOnlyViolation_Throws50409()
+ {
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId 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,
+ direction: System.Data.ParameterDirection.Output);
+ p1.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ // 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,
+ direction: System.Data.ParameterDirection.Output);
+ p2.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ var act = async () => await _connection.ExecuteAsync(
+ "dbo.usp_ChargeableCharConfig_InsertWithClose", p2,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ await act.Should().ThrowAsync()
+ .Where(ex => ex.Number == 50409,
+ "ValidFrom igual al activo debe generar THROW 50409 (forward-only)");
+
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'£' AND MedioId IS NULL");
+ }
+
+ [Fact]
+ public async Task usp_InsertWithClose_MedioNull_GlobalFallback_Works()
+ {
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId 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,
+ direction: System.Data.ParameterDirection.Output);
+ 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");
+
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'¥' AND MedioId IS NULL");
+ }
+
+ [Fact]
+ public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow()
+ {
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE MedioId 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,
+ direction: System.Data.ParameterDirection.Output);
+ p1.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p1,
+ commandType: System.Data.CommandType.StoredProcedure);
+ var firstId = p1.Get("@NewId")!.Value;
+
+ // 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,
+ direction: System.Data.ParameterDirection.Output);
+ p2.Add("@ClosedId", dbType: System.Data.DbType.Int64,
+ direction: System.Data.ParameterDirection.Output);
+
+ await _connection.ExecuteAsync("dbo.usp_ChargeableCharConfig_InsertWithClose", p2,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ // The UPDATE on the closed row should produce a history row
+ var historyCount = await _connection.ExecuteScalarAsync(
+ "SELECT COUNT(*) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id",
+ new { Id = firstId });
+
+ historyCount.Should().BeGreaterThanOrEqualTo(1,
+ "UPDATE en SYSTEM_VERSIONED table debe producir al menos 1 fila en history");
+
+ // Cleanup
+ await _connection.ExecuteAsync(
+ "DELETE FROM dbo.ChargeableCharConfig WHERE Symbol = N'#' AND MedioId IS NULL");
+ }
+
+ // ── V021: SP — usp_ChargeableCharConfig_GetActiveForMedio ────────────
+
+ [Fact]
+ public async Task V021_SP_GetActiveForMedio_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*)
+ FROM sys.objects
+ WHERE object_id = OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio')
+ AND type = 'P'");
+
+ exists.Should().Be(1, "usp_ChargeableCharConfig_GetActiveForMedio debe existir");
+ }
+
+ // ── V020: Permission existence ────────────────────────────────────────
+
+ [Fact]
+ public async Task V020_Permission_tasacion_caracteres_especiales_gestionar_Exists()
+ {
+ var exists = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*) FROM dbo.Permiso
+ WHERE Codigo = 'tasacion:caracteres_especiales:gestionar'");
+
+ exists.Should().Be(1,
+ "V020 debe insertar el permiso 'tasacion:caracteres_especiales:gestionar'");
+ }
+
+ // ── V022: Seed rows ───────────────────────────────────────────────────
+
+ [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'¡')
+ AND ValidTo IS NULL AND IsActive = 1");
+
+ count.Should().Be(4, "V022 debe sembrar exactamente 4 filas globales: $, %, !, ¡");
+ }
+
+ [Fact]
+ public async Task V022_AllSeedRowsHaveIsActive_True()
+ {
+ var inactiveCount = await _connection.ExecuteScalarAsync(@"
+ SELECT COUNT(*) FROM dbo.ChargeableCharConfig
+ WHERE MedioId 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");
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs
index 1f1f9e4..961b8c0 100644
--- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs
+++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs
@@ -83,8 +83,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
- // + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
- Assert.Equal(27, list.Count);
+ // + V018 (PRD-002) adds 'catalogo:productos:gestionar'
+ // + V020 (PRC-001) adds 'tasacion:caracteres_especiales:gestionar' = 28 total
+ Assert.Equal(28, list.Count);
}
[Fact]
diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs
index dc21349..9a2b887 100644
--- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs
+++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs
@@ -181,10 +181,11 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar'
- // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total
+ // + 1 from V018 (PRD-002): 'catalogo:productos:gestionar'
+ // + 1 from V020 (PRC-001): 'tasacion:caracteres_especiales:gestionar' = 28 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
- Assert.Equal(27, permisos.Count);
+ Assert.Equal(28, permisos.Count);
}
[Fact]
diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
index f2bf05a..775cdaa 100644
--- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs
+++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
@@ -69,6 +69,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice.
await EnsureV019SchemaAsync();
+ // V020/V021/V022 (PRC-001): ensure dbo.ChargeableCharConfig + temporal + SPs + permission + seed.
+ await EnsureV021SchemaAsync();
+
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -101,6 +104,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Product_History"),
// PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING.
new Respawn.Graph.Table("dbo", "ProductPrices_History"),
+ // PRC-001 (V021): ChargeableCharConfig es temporal — history protegida por SYSTEM_VERSIONING.
+ new Respawn.Graph.Table("dbo", "ChargeableCharConfig_History"),
]
});
@@ -122,6 +127,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
await SeedRolPermisosCanonicalAsync();
await SeedAdminAsync();
await SeedMediosCanonicalAsync();
+ await SeedChargeableCharConfigCanonicalAsync();
}
private async Task SeedRolCanonicalAsync()
@@ -227,7 +233,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
-- V017 (PRD-001): permiso para gestionar tipos de producto
('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'),
-- V018 (PRD-002): permiso para gestionar productos del catálogo
- ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo')
+ ('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo'),
+ -- V020 (PRC-001): permiso para gestionar caracteres tasables
+ ('tasacion:caracteres_especiales:gestionar', N'Gestionar caracteres tasables', N'Crear, editar precio y desactivar la configuracion de caracteres especiales para tasacion.', 'tasacion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -279,6 +287,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'catalogo:tipos:gestionar'),
-- V018 (PRD-002)
('admin', 'catalogo:productos:gestionar'),
+ -- V020 (PRC-001)
+ ('admin', 'tasacion:caracteres_especiales:gestionar'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
@@ -563,6 +573,34 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(sql);
}
+ ///
+ /// 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
+ /// must be reset between test classes — only the 4 global defaults are reseeded.
+ ///
+ private async Task SeedChargeableCharConfigCanonicalAsync()
+ {
+ const string sql = """
+ SET QUOTED_IDENTIFIER ON;
+ IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
+ 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)
+ 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);
+ END
+ """;
+ await _connection.ExecuteAsync(sql);
+ }
+
///
/// UDT-010 (V010): verifies that the audit infrastructure is present.
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
@@ -1267,4 +1305,216 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(createSp);
await _connection.ExecuteAsync(alterSp);
}
+
+ ///
+ /// PRC-001 (V020/V021/V022): applies dbo.ChargeableCharConfig schema + SYSTEM_VERSIONING
+ /// + filtered UX + SPs + permission 'tasacion:caracteres_especiales:gestionar' + seed data.
+ /// Mirrors V020+V021+V022 migrations (idempotente).
+ /// Permission y asignación a admin se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
+ /// IMPORTANT: dbo.ChargeableCharConfig_History must be in TablesToIgnore — SYSTEM_VERSIONING
+ /// prevents Respawn from directly truncating history tables (engine rejects).
+ ///
+ private async Task EnsureV021SchemaAsync()
+ {
+ const string createTable = """
+ IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
+ BEGIN
+ CREATE TABLE dbo.ChargeableCharConfig (
+ Id BIGINT IDENTITY(1,1) NOT NULL
+ CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
+ MedioId INT NULL,
+ Symbol NVARCHAR(4) NOT NULL,
+ Category NVARCHAR(32) NOT NULL,
+ PricePerUnit DECIMAL(18,4) NOT NULL,
+ ValidFrom DATE NOT NULL,
+ ValidTo DATE NULL,
+ IsActive BIT NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
+ FechaCreacion DATETIME2(3) NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
+ CONSTRAINT FK_ChargeableCharConfig_Medio
+ FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
+ CONSTRAINT CK_ChargeableCharConfig_Price_Positive
+ CHECK (PricePerUnit > 0),
+ CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
+ CHECK (LEN(Symbol) > 0),
+ CONSTRAINT CK_ChargeableCharConfig_ValidRange
+ CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
+ );
+ END
+ """;
+
+ const string addPeriod = """
+ IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
+ BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig
+ ADD
+ SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
+ SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
+ CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
+ PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
+ END
+ """;
+
+ const string setVersioning = """
+ IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
+ BEGIN
+ ALTER TABLE dbo.ChargeableCharConfig
+ SET (SYSTEM_VERSIONING = ON (
+ HISTORY_TABLE = dbo.ChargeableCharConfig_History,
+ HISTORY_RETENTION_PERIOD = 10 YEARS
+ ));
+ END
+ """;
+
+ const string createVigenteIndex = """
+ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
+ BEGIN
+ CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
+ ON dbo.ChargeableCharConfig (MedioId, Symbol)
+ WHERE ValidTo IS NULL;
+ END
+ """;
+
+ const string createQueryIndex = """
+ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query' AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
+ BEGIN
+ CREATE INDEX IX_ChargeableCharConfig_Query
+ ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
+ INCLUDE (PricePerUnit, IsActive, Category);
+ END
+ """;
+
+ 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');
+ """;
+
+ const string alterInsertSp = """
+ 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
+ """;
+
+ const string createGetActiveSp = """
+ IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NULL
+ EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
+ """;
+
+ const string alterGetActiveSp = """
+ 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
+ """;
+
+ const string seedV022 = """
+ 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)
+ 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);
+ """;
+
+ await _connection.ExecuteAsync(createTable);
+ await _connection.ExecuteAsync(addPeriod);
+ await _connection.ExecuteAsync(setVersioning);
+ await _connection.ExecuteAsync(createVigenteIndex);
+ await _connection.ExecuteAsync(createQueryIndex);
+ await _connection.ExecuteAsync(createInsertSp);
+ await _connection.ExecuteAsync(alterInsertSp);
+ await _connection.ExecuteAsync(createGetActiveSp);
+ await _connection.ExecuteAsync(alterGetActiveSp);
+ await _connection.ExecuteAsync(seedV022);
+ // Permission 'tasacion:caracteres_especiales:gestionar' and admin assignment
+ // are seeded from SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
+ }
}