diff --git a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
new file mode 100644
index 0000000..7bd1adc
--- /dev/null
+++ b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
@@ -0,0 +1,201 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using SIGCM2.Api.Authorization;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Common;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Application.Pricing.ChargeableChars.Create;
+using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
+using SIGCM2.Application.Pricing.ChargeableChars.GetById;
+using SIGCM2.Application.Pricing.ChargeableChars.List;
+using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+
+namespace SIGCM2.Api.Controllers;
+
+///
+/// PRC-001: Admin endpoints for ChargeableCharConfig management.
+/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
+/// Route base: api/v1/admin/chargeable-chars
+///
+[ApiController]
+[Route("api/v1/admin/chargeable-chars")]
+public sealed class ChargeableCharConfigController : ControllerBase
+{
+ private readonly IDispatcher _dispatcher;
+ private readonly IValidator _createValidator;
+ private readonly IValidator _scheduleValidator;
+
+ public ChargeableCharConfigController(
+ IDispatcher dispatcher,
+ IValidator createValidator,
+ IValidator scheduleValidator)
+ {
+ _dispatcher = dispatcher;
+ _createValidator = createValidator;
+ _scheduleValidator = scheduleValidator;
+ }
+
+ // ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
+
+ ///
+ /// Returns a paginated list of ChargeableCharConfig rows.
+ /// Filters: medioId (optional, long?), activeOnly (bool, default true).
+ /// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
+ /// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
+ ///
+ [HttpGet]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task List(
+ [FromQuery] long? medioId,
+ [FromQuery] bool activeOnly = true,
+ [FromQuery] int? page = null,
+ [FromQuery] int? pageSize = null,
+ [FromQuery] int? skip = null,
+ [FromQuery] int? take = null)
+ {
+ // Support both page/pageSize and skip/take query patterns
+ int resolvedPage;
+ int resolvedPageSize;
+
+ if (skip is not null || take is not null)
+ {
+ // Convert skip/take to page/pageSize
+ resolvedPageSize = Math.Min(take ?? 50, 200);
+ resolvedPage = resolvedPageSize > 0
+ ? ((skip ?? 0) / resolvedPageSize) + 1
+ : 1;
+ }
+ else
+ {
+ resolvedPage = page ?? 1;
+ resolvedPageSize = Math.Min(pageSize ?? 20, 200);
+ }
+
+ var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize);
+ var result = await _dispatcher.Send>(query);
+ return Ok(result);
+ }
+
+ // ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
+
+ ///
+ /// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
+ ///
+ [HttpGet("{id:long}")]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetById([FromRoute] long id)
+ {
+ var result = await _dispatcher.Send(
+ new GetChargeableCharConfigByIdQuery(id));
+ return result is null ? NotFound() : Ok(result);
+ }
+
+ // ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
+
+ ///
+ /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists.
+ /// Returns 201 Created with Location header pointing to GET /{id}.
+ ///
+ [HttpPost]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Create([FromBody] CreateChargeableCharConfigRequest request)
+ {
+ var command = new CreateChargeableCharConfigCommand(
+ request.MedioId,
+ request.Symbol,
+ request.Category,
+ request.PricePerUnit,
+ request.ValidFrom);
+
+ var validation = await _createValidator.ValidateAsync(command);
+ if (!validation.IsValid)
+ {
+ var errors = validation.Errors
+ .GroupBy(e => e.PropertyName)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
+ return BadRequest(new { errors });
+ }
+
+ var result = await _dispatcher.Send(command);
+ return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
+ }
+
+ // ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
+
+ ///
+ /// Schedules a price change for an existing ChargeableCharConfig.
+ /// Closes the current active row and opens a new one with the new price + ValidFrom.
+ /// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
+ ///
+ [HttpPut("{id:long}/price")]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task SchedulePriceChange(
+ [FromRoute] long id,
+ [FromBody] SchedulePriceChangeRequest request)
+ {
+ var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
+
+ var validation = await _scheduleValidator.ValidateAsync(command);
+ if (!validation.IsValid)
+ {
+ var errors = validation.Errors
+ .GroupBy(e => e.PropertyName)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
+ return BadRequest(new { errors });
+ }
+
+ var result = await _dispatcher.Send(command);
+ return Ok(result);
+ }
+
+ // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
+
+ ///
+ /// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
+ /// Idempotent: calling on an already-inactive row is a no-op.
+ ///
+ [HttpPatch("{id:long}/deactivate")]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task Deactivate([FromRoute] long id)
+ {
+ var result = await _dispatcher.Send(
+ new DeactivateChargeableCharConfigCommand(id));
+ return Ok(result);
+ }
+}
+
+// ── Request body records ──────────────────────────────────────────────────────
+
+/// PRC-001: Create ChargeableCharConfig request body.
+public sealed record CreateChargeableCharConfigRequest(
+ long? MedioId,
+ string Symbol,
+ string Category,
+ decimal PricePerUnit,
+ DateOnly ValidFrom);
+
+/// PRC-001: Schedule price change request body.
+public sealed record SchedulePriceChangeRequest(
+ decimal PricePerUnit,
+ DateOnly ValidFrom);
diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
index 9b3602e..3df5936 100644
--- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
+++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Exceptions;
+using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Api.Filters;
@@ -645,6 +646,67 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
+ // PRC-001: WordCounter + ChargeableCharConfig exceptions
+ case EmojiDetectedException emojiEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "emoji_not_allowed",
+ code = "EMOJI_NOT_ALLOWED",
+ message = emojiEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status400BadRequest
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case WordCountValidationException wordEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "word_count_validation",
+ code = "WORD_COUNT_VALIDATION",
+ field = wordEx.Field,
+ reason = wordEx.Reason,
+ message = wordEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status400BadRequest
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case ChargeableCharConfigInvalidException configInvalidEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "chargeable_char_invalid",
+ code = "CHARGEABLE_CHAR_INVALID",
+ field = configInvalidEx.Field,
+ reason = configInvalidEx.Reason,
+ message = configInvalidEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status400BadRequest
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "chargeable_char_forward_only",
+ code = "CHARGEABLE_CHAR_FORWARD_ONLY",
+ medioId = forwardOnlyCharEx.MedioId,
+ symbol = forwardOnlyCharEx.Symbol,
+ newValidFrom = forwardOnlyCharEx.NewValidFrom,
+ activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
+ message = forwardOnlyCharEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status409Conflict
+ };
+ context.ExceptionHandled = true;
+ break;
+
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)
diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
new file mode 100644
index 0000000..a2f9132
--- /dev/null
+++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
@@ -0,0 +1,528 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using SIGCM2.Application.Abstractions.Security;
+using SIGCM2.Application.Audit;
+using SIGCM2.Application.Common;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Domain.Entities;
+using SIGCM2.TestSupport;
+using Xunit;
+
+namespace SIGCM2.Api.Tests.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — E2E integration tests for:
+/// GET /api/v1/admin/chargeable-chars
+/// GET /api/v1/admin/chargeable-chars/{id}
+/// POST /api/v1/admin/chargeable-chars
+/// PUT /api/v1/admin/chargeable-chars/{id}/price
+/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate
+///
+/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
+/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission.
+///
+[Collection("ApiIntegration")]
+public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
+{
+ private const string ConnectionString = TestConnectionStrings.ApiTestDb;
+
+ private readonly TestWebAppFactory _factory;
+ private readonly HttpClient _client;
+
+ public ChargeableCharConfigControllerTests(TestWebAppFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient();
+ }
+
+ public Task InitializeAsync() => Task.CompletedTask;
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ // ── Auth helpers ──────────────────────────────────────────────────────────
+
+ /// Admin token — has 'tasacion:caracteres_especiales:gestionar' via 'admin' role.
+ private string GetAdminToken()
+ {
+ var jwt = _factory.Services.GetRequiredService();
+ return jwt.GenerateAccessToken(new Usuario(
+ id: 1, username: "admin", passwordHash: "x",
+ nombre: "Admin", apellido: "Sys", email: null,
+ rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
+ }
+
+ /// Cajero token — does NOT have 'tasacion:caracteres_especiales:gestionar'.
+ private string GetCajeroToken()
+ {
+ var jwt = _factory.Services.GetRequiredService();
+ return jwt.GenerateAccessToken(new Usuario(
+ id: 9999, username: "cajero_test", passwordHash: "x",
+ nombre: "Cajero", apellido: "Test", email: null,
+ rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
+ }
+
+ private HttpRequestMessage BuildRequest(
+ HttpMethod method, string url, object? body = null, string? token = null)
+ {
+ var req = new HttpRequestMessage(method, url);
+ if (token is not null)
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ if (body is not null)
+ req.Content = JsonContent.Create(body);
+ return req;
+ }
+
+ // ── DB seed helpers ───────────────────────────────────────────────────────
+
+ /// Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.
+ private async Task SeedConfigDirectAsync(
+ long? medioId, string symbol, string category, decimal pricePerUnit,
+ DateOnly validFrom, DateOnly? validTo, bool isActive = true)
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+ return await conn.QuerySingleAsync("""
+ INSERT INTO dbo.ChargeableCharConfig
+ (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
+ VALUES (@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
+ SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
+ """,
+ new
+ {
+ MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value,
+ Symbol = symbol,
+ Category = category,
+ PricePerUnit = pricePerUnit,
+ ValidFrom = validFrom.ToDateTime(TimeOnly.MinValue),
+ ValidTo = validTo.HasValue ? (object)validTo.Value.ToDateTime(TimeOnly.MinValue) : DBNull.Value,
+ IsActive = isActive ? 1 : 0
+ });
+ }
+
+ private static string TomorrowStr() =>
+ DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
+
+ private static string FutureDateStr(int daysAhead = 60) =>
+ DateOnly.FromDateTime(DateTime.UtcNow).AddDays(daysAhead).ToString("yyyy-MM-dd");
+
+ // ── GET /api/v1/admin/chargeable-chars ───────────────────────────────────
+
+ /// PRC-001-R3.1 — GET list returns paged result.
+ [Fact]
+ public async Task Get_List_ReturnsPagedResult()
+ {
+ // Seed 2 active rows with unique symbols to avoid conflicts
+ var sym1 = $"L{Guid.NewGuid():N}"[..1];
+ await SeedConfigDirectAsync(null, "§", "Currency", 1.50m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Get,
+ "/api/v1/admin/chargeable-chars?activeOnly=true", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.TryGetProperty("items", out _).Should().BeTrue("response must have 'items' property");
+ body.GetProperty("page").GetInt32().Should().BeGreaterThanOrEqualTo(1);
+ body.GetProperty("pageSize").GetInt32().Should().BeGreaterThanOrEqualTo(1);
+ body.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task Get_List_Unauthenticated_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars");
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task Get_List_WithoutPermission_Returns403()
+ {
+ var token = GetCajeroToken();
+ using var req = BuildRequest(HttpMethod.Get, "/api/v1/admin/chargeable-chars", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
+ // ── GET /api/v1/admin/chargeable-chars/{id} ──────────────────────────────
+
+ /// PRC-001-R3.1 — GET by id returns 200 + DTO.
+ [Fact]
+ public async Task Get_ById_Existing_Returns200()
+ {
+ var id = await SeedConfigDirectAsync(null, "€", "Currency", 2.00m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Get,
+ $"/api/v1/admin/chargeable-chars/{id}", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var dto = await resp.Content.ReadFromJsonAsync();
+ dto.GetProperty("id").GetInt64().Should().Be(id);
+ dto.GetProperty("symbol").GetString().Should().Be("€");
+ dto.GetProperty("isActive").GetBoolean().Should().BeTrue();
+ }
+
+ /// PRC-001-R3.1 — GET by non-existent id returns 404.
+ [Fact]
+ public async Task Get_ByIdMissing_Returns404()
+ {
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Get,
+ "/api/v1/admin/chargeable-chars/999999999", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ // ── POST /api/v1/admin/chargeable-chars ──────────────────────────────────
+
+ /// PRC-001-R3.2 — POST valid payload returns 201 + Location header.
+ [Fact]
+ public async Task Post_WithValidPayload_Returns201WithLocation()
+ {
+ var token = GetAdminToken();
+ var validFrom = TomorrowStr();
+
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new
+ {
+ medioId = (long?)null,
+ symbol = "¥",
+ category = "Currency",
+ pricePerUnit = 1.75m,
+ validFrom
+ },
+ token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.Created);
+ resp.Headers.Location.Should().NotBeNull("201 must include Location header");
+
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("id").GetInt64().Should().BeGreaterThan(0);
+ body.GetProperty("symbol").GetString().Should().Be("¥");
+ body.GetProperty("validFrom").GetString().Should().Be(validFrom);
+ }
+
+ /// PRC-001-R3.5 — POST without auth returns 401.
+ [Fact]
+ public async Task Post_Unauthenticated_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ /// PRC-001-R3.5 — POST without permission returns 403.
+ [Fact]
+ public async Task Post_WithoutPermission_Returns403()
+ {
+ var token = GetCajeroToken();
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
+ /// PRC-001-R3.2 — POST invalid price returns 400 validation failure.
+ [Fact]
+ public async Task Post_InvalidPrice_Returns400ValidationFailure()
+ {
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ /// PRC-001-R2.7 — POST with symbol too long returns 400.
+ [Fact]
+ public async Task Post_SymbolTooLong_Returns400()
+ {
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ /// PRC-001-R2.6 — POST with past validFrom returns 400.
+ [Fact]
+ public async Task Post_WithPastValidFrom_Returns400()
+ {
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = "2020-01-01" },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ ///
+ /// 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).
+ ///
+ [Fact]
+ public async Task Post_WithEmojiSymbol_Returns201_BecauseEmojiRejectionIsDeferred()
+ {
+ 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 { medioId = (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)");
+ }
+
+ // ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
+
+ /// PRC-001-R3.3 — PUT schedules price change, returns 200.
+ [Fact]
+ public async Task Put_PriceChange_Returns200()
+ {
+ // Seed an active row
+ var existingId = await SeedConfigDirectAsync(null, "★", "Currency", 1.00m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ var newValidFrom = FutureDateStr(30);
+
+ using var req = BuildRequest(HttpMethod.Put,
+ $"/api/v1/admin/chargeable-chars/{existingId}/price",
+ body: new { pricePerUnit = 2.50m, validFrom = newValidFrom },
+ token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("newId").GetInt64().Should().BeGreaterThan(0);
+ body.GetProperty("newValidFrom").GetString().Should().Be(newValidFrom);
+ }
+
+ /// PRC-001-R3.3 — PUT with retroactive date returns 409 ForwardOnly.
+ [Fact]
+ public async Task Put_PriceBackdateAttempt_Returns409()
+ {
+ // Seed an active row with a far-future ValidFrom
+ var existingId = await SeedConfigDirectAsync(null, "♦", "Currency", 1.00m,
+ new DateOnly(2099, 12, 1), null, true);
+
+ var token = GetAdminToken();
+ // Try to schedule before the existing ValidFrom
+ using var req = BuildRequest(HttpMethod.Put,
+ $"/api/v1/admin/chargeable-chars/{existingId}/price",
+ body: new { pricePerUnit = 2.00m, validFrom = "2099-11-01" },
+ token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("error").GetString().Should().Be("chargeable_char_forward_only");
+ }
+
+ // ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ────────────────
+
+ /// PRC-001-R3.4 — PATCH deactivate returns 200 OK.
+ [Fact]
+ public async Task Patch_Deactivate_Returns200()
+ {
+ var id = await SeedConfigDirectAsync(null, "▲", "Currency", 1.00m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ /// PRC-001 — PATCH deactivate on already-inactive row is idempotent → 200 OK.
+ [Fact]
+ public async Task Patch_Deactivate_AlreadyInactive_Returns200()
+ {
+ // Seed an already-inactive row
+ var id = await SeedConfigDirectAsync(null, "▼", "Currency", 1.00m,
+ new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31), false);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ // Idempotent — no exception thrown
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ // ── Audit ─────────────────────────────────────────────────────────────────
+
+ /// PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.
+ [Fact]
+ public async Task AuditEvent_EmittedOnCreate()
+ {
+ var token = GetAdminToken();
+ var validFrom = TomorrowStr();
+
+ using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
+ body: new { medioId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Created,
+ because: "POST must succeed before we can verify audit");
+
+ var body = await resp.Content.ReadFromJsonAsync();
+ var newId = body.GetProperty("id").GetInt64();
+
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+ var auditCount = await conn.QuerySingleAsync("""
+ SELECT COUNT(1) FROM dbo.AuditEvent
+ WHERE Action = 'tasacion.chargeable_char.create'
+ AND TargetType = 'ChargeableCharConfig'
+ AND TargetId = @TargetId
+ """, new { TargetId = newId.ToString() });
+
+ auditCount.Should().Be(1,
+ because: "IAuditLogger must record tasacion.chargeable_char.create after successful POST");
+ }
+
+ /// PRC-001-R3.6 — PUT price change emits audit event.
+ [Fact]
+ public async Task AuditEvent_EmittedOnPriceChange()
+ {
+ var existingId = await SeedConfigDirectAsync(null, "↗", "Currency", 1.00m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ var newValidFrom = FutureDateStr(45);
+
+ using var req = BuildRequest(HttpMethod.Put,
+ $"/api/v1/admin/chargeable-chars/{existingId}/price",
+ body: new { pricePerUnit = 3.00m, validFrom = newValidFrom },
+ token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK,
+ because: "PUT must succeed before we can verify audit");
+
+ var body = await resp.Content.ReadFromJsonAsync();
+ var newId = body.GetProperty("newId").GetInt64();
+
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+ var auditCount = await conn.QuerySingleAsync("""
+ SELECT COUNT(1) FROM dbo.AuditEvent
+ WHERE Action = 'tasacion.chargeable_char.price_change'
+ AND TargetType = 'ChargeableCharConfig'
+ AND TargetId = @TargetId
+ """, new { TargetId = newId.ToString() });
+
+ auditCount.Should().Be(1,
+ because: "IAuditLogger must record tasacion.chargeable_char.price_change after successful PUT");
+ }
+
+ /// PRC-001-R3.6 — PATCH deactivate emits audit event.
+ [Fact]
+ public async Task AuditEvent_EmittedOnDeactivate()
+ {
+ var id = await SeedConfigDirectAsync(null, "→", "Currency", 1.00m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/deactivate", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK,
+ because: "PATCH must succeed before we can verify audit");
+
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+ var auditCount = await conn.QuerySingleAsync("""
+ SELECT COUNT(1) FROM dbo.AuditEvent
+ WHERE Action = 'tasacion.chargeable_char.deactivate'
+ AND TargetType = 'ChargeableCharConfig'
+ AND TargetId = @TargetId
+ """, new { TargetId = id.ToString() });
+
+ auditCount.Should().Be(1,
+ because: "IAuditLogger must record tasacion.chargeable_char.deactivate after successful PATCH");
+ }
+
+ /// PRC-001-R3.7 — Audit failure rolls back insert (fail-closed).
+ [Fact]
+ public async Task Audit_Rollback_OnLoggerFailure()
+ {
+ await using var conn = new SqlConnection(ConnectionString);
+ await conn.OpenAsync();
+
+ var countBefore = await conn.ExecuteScalarAsync(
+ "SELECT COUNT(1) FROM dbo.ChargeableCharConfig");
+
+ // Build a mock IAuditLogger that throws
+ var throwingAudit = Substitute.For();
+ throwingAudit
+ .LogAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any