refinement phase 6

This commit is contained in:
hamid
2026-07-13 17:03:45 +03:30
parent d4147342da
commit 70268ecc06
33 changed files with 7262 additions and 53 deletions
@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
using Baya.Application.Features.Refunds.Queries.ListRefunds;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
@@ -37,4 +39,20 @@ public sealed class AdminRefundsController(ISender sender) : BaseController
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// Reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — settle it (posts the
// deferred refund_payable ↔ escrow_held clearing). Idempotent.
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> ConfirmSettlement(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ConfirmRefundSettlementCommand(id), cancellationToken));
// Reconciliation reported the customer cash-back did not land — fail the processing refund (no ledger moves).
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> MarkFailed(long id, MarkRefundFailedBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new MarkRefundSettlementFailedCommand(id, body.Reason), cancellationToken));
}
/// <summary>The mark-failed body (the id comes from the route).</summary>
public record MarkRefundFailedBody(string? Reason);
@@ -29,6 +29,15 @@ public interface IRefundRepository
/// <summary>Tracked pending clawback — for the admin write-off. Null when absent or already resolved.</summary>
Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>Tracked refund row — for the settlement confirm/fail transition of a <c>processing</c> refund.
/// Null when absent.</summary>
Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>The id of the <c>processing</c> refund for a captured transaction — the BNPL cash-back callback
/// resolves the refund it should settle from the same <c>payment_transaction</c> the revert was created
/// against. Null when there is no in-flight (processing) refund for it.</summary>
Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken);
Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
@@ -7,8 +7,10 @@ using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
@@ -22,7 +24,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
IDateTimeProvider dateTimeProvider)
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
{
private enum CallbackAction { None, Verify, Settle, Revert }
private enum CallbackAction { None, Verify, Settle, Revert, RefundConfirmed }
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken)
{
@@ -85,7 +87,7 @@ internal sealed class HandleBnplCallbackCommandHandler(
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
}
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken);
var dispatched = await DispatchAsync(action, bnpl, verification.ExternalEventId, rawBody, cancellationToken);
if (dispatched)
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
@@ -96,25 +98,48 @@ internal sealed class HandleBnplCallbackCommandHandler(
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
}
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken)
private async Task<bool> DispatchAsync(CallbackAction action, BnplTransaction bnpl, string eventId, string rawBody, CancellationToken cancellationToken)
{
var result = action switch
switch (action)
{
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess,
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess,
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess,
_ => false
};
return result;
case CallbackAction.Verify:
return (await sender.Send(new VerifyBnplOrderCommand(bnpl.Id, rawBody), cancellationToken)).IsSuccess;
case CallbackAction.Settle:
return (await sender.Send(new SettleBnplOrderCommand(bnpl.Id, $"bnpl-settle-{bnpl.Id}-{eventId}", rawBody), cancellationToken)).IsSuccess;
case CallbackAction.Revert:
return (await sender.Send(new RevertBnplOrderCommand(bnpl.Id, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess;
case CallbackAction.RefundConfirmed:
{
// The provider confirmed the customer cash-back for an earlier revert. Resolve the processing
// refund created against this order's payment_transaction and settle it (posts the deferred
// refund_payable ↔ escrow_held clearing). No in-flight refund → retryable (not a silent success).
var refundId = await unitOfWork.RefundRepository.GetProcessingRefundIdForTransactionAsync(bnpl.PaymentTransactionId, cancellationToken);
if (refundId is null)
return false;
return (await sender.Send(new ConfirmRefundSettlementCommand(refundId.Value), cancellationToken)).IsSuccess;
}
default:
return false;
}
}
private static CallbackAction ResolveAction(string eventType)
{
// A "revert/refund confirmed/completed/settled" (or "cashback") event closes an EARLIER revert's async
// customer cash-back — distinct from the order-level "settle". Checked first so "revert_settled" doesn't
// fall through to the order-settle branch below.
var isRevertScoped = eventType.Contains("revert", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("refund", StringComparison.OrdinalIgnoreCase);
if (eventType.Contains("cashback", StringComparison.OrdinalIgnoreCase)
|| (isRevertScoped && (eventType.Contains("complet", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("confirm", StringComparison.OrdinalIgnoreCase)
|| eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))))
return CallbackAction.RefundConfirmed;
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Settle;
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Verify;
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase))
if (isRevertScoped)
return CallbackAction.Revert;
return CallbackAction.None;
}
@@ -0,0 +1,79 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
/// <summary>
/// Closes the money loop left open by a BNPL/manual refund. A card refund clears its
/// <c>refund_payable ↔ escrow_held</c> leg immediately at create time; a BNPL/manual refund sits in
/// <c>processing</c> until the provider/bank confirms the customer actually got the cash — at which point this
/// posts the deferred clearing so the ledger reconciles with the bank. Runs under the same
/// <c>booking:{id}:refund</c> lock as <c>CreateRefundCommand</c>, and re-reads the refund row <b>inside</b> the
/// lock so a racing/replayed confirm sees the committed (already-succeeded) state and no-ops instead of
/// double-clearing.
/// </summary>
internal sealed class ConfirmRefundSettlementCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<ConfirmRefundSettlementCommand, OperationResult<RefundSettlementResult>>
{
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(ConfirmRefundSettlementCommand request, CancellationToken cancellationToken)
{
// A no-tracking read first to key the lock (and carry the customer's user id for the notification).
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
if (projection is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
// Load the tracked row INSIDE the lock so its status reflects committed truth even under contention.
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
if (refund is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
// Idempotent: an already-succeeded refund (a replayed callback, or a card refund that cleared at create
// time) is a no-op success — the clearing must never post twice.
if (refund.Status == RefundStatus.Succeeded)
return Result(refund);
if (refund.Status != RefundStatus.Processing)
return OperationResult<RefundSettlementResult>.ConflictResult(
$"Only a processing refund can be settled (was {refund.Status}).");
var now = dateTimeProvider.UtcNow.UtcDateTime;
refund.MarkSucceededReconciled(now);
// The customer cash-back is confirmed → clear refund_payable ↔ escrow_held for the refunded total, in the
// same commit as the status flip so the ledger can never observe a settled refund with an unposted clearing.
var clearing = LedgerPosting.RefundPayableClearing(refund.BookingId, refund.Amount, refund.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken);
await unitOfWork.CommitAsync();
await NotifyCustomerAsync(projection.CustomerUserId, refund, cancellationToken);
return Result(refund);
}
private async Task NotifyCustomerAsync(int customerUserId, Refund refund, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = refund.BookingId, refund_id = refund.Id, refund.Status });
await notifications.DispatchAsync(
new Notification(customerUserId, "refund_completed", "Refund completed",
"Your refund has been completed and the funds are on their way to you.", payload),
cancellationToken);
}
private static OperationResult<RefundSettlementResult> Result(Refund refund)
=> OperationResult<RefundSettlementResult>.SuccessResult(
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
}
@@ -0,0 +1,15 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
/// <summary>
/// Reconciliation confirmed the customer cash-back for a <c>processing</c> BNPL/manual refund: transitions it
/// <c>processing → succeeded</c>, stamps the settled instant, and posts the deferred
/// <c>refund_payable ↔ escrow_held</c> clearing in the same commit. Reached two ways — the admin
/// <c>confirm_settlement</c> action and the BNPL provider cash-back callback. Idempotent: a replay against an
/// already-succeeded refund is a no-op success (the clearing is never posted twice).
/// </summary>
public record ConfirmRefundSettlementCommand(long RefundId) : IRequest<OperationResult<RefundSettlementResult>>;
@@ -103,15 +103,21 @@ internal sealed class CreateRefundCommandHandler(
RefundPercentageApplied = context.CancellationRefundPercentage
};
// Execute the channel (external, behind its seam) BEFORE persisting, so a provider refusal leaves the
// refund row failed with no ledger. The idempotency key makes a retried channel call a no-op.
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
// Crash-window fix: persist the refund as an approved INTENT (committed) BEFORE the external channel call,
// so a crash between provider success and our commit leaves a reconcilable record rather than a silently
// executed refund with no row. This is the same claim-first / execute-second shape the webhook handler uses.
await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken);
await unitOfWork.CommitAsync();
// Execute the channel (external, behind its seam) against the already-persisted row. The idempotency key
// makes a retried channel call a no-op; a provider refusal marks the row failed with no ledger.
var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken);
if (!executed)
{
await unitOfWork.CommitAsync(); // persist the failed transition; no ledger is posted
return OperationResult<CreateRefundResult>.FailureResult("channel", "The refund channel refused the reversal.");
}
// Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is
// irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed.
@@ -0,0 +1,52 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
/// <summary>
/// Fails a <c>processing</c> BNPL/manual refund whose customer cash-back reconciliation did not succeed. No ledger
/// moves (the clearing was never posted for a processing refund). Runs under the <c>booking:{id}:refund</c> lock
/// and re-reads the row inside it so a racing confirm/fail is serialized and a replay no-ops.
/// </summary>
internal sealed class MarkRefundSettlementFailedCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<MarkRefundSettlementFailedCommand, OperationResult<RefundSettlementResult>>
{
public async ValueTask<OperationResult<RefundSettlementResult>> Handle(MarkRefundSettlementFailedCommand request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken);
if (projection is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
await using var _ = await distributedLock.AcquireAsync($"booking:{projection.Refund.BookingId}:refund", cancellationToken);
var refund = await unitOfWork.RefundRepository.GetTrackedRefundByIdAsync(request.RefundId, cancellationToken);
if (refund is null)
return OperationResult<RefundSettlementResult>.NotFoundResult("Refund not found.");
// Idempotent: already-failed is a no-op success.
if (refund.Status == RefundStatus.Failed)
return Result(refund);
if (refund.Status != RefundStatus.Processing)
return OperationResult<RefundSettlementResult>.ConflictResult(
$"Only a processing refund can be failed (was {refund.Status}).");
refund.MarkFailed(request.Reason ?? "settlement_failed");
await unitOfWork.CommitAsync();
return Result(refund);
}
private static OperationResult<RefundSettlementResult> Result(Refund refund)
=> OperationResult<RefundSettlementResult>.SuccessResult(
new RefundSettlementResult(refund.Id, refund.BookingId, refund.Status, refund.ProcessedAt));
}
@@ -0,0 +1,14 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Mediator;
namespace Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
/// <summary>
/// The counterpart to <c>ConfirmRefundSettlementCommand</c>: reconciliation reports that the BNPL/manual customer
/// cash-back for a <c>processing</c> refund did <b>not</b> land. Transitions <c>processing → failed</c> and records
/// the reason; posts no ledger (nothing cleared). Idempotent: a replay against an already-failed refund is a
/// no-op success.
/// </summary>
public record MarkRefundSettlementFailedCommand(long RefundId, string? Reason) : IRequest<OperationResult<RefundSettlementResult>>;
@@ -66,6 +66,15 @@ public record RefundStatusDto(
/// to <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund);
/// <summary>What the settlement transition commands return — the refund's identity, its booking, the resulting
/// status (<c>succeeded</c>/<c>failed</c>) and when it was stamped settled. Reconciliation of a BNPL/manual
/// refund's async customer cash-back.</summary>
public record RefundSettlementResult(
long RefundId,
long BookingId,
string Status,
DateTime? CompletedAt);
/// <summary>What <c>CreateRefundCommand</c> returns — the created refund's identity, channel, terminal-ish
/// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string.</summary>
public record CreateRefundResult(
@@ -18,7 +18,7 @@ namespace Baya.Domain.Entities.Payouts;
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
/// </para>
/// </summary>
public class NursePayout : BaseEntity<long>
public class NursePayout : BaseEntity<long>, IAuditable
{
public long BatchId { get; set; }
public NursePayoutBatch Batch { get; set; } = null!;
@@ -28,7 +28,9 @@ public class NursePayout : BaseEntity<long>
/// <summary>The verified primary account paid (FK <c>nurse_bank_accounts</c>).</summary>
public long BankAccountId { get; set; }
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.</summary>
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.
/// <see cref="AuditRedactedAttribute"/> so the audit-log diff records a redaction marker, never the plaintext IBAN.</summary>
[AuditRedacted]
public string IbanSnapshot { get; set; } = null!;
/// <summary>Σ eligible booking payouts for the window (IRR).</summary>
@@ -17,7 +17,7 @@ namespace Baya.Domain.Entities.Payouts;
/// Money is IRR <c>BIGINT</c>, no floats.
/// </para>
/// </summary>
public class NursePayoutBatch : BaseEntity<long>
public class NursePayoutBatch : BaseEntity<long>, IAuditable
{
public DateOnly PeriodStart { get; set; }
@@ -14,7 +14,7 @@ namespace Baya.Domain.Entities.Refunds;
/// when a payout batch nets the clawback out — <c>nurse_payouts</c> arrives in b13.
/// </para>
/// </summary>
public class NurseClawback : BaseEntity<long>
public class NurseClawback : BaseEntity<long>, IAuditable
{
public long NurseId { get; set; }
public long BookingId { get; set; }
@@ -16,7 +16,7 @@ namespace Baya.Domain.Entities.Refunds;
/// differ.
/// </para>
/// </summary>
public class Refund : BaseEntity<long>
public class Refund : BaseEntity<long>, IAuditable
{
/// <summary>The captured transaction being reversed (N:1 — a transaction may have several partial refunds).</summary>
public long PaymentTransactionId { get; set; }
@@ -101,8 +101,11 @@ public class Refund : BaseEntity<long>
ExpectedCustomerRefundEta = expectedCustomerRefundEta;
}
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual).</summary>
public void MarkSucceededAsync(DateTime now)
/// <summary>Reconciliation confirmed the customer cash-back for a <c>processing</c> refund (BNPL/manual)
/// the admin <c>confirm_settlement</c> or the provider cash-back callback. Stamps <see cref="ProcessedAt"/>
/// (the settled-at instant); the caller posts the <c>refund_payable ↔ escrow_held</c> clearing in the same
/// commit. (Not async — the name only mirrored the BNPL/manual "async settlement" concept.)</summary>
public void MarkSucceededReconciled(DateTime now)
{
Transition(RefundStatus.Succeeded);
ProcessedAt = now;
@@ -11,7 +11,7 @@ namespace Baya.Domain.Entities.Verification;
/// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately
/// cut — never reintroduce a second copy of this state.
/// </summary>
public class NurseVerification : BaseEntity<long>
public class NurseVerification : BaseEntity<long>, IAuditable
{
public long NurseId { get; set; }
@@ -46,7 +46,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
// (19) refund_ticket_required — retired in refinement-phase-6. b15 unconditionally auto-opens a refund
// ticket, so the config-gated rule had no consumer left; the seed row is deleted by that migration.
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Invoices;
using Baya.Domain.Entities.PartnerCenters;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -8,8 +9,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig;
/// <summary>
/// <c>invoices</c> — one issued invoice per booking (UNIQUE <c>booking_id</c>) with a UNIQUE, sequential
/// <c>invoice_number</c> drawn from <see cref="InvoiceNumberSequence"/>. VAT (<c>vat_irr</c>) is computed on the
/// commission line only. <c>partner_center_id</c> is a nullable column with <b>no FK</b> — <c>partner_centers</c>
/// is a forward-dep on b15.
/// commission line only. <c>partner_center_id</c> is a nullable FK to <c>partner.PartnerCenters</c> (b15 shipped
/// the table; refinement-phase-6 added the constraint + index, <c>ON DELETE NO ACTION</c>).
/// </summary>
internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{
@@ -26,8 +27,11 @@ internal sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
builder.HasIndex(i => i.InvoiceNumber).IsUnique();
builder.HasIndex(i => i.BookingId).IsUnique();
builder.HasIndex(i => i.PartnerCenterId);
builder.HasOne<Booking>().WithMany().HasForeignKey(i => i.BookingId).IsRequired();
// Forward-dep FK to the b15 partner_centers table (refinement-phase-6). Optional (nullable); NO ACTION.
builder.HasOne<PartnerCenter>().WithMany().HasForeignKey(i => i.PartnerCenterId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(i => i.DeletedAt == null);
}
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -8,8 +9,9 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>nurse_clawbacks</c> — a first-class receivable opened when a booking is refunded after the nurse was
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable columns + indexes now
/// with <b>no FK</b> — <c>nurse_payouts</c> is a forward-dep on b13, which sets the values and wires the FKs.
/// already paid. <c>original_payout_id</c> / <c>recovered_in_payout_id</c> are nullable FKs to
/// <c>payouts.NursePayouts</c> (b13 shipped the table and sets the values; refinement-phase-6 added the
/// constraints, <c>ON DELETE NO ACTION</c>).
/// </summary>
internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawback>
{
@@ -30,6 +32,10 @@ internal sealed class NurseClawbackConfig : IEntityTypeConfiguration<NurseClawba
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(c => c.NurseId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(c => c.BookingId).IsRequired();
builder.HasOne<Refund>().WithMany().HasForeignKey(c => c.RefundId).IsRequired();
// Forward-dep FKs to the b13 payouts table (refinement-phase-6). Both optional (nullable); NO ACTION so
// deleting a payout never cascades into the receivable rows. Two distinct FKs to the same principal.
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.OriginalPayoutId).OnDelete(DeleteBehavior.NoAction);
builder.HasOne<NursePayout>().WithMany().HasForeignKey(c => c.RecoveredInPayoutId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(c => c.DeletedAt == null);
}
@@ -1,5 +1,6 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
@@ -10,8 +11,8 @@ namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig;
/// <summary>
/// <c>refunds</c> — 1:N per <c>payment_transaction</c>. The <c>amount = fee_leg + payout_leg</c> reconciliation
/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a
/// single-row constraint). <c>ticket_id</c> is a nullable column + index now with <b>no FK</b> — the
/// <c>tickets</c> table is a forward-dep on b15, which wires the real FK target.
/// single-row constraint). <c>ticket_id</c> is a nullable FK to <c>messaging.Tickets</c> (b15 shipped the table;
/// refinement-phase-6 added the constraint, <c>ON DELETE NO ACTION</c>).
/// </summary>
internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
{
@@ -38,12 +39,14 @@ internal sealed class RefundConfig : IEntityTypeConfiguration<Refund>
builder.HasIndex(r => r.BookingId);
builder.HasIndex(r => r.RequestedByCustomerId);
builder.HasIndex(r => r.Status);
// Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist).
builder.HasIndex(r => r.TicketId);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired();
// Forward-dep FK to the b15 tickets table (refinement-phase-6). Optional (nullable); NO ACTION so deleting
// a ticket never cascades into money rows.
builder.HasOne<Ticket>().WithMany().HasForeignKey(r => r.TicketId).OnDelete(DeleteBehavior.NoAction);
builder.HasQueryFilter(r => r.DeletedAt == null);
}
@@ -0,0 +1,98 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefinementPhase6MoneyFks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 19L);
migrationBuilder.CreateIndex(
name: "IX_Invoices_PartnerCenterId",
schema: "payments",
table: "Invoices",
column: "PartnerCenterId");
migrationBuilder.AddForeignKey(
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
schema: "payments",
table: "Invoices",
column: "PartnerCenterId",
principalSchema: "partner",
principalTable: "PartnerCenters",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "OriginalPayoutId",
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks",
column: "RecoveredInPayoutId",
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Refunds_Tickets_TicketId",
schema: "payments",
table: "Refunds",
column: "TicketId",
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Invoices_PartnerCenters_PartnerCenterId",
schema: "payments",
table: "Invoices");
migrationBuilder.DropForeignKey(
name: "FK_NurseClawbacks_NursePayouts_OriginalPayoutId",
schema: "payments",
table: "NurseClawbacks");
migrationBuilder.DropForeignKey(
name: "FK_NurseClawbacks_NursePayouts_RecoveredInPayoutId",
schema: "payments",
table: "NurseClawbacks");
migrationBuilder.DropForeignKey(
name: "FK_Refunds_Tickets_TicketId",
schema: "payments",
table: "Refunds");
migrationBuilder.DropIndex(
name: "IX_Invoices_PartnerCenterId",
schema: "payments",
table: "Invoices");
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[] { 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" });
}
}
}
@@ -1266,15 +1266,6 @@ namespace Baya.Infrastructure.Persistence.Migrations
Value = "1"
},
new
{
Id = 19L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.",
Key = "refund_ticket_required",
Value = "false"
},
new
{
Id = 20L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
@@ -2932,6 +2923,8 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.HasIndex("InvoiceNumber")
.IsUnique();
b.HasIndex("PartnerCenterId");
b.ToTable("Invoices", "payments");
});
@@ -5466,6 +5459,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null)
.WithMany()
.HasForeignKey("PartnerCenterId")
.OnDelete(DeleteBehavior.NoAction);
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
@@ -5650,6 +5648,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany()
.HasForeignKey("OriginalPayoutId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany()
.HasForeignKey("RecoveredInPayoutId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId")
@@ -5676,6 +5684,11 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("RequestedByCustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", null)
.WithMany()
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.NoAction);
});
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
@@ -55,6 +55,16 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
public Task<NurseClawback?> GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken)
=> DbContext.Set<NurseClawback>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public Task<Refund?> GetTrackedRefundByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
public Task<long?> GetProcessingRefundIdForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.PaymentTransactionId == paymentTransactionId && r.Status == RefundStatus.Processing)
.OrderByDescending(r => r.Id)
.Select(r => (long?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<RefundListItemDto>> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking;
@@ -0,0 +1,137 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Messaging.Commands.PostMessage;
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Messaging;
/// <summary>
/// Foundation (handler-level) coverage of the <c>is_internal</c> hard visibility boundary — previously only
/// exercised end-to-end (refinement-phase-6 §6.5). The user thread view strips internal notes in the projection;
/// the admin view returns them; a non-staff caller can neither post nor read one.
/// </summary>
public sealed class MessagingInternalBoundaryTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private readonly SqliteConnection _connection;
private readonly ApplicationDbContext _db;
private readonly UnitOfWork _uow;
private readonly int _customerUserId;
private readonly int _adminUserId;
private readonly long _ticketId;
public MessagingInternalBoundaryTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
_db.Database.EnsureCreated();
_uow = new UnitOfWork(_db);
var customer = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
var admin = new User { UserName = "admin1", PhoneNumber = "09120000009", Gender = "male", Name = "مدیر", FamilyName = "سیستم", IsActive = true };
_db.Users.AddRange(customer, admin);
_db.SaveChanges();
_customerUserId = customer.Id;
_adminUserId = admin.Id;
var ticket = new Ticket { ReferenceCode = "TKT-BND001", Category = TicketCategory.Support, OpenedById = customer.Id };
_db.Set<Ticket>().Add(ticket);
_db.SaveChanges();
_ticketId = ticket.Id;
_db.Set<TicketParticipant>().Add(new TicketParticipant { TicketId = ticket.Id, UserId = customer.Id, AddedById = customer.Id });
_db.Set<TicketMessage>().AddRange(
new TicketMessage { TicketId = ticket.Id, SenderId = customer.Id, Body = "public question", IsInternal = false, SentAt = Now },
new TicketMessage { TicketId = ticket.Id, SenderId = admin.Id, Body = "internal staff note", IsInternal = true, SentAt = Now.AddMinutes(1) });
_db.SaveChanges();
}
private ICurrentUser AsCustomer() => User(_customerUserId, RoleNames.Customer);
private ICurrentUser AsAdmin() => User(_adminUserId, RoleNames.Admin);
private static ICurrentUser User(int id, string role)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(id);
u.Roles.Returns(new[] { role });
return u;
}
private IDateTimeProvider Clock()
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(Now);
return c;
}
private GetTicketThreadQueryHandler ThreadHandler(ICurrentUser user) => new(user, _uow, Clock());
private PostMessageCommandHandler PostHandler(ICurrentUser user) => new(user, _uow, Clock(), Substitute.For<INotificationDispatcher>());
[Fact]
public async Task User_view_strips_internal_messages()
{
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Single(result.Result.Messages);
Assert.All(result.Result.Messages, m => Assert.False(m.IsInternal));
Assert.DoesNotContain(result.Result.Messages, m => m.Body == "internal staff note");
}
[Fact]
public async Task Admin_view_includes_internal_messages()
{
var result = await ThreadHandler(AsAdmin()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Result.Messages.Count);
Assert.Contains(result.Result.Messages, m => m is { IsInternal: true, Body: "internal staff note" });
}
[Fact]
public async Task Non_staff_admin_view_request_is_forbidden()
{
var result = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: true), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsForbidden);
}
[Fact]
public async Task Non_staff_cannot_post_an_internal_note()
{
var result = await PostHandler(AsCustomer()).Handle(new PostMessageCommand(_ticketId, "sneaky", IsInternal: true), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsForbidden);
Assert.DoesNotContain(_db.Set<TicketMessage>().AsNoTracking(), m => m.Body == "sneaky");
}
[Fact]
public async Task Staff_internal_note_is_never_returned_in_the_user_view()
{
var post = await PostHandler(AsAdmin()).Handle(new PostMessageCommand(_ticketId, "second internal note", IsInternal: true), CancellationToken.None);
Assert.True(post.IsSuccess);
var userView = await ThreadHandler(AsCustomer()).Handle(new GetTicketThreadQuery(_ticketId, AsAdmin: false), CancellationToken.None);
Assert.True(userView.IsSuccess);
Assert.All(userView.Result.Messages, m => Assert.False(m.IsInternal));
Assert.DoesNotContain(userView.Result.Messages, m => m.Body == "second internal note");
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}
@@ -84,6 +84,45 @@ public class PaymentWebhookTests
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
}
[Fact]
public async Task Racing_same_key_insert_is_caught_as_an_idempotent_no_op()
{
using var host = new PaymentsTestHost();
var (_, reference) = await SeedPendingAsync(host);
// A provider that omits external_event_id skips the read-dedup, so the (provider_code, external_event_id)
// UNIQUE is the SOLE backstop — exactly the state a true concurrent insert reaches when both requests read
// "no existing event" before either commits. Pre-seed the colliding empty-key row so the handler's own
// insert loses the unique race and must be treated as an idempotent no-op (DbUpdateException → duplicate).
var existing = new PaymentWebhookEvent
{
ProviderCode = "zarinpal", ExternalEventId = string.Empty, EventType = "payment.succeeded",
SignatureValid = true, PayloadJson = "{}", ReceivedAt = Now.UtcDateTime
};
existing.MarkProcessed(null, Now.UtcDateTime);
host.Db.Set<PaymentWebhookEvent>().Add(existing);
host.Db.SaveChanges();
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
var sender = SenderRoutingConfirmTo(confirm);
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
// No external_event_id in the body → verification.ExternalEventId == "" (the read-dedup is skipped).
var body = $"{{\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}";
var result = await handler.Handle(
new HandlePaymentWebhookCommand("zarinpal", new Dictionary<string, string>(), body), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.Duplicate);
Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus);
// The insert lost the race → no confirm, no ledger, and only the pre-existing event row survives.
await sender.DidNotReceive().Send(Arg.Any<ConfirmPaymentAndPostLedgerCommand>(), Arg.Any<CancellationToken>());
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking());
Assert.Single(host.Db.Set<PaymentWebhookEvent>().AsNoTracking());
}
[Fact]
public async Task Unverified_signature_callback_mutates_nothing()
{
@@ -0,0 +1,86 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.WriteOffClawback;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// The admin bad-debt path (previously untested — refinement-phase-6 §6.5): writing off a <c>pending</c> nurse
/// clawback posts a balanced <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> group and is guarded
/// (404 unknown / 409 non-pending / idempotent-ish once resolved).
/// </summary>
public class ClawbackWriteOffTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static async Task<(long ClawbackId, long BookingId)> SeedPendingClawbackAsync(RefundsTestHost host)
{
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000);
var create = new CreateRefundCommandHandler(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(paid: true),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
var refund = await create.Handle(
new CreateRefundCommand(bookingId, null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
CancellationToken.None);
Assert.True(refund.IsSuccess);
Assert.NotNull(refund.Result.ClawbackId);
return (refund.Result.ClawbackId!.Value, bookingId);
}
[Fact]
public async Task Write_off_posts_a_balanced_bad_debt_group_and_resolves_the_clawback()
{
using var host = new RefundsTestHost();
var (clawbackId, bookingId) = await SeedPendingClawbackAsync(host);
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var result = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable_after_dispute"), CancellationToken.None);
Assert.True(result.IsSuccess);
var clawback = host.Db.Set<NurseClawback>().AsNoTracking().Single(c => c.Id == clawbackId);
Assert.Equal(ClawbackStatus.WrittenOff, clawback.Status);
// The correction group: DEBIT bad_debt == CREDIT nurse_clawback_receivable, both for the clawback amount.
var group = host.LedgerFor(bookingId).Where(l => l.SourceRefType == LedgerSourceRefType.Clawback).ToList();
Assert.Equal(2, group.Count);
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.BadDebt && l.Direction == LedgerDirection.Debit).AmountIrr);
Assert.Equal(8_500_000, group.Single(l => l.AccountType == LedgerAccountType.NurseClawbackReceivable && l.Direction == LedgerDirection.Credit).AmountIrr);
Assert.Equal(group.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
group.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
}
[Fact]
public async Task Write_off_of_an_unknown_clawback_is_not_found()
{
using var host = new RefundsTestHost();
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var result = await handler.Handle(new WriteOffClawbackCommand(999_999, "x"), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Second_write_off_of_the_same_clawback_is_a_conflict()
{
using var host = new RefundsTestHost();
var (clawbackId, _) = await SeedPendingClawbackAsync(host);
var handler = new WriteOffClawbackCommandHandler(host.UnitOfWork, host.Clock(Now));
var first = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "uncollectable"), CancellationToken.None);
var second = await handler.Handle(new WriteOffClawbackCommand(clawbackId, "again"), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.False(second.IsSuccess);
Assert.True(second.IsConflict);
}
}
@@ -16,7 +16,7 @@ public class RefundHandlerTests
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), TestSenders.WithTicketHooks());
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
private static CreateRefundCommand FullRefund(long bookingId)
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
@@ -0,0 +1,122 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Refunds;
/// <summary>
/// The refinement-phase-6 fix: a BNPL/manual refund that lands in <c>processing</c> can be settled (admin
/// confirm / provider cash-back callback) so the deferred <c>refund_payable ↔ escrow_held</c> clearing posts and
/// the ledger reconciles — previously that path was unreachable. Also covers <c>mark_failed</c> and replay
/// idempotency.
/// </summary>
public class RefundSettlementTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset Later = new(2026, 8, 12, 10, 0, 0, TimeSpan.Zero);
private static CreateRefundCommandHandler CreateHandler(RefundsTestHost host)
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(paid: false),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), host.Senders());
private static ConfirmRefundSettlementCommandHandler ConfirmHandler(RefundsTestHost host, INotificationDispatcher notifications)
=> new(host.UnitOfWork, host.Lock(), host.Clock(Later), notifications);
private static async Task<long> SeedProcessingBnplRefundAsync(RefundsTestHost host)
{
var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl);
var created = await CreateHandler(host).Handle(
new CreateRefundCommand(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null),
CancellationToken.None);
Assert.True(created.IsSuccess);
Assert.Equal(RefundStatus.Processing, created.Result.Status); // BNPL waits — no clearing yet
Assert.Equal(3, host.LedgerFor(bookingId).Count); // reversal only
return created.Result.RefundId;
}
[Fact]
public async Task Confirm_settlement_posts_the_deferred_clearing_and_the_ledger_reconciles()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var notifications = Substitute.For<INotificationDispatcher>();
var result = await ConfirmHandler(host, notifications).Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, result.Result.Status);
Assert.NotNull(result.Result.CompletedAt);
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // reversal (3) + clearing (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// The clearing leg: refund_payable is fully drained (credit at reversal == debit at clearing) and escrow
// is credited back the refunded total — i.e. escrow no longer overstates the held funds.
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr));
Assert.Equal(10_000_000, legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
await notifications.Received(1).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Replayed_confirm_settlement_is_an_idempotent_no_op()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var handler = ConfirmHandler(host, Substitute.For<INotificationDispatcher>());
var first = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
var replay = await handler.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.True(replay.IsSuccess);
Assert.Equal(RefundStatus.Succeeded, replay.Result.Status);
// The clearing is posted exactly once — no double credit to escrow.
Assert.Equal(5, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task Confirm_settlement_of_a_missing_refund_is_not_found()
{
using var host = new RefundsTestHost();
var result = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
.Handle(new ConfirmRefundSettlementCommand(999_999), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Mark_failed_transitions_processing_to_failed_without_posting_a_clearing()
{
using var host = new RefundsTestHost();
var refundId = await SeedProcessingBnplRefundAsync(host);
var bookingId = host.Db.Set<Refund>().AsNoTracking().Single(r => r.Id == refundId).BookingId;
var handler = new MarkRefundSettlementFailedCommandHandler(host.UnitOfWork, host.Lock(), host.Clock(Later));
var result = await handler.Handle(new MarkRefundSettlementFailedCommand(refundId, "bank_rejected"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(RefundStatus.Failed, result.Result.Status);
Assert.Equal(3, host.LedgerFor(bookingId).Count); // no clearing posted
// A settled confirm can no longer be applied to a failed refund.
var confirm = await ConfirmHandler(host, Substitute.For<INotificationDispatcher>())
.Handle(new ConfirmRefundSettlementCommand(refundId), CancellationToken.None);
Assert.False(confirm.IsSuccess);
Assert.True(confirm.IsConflict);
}
}
@@ -1,15 +1,21 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Mediator;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
@@ -32,6 +38,10 @@ public sealed class RefundsTestHost : IDisposable
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
/// <summary>A real seeded ticket row so the <c>refunds.ticket_id</c> FK (refinement-phase-6) is satisfied when
/// the auto-anchor hook returns this id.</summary>
public long TicketId { get; }
private readonly long _cityId;
private readonly long _categoryId;
private readonly long _patientId;
@@ -89,6 +99,14 @@ public sealed class RefundsTestHost : IDisposable
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
var ticket = new Ticket
{
ReferenceCode = "TKT-TEST01", Category = TicketCategory.Refund, OpenedById = CustomerUserId
};
Db.Set<Ticket>().Add(ticket);
Db.SaveChanges();
TicketId = ticket.Id;
}
/// <summary>Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy
@@ -166,12 +184,11 @@ public sealed class RefundsTestHost : IDisposable
return c;
}
public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10)
public IPlatformConfig Config(decimal vatRate = 0.10m, int bnplEtaDays = 10)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("vat_rate", Arg.Any<CancellationToken>()).Returns(vatRate);
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(0.15m);
cfg.GetConfig<bool>("refund_ticket_required", Arg.Any<CancellationToken>()).Returns(ticketRequired);
cfg.GetConfig<int>("bnpl_refund_eta_business_days", Arg.Any<CancellationToken>()).Returns(bnplEtaDays);
return cfg;
}
@@ -203,6 +220,19 @@ public sealed class RefundsTestHost : IDisposable
public IDistributedLock Lock() => new NoOpLock();
/// <summary>An <see cref="ISender"/> whose refund ticket-anchor hook returns the <b>real</b> seeded
/// <see cref="TicketId"/>, so the refund row satisfies the <c>refunds.ticket_id</c> FK.</summary>
public ISender Senders()
{
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<OpenTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<OpenTicketResult>.SuccessResult(
new OpenTicketResult(TicketId, "TKT-TEST01", "open", "refund"))));
sender.Send(Arg.Any<AutoCreateCoordinationTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<bool>.SuccessResult(true)));
return sender;
}
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();