diff --git a/database/README.md b/database/README.md
index a8f0932..1126d38 100644
--- a/database/README.md
+++ b/database/README.md
@@ -41,6 +41,7 @@ database/
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
+| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
## Convenciones
diff --git a/database/migrations/V025_ROLLBACK.sql b/database/migrations/V025_ROLLBACK.sql
new file mode 100644
index 0000000..c74d050
--- /dev/null
+++ b/database/migrations/V025_ROLLBACK.sql
@@ -0,0 +1,20 @@
+-- V025_ROLLBACK.sql
+-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
+-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+DELETE FROM dbo.ChargeableCharConfig
+ WHERE ProductTypeId IS NOT NULL
+ AND ValidTo IS NULL
+ AND Symbol IN (N'$', N'%', N'!', N'¡')
+ AND ProductTypeId IN (
+ SELECT Id FROM dbo.ProductType
+ WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
+ );
+
+PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
+GO
diff --git a/database/migrations/V025__seed_chargeable_char_overrides_demo.sql b/database/migrations/V025__seed_chargeable_char_overrides_demo.sql
new file mode 100644
index 0000000..0f06fbb
--- /dev/null
+++ b/database/migrations/V025__seed_chargeable_char_overrides_demo.sql
@@ -0,0 +1,101 @@
+-- V025__seed_chargeable_char_overrides_demo.sql
+-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
+--
+-- Estrategia:
+-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
+-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
+-- inserta overrides con precios ficticios coherentes con datos de demo del resto
+-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
+-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
+-- los overrides que falten (MERGE idempotente).
+--
+-- Precios ficticios (placeholders de demo — NO son tarifas reales):
+-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
+-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
+-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
+--
+-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
+-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
+-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+SET NOCOUNT ON;
+GO
+
+DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
+DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
+DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
+
+DECLARE @DemoValidFrom DATE = '2026-01-01';
+
+-- Clasificado overrides
+IF @ClasificadoId IS NOT NULL
+BEGIN
+ MERGE dbo.ChargeableCharConfig AS t
+ USING (VALUES
+ (@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
+ ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
+ ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
+ WHEN NOT MATCHED THEN
+ INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
+ VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
+ PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
+END
+ELSE
+ PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
+GO
+
+DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
+DECLARE @DemoValidFrom DATE = '2026-01-01';
+
+-- Notables overrides
+IF @NotablesId IS NOT NULL
+BEGIN
+ MERGE dbo.ChargeableCharConfig AS t
+ USING (VALUES
+ (@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
+ ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
+ ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
+ WHEN NOT MATCHED THEN
+ INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
+ VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
+ PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
+END
+ELSE
+ PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
+GO
+
+DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
+DECLARE @DemoValidFrom DATE = '2026-01-01';
+
+-- Fúnebres overrides
+IF @FunebresId IS NOT NULL
+BEGIN
+ MERGE dbo.ChargeableCharConfig AS t
+ USING (VALUES
+ (@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
+ (@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
+ ) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
+ ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
+ WHEN NOT MATCHED THEN
+ INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
+ VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
+ PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
+END
+ELSE
+ PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
+GO
+
+PRINT '';
+PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
+PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
+GO
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs
index 9fa3789..271df38 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs
@@ -1,6 +1,7 @@
using FluentValidation;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Pricing.ChargeableChars;
+using SIGCM2.Domain.Pricing.WordCounter;
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
@@ -19,7 +20,9 @@ public sealed class CreateChargeableCharConfigCommandValidator
.NotEmpty()
.WithMessage("Symbol no puede estar vacío.")
.MaximumLength(4)
- .WithMessage("Symbol no puede exceder 4 caracteres.");
+ .WithMessage("Symbol no puede exceder 4 caracteres.")
+ .Must(s => !WordCounterService.ContainsEmoji(s))
+ .WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
RuleFor(x => x.Category)
.NotEmpty()
@@ -28,8 +31,8 @@ public sealed class CreateChargeableCharConfigCommandValidator
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
RuleFor(x => x.PricePerUnit)
- .GreaterThan(0m)
- .WithMessage("PricePerUnit debe ser > 0.");
+ .GreaterThanOrEqualTo(0m)
+ .WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
RuleFor(x => x.ValidFrom)
.GreaterThanOrEqualTo(today)
diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
index 81530ad..199a123 100644
--- a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
+++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
@@ -96,6 +96,22 @@ public sealed class WordCounterService
return new WordCountResult(tokens.Length, counts);
}
+ ///
+ /// Returns true if the given string contains any emoji codepoint.
+ /// Used by validators that must reject emojis in user-facing identifiers
+ /// (e.g. ChargeableCharConfig.Symbol) where the frontend blocker can be bypassed
+ /// by direct API calls. Shares the same IsEmojiRune Unicode ranges used by Count().
+ ///
+ public static bool ContainsEmoji(string? text)
+ {
+ if (string.IsNullOrEmpty(text)) return false;
+ foreach (var rune in text.EnumerateRunes())
+ {
+ if (IsEmojiRune(rune)) return true;
+ }
+ return false;
+ }
+
///
/// Returns true if the given rune is an emoji codepoint.
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
diff --git a/src/web/tsconfig.json b/src/web/tsconfig.json
index fec8c8e..a5b41e9 100644
--- a/src/web/tsconfig.json
+++ b/src/web/tsconfig.json
@@ -6,6 +6,7 @@
],
"compilerOptions": {
"baseUrl": ".",
+ "ignoreDeprecations": "6.0",
"paths": {
"@/*": ["./src/*"]
}
diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
index ce14d3e..50a64da 100644
--- a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
@@ -243,9 +243,11 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
[Fact]
public async Task Post_InvalidPrice_Returns400ValidationFailure()
{
+ // PRC-001 followup #57: PricePerUnit >= 0 is now valid (opt-in billing).
+ // Use -1 to still exercise the negative rejection path.
var token = GetAdminToken();
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
- body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
+ body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = -1m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -276,25 +278,22 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
}
///
- /// PRC-001-R2.7 — Emoji symbols are explicitly DEFERRED per spec.
- /// The ChargeableCharConfig Symbol field accepts any 1–4 char value including emojis.
- /// "😀" in C# has string.Length = 2 (UTF-16 surrogate pair), so it passes MaximumLength(4).
- /// This test documents the deferred behavior: emoji in Symbol is accepted at config level.
- /// The EmojiDetectedException applies only to WordCounterService (ad text, not config symbols).
+ /// PRC-001 followup #55 — Business decision (2026-04-21): emoji Symbols are NOT allowed.
+ /// Validator delegates to WordCounterService.ContainsEmoji which checks every rune against
+ /// the Unicode emoji ranges (Emoticons, Pictographs, Dingbats, VS-16, ZWJ, etc.).
+ /// This provides a defensive check beyond the frontend SymbolInput blocker — direct API
+ /// calls (Postman, adversarial clients) can't bypass it.
///
[Fact]
- public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred()
+ public async Task Post_WithEmojiSymbol_Returns400()
{
var token = GetAdminToken();
- // "😀" has C# string.Length == 2 (UTF-16 surrogate pair) — passes MaximumLength(4).
- // Emoji rejection for config Symbols is deferred to PRC-002+ per spec R2.7.
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new { productTypeId = (long?)null, symbol = "😀", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
- // Accepted: emoji symbols deferred per spec. If business later rejects them, update validator + this test.
- resp.StatusCode.Should().Be(HttpStatusCode.Created,
- because: "emoji symbol rejection is deferred (spec R2.7). Symbol '😀' has length 2 in C# (UTF-16) → passes MaximumLength(4)");
+ resp.StatusCode.Should().Be(HttpStatusCode.BadRequest,
+ because: "emoji symbols are rejected by validator via WordCounterService.ContainsEmoji (#55)");
}
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
index d6ad20e..8ff9c59 100644
--- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
@@ -92,10 +92,13 @@ public class CreateChargeableCharConfigCommandValidatorTests
// ── PricePerUnit ─────────────────────────────────────────────────────────────
[Fact]
- public void PricePerUnit_Zero_FailsValidation()
+ public void PricePerUnit_Zero_Passes_OptInBilling()
{
+ // PRC-001 followup #57: opt-in billing — 0 is a valid price meaning
+ // "this Symbol exists but does NOT charge extra for this ProductType".
+ // Aligned with DB check constraint CK_ChargeableCharConfig_Price_NonNegative (>= 0).
var cmd = ValidCmd() with { PricePerUnit = 0m };
- _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
}
[Fact]
@@ -112,6 +115,35 @@ public class CreateChargeableCharConfigCommandValidatorTests
_validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
}
+ // ── Symbol emoji rejection (PRC-001 followup #55) ───────────────────────────
+
+ [Theory]
+ [InlineData("😀")] // smiley face (U+1F600, Emoticons block)
+ [InlineData("🚗")] // car (U+1F697, Transport block)
+ [InlineData("🔥")] // fire (U+1F525, Misc Symbols & Pictographs)
+ [InlineData("❤️")] // red heart + VS-16 (U+2764 U+FE0F)
+ [InlineData("☀️")] // sun (U+2600 in Misc Symbols block)
+ public void Symbol_WithEmoji_FailsValidation(string emojiSymbol)
+ {
+ var cmd = ValidCmd() with { Symbol = emojiSymbol };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
+ }
+
+ [Theory]
+ [InlineData("$")]
+ [InlineData("%")]
+ [InlineData("!")]
+ [InlineData("¡")]
+ [InlineData("@")]
+ [InlineData("€")]
+ [InlineData("##")]
+ [InlineData("ABCD")]
+ public void Symbol_WithoutEmoji_Passes(string plainSymbol)
+ {
+ var cmd = ValidCmd() with { Symbol = plainSymbol };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
+ }
+
// ── ValidFrom ────────────────────────────────────────────────────────────────
[Fact]