diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs
new file mode 100644
index 0000000..000ed94
--- /dev/null
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs
@@ -0,0 +1,79 @@
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Abstractions.Persistence;
+
+///
+/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
+/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
+///
+/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
+/// closes any active row for (MedioId, Symbol) and inserts the new row.
+///
+/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio which returns
+/// both per-medio rows AND global (MedioId IS NULL) rows for the given asOfDate.
+/// The Application service applies the per-medio > global priority rule.
+///
+public interface IChargeableCharConfigRepository
+{
+ ///
+ /// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
+ /// Closes any active row matching (MedioId, Symbol) and inserts a new one.
+ /// Returns the Id of the newly inserted row.
+ /// Throws:
+ /// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
+ /// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
+ ///
+ Task InsertWithCloseAsync(
+ long? medioId,
+ string symbol,
+ string category,
+ decimal price,
+ DateOnly validFrom,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
+ /// for the specified medio, including global rows (MedioId IS NULL).
+ /// The SP returns both per-medio AND global rows — callers apply priority.
+ ///
+ Task> GetActiveForMedioAsync(
+ long medioId,
+ DateOnly asOfDate,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns paginated rows filtered by MedioId and IsActive.
+ /// Skip = (page - 1) * pageSize computed by the caller.
+ ///
+ Task> ListAsync(
+ long? medioId,
+ bool activeOnly,
+ int skip,
+ int take,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns total row count for the given filters (used for pagination metadata).
+ ///
+ Task CountAsync(
+ long? medioId,
+ bool activeOnly,
+ CancellationToken ct = default);
+
+ ///
+ /// Returns the row with the given Id, or null if not found.
+ ///
+ Task GetByIdAsync(
+ long id,
+ CancellationToken ct = default);
+
+ ///
+ /// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
+ /// Idempotent: no-op if already inactive.
+ /// Called inside the ambient TransactionScope of the handler.
+ ///
+ Task DeactivateAsync(
+ long id,
+ DateOnly today,
+ CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs
index c5d7470..79ed5e3 100644
--- a/src/api/SIGCM2.Application/DependencyInjection.cs
+++ b/src/api/SIGCM2.Application/DependencyInjection.cs
@@ -83,6 +83,12 @@ using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.GetById;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Application.Pricing.ChargeableChars.Create;
+using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
+using SIGCM2.Application.Pricing.ChargeableChars.List;
+using SIGCM2.Application.Pricing.ChargeableChars.GetById;
namespace SIGCM2.Application;
@@ -200,6 +206,14 @@ public static class DependencyInjection
services.AddScoped>, ListProductTypesQueryHandler>();
services.AddScoped, GetProductTypeByIdQueryHandler>();
+ // ChargeableCharConfig (PRC-001)
+ services.AddScoped, CreateChargeableCharConfigCommandHandler>();
+ services.AddScoped, SchedulePriceChangeCommandHandler>();
+ services.AddScoped, DeactivateChargeableCharConfigCommandHandler>();
+ services.AddScoped>, ListChargeableCharConfigQueryHandler>();
+ services.AddScoped, GetChargeableCharConfigByIdQueryHandler>();
+ services.AddScoped();
+
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining();
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs
new file mode 100644
index 0000000..a2e85b4
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs
@@ -0,0 +1,14 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
+///
+public sealed record ChargeableCharConfigDto(
+ long Id,
+ long? MedioId,
+ string Symbol,
+ string Category,
+ decimal PricePerUnit,
+ DateOnly ValidFrom,
+ DateOnly? ValidTo,
+ bool IsActive);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs
new file mode 100644
index 0000000..6de3f85
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs
@@ -0,0 +1,43 @@
+using SIGCM2.Application.Abstractions.Persistence;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Implements IChargeableCharConfigService.
+/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies
+/// the per-medio > global priority rule in memory.
+///
+/// Priority rule: if the same Symbol appears as both global (MedioId IS NULL) and
+/// per-medio, the per-medio row wins. The SP returns both; we resolve in Application.
+///
+public sealed class ChargeableCharConfigService : IChargeableCharConfigService
+{
+ private readonly IChargeableCharConfigRepository _repo;
+
+ public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
+ {
+ _repo = repo;
+ }
+
+ ///
+ public async Task> GetActiveConfigForMedioAsync(
+ long medioId,
+ DateOnly asOf,
+ CancellationToken ct = default)
+ {
+ var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct);
+
+ // Build a dictionary keyed by Symbol.
+ // Per-medio rows (MedioId != null) take priority over global rows (MedioId == null).
+ var result = new Dictionary(StringComparer.Ordinal);
+
+ // Two-pass: first add global rows, then overwrite with per-medio rows.
+ foreach (var row in allRows.Where(r => r.MedioId is null))
+ result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
+
+ foreach (var row in allRows.Where(r => r.MedioId is not null))
+ result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
+
+ return result;
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs
new file mode 100644
index 0000000..b9e2848
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharSnapshot.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
+/// at the time of word counting. Used by IChargeableCharConfigService.
+/// Keyed by Symbol in the returned dictionary.
+///
+public sealed record ChargeableCharSnapshot(
+ string Category,
+ decimal PricePerUnit);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs
new file mode 100644
index 0000000..0bf24a4
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs
@@ -0,0 +1,12 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
+
+///
+/// PRC-001 — Command to create a new ChargeableCharConfig.
+/// MedioId = null → global config. MedioId set → per-medio config.
+///
+public sealed record CreateChargeableCharConfigCommand(
+ long? MedioId,
+ string Symbol,
+ string Category,
+ decimal PricePerUnit,
+ DateOnly ValidFrom);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs
new file mode 100644
index 0000000..be112a8
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs
@@ -0,0 +1,69 @@
+using System.Transactions;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
+
+///
+/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
+/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
+///
+public sealed class CreateChargeableCharConfigCommandHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public CreateChargeableCharConfigCommandHandler(
+ IChargeableCharConfigRepository repo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _repo = repo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(CreateChargeableCharConfigCommand command)
+ {
+ long newId;
+ using (var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled))
+ {
+ newId = await _repo.InsertWithCloseAsync(
+ command.MedioId,
+ command.Symbol,
+ command.Category,
+ command.PricePerUnit,
+ command.ValidFrom);
+
+ await _audit.LogAsync(
+ action: "tasacion.chargeable_char.create",
+ targetType: "ChargeableCharConfig",
+ targetId: newId.ToString(),
+ metadata: new
+ {
+ after = new
+ {
+ command.MedioId,
+ command.Symbol,
+ command.Category,
+ command.PricePerUnit,
+ validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
+ }
+ });
+
+ tx.Complete();
+ }
+
+ return new CreateChargeableCharConfigResponse(
+ newId,
+ command.Symbol,
+ command.PricePerUnit,
+ command.ValidFrom);
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs
new file mode 100644
index 0000000..9fa3789
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandValidator.cs
@@ -0,0 +1,38 @@
+using FluentValidation;
+using SIGCM2.Application.Common;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
+
+///
+/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
+/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
+///
+public sealed class CreateChargeableCharConfigCommandValidator
+ : AbstractValidator
+{
+ public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
+ {
+ var today = timeProvider.GetArgentinaToday();
+
+ RuleFor(x => x.Symbol)
+ .NotEmpty()
+ .WithMessage("Symbol no puede estar vacío.")
+ .MaximumLength(4)
+ .WithMessage("Symbol no puede exceder 4 caracteres.");
+
+ RuleFor(x => x.Category)
+ .NotEmpty()
+ .WithMessage("Category no puede estar vacío.")
+ .Must(ChargeableCharCategories.IsValid)
+ .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.");
+
+ RuleFor(x => x.ValidFrom)
+ .GreaterThanOrEqualTo(today)
+ .WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs
new file mode 100644
index 0000000..07e9205
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigResponse.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
+
+///
+/// PRC-001 — Response for CreateChargeableCharConfigCommand.
+///
+public sealed record CreateChargeableCharConfigResponse(
+ long Id,
+ string Symbol,
+ decimal PricePerUnit,
+ DateOnly ValidFrom);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs
new file mode 100644
index 0000000..a7c35c5
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommand.cs
@@ -0,0 +1,6 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
+
+///
+/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
+///
+public sealed record DeactivateChargeableCharConfigCommand(long Id);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs
new file mode 100644
index 0000000..1eda637
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs
@@ -0,0 +1,70 @@
+using System.Transactions;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
+
+///
+/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
+/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
+///
+public sealed class DeactivateChargeableCharConfigCommandHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public DeactivateChargeableCharConfigCommandHandler(
+ IChargeableCharConfigRepository repo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _repo = repo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(
+ DeactivateChargeableCharConfigCommand command)
+ {
+ var today = _timeProvider.GetArgentinaToday();
+
+ // 1. Load existing — ensures the row exists.
+ var existing = await _repo.GetByIdAsync(command.Id)
+ ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
+
+ // 2. TX + deactivate + audit (fail-closed).
+ using (var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled))
+ {
+ await _repo.DeactivateAsync(command.Id, today);
+
+ await _audit.LogAsync(
+ action: "tasacion.chargeable_char.deactivate",
+ targetType: "ChargeableCharConfig",
+ targetId: command.Id.ToString(),
+ metadata: new
+ {
+ before = new
+ {
+ id = existing.Id,
+ symbol = existing.Symbol,
+ medioId = existing.MedioId,
+ validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
+ },
+ deactivatedOn = today.ToString("yyyy-MM-dd"),
+ });
+
+ tx.Complete();
+ }
+
+ return new DeactivateChargeableCharConfigResponse(
+ Id: command.Id,
+ ValidTo: today);
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs
new file mode 100644
index 0000000..c8286b6
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigResponse.cs
@@ -0,0 +1,9 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
+
+///
+/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
+/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
+///
+public sealed record DeactivateChargeableCharConfigResponse(
+ long Id,
+ DateOnly ValidTo);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs
new file mode 100644
index 0000000..0c084c4
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQuery.cs
@@ -0,0 +1,7 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
+
+///
+/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
+/// Returns null if not found (caller decides whether to 404).
+///
+public sealed record GetChargeableCharConfigByIdQuery(long Id);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs
new file mode 100644
index 0000000..10710a6
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs
@@ -0,0 +1,37 @@
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
+
+///
+/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
+/// Returns null DTO when not found (API layer maps to 404).
+///
+public sealed class GetChargeableCharConfigByIdQueryHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+
+ public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
+ {
+ _repo = repo;
+ }
+
+ public async Task Handle(GetChargeableCharConfigByIdQuery query)
+ {
+ var entity = await _repo.GetByIdAsync(query.Id);
+ return entity is null ? null : ToDto(entity);
+ }
+
+ private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
+ c.Id,
+ c.MedioId,
+ c.Symbol,
+ c.Category,
+ c.PricePerUnit,
+ c.ValidFrom,
+ c.ValidTo,
+ c.IsActive);
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs
new file mode 100644
index 0000000..c73d1e4
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs
@@ -0,0 +1,21 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Application service for resolving active chargeable-char config for a Medio.
+///
+/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol.
+/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
+///
+public interface IChargeableCharConfigService
+{
+ ///
+ /// Returns the resolved active config for the given medio as of the given date.
+ /// Per-medio rows take priority over global rows for the same Symbol.
+ /// Global rows are used as fallback when no per-medio row exists for that Symbol.
+ /// Returns an empty dictionary if no config exists at all.
+ ///
+ Task> GetActiveConfigForMedioAsync(
+ long medioId,
+ DateOnly asOf,
+ CancellationToken ct = default);
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs
new file mode 100644
index 0000000..a54efd7
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs
@@ -0,0 +1,11 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.List;
+
+///
+/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
+/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
+///
+public sealed record ListChargeableCharConfigQuery(
+ long? MedioId,
+ bool ActiveOnly,
+ int Page = 1,
+ int PageSize = 20);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs
new file mode 100644
index 0000000..8510c3a
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs
@@ -0,0 +1,46 @@
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Common;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.List;
+
+///
+/// PRC-001 — Handler for ListChargeableCharConfigQuery.
+/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
+///
+public sealed class ListChargeableCharConfigQueryHandler
+ : ICommandHandler>
+{
+ private readonly IChargeableCharConfigRepository _repo;
+
+ public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
+ {
+ _repo = repo;
+ }
+
+ public async Task> Handle(ListChargeableCharConfigQuery query)
+ {
+ var page = Math.Max(1, query.Page);
+ var pageSize = Math.Clamp(query.PageSize, 1, 100);
+ var skip = (page - 1) * pageSize;
+
+ var items = await _repo.ListAsync(query.MedioId, query.ActiveOnly, skip, pageSize);
+ var total = await _repo.CountAsync(query.MedioId, query.ActiveOnly);
+
+ var dtos = items.Select(ToDto).ToList();
+
+ return new PagedResult(dtos, page, pageSize, total);
+ }
+
+ private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
+ c.Id,
+ c.MedioId,
+ c.Symbol,
+ c.Category,
+ c.PricePerUnit,
+ c.ValidFrom,
+ c.ValidTo,
+ c.IsActive);
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs
new file mode 100644
index 0000000..1cd7cc3
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommand.cs
@@ -0,0 +1,11 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+
+///
+/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
+/// Id: the existing row whose price should be superseded.
+/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
+///
+public sealed record SchedulePriceChangeCommand(
+ long Id,
+ decimal PricePerUnit,
+ DateOnly ValidFrom);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs
new file mode 100644
index 0000000..2b0c1f3
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs
@@ -0,0 +1,80 @@
+using System.Transactions;
+using SIGCM2.Application.Abstractions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+
+///
+/// PRC-001 — Handler for SchedulePriceChangeCommand.
+/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
+///
+public sealed class SchedulePriceChangeCommandHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public SchedulePriceChangeCommandHandler(
+ IChargeableCharConfigRepository repo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _repo = repo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(SchedulePriceChangeCommand command)
+ {
+ // 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
+ var existing = await _repo.GetByIdAsync(command.Id)
+ ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
+
+ // 2. Domain entity validates forward-only rule and builds the new entity value.
+ // ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
+ var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
+
+ // 3. TX + SP + audit (fail-closed).
+ long newId;
+ using (var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled))
+ {
+ newId = await _repo.InsertWithCloseAsync(
+ newEntity.MedioId,
+ newEntity.Symbol,
+ newEntity.Category,
+ newEntity.PricePerUnit,
+ newEntity.ValidFrom);
+
+ await _audit.LogAsync(
+ action: "tasacion.chargeable_char.price_change",
+ targetType: "ChargeableCharConfig",
+ targetId: newId.ToString(),
+ metadata: new
+ {
+ before = new
+ {
+ id = existing.Id,
+ pricePerUnit = existing.PricePerUnit,
+ validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
+ },
+ after = new
+ {
+ pricePerUnit = newEntity.PricePerUnit,
+ validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
+ }
+ });
+
+ tx.Complete();
+ }
+
+ return new SchedulePriceChangeResponse(
+ NewId: newId,
+ PreviousValidFrom: existing.ValidFrom,
+ NewValidFrom: newEntity.ValidFrom);
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs
new file mode 100644
index 0000000..722473c
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandValidator.cs
@@ -0,0 +1,30 @@
+using FluentValidation;
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+
+///
+/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
+/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
+/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
+/// where the existing entity is loaded.
+///
+public sealed class SchedulePriceChangeCommandValidator : AbstractValidator
+{
+ public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
+ {
+ var today = timeProvider.GetArgentinaToday();
+
+ RuleFor(x => x.Id)
+ .GreaterThan(0L)
+ .WithMessage("Id debe ser un entero positivo.");
+
+ RuleFor(x => x.PricePerUnit)
+ .GreaterThan(0m)
+ .WithMessage("PricePerUnit debe ser > 0.");
+
+ RuleFor(x => x.ValidFrom)
+ .GreaterThanOrEqualTo(today)
+ .WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs
new file mode 100644
index 0000000..21afc9e
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeResponse.cs
@@ -0,0 +1,9 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
+
+///
+/// PRC-001 — Response for SchedulePriceChangeCommand.
+///
+public sealed record SchedulePriceChangeResponse(
+ long NewId,
+ DateOnly PreviousValidFrom,
+ DateOnly NewValidFrom);
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs
new file mode 100644
index 0000000..4a6cbfb
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs
@@ -0,0 +1,113 @@
+using FluentAssertions;
+using NSubstitute;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Pricing.ChargeableChars;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — ChargeableCharConfigService tests.
+/// Covers: per-medio wins over global, global fallback when no per-medio,
+/// empty result when no config at all.
+///
+public class ChargeableCharConfigServiceTests
+{
+ private readonly IChargeableCharConfigRepository _repo = Substitute.For();
+ private readonly ChargeableCharConfigService _service;
+
+ private static readonly DateOnly AsOf = new(2026, 4, 20);
+
+ public ChargeableCharConfigServiceTests()
+ {
+ _service = new ChargeableCharConfigService(_repo);
+ }
+
+ private static ChargeableCharConfig GlobalConfig(string symbol, decimal price) =>
+ ChargeableCharConfig.Rehydrate(10L, null, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
+
+ private static ChargeableCharConfig MedioConfig(long id, int medioId, string symbol, decimal price) =>
+ ChargeableCharConfig.Rehydrate(id, medioId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
+
+ // ── Global fallback ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs()
+ {
+ _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ .Returns(new List
+ {
+ GlobalConfig("$", 1.0m),
+ GlobalConfig("%", 0.5m),
+ });
+
+ var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+
+ result.Should().ContainKey("$");
+ result["$"].PricePerUnit.Should().Be(1.0m);
+ result.Should().ContainKey("%");
+ }
+
+ // ── Per-medio wins over global ───────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol()
+ {
+ _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any())
+ .Returns(new List
+ {
+ GlobalConfig("$", 1.0m), // global price = 1.0
+ MedioConfig(20L, 5, "$", 3.0m), // per-medio price = 3.0 → wins
+ GlobalConfig("%", 0.5m), // global only
+ });
+
+ var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
+
+ result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins
+ result["%"].PricePerUnit.Should().Be(0.5m); // global only
+ }
+
+ [Fact]
+ public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory()
+ {
+ _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any())
+ .Returns(new List
+ {
+ MedioConfig(20L, 5, "$", 3.0m),
+ });
+
+ var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
+
+ result["$"].Category.Should().Be(ChargeableCharCategories.Currency);
+ }
+
+ // ── Empty result ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary()
+ {
+ _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ .Returns(new List());
+
+ var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+
+ result.Should().BeEmpty();
+ }
+
+ // ── Key: Symbol, Value: snapshot ─────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetActiveConfig_KeyIsSymbol()
+ {
+ _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ .Returns(new List
+ {
+ GlobalConfig("!", 2.0m),
+ });
+
+ var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+
+ result.Should().ContainKey("!");
+ result["!"].PricePerUnit.Should().Be(2.0m);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
new file mode 100644
index 0000000..d8e0893
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
@@ -0,0 +1,145 @@
+using FluentValidation.TestHelper;
+using Microsoft.Extensions.Time.Testing;
+using SIGCM2.Application.Pricing.ChargeableChars.Create;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Validator tests for CreateChargeableCharConfigCommand.
+/// Covers: Symbol length, Category enum, PricePerUnit > 0, ValidFrom >= today_AR.
+///
+public class CreateChargeableCharConfigCommandValidatorTests
+{
+ private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
+ private readonly CreateChargeableCharConfigCommandValidator _validator;
+
+ private static readonly DateOnly Today = new(2026, 4, 20);
+ private static readonly DateOnly Yesterday = new(2026, 4, 19);
+ private static readonly DateOnly Tomorrow = new(2026, 4, 21);
+
+ public CreateChargeableCharConfigCommandValidatorTests()
+ {
+ _validator = new CreateChargeableCharConfigCommandValidator(_time);
+ }
+
+ private static CreateChargeableCharConfigCommand ValidCmd() => new(
+ MedioId: null,
+ Symbol: "$",
+ Category: ChargeableCharCategories.Currency,
+ PricePerUnit: 1.0m,
+ ValidFrom: Today);
+
+ // ── Symbol ───────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Symbol_Empty_FailsValidation()
+ {
+ var cmd = ValidCmd() with { Symbol = "" };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
+ }
+
+ [Fact]
+ public void Symbol_TooLong_FailsValidation()
+ {
+ // max 4 chars
+ var cmd = ValidCmd() with { Symbol = "ABCDE" };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Symbol);
+ }
+
+ [Fact]
+ public void Symbol_SingleChar_Passes()
+ {
+ var cmd = ValidCmd() with { Symbol = "$" };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
+ }
+
+ [Fact]
+ public void Symbol_FourChars_Passes()
+ {
+ var cmd = ValidCmd() with { Symbol = "ABCD" };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Symbol);
+ }
+
+ // ── Category ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Category_Invalid_FailsValidation()
+ {
+ var cmd = ValidCmd() with { Category = "Unknown" };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
+ }
+
+ [Fact]
+ public void Category_Empty_FailsValidation()
+ {
+ var cmd = ValidCmd() with { Category = "" };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Category);
+ }
+
+ [Theory]
+ [InlineData("Currency")]
+ [InlineData("Percentage")]
+ [InlineData("Exclamation")]
+ [InlineData("Question")]
+ [InlineData("Other")]
+ public void Category_ValidValues_Pass(string category)
+ {
+ var cmd = ValidCmd() with { Category = category };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Category);
+ }
+
+ // ── PricePerUnit ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void PricePerUnit_Zero_FailsValidation()
+ {
+ var cmd = ValidCmd() with { PricePerUnit = 0m };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
+ }
+
+ [Fact]
+ public void PricePerUnit_Negative_FailsValidation()
+ {
+ var cmd = ValidCmd() with { PricePerUnit = -1m };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PricePerUnit);
+ }
+
+ [Fact]
+ public void PricePerUnit_Positive_Passes()
+ {
+ var cmd = ValidCmd() with { PricePerUnit = 0.01m };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PricePerUnit);
+ }
+
+ // ── ValidFrom ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ValidFrom_InPast_FailsValidation()
+ {
+ var cmd = ValidCmd() with { ValidFrom = Yesterday };
+ _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ValidFrom);
+ }
+
+ [Fact]
+ public void ValidFrom_Today_Passes()
+ {
+ var cmd = ValidCmd() with { ValidFrom = Today };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
+ }
+
+ [Fact]
+ public void ValidFrom_Future_Passes()
+ {
+ var cmd = ValidCmd() with { ValidFrom = Tomorrow };
+ _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ValidFrom);
+ }
+
+ // ── Happy path ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ValidCommand_PassesAllRules()
+ {
+ _validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors();
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs
new file mode 100644
index 0000000..77121e2
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs
@@ -0,0 +1,138 @@
+using System.Transactions;
+using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using SIGCM2.Application.Abstractions.Persistence;
+using SIGCM2.Application.Audit;
+using SIGCM2.Application.Pricing.ChargeableChars.Create;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — CreateChargeableCharConfigCommandHandler tests.
+/// Covers: happy path, audit emit, audit fail → rollback, validator chain.
+/// NSubstitute + FakeTimeProvider.
+///
+public class CreateChargeableCharConfigHandlerTests
+{
+ // Hoy en ART: 2026-04-20T12:00:00 UTC → 2026-04-20T09:00:00 ART
+ private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
+ private readonly IChargeableCharConfigRepository _repo = Substitute.For();
+ private readonly IAuditLogger _audit = Substitute.For();
+ private readonly CreateChargeableCharConfigCommandHandler _handler;
+
+ private static readonly DateOnly Today = new(2026, 4, 20);
+ private static readonly DateOnly Tomorrow = new(2026, 4, 21);
+
+ public CreateChargeableCharConfigHandlerTests()
+ {
+ _repo.InsertWithCloseAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(42L);
+
+ _handler = new CreateChargeableCharConfigCommandHandler(_repo, _audit, _time);
+ }
+
+ private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
+ MedioId: null,
+ Symbol: "$",
+ Category: ChargeableCharCategories.Currency,
+ PricePerUnit: 1.5m,
+ ValidFrom: validFrom ?? Today);
+
+ // ── Happy path ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Handle_HappyPath_ReturnsCreateResponse()
+ {
+ var result = await _handler.Handle(ValidCmd());
+
+ result.Should().NotBeNull();
+ result.Id.Should().Be(42L);
+ result.Symbol.Should().Be("$");
+ result.PricePerUnit.Should().Be(1.5m);
+ result.ValidFrom.Should().Be(Today);
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_CallsInsertWithCloseAsync()
+ {
+ await _handler.Handle(ValidCmd());
+
+ await _repo.Received(1).InsertWithCloseAsync(
+ null, "$", ChargeableCharCategories.Currency, 1.5m, Today, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_EmitsAuditEvent()
+ {
+ await _handler.Handle(ValidCmd());
+
+ await _audit.Received(1).LogAsync(
+ action: "tasacion.chargeable_char.create",
+ targetType: "ChargeableCharConfig",
+ targetId: "42",
+ metadata: Arg.Any