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