refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)

Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
This commit is contained in:
2026-04-21 10:54:47 -03:00
parent 5c1675e59a
commit f7fb76219a
35 changed files with 1273 additions and 273 deletions

View File

@@ -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
/// <summary>
/// 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.
/// </summary>
@@ -49,7 +51,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
return Ok(result);
}
@@ -100,7 +102,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
/// <summary>
/// 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}.
/// </summary>
[HttpPost]
@@ -113,7 +115,7 @@ public sealed class ChargeableCharConfigController : ControllerBase
public async Task<IActionResult> 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 ─────────────────
/// <summary>
/// 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
/// </summary>
[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<IActionResult> Reactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
new ReactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<IActionResult> Delete([FromRoute] long id)
{
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
new DeleteChargeableCharConfigCommand(id));
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
public sealed record CreateChargeableCharConfigRequest(
long? MedioId,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,

View File

@@ -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)

View File

@@ -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.
/// </summary>
public interface IChargeableCharConfigRepository
{
/// <summary>
/// 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)
/// </summary>
Task<long> InsertWithCloseAsync(
long? medioId,
long? productTypeId,
string symbol,
string category,
decimal price,
@@ -33,20 +33,20 @@ public interface IChargeableCharConfigRepository
/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
long medioId,
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default);
/// <summary>
/// Returns paginated rows filtered by MedioId and IsActive.
/// Returns paginated rows filtered by ProductTypeId and IsActive.
/// Skip = (page - 1) * pageSize computed by the caller.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> 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).
/// </summary>
Task<int> CountAsync(
long? medioId,
long? productTypeId,
bool activeOnly,
CancellationToken ct = default);
@@ -76,4 +76,29 @@ public interface IChargeableCharConfigRepository
long id,
DateOnly today,
CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<ChargeableCharConfig> ReactivateAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task DeleteAsync(
long id,
CancellationToken ct = default);
}

View File

@@ -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<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();

View File

@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
/// </summary>
public sealed record ChargeableCharConfigDto(
long Id,
long? MedioId,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,

View File

@@ -4,11 +4,11 @@ namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// 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.
/// </summary>
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
{
@@ -20,22 +20,22 @@ public sealed class ChargeableCharConfigService : IChargeableCharConfigService
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
long medioId,
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> 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<string, ChargeableCharSnapshot>(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;

View File

@@ -2,10 +2,10 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// 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.
/// </summary>
public sealed record CreateChargeableCharConfigCommand(
long? MedioId,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,

View File

@@ -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,

View File

@@ -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"),

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// 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.
/// </summary>
public sealed record DeleteChargeableCharConfigCommand(long Id);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class DeleteChargeableCharConfigCommandHandler
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
{
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<DeleteChargeableCharConfigResponse> 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);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// PRC-001 — Response for a successful delete operation.
/// </summary>
public sealed record DeleteChargeableCharConfigResponse(long Id);

View File

@@ -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,

View File

@@ -1,21 +1,21 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// 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.
/// </summary>
public interface IChargeableCharConfigService
{
/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForMedioAsync(
long medioId,
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long productTypeId,
DateOnly asOf,
CancellationToken ct = default);
}

View File

@@ -5,7 +5,7 @@ namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
/// </summary>
public sealed record ListChargeableCharConfigQuery(
long? MedioId,
long? ProductTypeId,
bool ActiveOnly,
int Page = 1,
int PageSize = 20);

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// 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).
/// </summary>
public sealed record ReactivateChargeableCharConfigCommand(long Id);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class ReactivateChargeableCharConfigCommandHandler
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
{
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<ReactivateChargeableCharConfigResponse> 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);
}
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// PRC-001 — Response for a successful reactivation.
/// Returns the current state of the row after it has been re-opened.
/// </summary>
public sealed record ReactivateChargeableCharConfigResponse(
long Id,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom,
bool IsActive);

View File

@@ -28,7 +28,7 @@ public sealed class SchedulePriceChangeCommandHandler
public async Task<SchedulePriceChangeResponse> 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,

View File

@@ -5,18 +5,18 @@ namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// 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).
/// </summary>
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.
/// </summary>
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);
}
/// <summary>
@@ -69,9 +69,9 @@ public sealed class ChargeableCharConfig
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
/// </summary>
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);
/// <summary>
/// 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);
}
/// <summary>

View File

@@ -8,19 +8,19 @@ namespace SIGCM2.Domain.Pricing.Exceptions;
/// </summary>
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;

View File

@@ -0,0 +1,29 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// 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)
/// </summary>
public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
{
public long Id { get; }
/// <summary>
/// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
/// </summary>
public string Reason { get; }
public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
: base($"Reactivation not allowed for config {id}: {reason}")
{
Id = id;
Reason = reason;
}
}

View File

@@ -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.
/// </summary>
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{
@@ -33,7 +44,7 @@ public sealed class ChargeableCharConfigRepository : IChargeableCharConfigReposi
/// <inheritdoc/>
public async Task<long> 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
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
long medioId,
public async Task<IReadOnlyList<ChargeableCharConfig>> 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<ChargeableCharConfigRow>(
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
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> 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
/// <inheritdoc/>
public async Task<int> 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));
}
/// <inheritdoc/>
public async Task<ChargeableCharConfig> 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).");
}
/// <inheritdoc/>
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,