diff --git a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
index 7bd1adc..ebea321 100644
--- a/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
+++ b/src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
@@ -6,8 +6,10 @@ 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.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
using SIGCM2.Application.Pricing.ChargeableChars.List;
+using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
namespace SIGCM2.Api.Controllers;
@@ -39,7 +41,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
///
/// Returns a paginated list of ChargeableCharConfig rows.
- /// Filters: medioId (optional, long?), activeOnly (bool, default true).
+ /// Filters: productTypeId (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.
///
@@ -49,7 +51,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task List(
- [FromQuery] long? medioId,
+ [FromQuery] long? productTypeId,
[FromQuery] bool activeOnly = true,
[FromQuery] int? page = null,
[FromQuery] int? pageSize = null,
@@ -74,7 +76,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
}
- var query = new ListChargeableCharConfigQuery(medioId, activeOnly, resolvedPage, resolvedPageSize);
+ var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
var result = await _dispatcher.Send>(query);
return Ok(result);
}
@@ -100,7 +102,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
///
- /// Creates a new ChargeableCharConfig row. Closes the current active row for (MedioId, Symbol) if one exists.
+ /// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
/// Returns 201 Created with Location header pointing to GET /{id}.
///
[HttpPost]
@@ -113,7 +115,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
public async Task Create([FromBody] CreateChargeableCharConfigRequest request)
{
var command = new CreateChargeableCharConfigCommand(
- request.MedioId,
+ request.ProductTypeId,
request.Symbol,
request.Category,
request.PricePerUnit,
@@ -183,13 +185,57 @@ public sealed class ChargeableCharConfigController : ControllerBase
new DeactivateChargeableCharConfigCommand(id));
return Ok(result);
}
+
+ // ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
+
+ ///
+ /// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
+ /// Guard rules (enforced by SP):
+ /// - ALREADY_ACTIVE: target row is already active → 409
+ /// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
+ /// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
+ ///
+ [HttpPatch("{id:long}/reactivate")]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Reactivate([FromRoute] long id)
+ {
+ var result = await _dispatcher.Send(
+ new ReactivateChargeableCharConfigCommand(id));
+ return Ok(result);
+ }
+
+ // ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
+
+ ///
+ /// Deletes a ChargeableCharConfig row.
+ /// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
+ /// The row disappears from all current-state queries.
+ /// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
+ /// Returns 200 + { id } consistent with the Deactivate pattern.
+ ///
+ [HttpDelete("{id:long}")]
+ [RequirePermission("tasacion:caracteres_especiales:gestionar")]
+ [ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete([FromRoute] long id)
+ {
+ var result = await _dispatcher.Send(
+ new DeleteChargeableCharConfigCommand(id));
+ return Ok(result);
+ }
}
// ── Request body records ──────────────────────────────────────────────────────
/// PRC-001: Create ChargeableCharConfig request body.
public sealed record CreateChargeableCharConfigRequest(
- long? MedioId,
+ long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
index 3df5936..68c6b4c 100644
--- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
+++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
@@ -695,7 +695,7 @@ public sealed class ExceptionFilter : IExceptionFilter
{
error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
- medioId = forwardOnlyCharEx.MedioId,
+ productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
@@ -707,6 +707,33 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
+ case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "chargeable_char_reactivation_not_allowed",
+ code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
+ id = reactivationEx.Id,
+ reason = reactivationEx.Reason,
+ message = reactivationEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status409Conflict
+ };
+ context.ExceptionHandled = true;
+ break;
+
+ case KeyNotFoundException keyNotFoundEx:
+ context.Result = new ObjectResult(new
+ {
+ error = "not_found",
+ message = keyNotFoundEx.Message
+ })
+ {
+ StatusCode = StatusCodes.Status404NotFound
+ };
+ context.ExceptionHandled = true;
+ break;
+
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)
diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs
index 000ed94..24a787c 100644
--- a/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs
+++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IChargeableCharConfigRepository.cs
@@ -7,24 +7,24 @@ namespace SIGCM2.Application.Abstractions.Persistence;
/// 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.
+/// closes any active row for (ProductTypeId, 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.
+/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
+/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
+/// The Application service applies the per-ProductType > 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.
+ /// Closes any active row matching (ProductTypeId, 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,
+ long? productTypeId,
string symbol,
string category,
decimal price,
@@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository
///
/// 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.
+ /// for the specified ProductType, including global rows (ProductTypeId IS NULL).
+ /// The SP returns both per-ProductType AND global rows — callers apply priority.
///
- Task> GetActiveForMedioAsync(
- long medioId,
+ Task> GetActiveForProductTypeAsync(
+ long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default);
///
- /// Returns paginated rows filtered by MedioId and IsActive.
+ /// Returns paginated rows filtered by ProductTypeId and IsActive.
/// Skip = (page - 1) * pageSize computed by the caller.
///
Task> ListAsync(
- long? medioId,
+ long? productTypeId,
bool activeOnly,
int skip,
int take,
@@ -56,7 +56,7 @@ public interface IChargeableCharConfigRepository
/// Returns total row count for the given filters (used for pagination metadata).
///
Task CountAsync(
- long? medioId,
+ long? productTypeId,
bool activeOnly,
CancellationToken ct = default);
@@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository
long id,
DateOnly today,
CancellationToken ct = default);
+
+ ///
+ /// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
+ /// Guard rules (enforced by SP):
+ /// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
+ /// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
+ /// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
+ /// 50404 → row not found → ChargeableCharConfigInvalidException
+ /// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
+ ///
+ Task ReactivateAsync(
+ long id,
+ CancellationToken ct = default);
+
+ ///
+ /// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
+ /// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
+ /// SysEndTime set to the delete time. The row disappears from all current-state queries but
+ /// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
+ /// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
+ /// Throws KeyNotFoundException if the row does not exist.
+ ///
+ Task DeleteAsync(
+ long id,
+ CancellationToken ct = default);
}
diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs
index 79ed5e3..4352129 100644
--- a/src/api/SIGCM2.Application/DependencyInjection.cs
+++ b/src/api/SIGCM2.Application/DependencyInjection.cs
@@ -87,6 +87,8 @@ 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.Reactivate;
+using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
@@ -210,6 +212,8 @@ public static class DependencyInjection
services.AddScoped, CreateChargeableCharConfigCommandHandler>();
services.AddScoped, SchedulePriceChangeCommandHandler>();
services.AddScoped, DeactivateChargeableCharConfigCommandHandler>();
+ services.AddScoped, ReactivateChargeableCharConfigCommandHandler>();
+ services.AddScoped, DeleteChargeableCharConfigCommandHandler>();
services.AddScoped>, ListChargeableCharConfigQueryHandler>();
services.AddScoped, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped();
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs
index a2e85b4..6481639 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigDto.cs
@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
///
public sealed record ChargeableCharConfigDto(
long Id,
- long? MedioId,
+ long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs
index 6de3f85..4cb580e 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/ChargeableCharConfigService.cs
@@ -4,11 +4,11 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
///
/// PRC-001 — Implements IChargeableCharConfigService.
-/// Delegates to IChargeableCharConfigRepository.GetActiveForMedioAsync, then applies
-/// the per-medio > global priority rule in memory.
+/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
+/// the per-ProductType > 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.
+/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
+/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
///
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
{
@@ -20,22 +20,22 @@ public sealed class ChargeableCharConfigService : IChargeableCharConfigService
}
///
- public async Task> GetActiveConfigForMedioAsync(
- long medioId,
+ public async Task> GetActiveConfigForProductTypeAsync(
+ long productTypeId,
DateOnly asOf,
CancellationToken ct = default)
{
- var allRows = await _repo.GetActiveForMedioAsync(medioId, asOf, ct);
+ var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
// Build a dictionary keyed by Symbol.
- // Per-medio rows (MedioId != null) take priority over global rows (MedioId == null).
+ // Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == 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))
+ // Two-pass: first add global rows, then overwrite with per-ProductType rows.
+ foreach (var row in allRows.Where(r => r.ProductTypeId is null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
- foreach (var row in allRows.Where(r => r.MedioId is not null))
+ foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
return result;
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs
index 0bf24a4..14da49e 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommand.cs
@@ -2,10 +2,10 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
///
/// PRC-001 — Command to create a new ChargeableCharConfig.
-/// MedioId = null → global config. MedioId set → per-medio config.
+/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
///
public sealed record CreateChargeableCharConfigCommand(
- long? MedioId,
+ long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs
index be112a8..f9f6d80 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Create/CreateChargeableCharConfigCommandHandler.cs
@@ -35,7 +35,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
- command.MedioId,
+ command.ProductTypeId,
command.Symbol,
command.Category,
command.PricePerUnit,
@@ -49,7 +49,7 @@ public sealed class CreateChargeableCharConfigCommandHandler
{
after = new
{
- command.MedioId,
+ command.ProductTypeId,
command.Symbol,
command.Category,
command.PricePerUnit,
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs
index 1eda637..509f208 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Deactivate/DeactivateChargeableCharConfigCommandHandler.cs
@@ -54,7 +54,7 @@ public sealed class DeactivateChargeableCharConfigCommandHandler
{
id = existing.Id,
symbol = existing.Symbol,
- medioId = existing.MedioId,
+ productTypeId = existing.ProductTypeId,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
deactivatedOn = today.ToString("yyyy-MM-dd"),
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs
new file mode 100644
index 0000000..0b8b7c3
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommand.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
+
+///
+/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
+/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
+/// (SysEndTime = delete time). The row disappears from all current-state queries but
+/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
+/// to the FAC-001 followup issue.
+///
+public sealed record DeleteChargeableCharConfigCommand(long Id);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs
new file mode 100644
index 0000000..3b1b5ee
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigCommandHandler.cs
@@ -0,0 +1,75 @@
+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.Delete;
+
+///
+/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
+/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
+///
+/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
+/// SysEndTime = deletion timestamp. This means:
+/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
+/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
+/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
+///
+/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
+///
+public sealed class DeleteChargeableCharConfigCommandHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public DeleteChargeableCharConfigCommandHandler(
+ IChargeableCharConfigRepository repo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _repo = repo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(
+ DeleteChargeableCharConfigCommand command)
+ {
+ // 1. Load existing — ensures the row exists before opening TX.
+ var existing = await _repo.GetByIdAsync(command.Id)
+ ?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
+
+ // 2. TX + delete + audit (fail-closed).
+ using (var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled))
+ {
+ await _repo.DeleteAsync(command.Id);
+
+ await _audit.LogAsync(
+ action: "tasacion.chargeable_char.delete",
+ targetType: "ChargeableCharConfig",
+ targetId: command.Id.ToString(),
+ metadata: new
+ {
+ before = new
+ {
+ id = existing.Id,
+ symbol = existing.Symbol,
+ productTypeId = existing.ProductTypeId,
+ isActive = existing.IsActive,
+ validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
+ },
+ deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
+ });
+
+ tx.Complete();
+ }
+
+ return new DeleteChargeableCharConfigResponse(Id: command.Id);
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs
new file mode 100644
index 0000000..fba1fc9
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Delete/DeleteChargeableCharConfigResponse.cs
@@ -0,0 +1,6 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
+
+///
+/// PRC-001 — Response for a successful delete operation.
+///
+public sealed record DeleteChargeableCharConfigResponse(long Id);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs
index 10710a6..65481c7 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/GetById/GetChargeableCharConfigByIdQueryHandler.cs
@@ -27,7 +27,7 @@ public sealed class GetChargeableCharConfigByIdQueryHandler
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
- c.MedioId,
+ c.ProductTypeId,
c.Symbol,
c.Category,
c.PricePerUnit,
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs
index c73d1e4..15a66b3 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/IChargeableCharConfigService.cs
@@ -1,21 +1,21 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
///
-/// PRC-001 — Application service for resolving active chargeable-char config for a Medio.
+/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
///
-/// Priority rule: per-medio row overrides global (MedioId IS NULL) for the same Symbol.
+/// Priority rule: per-ProductType row overrides global (ProductTypeId 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 the resolved active config for the given ProductType as of the given date.
+ /// Per-ProductType rows take priority over global rows for the same Symbol.
+ /// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
/// Returns an empty dictionary if no config exists at all.
///
- Task> GetActiveConfigForMedioAsync(
- long medioId,
+ Task> GetActiveConfigForProductTypeAsync(
+ long productTypeId,
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
index a54efd7..cd9d6f0 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQuery.cs
@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
///
public sealed record ListChargeableCharConfigQuery(
- long? MedioId,
+ long? ProductTypeId,
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
index 8510c3a..13467e3 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/List/ListChargeableCharConfigQueryHandler.cs
@@ -26,8 +26,8 @@ public sealed class ListChargeableCharConfigQueryHandler
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 items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
+ var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
var dtos = items.Select(ToDto).ToList();
@@ -36,7 +36,7 @@ public sealed class ListChargeableCharConfigQueryHandler
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
- c.MedioId,
+ c.ProductTypeId,
c.Symbol,
c.Category,
c.PricePerUnit,
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs
new file mode 100644
index 0000000..541c48a
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommand.cs
@@ -0,0 +1,7 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
+
+///
+/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
+/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
+///
+public sealed record ReactivateChargeableCharConfigCommand(long Id);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs
new file mode 100644
index 0000000..474859d
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigCommandHandler.cs
@@ -0,0 +1,71 @@
+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.Reactivate;
+
+///
+/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
+/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
+///
+/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
+/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
+/// ExceptionFilter which maps them to HTTP 409.
+///
+public sealed class ReactivateChargeableCharConfigCommandHandler
+ : ICommandHandler
+{
+ private readonly IChargeableCharConfigRepository _repo;
+ private readonly IAuditLogger _audit;
+ private readonly TimeProvider _timeProvider;
+
+ public ReactivateChargeableCharConfigCommandHandler(
+ IChargeableCharConfigRepository repo,
+ IAuditLogger audit,
+ TimeProvider timeProvider)
+ {
+ _repo = repo;
+ _audit = audit;
+ _timeProvider = timeProvider;
+ }
+
+ public async Task Handle(
+ ReactivateChargeableCharConfigCommand command)
+ {
+ // Open TX before calling SP so that audit failure rolls back the SP work.
+ using var tx = new TransactionScope(
+ TransactionScopeOption.Required,
+ new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
+ TransactionScopeAsyncFlowOption.Enabled);
+
+ // SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
+ // Returns the reactivated entity so we can populate the response and the audit log.
+ var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
+
+ await _audit.LogAsync(
+ action: "tasacion.chargeable_char.reactivate",
+ targetType: "ChargeableCharConfig",
+ targetId: command.Id.ToString(),
+ metadata: new
+ {
+ id = reactivated.Id,
+ symbol = reactivated.Symbol,
+ productTypeId = reactivated.ProductTypeId,
+ validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
+ reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
+ });
+
+ tx.Complete();
+
+ return new ReactivateChargeableCharConfigResponse(
+ Id: reactivated.Id,
+ ProductTypeId: reactivated.ProductTypeId,
+ Symbol: reactivated.Symbol,
+ Category: reactivated.Category,
+ PricePerUnit: reactivated.PricePerUnit,
+ ValidFrom: reactivated.ValidFrom,
+ IsActive: reactivated.IsActive);
+ }
+}
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs
new file mode 100644
index 0000000..6c8b141
--- /dev/null
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/Reactivate/ReactivateChargeableCharConfigResponse.cs
@@ -0,0 +1,14 @@
+namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
+
+///
+/// PRC-001 — Response for a successful reactivation.
+/// Returns the current state of the row after it has been re-opened.
+///
+public sealed record ReactivateChargeableCharConfigResponse(
+ long Id,
+ long? ProductTypeId,
+ string Symbol,
+ string Category,
+ decimal PricePerUnit,
+ DateOnly ValidFrom,
+ bool IsActive);
diff --git a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs
index 2b0c1f3..8f78d76 100644
--- a/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs
+++ b/src/api/SIGCM2.Application/Pricing/ChargeableChars/SchedulePrice/SchedulePriceChangeCommandHandler.cs
@@ -28,7 +28,7 @@ public sealed class SchedulePriceChangeCommandHandler
public async Task Handle(SchedulePriceChangeCommand command)
{
- // 1. Load existing row — validates it exists and exposes MedioId/Symbol/Category.
+ // 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
@@ -44,7 +44,7 @@ public sealed class SchedulePriceChangeCommandHandler
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
- newEntity.MedioId,
+ newEntity.ProductTypeId,
newEntity.Symbol,
newEntity.Category,
newEntity.PricePerUnit,
diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs
index 5d4337a..0cb5049 100644
--- a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs
+++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs
@@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars;
///
/// PRC-001 — Rich domain entity for chargeable character configuration.
/// Represents a price-per-occurrence for a special character in classified ad text,
-/// scoped to a Medio (MedioId) or global (MedioId = null).
+/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null).
///
/// Forward-only price history: each new price schedules a NEW row; the current row
/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
/// this instance — it returns a new one. The actual close+insert happens in the repository.
///
-/// MedioId = null → global default (lowest priority, overridden by per-medio row).
+/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row).
///
public sealed class ChargeableCharConfig
{
public long Id { get; }
- public int? MedioId { get; }
+ public int? ProductTypeId { get; }
public string Symbol { get; }
public string Category { get; }
public decimal PricePerUnit { get; private set; }
@@ -25,11 +25,11 @@ public sealed class ChargeableCharConfig
public bool IsActive { get; private set; }
private ChargeableCharConfig(
- long id, int? medioId, string symbol, string category,
+ long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
{
Id = id;
- MedioId = medioId;
+ ProductTypeId = productTypeId;
Symbol = symbol;
Category = category;
PricePerUnit = price;
@@ -43,7 +43,7 @@ public sealed class ChargeableCharConfig
/// Id is set to 0 until the entity is persisted.
///
public static ChargeableCharConfig Create(
- int? medioId, string symbol, string category, decimal price, DateOnly validFrom)
+ int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom)
{
if (string.IsNullOrWhiteSpace(symbol))
throw new ChargeableCharConfigInvalidException(
@@ -61,7 +61,7 @@ public sealed class ChargeableCharConfig
throw new ChargeableCharConfigInvalidException(
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
- return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true);
+ return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true);
}
///
@@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
///
public static ChargeableCharConfig Rehydrate(
- long id, int? medioId, string symbol, string category,
+ long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
- => new(id, medioId, symbol, category, price, validFrom, validTo, isActive);
+ => new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive);
///
/// Schedules a new price (forward-only semantics).
@@ -93,10 +93,10 @@ public sealed class ChargeableCharConfig
$"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
if (newValidFrom <= ValidFrom)
- throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom);
+ throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom);
// Create validates price > 0 and category — reuse factory
- return Create(MedioId, Symbol, Category, newPrice, newValidFrom);
+ return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
}
///
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs
index e82c1a9..0928a39 100644
--- a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs
@@ -8,19 +8,19 @@ namespace SIGCM2.Domain.Pricing.Exceptions;
///
public sealed class ChargeableCharConfigForwardOnlyException : DomainException
{
- public int? MedioId { get; }
+ public int? ProductTypeId { get; }
public string Symbol { get; }
public DateOnly NewValidFrom { get; }
public DateOnly ActiveValidFrom { get; }
public ChargeableCharConfigForwardOnlyException(
- int? medioId,
+ int? productTypeId,
string symbol,
DateOnly newValidFrom,
DateOnly activeValidFrom)
: base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).")
{
- MedioId = medioId;
+ ProductTypeId = productTypeId;
Symbol = symbol;
NewValidFrom = newValidFrom;
ActiveValidFrom = activeValidFrom;
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs
new file mode 100644
index 0000000..3c09fd5
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigReactivationNotAllowedException.cs
@@ -0,0 +1,29 @@
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — Thrown when a reactivation attempt is blocked by a guard rule.
+/// Maps to HTTP 409.
+///
+/// Reason codes:
+/// ALREADY_ACTIVE — target row is currently active (50410)
+/// VIGENTE_EXISTS — a different active row exists for (ProductTypeId, Symbol) (50411)
+/// POSTERIOR_ROWS_EXIST — rows with higher ValidFrom exist after the target row (50412)
+///
+public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
+{
+ public long Id { get; }
+
+ ///
+ /// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
+ ///
+ public string Reason { get; }
+
+ public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
+ : base($"Reactivation not allowed for config {id}: {reason}")
+ {
+ Id = id;
+ Reason = reason;
+ }
+}
diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs
index 1440cd4..dc5885e 100644
--- a/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs
+++ b/src/api/SIGCM2.Infrastructure/Persistence/ChargeableCharConfigRepository.cs
@@ -11,16 +11,27 @@ namespace SIGCM2.Infrastructure.Persistence;
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
-/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found)
+/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
///
-/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio.
-/// Returns all rows (global + per-medio) — the Application service applies priority.
+/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
+/// Returns all rows (global + per-ProductType) — the Application service applies priority.
+///
+/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps:
+/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
+/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
+/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
+/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found)
+///
+/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException.
+/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current
+/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the
+/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved.
///
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
///
-/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
+/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
///
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{
@@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
///
public async Task InsertWithCloseAsync(
- long? medioId,
+ long? productTypeId,
string symbol,
string category,
decimal price,
@@ -41,14 +52,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default)
{
var p = new DynamicParameters();
- // SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021)
- p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32);
- p.Add("@Symbol", symbol, DbType.String, size: 4);
- p.Add("@Category", category, DbType.String, size: 32);
- p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
- p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
- p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
- p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
+ // SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
+ p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
+ p.Add("@Symbol", symbol, DbType.String, size: 4);
+ p.Add("@Category", category, DbType.String, size: 32);
+ p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
+ p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
+ p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
+ p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
@@ -64,16 +75,16 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
}
catch (SqlException ex) when (ex.Number == 50404)
{
- // Medio not found (SP validates MedioId when not null)
+ // ProductType not found (SP validates ProductTypeId when not null)
throw new ChargeableCharConfigInvalidException(
- nameof(medioId),
- $"Medio with Id={medioId} not found.");
+ nameof(productTypeId),
+ $"ProductType with Id={productTypeId} not found.");
}
catch (SqlException ex) when (ex.Number == 50409)
{
// Forward-only violation: new ValidFrom <= active.ValidFrom
throw new ChargeableCharConfigForwardOnlyException(
- medioId.HasValue ? (int?)checked((int)medioId.Value) : null,
+ productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
symbol,
validFrom,
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
@@ -83,22 +94,22 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
}
///
- public async Task> GetActiveForMedioAsync(
- long medioId,
+ public async Task> GetActiveForProductTypeAsync(
+ long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default)
{
var p = new DynamicParameters();
- // SP @MedioId is INT
- p.Add("@MedioId", checked((int)medioId), DbType.Int32);
- p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
+ // SP @ProductTypeId is INT
+ p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
+ p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync(
new CommandDefinition(
- "dbo.usp_ChargeableCharConfig_GetActiveForMedio",
+ "dbo.usp_ChargeableCharConfig_GetActiveForProductType",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
@@ -108,20 +119,20 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
///
public async Task> ListAsync(
- long? medioId,
+ long? productTypeId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default)
{
- // NULL-aware MedioId filter:
- // - medioId provided → filter to that medio only
- // - medioId null → return all rows regardless of medio
+ // NULL-aware ProductTypeId filter:
+ // - productTypeId provided → filter to that ProductType only
+ // - productTypeId null → return all rows regardless of ProductType
// activeOnly filters by IsActive = 1.
const string sql = """
- SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
+ SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
- WHERE (@MedioId IS NULL OR MedioId = @MedioId)
+ WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
ORDER BY ValidFrom DESC, Id DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
@@ -135,10 +146,10 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql,
new
{
- MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
- ActiveOnly = activeOnly ? 1 : 0,
- Skip = skip,
- Take = take
+ ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
+ ActiveOnly = activeOnly ? 1 : 0,
+ Skip = skip,
+ Take = take
},
cancellationToken: ct));
@@ -147,14 +158,14 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
///
public async Task CountAsync(
- long? medioId,
+ long? productTypeId,
bool activeOnly,
CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
- WHERE (@MedioId IS NULL OR MedioId = @MedioId)
+ WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
""";
@@ -166,8 +177,8 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
sql,
new
{
- MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
- ActiveOnly = activeOnly ? 1 : 0
+ ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
+ ActiveOnly = activeOnly ? 1 : 0
},
cancellationToken: ct));
}
@@ -178,7 +189,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
CancellationToken ct = default)
{
const string sql = """
- SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
+ SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE Id = @Id
""";
@@ -221,24 +232,91 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
cancellationToken: ct));
}
+ ///
+ public async Task ReactivateAsync(
+ long id,
+ CancellationToken ct = default)
+ {
+ var p = new DynamicParameters();
+ p.Add("@Id", id, DbType.Int64);
+
+ await using var connection = _factory.CreateConnection();
+ await connection.OpenAsync(ct);
+
+ try
+ {
+ await connection.ExecuteAsync(
+ new CommandDefinition(
+ "dbo.usp_ChargeableCharConfig_ReactivateWithGuard",
+ p,
+ commandType: CommandType.StoredProcedure,
+ cancellationToken: ct));
+ }
+ catch (SqlException ex) when (ex.Number == 50404)
+ {
+ throw new ChargeableCharConfigInvalidException(
+ nameof(id), $"ChargeableCharConfig with Id={id} not found.");
+ }
+ catch (SqlException ex) when (ex.Number == 50410)
+ {
+ throw new ChargeableCharConfigReactivationNotAllowedException(id, "ALREADY_ACTIVE");
+ }
+ catch (SqlException ex) when (ex.Number == 50411)
+ {
+ throw new ChargeableCharConfigReactivationNotAllowedException(id, "VIGENTE_EXISTS");
+ }
+ catch (SqlException ex) when (ex.Number == 50412)
+ {
+ throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
+ }
+
+ // Fetch the reactivated row to return its current state.
+ var reactivated = await GetByIdAsync(id, ct);
+ return reactivated
+ ?? throw new ChargeableCharConfigInvalidException(
+ nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).");
+ }
+
+ ///
+ public async Task DeleteAsync(
+ long id,
+ CancellationToken ct = default)
+ {
+ // NOTE: With SYSTEM_VERSIONING ON on dbo.ChargeableCharConfig, this DELETE moves
+ // the row to dbo.ChargeableCharConfig_History (SysEndTime = deletion timestamp).
+ // The row disappears from current-state queries but is still queryable via
+ // FOR SYSTEM_TIME. Temporal audit trail is preserved.
+ // Future FAC-001 will add a guard to block delete if the row was used in invoicing.
+ const string sql = "DELETE FROM dbo.ChargeableCharConfig WHERE Id = @Id";
+
+ await using var connection = _factory.CreateConnection();
+ await connection.OpenAsync(ct);
+
+ var rowsAffected = await connection.ExecuteAsync(
+ new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
+
+ if (rowsAffected == 0)
+ throw new KeyNotFoundException($"ChargeableCharConfig with Id={id} not found.");
+ }
+
// ── Row mapper ────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
// Same pattern as ProductPriceRepository.
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
=> ChargeableCharConfig.Rehydrate(
- id: r.Id,
- medioId: r.MedioId,
- symbol: r.Symbol,
- category: r.Category,
- price: r.PricePerUnit,
- validFrom: DateOnly.FromDateTime(r.ValidFrom),
- validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
- isActive: r.IsActive);
+ id: r.Id,
+ productTypeId: r.ProductTypeId,
+ symbol: r.Symbol,
+ category: r.Category,
+ price: r.PricePerUnit,
+ validFrom: DateOnly.FromDateTime(r.ValidFrom),
+ validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
+ isActive: r.IsActive);
private sealed record ChargeableCharConfigRow(
long Id,
- int? MedioId,
+ int? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
diff --git a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
index a2f9132..ce14d3e 100644
--- a/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
+++ b/tests/SIGCM2.Api.Tests/Pricing/ChargeableChars/ChargeableCharConfigControllerTests.cs
@@ -26,6 +26,8 @@ namespace SIGCM2.Api.Tests.Pricing.ChargeableChars;
/// POST /api/v1/admin/chargeable-chars
/// PUT /api/v1/admin/chargeable-chars/{id}/price
/// PATCH /api/v1/admin/chargeable-chars/{id}/deactivate
+/// PATCH /api/v1/admin/chargeable-chars/{id}/reactivate
+/// DELETE /api/v1/admin/chargeable-chars/{id}
///
/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory).
/// All mutations require 'tasacion:caracteres_especiales:gestionar' permission.
@@ -84,20 +86,20 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
/// Inserts a ChargeableCharConfig row directly (bypasses SP guard) for scenario setup.
private async Task SeedConfigDirectAsync(
- long? medioId, string symbol, string category, decimal pricePerUnit,
+ long? productTypeId, 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());
+ (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive, FechaCreacion)
+ VALUES (@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, @ValidTo, @IsActive, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""",
new
{
- MedioId = medioId.HasValue ? (object)(int)medioId.Value : DBNull.Value,
+ ProductTypeId = productTypeId.HasValue ? (object)(int)productTypeId.Value : DBNull.Value,
Symbol = symbol,
Category = category,
PricePerUnit = pricePerUnit,
@@ -119,8 +121,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
[Fact]
public async Task Get_List_ReturnsPagedResult()
{
- // Seed 2 active rows with unique symbols to avoid conflicts
- var sym1 = $"L{Guid.NewGuid():N}"[..1];
+ // Seed an active row with unique symbol to avoid conflicts
await SeedConfigDirectAsync(null, "§", "Currency", 1.50m,
new DateOnly(2026, 1, 1), null, true);
@@ -198,7 +199,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/chargeable-chars",
body: new
{
- medioId = (long?)null,
+ productTypeId = (long?)null,
symbol = "¥",
category = "Currency",
pricePerUnit = 1.75m,
@@ -221,7 +222,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
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() });
+ body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() });
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
@@ -232,7 +233,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
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() },
+ body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
@@ -244,7 +245,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
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() },
+ body: new { productTypeId = (long?)null, symbol = "£", category = "Currency", pricePerUnit = 0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -256,7 +257,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
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() },
+ body: new { productTypeId = (long?)null, symbol = "$$$$$", category = "Currency", pricePerUnit = 1.0m, validFrom = TomorrowStr() },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
@@ -268,7 +269,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
{
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" },
+ body: new { productTypeId = (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);
@@ -288,7 +289,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
// "😀" 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() },
+ 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.
@@ -376,6 +377,229 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
resp.StatusCode.Should().Be(HttpStatusCode.OK);
}
+ // ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
+
+ /// PRC-001 — PATCH reactivate on last closed row returns 200.
+ [Fact]
+ public async Task Patch_Reactivate_LastClosed_Returns200()
+ {
+ // Seed a closed row (isActive=false) — no other active row for this symbol
+ // Use a unique symbol to avoid conflicts with other tests
+ var uniqueSymbol = $"R{Guid.NewGuid():N}"[..1];
+ var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("id").GetInt64().Should().Be(id);
+ body.GetProperty("isActive").GetBoolean().Should().BeTrue();
+ }
+
+ /// PRC-001 — PATCH reactivate on already-active row returns 409 ALREADY_ACTIVE.
+ [Fact]
+ public async Task Patch_Reactivate_AlreadyActive_Returns409_AlreadyActive()
+ {
+ var uniqueSymbol = $"A{Guid.NewGuid():N}"[..1];
+ var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), null, true); // already active
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
+ body.GetProperty("reason").GetString().Should().Be("ALREADY_ACTIVE");
+ }
+
+ /// PRC-001 — PATCH reactivate when vigente exists returns 409 VIGENTE_EXISTS.
+ [Fact]
+ public async Task Patch_Reactivate_VigenteExists_Returns409_VigenteExists()
+ {
+ // Seed: one active (vigente) row + one closed row for the same symbol
+ var uniqueSymbol = $"V{Guid.NewGuid():N}"[..1];
+
+ // First: closed row (older)
+ var closedId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
+
+ // Second: active vigente row (newer — this blocks reactivation of closedId)
+ await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
+ new DateOnly(2026, 4, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{closedId}/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
+ body.GetProperty("reason").GetString().Should().Be("VIGENTE_EXISTS");
+ }
+
+ /// PRC-001 — PATCH reactivate when posterior rows exist returns 409 POSTERIOR_ROWS_EXIST.
+ [Fact]
+ public async Task Patch_Reactivate_PosteriorRowsExist_Returns409_PosteriorRowsExist()
+ {
+ // Seed: target closed row + a posterior (newer ValidFrom) closed row for the same symbol
+ var uniqueSymbol = $"P{Guid.NewGuid():N}"[..1];
+
+ // First: target closed row
+ var targetId = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), new DateOnly(2026, 3, 31), false);
+
+ // Second: posterior closed row (newer ValidFrom, also closed — blocks reactivation)
+ await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 1.00m,
+ new DateOnly(2026, 4, 1), new DateOnly(2026, 6, 30), false);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{targetId}/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("code").GetString().Should().Be("CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED");
+ body.GetProperty("reason").GetString().Should().Be("POSTERIOR_ROWS_EXIST");
+ }
+
+ /// PRC-001 — PATCH reactivate without auth returns 401.
+ [Fact]
+ public async Task Patch_Reactivate_Unauthorized_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Patch,
+ "/api/v1/admin/chargeable-chars/1/reactivate");
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ /// PRC-001 — PATCH reactivate without permission returns 403.
+ [Fact]
+ public async Task Patch_Reactivate_WithoutPermission_Returns403()
+ {
+ var token = GetCajeroToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ "/api/v1/admin/chargeable-chars/1/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
+ /// PRC-001 — PATCH reactivate emits audit event.
+ [Fact]
+ public async Task Patch_Reactivate_AuditEventEmitted()
+ {
+ var uniqueSymbol = $"Q{Guid.NewGuid():N}"[..1];
+ var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 1), false);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Patch,
+ $"/api/v1/admin/chargeable-chars/{id}/reactivate", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK,
+ because: "PATCH reactivate must succeed before checking 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.reactivate'
+ AND TargetType = 'ChargeableCharConfig'
+ AND TargetId = @TargetId
+ """, new { TargetId = id.ToString() });
+
+ auditCount.Should().Be(1,
+ because: "IAuditLogger must record tasacion.chargeable_char.reactivate after successful PATCH");
+ }
+
+ // ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
+
+ /// PRC-001 — DELETE existing row returns 200.
+ [Fact]
+ public async Task Delete_Existing_Returns200()
+ {
+ var uniqueSymbol = $"D{Guid.NewGuid():N}"[..1];
+ var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Delete,
+ $"/api/v1/admin/chargeable-chars/{id}", token: token);
+ var resp = await _client.SendAsync(req);
+
+ resp.StatusCode.Should().Be(HttpStatusCode.OK);
+ var body = await resp.Content.ReadFromJsonAsync();
+ body.GetProperty("id").GetInt64().Should().Be(id);
+ }
+
+ /// PRC-001 — DELETE non-existent row returns 404.
+ [Fact]
+ public async Task Delete_NotFound_Returns404()
+ {
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Delete,
+ "/api/v1/admin/chargeable-chars/999999998", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ /// PRC-001 — DELETE emits audit event.
+ [Fact]
+ public async Task Delete_AuditEventEmitted()
+ {
+ var uniqueSymbol = $"E{Guid.NewGuid():N}"[..1];
+ var id = await SeedConfigDirectAsync(null, uniqueSymbol, "Other", 0.50m,
+ new DateOnly(2026, 1, 1), null, true);
+
+ var token = GetAdminToken();
+ using var req = BuildRequest(HttpMethod.Delete,
+ $"/api/v1/admin/chargeable-chars/{id}", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.OK,
+ because: "DELETE must succeed before checking 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.delete'
+ AND TargetType = 'ChargeableCharConfig'
+ AND TargetId = @TargetId
+ """, new { TargetId = id.ToString() });
+
+ auditCount.Should().Be(1,
+ because: "IAuditLogger must record tasacion.chargeable_char.delete after successful DELETE");
+ }
+
+ /// PRC-001 — DELETE without auth returns 401.
+ [Fact]
+ public async Task Delete_Unauthorized_Returns401()
+ {
+ using var req = BuildRequest(HttpMethod.Delete,
+ "/api/v1/admin/chargeable-chars/1");
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ /// PRC-001 — DELETE without permission returns 403.
+ [Fact]
+ public async Task Delete_WithoutPermission_Returns403()
+ {
+ var token = GetCajeroToken();
+ using var req = BuildRequest(HttpMethod.Delete,
+ "/api/v1/admin/chargeable-chars/1", token: token);
+ var resp = await _client.SendAsync(req);
+ resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
// ── Audit ─────────────────────────────────────────────────────────────────
/// PRC-001-R3.6 — POST emits audit event chargeable_char_config.created.
@@ -386,7 +610,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
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 },
+ body: new { productTypeId = (long?)null, symbol = "↑", category = "Currency", pricePerUnit = 1.10m, validFrom },
token: token);
var resp = await _client.SendAsync(req);
resp.StatusCode.Should().Be(HttpStatusCode.Created,
@@ -502,7 +726,7 @@ public sealed class ChargeableCharConfigControllerTests : IAsyncLifetime
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = JsonContent.Create(new
{
- medioId = (long?)null,
+ productTypeId = (long?)null,
symbol = "←",
category = "Currency",
pricePerUnit = 1.50m,
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs
new file mode 100644
index 0000000..44d96d9
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigReactivationNotAllowedExceptionTests.cs
@@ -0,0 +1,32 @@
+using FluentAssertions;
+using SIGCM2.Domain.Pricing.Exceptions;
+
+namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Unit tests for ChargeableCharConfigReactivationNotAllowedException.
+///
+public sealed class ChargeableCharConfigReactivationNotAllowedExceptionTests
+{
+ [Theory]
+ [InlineData("ALREADY_ACTIVE")]
+ [InlineData("VIGENTE_EXISTS")]
+ [InlineData("POSTERIOR_ROWS_EXIST")]
+ public void Constructor_SetsIdAndReason(string reason)
+ {
+ var ex = new ChargeableCharConfigReactivationNotAllowedException(42L, reason);
+
+ ex.Id.Should().Be(42L);
+ ex.Reason.Should().Be(reason);
+ ex.Message.Should().Contain("42");
+ ex.Message.Should().Contain(reason);
+ }
+
+ [Fact]
+ public void Exception_IsDomainException()
+ {
+ var ex = new ChargeableCharConfigReactivationNotAllowedException(1L, "ALREADY_ACTIVE");
+
+ ex.Should().BeAssignableTo();
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs
index 1f74e64..c518a76 100644
--- a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs
@@ -95,15 +95,15 @@ public sealed class ChargeableCharConfigTests
entity.Category.Should().Be("Currency");
entity.PricePerUnit.Should().Be(1.5m);
entity.ValidFrom.Should().Be(Today);
- entity.MedioId.Should().BeNull();
+ entity.ProductTypeId.Should().BeNull();
}
[Fact]
- public void Create_WithMedioId_SetsCorrectly()
+ public void Create_WithProductTypeId_SetsCorrectly()
{
var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today);
- entity.MedioId.Should().Be(5);
+ entity.ProductTypeId.Should().Be(5);
}
[Fact]
@@ -218,11 +218,11 @@ public sealed class ChargeableCharConfigTests
{
// Rehydrate can create entities that would fail Create (e.g., IsActive=false)
var entity = ChargeableCharConfig.Rehydrate(
- id: 42, medioId: 5, symbol: "$", category: "Currency",
+ id: 42, productTypeId: 5, symbol: "$", category: "Currency",
price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false);
entity.Id.Should().Be(42);
- entity.MedioId.Should().Be(5);
+ entity.ProductTypeId.Should().Be(5);
entity.Symbol.Should().Be("$");
entity.Category.Should().Be("Currency");
entity.PricePerUnit.Should().Be(1.5m);
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs
index 54514d2..8d06c96 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs
@@ -51,15 +51,15 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Seed two dedicated ProductTypes for override/fallback resolution tests.
// V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
_productType1Id = await conn.ExecuteScalarAsync("""
- INSERT INTO dbo.ProductType (Nombre, IsActive)
+ INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
- VALUES ('Hardening PT1 (override)', 1)
+ VALUES ('Hardening PT1 (override)', 'H_PT1', 1)
""");
_productType2Id = await conn.ExecuteScalarAsync("""
- INSERT INTO dbo.ProductType (Nombre, IsActive)
+ INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
- VALUES ('Hardening PT2 (fallback)', 1)
+ VALUES ('Hardening PT2 (fallback)', 'H_PT2', 1)
""");
}
@@ -68,11 +68,11 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// ─────────────────────────────────────────────────────────────────────────
// T7.1 — Concurrency: only one winner survives the race
//
- // Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol).
+ // Three parallel connections try to InsertWithClose for the same (ProductTypeId=null, Symbol).
// The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit.
// The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205).
//
- // After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol).
+ // After resolution: exactly 1 vigente row exists for (ProductTypeId=NULL, Symbol).
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -94,7 +94,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await conn.OpenAsync();
var p = new DynamicParameters();
- p.Add("@MedioId", null, System.Data.DbType.Int32);
+ p.Add("@ProductTypeId", null, System.Data.DbType.Int32);
p.Add("@Symbol", symbol, System.Data.DbType.String);
p.Add("@Category", category, System.Data.DbType.String);
p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4);
@@ -278,10 +278,6 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
// GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
//
- // NOTE: C# method calls (GetActiveForMedioAsync, GetActiveConfigForMedioAsync) will be
- // renamed in Agent 2 (Backend refactor). These tests will FAIL COMPILATION until Agent 2.
- // SQL-level assertions in this test (the ExecInsertWithCloseAsync helper) are already
- // updated for V023 (@ProductTypeId param). The C# repo/service method calls are left as-is.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
@@ -297,13 +293,13 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// Build the repository + service (C# method will be renamed in Agent 2)
var repo = BuildRepository();
- var rows = await repo.GetActiveForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
+ var rows = await repo.GetActiveForProductTypeAsync((long)_productType1Id, asOf);
// The per-PT '$' must be returned
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it");
- dollarRow!.MedioId.Should().Be(_productType1Id, // TODO Agent 2: rename to ProductTypeId
+ dollarRow!.ProductTypeId.Should().Be(_productType1Id,
"the per-PT row (ProductTypeId = PT1) must take priority over the global row");
dollarRow.PricePerUnit.Should().Be(5.0000m,
@@ -318,7 +314,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
// ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync
// provides '$' at global price (0.0000 after V024).
var repo = BuildRepository();
- var rows = await repo.GetActiveForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2: rename to GetActiveForProductTypeAsync
+ var rows = await repo.GetActiveForProductTypeAsync((long)_productType2Id, asOf);
// Must have at least the global '$' from seed
rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");
@@ -326,7 +322,7 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");
- dollarRow!.MedioId.Should().BeNull( // TODO Agent 2: rename to ProductTypeId
+ dollarRow!.ProductTypeId.Should().BeNull(
"ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
}
@@ -343,11 +339,10 @@ public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
// Build the service (wraps repo with priority resolution)
- // TODO Agent 2: rename GetActiveConfigForMedioAsync → GetActiveConfigForProductTypeAsync
var service = BuildService();
- var pt1Config = await service.GetActiveConfigForMedioAsync((long)_productType1Id, asOf); // TODO Agent 2
- var pt2Config = await service.GetActiveConfigForMedioAsync((long)_productType2Id, asOf); // TODO Agent 2
+ var pt1Config = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf);
+ var pt2Config = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf);
// ProductType1: '%' must come from per-PT override at 3.00
pt1Config.Should().ContainKey("%",
diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs
index 16acd8b..8a70c9a 100644
--- a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigRepositoryIntegrationTests.cs
@@ -16,9 +16,8 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync().
/// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating.
///
-/// V023 scope delta: MedioId → ProductTypeId. C# method/property renames (InsertWithCloseAsync
-/// medioId: param, GetActiveForMedioAsync, entity.MedioId) are deferred to Agent 2 (Backend refactor).
-/// This class will FAIL COMPILATION after Agent 2 renames the domain layer — expected.
+/// V023 scope delta: MedioId → ProductTypeId. Uses dbo.ProductType for per-PT override tests.
+/// Uses unique name "RepoIntegration PT1" to avoid uniqueness conflicts with HardeningTests.
///
/// Spec coverage:
/// T4.1 InsertWithCloseAsync — first insert for symbol → new row, returns Id
@@ -33,12 +32,16 @@ namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
/// T4.10 GetByIdAsync — exists → returns entity
/// T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today
/// T4.12 DeactivateAsync — already inactive → idempotent (no-op)
+/// T4.13 ReactivateAsync — last closed row → returns reactivated entity
+/// T4.14 ReactivateAsync — already active → throws ALREADY_ACTIVE
+/// T4.15 DeleteAsync — row exists → deleted (0 rows after)
+/// T4.16 DeleteAsync — row not found → throws KeyNotFoundException
///
[Collection("Database")]
public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{
private readonly SqlTestFixture _db;
- private int _medioId;
+ private int _productTypeId;
public ChargeableCharConfigRepositoryIntegrationTests(SqlTestFixture db)
{
@@ -49,14 +52,15 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{
await _db.ResetAndSeedAsync();
- // Create a dedicated Medio for per-medio tests
+ // Create a dedicated ProductType for per-PT tests.
+ // Unique name to avoid conflicts with HardeningTests ("RepoIntegration PT1").
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
- _medioId = await conn.ExecuteScalarAsync("""
- INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
+ _productTypeId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
- VALUES ('REPO_TEST', 'Medio RepoTest', 1, 1)
+ VALUES ('RepoIntegration PT1', 'RI_PT1', 1)
""");
}
@@ -69,16 +73,16 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
[Fact]
public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId()
{
- // NEW symbol not in canonical seed — use per-medio so it doesn't conflict
+ // NEW symbol not in canonical seed — use per-PT so it doesn't conflict
const string symbol = "@";
var repo = BuildRepository();
var newId = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Other",
- price: 2.5000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 2.5000m,
+ validFrom: new DateOnly(2026, 1, 1));
newId.Should().BeGreaterThan(0, "first insert must return the new row's Id");
@@ -108,20 +112,20 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// First insert — becomes the vigente
var firstId = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Other",
- price: 1.0000m,
- validFrom: new DateOnly(2026, 3, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 1.0000m,
+ validFrom: new DateOnly(2026, 3, 1));
// Second insert (forward) — must close the first
var secondValidFrom = new DateOnly(2026, 6, 1);
var secondId = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Other",
- price: 2.0000m,
- validFrom: secondValidFrom);
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 2.0000m,
+ validFrom: secondValidFrom);
secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id");
@@ -157,19 +161,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Establish a vigente at 2026-04-01
await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Currency",
- price: 1.5000m,
- validFrom: new DateOnly(2026, 4, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Currency",
+ price: 1.5000m,
+ validFrom: new DateOnly(2026, 4, 1));
// Try to insert retroactively — SP will THROW 50409
var act = async () => await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Currency",
- price: 1.2000m,
- validFrom: new DateOnly(2026, 3, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Currency",
+ price: 1.2000m,
+ validFrom: new DateOnly(2026, 3, 1));
await act.Should()
.ThrowAsync(
@@ -188,19 +192,19 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Insert the first row
var firstId = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Currency",
- price: 3.0000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Currency",
+ price: 3.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
// Insert a second row — this triggers an UPDATE on the first row → history
await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: symbol,
- category: "Currency",
- price: 4.0000m,
- validFrom: new DateOnly(2026, 7, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Currency",
+ price: 4.0000m,
+ validFrom: new DateOnly(2026, 7, 1));
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
@@ -214,64 +218,61 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
}
// ─────────────────────────────────────────────────────────────────────────
- // T4.5 — GetActiveForMedioAsync: medio has override → returns both medio and global rows
- // Note: SP returns ALL rows (global + per-medio); service does priority resolution.
+ // T4.5 — GetActiveForProductTypeAsync: PT has override → returns both PT and global rows
+ // Note: SP returns ALL rows (global + per-PT); service does priority resolution.
// This test verifies the REPOSITORY returns both, not just one.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
- public async Task GetActiveForMedioAsync_MedioHasOverride_ReturnsBothMedioAndGlobalRows()
+ public async Task GetActiveForProductTypeAsync_PTHasOverride_ReturnsBothPTAndGlobalRows()
{
var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1);
- // Add a per-medio override for symbol '$'
+ // Add a per-PT override for symbol '$'
// Canonical seed already has global '$' from ResetAndSeedAsync
await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: "$",
- category: "Currency",
- price: 5.0000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: "$",
+ category: "Currency",
+ price: 5.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
- var rows = await repo.GetActiveForMedioAsync((long)_medioId, asOf);
+ var rows = await repo.GetActiveForProductTypeAsync((long)_productTypeId, asOf);
- // The SP returns both the per-medio '$' AND global rows for other symbols
- // (at minimum: global '$' was replaced by per-medio; other globals still present)
- // SP uses ROW_NUMBER to pick 1 row per Symbol, preferring per-medio.
- // So we should get exactly one row per symbol that is active as of asOf.
+ // The SP returns both the per-PT '$' AND global rows for other symbols
rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed");
var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
dollarRow.Should().NotBeNull("the SP must return a row for '$'");
- dollarRow!.MedioId.Should().Be(_medioId,
- "per-medio row takes priority over global in the SP's ROW_NUMBER ordering");
+ dollarRow!.ProductTypeId.Should().Be(_productTypeId,
+ "per-PT row takes priority over global in the SP's ROW_NUMBER ordering");
}
// ─────────────────────────────────────────────────────────────────────────
- // T4.6 — GetActiveForMedioAsync: no medio override → returns only global rows
+ // T4.6 — GetActiveForProductTypeAsync: no PT override → returns only global rows
// ─────────────────────────────────────────────────────────────────────────
[Fact]
- public async Task GetActiveForMedioAsync_NoMedioOverride_ReturnsOnlyGlobalRows()
+ public async Task GetActiveForProductTypeAsync_NoPTOverride_ReturnsOnlyGlobalRows()
{
var repo = BuildRepository();
var asOf = new DateOnly(2026, 6, 1);
- // Use a DIFFERENT medioId that has no per-medio rows
+ // Use a DIFFERENT productTypeId that has no per-PT rows
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
- var otherMedioId = await conn.ExecuteScalarAsync("""
- INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
+ var otherPTId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.ProductType (Nombre, Codigo, Activo)
OUTPUT INSERTED.Id
- VALUES ('REPO_NO_OVRD', 'Medio sin override', 1, 1)
+ VALUES ('RepoIntegration PT2 NoOverride', 'RI_PT2', 1)
""");
- var rows = await repo.GetActiveForMedioAsync((long)otherMedioId, asOf);
+ var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf);
rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01");
rows.Should().AllSatisfy(r =>
- r.MedioId.Should().BeNull("all returned rows must be global (MedioId = NULL)"));
+ r.ProductTypeId.Should().BeNull("all returned rows must be global (ProductTypeId = NULL)"));
}
// ─────────────────────────────────────────────────────────────────────────
@@ -284,8 +285,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
// Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2).
- var page1 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 0, take: 2);
- var page2 = await repo.ListAsync(medioId: null, activeOnly: false, skip: 2, take: 2);
+ var page1 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 0, take: 2);
+ var page2 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 2, take: 2);
page1.Should().HaveCount(2, "take=2 with at least 4 rows");
page2.Should().HaveCount(2, "second page of 4 rows");
@@ -299,7 +300,7 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
public async Task ListAsync_PageBeyondTotal_ReturnsEmpty()
{
var repo = BuildRepository();
- var result = await repo.ListAsync(medioId: null, activeOnly: false, skip: 1000, take: 10);
+ var result = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 1000, take: 10);
result.Should().BeEmpty("skip far beyond available data must return empty");
}
@@ -313,8 +314,8 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
// Canonical seed: 4 active global rows
- var countAll = await repo.CountAsync(medioId: null, activeOnly: false);
- var countActive = await repo.CountAsync(medioId: null, activeOnly: true);
+ var countAll = await repo.CountAsync(productTypeId: null, activeOnly: false);
+ var countActive = await repo.CountAsync(productTypeId: null, activeOnly: true);
countAll.Should().BeGreaterThanOrEqualTo(4,
"canonical seed provides at least 4 rows (may have more if other tests ran)");
@@ -331,18 +332,18 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
// Insert a row, then deactivate it — active count should decrease by 1
var id = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: "~",
- category: "Other",
- price: 1.0000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: "~",
+ category: "Other",
+ price: 1.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
var today = new DateOnly(2026, 4, 20);
- var beforeDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true);
+ var beforeDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true);
await repo.DeactivateAsync(id, today);
- var afterDeactivate = await repo.CountAsync(medioId: (long?)_medioId, activeOnly: true);
+ var afterDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true);
afterDeactivate.Should().Be(beforeDeactivate - 1,
"deactivating one row must decrease the active count by 1");
@@ -371,17 +372,17 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var expectedValidFrom = new DateOnly(2026, 2, 1);
var id = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: "^",
- category: "Other",
- price: 7.5000m,
- validFrom: expectedValidFrom);
+ productTypeId: (long?)_productTypeId,
+ symbol: "^",
+ category: "Other",
+ price: 7.5000m,
+ validFrom: expectedValidFrom);
var entity = await repo.GetByIdAsync(id);
entity.Should().NotBeNull();
entity!.Id.Should().Be(id);
- entity.MedioId.Should().Be(_medioId);
+ entity.ProductTypeId.Should().Be(_productTypeId);
entity.Symbol.Should().Be("^");
entity.Category.Should().Be("Other");
entity.PricePerUnit.Should().Be(7.5000m);
@@ -400,11 +401,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: "&",
- category: "Other",
- price: 1.0000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: "&",
+ category: "Other",
+ price: 1.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
var today = new DateOnly(2026, 4, 20);
await repo.DeactivateAsync(id, today);
@@ -426,11 +427,11 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
var repo = BuildRepository();
var id = await repo.InsertWithCloseAsync(
- medioId: (long?)_medioId,
- symbol: "*",
- category: "Other",
- price: 1.0000m,
- validFrom: new DateOnly(2026, 1, 1));
+ productTypeId: (long?)_productTypeId,
+ symbol: "*",
+ category: "Other",
+ price: 1.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
var today = new DateOnly(2026, 4, 20);
@@ -447,6 +448,101 @@ public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
entity.ValidTo.Should().Be(today);
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // T4.13 — ReactivateAsync: last closed row → returns reactivated entity
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ReactivateAsync_LastClosedRow_ReturnsReactivatedEntity()
+ {
+ var repo = BuildRepository();
+
+ // Insert a row, then deactivate it — it becomes "last closed" for this symbol
+ const string symbol = "≈";
+ var id = await repo.InsertWithCloseAsync(
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 2.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
+
+ var today = new DateOnly(2026, 4, 20);
+ await repo.DeactivateAsync(id, today);
+
+ // Reactivate — no posterior rows, no vigente
+ var reactivated = await repo.ReactivateAsync(id);
+
+ reactivated.Should().NotBeNull();
+ reactivated.Id.Should().Be(id);
+ reactivated.IsActive.Should().BeTrue("row must be active after reactivation");
+ reactivated.ValidTo.Should().BeNull("ValidTo must be NULL after reactivation");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T4.14 — ReactivateAsync: already active → throws ALREADY_ACTIVE
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ReactivateAsync_AlreadyActive_ThrowsAlreadyActive()
+ {
+ var repo = BuildRepository();
+
+ // Insert an active row — do NOT deactivate it
+ const string symbol = "≠";
+ var id = await repo.InsertWithCloseAsync(
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 3.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
+
+ var act = async () => await repo.ReactivateAsync(id);
+
+ await act.Should()
+ .ThrowAsync()
+ .Where(e => e.Reason == "ALREADY_ACTIVE",
+ "SP 50410 → ALREADY_ACTIVE reason");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T4.15 — DeleteAsync: row exists → deleted (0 rows after)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DeleteAsync_ExistingRow_RowIsGone()
+ {
+ var repo = BuildRepository();
+
+ const string symbol = "∞";
+ var id = await repo.InsertWithCloseAsync(
+ productTypeId: (long?)_productTypeId,
+ symbol: symbol,
+ category: "Other",
+ price: 1.0000m,
+ validFrom: new DateOnly(2026, 1, 1));
+
+ await repo.DeleteAsync(id);
+
+ // Row must be gone from current state
+ var entity = await repo.GetByIdAsync(id);
+ entity.Should().BeNull("deleted row must not appear in GetByIdAsync");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T4.16 — DeleteAsync: row not found → throws KeyNotFoundException
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DeleteAsync_NotFound_ThrowsKeyNotFoundException()
+ {
+ var repo = BuildRepository();
+
+ var act = async () => await repo.DeleteAsync(999_999_997L);
+
+ await act.Should().ThrowAsync(
+ "non-existent Id must throw KeyNotFoundException");
+ }
+
// ── Helper ───────────────────────────────────────────────────────────────
private static ChargeableCharConfigRepository BuildRepository()
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs
index 4a6cbfb..cf74493 100644
--- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/ChargeableCharConfigServiceTests.cs
@@ -26,57 +26,57 @@ public class ChargeableCharConfigServiceTests
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);
+ private static ChargeableCharConfig ProductTypeConfig(long id, int productTypeId, string symbol, decimal price) =>
+ ChargeableCharConfig.Rehydrate(id, productTypeId, symbol, ChargeableCharCategories.Currency, price, AsOf, null, true);
// ── Global fallback ─────────────────────────────────────────────────────────
[Fact]
- public async Task GetActiveConfig_NoPerMedio_ReturnsGlobalConfigs()
+ public async Task GetActiveConfig_NoPerProductType_ReturnsGlobalConfigs()
{
- _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any())
.Returns(new List
{
GlobalConfig("$", 1.0m),
GlobalConfig("%", 0.5m),
});
- var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+ var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
result.Should().ContainKey("$");
result["$"].PricePerUnit.Should().Be(1.0m);
result.Should().ContainKey("%");
}
- // ── Per-medio wins over global ───────────────────────────────────────────────
+ // ── Per-ProductType wins over global ─────────────────────────────────────────
[Fact]
- public async Task GetActiveConfig_PerMedioExists_OverridesGlobalForSameSymbol()
+ public async Task GetActiveConfig_PerProductTypeExists_OverridesGlobalForSameSymbol()
{
- _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any())
+ _repo.GetActiveForProductTypeAsync(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
+ GlobalConfig("$", 1.0m), // global price = 1.0
+ ProductTypeConfig(20L, 5, "$", 3.0m), // per-PT price = 3.0 → wins
+ GlobalConfig("%", 0.5m), // global only
});
- var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
+ var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None);
- result["$"].PricePerUnit.Should().Be(3.0m); // per-medio wins
+ result["$"].PricePerUnit.Should().Be(3.0m); // per-PT wins
result["%"].PricePerUnit.Should().Be(0.5m); // global only
}
[Fact]
- public async Task GetActiveConfig_PerMedioExists_IncludesCorrectCategory()
+ public async Task GetActiveConfig_PerProductTypeExists_IncludesCorrectCategory()
{
- _repo.GetActiveForMedioAsync(5, AsOf, Arg.Any())
+ _repo.GetActiveForProductTypeAsync(5, AsOf, Arg.Any())
.Returns(new List
{
- MedioConfig(20L, 5, "$", 3.0m),
+ ProductTypeConfig(20L, 5, "$", 3.0m),
});
- var result = await _service.GetActiveConfigForMedioAsync(5, AsOf, CancellationToken.None);
+ var result = await _service.GetActiveConfigForProductTypeAsync(5, AsOf, CancellationToken.None);
result["$"].Category.Should().Be(ChargeableCharCategories.Currency);
}
@@ -86,10 +86,10 @@ public class ChargeableCharConfigServiceTests
[Fact]
public async Task GetActiveConfig_NoConfigAtAll_ReturnsEmptyDictionary()
{
- _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any())
.Returns(new List());
- var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+ var result = await _service.GetActiveConfigForProductTypeAsync(1, AsOf, CancellationToken.None);
result.Should().BeEmpty();
}
@@ -99,13 +99,13 @@ public class ChargeableCharConfigServiceTests
[Fact]
public async Task GetActiveConfig_KeyIsSymbol()
{
- _repo.GetActiveForMedioAsync(1, AsOf, Arg.Any())
+ _repo.GetActiveForProductTypeAsync(1, AsOf, Arg.Any())
.Returns(new List
{
GlobalConfig("!", 2.0m),
});
- var result = await _service.GetActiveConfigForMedioAsync(1, AsOf, CancellationToken.None);
+ var result = await _service.GetActiveConfigForProductTypeAsync(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
index d8e0893..d6ad20e 100644
--- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigCommandValidatorTests.cs
@@ -24,7 +24,7 @@ public class CreateChargeableCharConfigCommandValidatorTests
}
private static CreateChargeableCharConfigCommand ValidCmd() => new(
- MedioId: null,
+ ProductTypeId: null,
Symbol: "$",
Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.0m,
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs
index 77121e2..a532a2e 100644
--- a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/CreateChargeableCharConfigHandlerTests.cs
@@ -37,7 +37,7 @@ public class CreateChargeableCharConfigHandlerTests
}
private static CreateChargeableCharConfigCommand ValidCmd(DateOnly? validFrom = null) => new(
- MedioId: null,
+ ProductTypeId: null,
Symbol: "$",
Category: ChargeableCharCategories.Currency,
PricePerUnit: 1.5m,
@@ -80,9 +80,9 @@ public class CreateChargeableCharConfigHandlerTests
}
[Fact]
- public async Task Handle_WithMedioId_PassesMedioIdToRepo()
+ public async Task Handle_WithProductTypeId_PassesProductTypeIdToRepo()
{
- var cmd = ValidCmd() with { MedioId = 7 };
+ var cmd = ValidCmd() with { ProductTypeId = 7 };
await _handler.Handle(cmd);
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs
new file mode 100644
index 0000000..2d30a76
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/DeleteChargeableCharConfigHandlerTests.cs
@@ -0,0 +1,113 @@
+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.Delete;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+
+namespace SIGCM2.Application.Tests.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — DeleteChargeableCharConfigCommandHandler tests.
+/// Strict TDD — RED written before implementation.
+/// Covers: happy path, not-found, audit emission, audit fail-closed.
+///
+public class DeleteChargeableCharConfigHandlerTests
+{
+ 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 DeleteChargeableCharConfigCommandHandler _handler;
+
+ private static ChargeableCharConfig SomeConfig() =>
+ ChargeableCharConfig.Rehydrate(
+ id: 1L, productTypeId: null, symbol: "$", category: ChargeableCharCategories.Currency,
+ price: 1.5m, validFrom: new DateOnly(2026, 1, 1), validTo: null, isActive: true);
+
+ public DeleteChargeableCharConfigHandlerTests()
+ {
+ _repo.GetByIdAsync(1L, Arg.Any())
+ .Returns(SomeConfig());
+
+ _handler = new DeleteChargeableCharConfigCommandHandler(_repo, _audit, _time);
+ }
+
+ private static DeleteChargeableCharConfigCommand ValidCmd() => new(Id: 1L);
+
+ // ── Happy path ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Handle_HappyPath_ReturnsResponse()
+ {
+ var result = await _handler.Handle(ValidCmd());
+
+ result.Should().NotBeNull();
+ result.Id.Should().Be(1L);
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_CallsDeleteAsync()
+ {
+ await _handler.Handle(ValidCmd());
+
+ await _repo.Received(1).DeleteAsync(1L, Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_HappyPath_EmitsAuditDelete()
+ {
+ await _handler.Handle(ValidCmd());
+
+ await _audit.Received(1).LogAsync(
+ action: "tasacion.chargeable_char.delete",
+ targetType: "ChargeableCharConfig",
+ targetId: "1",
+ metadata: Arg.Any