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
@@ -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; }