backend phase 13 & frontend phase 6

This commit is contained in:
hamid
2026-07-09 04:09:35 +03:30
parent dc64472631
commit de53f9d8a6
97 changed files with 11969 additions and 77 deletions
@@ -0,0 +1,71 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// The swappable PAYA/SATNA <b>bank payout rail</b> — the mocked stand-in for a real transferor (Jibit / Vandar /
/// Sadad payout API) that moves money out of the platform's registered source settlement account to each nurse's
/// verified Sheba. This is the one irreversible money-out step, so the seam carries an
/// <paramref name="idempotencyKey" /> and the whole submit is idempotent: a retried
/// <see cref="SubmitPayoutBatchAsync" /> for the same key never re-sends an already-<c>paid</c> instruction.
/// Handlers depend only on this contract; the concrete provider is a config-selected registration change, never
/// an <c>if (mock)</c> branch. <b>Every amount crossing this seam is IRR <c>long</c>.</b>
/// </summary>
public interface IBankTransferProvider
{
/// <summary>Submits one <see cref="PayoutInstruction" /> per payout to the rail and returns a deterministic
/// <c>externalBatchRef</c> plus a per-instruction result carrying the bank track id
/// (<c>transfer_reference</c>) and status. The mock moves no money; a real transferor registers the batch
/// against the source account and routes each PAYA/SATNA transfer.</summary>
ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> instructions,
string idempotencyKey,
CancellationToken cancellationToken = default);
/// <summary>The reconciliation read — echoes the batch's settled status (the real callback flips
/// <c>submitted → paid/failed</c>).</summary>
ValueTask<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default);
}
/// <summary>The settled state of a single transfer (or the batch echo). Forward-only on the payout row.</summary>
public enum BankTransferStatus
{
/// <summary>The rail accepted the instruction (a track id was issued) but the transfer is not yet confirmed.</summary>
Submitted,
/// <summary>The transfer is confirmed — an irreversible IBAN movement.</summary>
Paid,
/// <summary>The rail rejected the transfer (closed day / insufficient provider balance / bad Sheba).</summary>
Failed
}
/// <summary>The two Iranian interbank rails. Persisted/echoed as these stable codes; selection is by value
/// (SATNA for high-value rows above a config threshold, else PAYA).</summary>
public static class BankTransferMethod
{
/// <summary>Batch ACH-style clearing — the default for ordinary-value payouts.</summary>
public const string Paya = "paya";
/// <summary>Real-time gross settlement — chosen for high-value rows above the SATNA threshold.</summary>
public const string Satna = "satna";
}
/// <param name="PayoutId">The <c>nurse_payouts</c> row this instruction settles.</param>
/// <param name="Iban">The nurse's verified primary Sheba (the payout destination).</param>
/// <param name="AmountIrr">The net amount to transfer (IRR).</param>
/// <param name="Method">A <see cref="BankTransferMethod" /> code — PAYA or SATNA.</param>
public sealed record PayoutInstruction(long PayoutId, string Iban, long AmountIrr, string Method);
/// <param name="ExternalBatchRef">The rail's own batch reference, for reconciliation.</param>
/// <param name="Results">One result per submitted instruction.</param>
public sealed record PayoutBatchSubmitResult(string ExternalBatchRef, IReadOnlyList<PayoutInstructionResult> Results);
/// <param name="PayoutId">The payout this result belongs to.</param>
/// <param name="Status">The transfer outcome — drives the payout status machine.</param>
/// <param name="TransferReference">The bank track id, when the rail accepted it; null on failure.</param>
/// <param name="Method">The rail the transfer took (the honoured <see cref="PayoutInstruction.Method" />).</param>
/// <param name="FailureReason">Why the rail rejected it, when <see cref="Status" /> is
/// <see cref="BankTransferStatus.Failed" />.</param>
public sealed record PayoutInstructionResult(
long PayoutId, BankTransferStatus Status, string? TransferReference, string Method, string? FailureReason);
@@ -0,0 +1,77 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The payouts aggregate — the weekly batch, its per-nurse payouts, and the anti-double-pay booking links. Reads
/// project to DTOs (<c>AsNoTracking</c> + <c>.Select</c>); writes load tracked rows. The payout <b>ledger legs</b>
/// are appended through <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper) — this repo owns the
/// payout rows and the money facts a batch is built from. Money is IRR <c>long</c>. The
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE is the authoritative one-payout-per-booking backstop; the
/// eligibility predicate's "not already linked" filter is the fast first line.
/// </summary>
public interface IPayoutRepository
{
// ---- eligibility (preview + build) ----
/// <summary>The payout-eligible, unpaid bookings for the window: <c>status='completed'</c> AND
/// <c>dispute_window_ends_at &lt; now</c> AND no active refund AND not already in a link row (and, when
/// <paramref name="requireBnplSettlement"/> is set, its BNPL provider settlement is received). One row per
/// booking (nurse + this booking's payout portion) — grouped by nurse in the build handler.</summary>
Task<IReadOnlyList<EligibleBookingRow>> GetEligibleBookingsAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken);
/// <summary>The same eligibility set as a per-nurse preview (paginated), netting pending clawbacks and
/// flagging any nurse without a verified primary IBAN.</summary>
Task<PagedResult<EligibleNurseEarningsDto>> GetEligiblePreviewAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The nurse's verified primary payout account (<c>is_primary=1 AND is_verified=1 AND
/// matched_national_id=1</c>) — the first-payout gate. Null when the nurse has none (the payout is skipped
/// with a recorded reason). The IBAN is decrypted by the EF converter on projection.</summary>
Task<VerifiedPayoutAccount?> GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>The nurse's display name (for the batch preview/detail), or null.</summary>
Task<IReadOnlyDictionary<long, string>> GetNurseNamesAsync(IReadOnlyList<long> nurseIds, CancellationToken cancellationToken);
/// <summary>The sum of the nurse's <c>pending</c> clawbacks (IRR) — netted (capped at earnings) into a payout
/// at build time.</summary>
Task<long> GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>The nurse's <c>pending</c> clawbacks, oldest first, tracked — the execute step marks them
/// <c>recovered</c> (with <c>recovered_in_payout_id</c> + <c>resolved_at</c>) up to the payout's frozen
/// <c>clawback_applied_irr</c>.</summary>
Task<IReadOnlyList<NurseClawback>> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken);
// ---- writes ----
Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken);
/// <summary>The tracked batch with its payouts + links — loaded by execute/retry to drive the transfers and
/// post the ledger. Null when absent.</summary>
Task<NursePayoutBatch?> GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken);
/// <summary>A single tracked payout (with its batch) — for retry/mark-failed. Null when absent.</summary>
Task<NursePayout?> GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken);
/// <summary>Whether a payout already has a posted ledger group — makes the execute ledger post idempotent so
/// a retried execute never double-posts.</summary>
Task<bool> LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken);
// ---- reads (admin + nurse) ----
Task<PagedResult<PayoutBatchDto>> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PayoutBatchDetailDto?> GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary>
Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
}
/// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into
/// the encrypted <c>iban_snapshot</c> at build time).</summary>
public record VerifiedPayoutAccount(long BankAccountId, string Iban);
@@ -22,6 +22,7 @@ public interface IUnitOfWork
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,114 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
/// <summary>
/// The one irreversible money-out step. Under <c>lock(payout:batch)</c> it submits the batch's unpaid payouts to
/// the <see cref="IBankTransferProvider"/> (PAYA/SATNA by the config threshold), then for each accepted transfer
/// posts the balanced payout ledger group (<c>DEBIT nurse_payable / CREDIT escrow_held</c>) via b10's helper and
/// nets recovered clawbacks (<c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> + marks the row
/// <c>recovered</c>). The forward-only payout status machine + the ledger-exists guard + the batch idempotency key
/// make a retried execute never double-send a transfer or double-post the ledger.
/// </summary>
internal sealed class ExecutePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IBankTransferProvider bankTransfer,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ExecutePayoutBatchCommand, OperationResult<ExecutePayoutBatchResult>>
{
public async ValueTask<OperationResult<ExecutePayoutBatchResult>> Handle(
ExecutePayoutBatchCommand request, CancellationToken cancellationToken)
{
var now = dateTimeProvider.UtcNow.UtcDateTime;
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(request.BatchId, cancellationToken);
if (batch is null)
return OperationResult<ExecutePayoutBatchResult>.NotFoundResult("Payout batch not found.");
// Idempotent: a fully-settled batch has nothing left to submit.
if (batch.Status == PayoutBatchStatus.Completed)
return OperationResult<ExecutePayoutBatchResult>.SuccessResult(Summarize(batch));
if (batch.Status == PayoutBatchStatus.Failed)
return OperationResult<ExecutePayoutBatchResult>.ConflictResult("This batch has already failed; open a new batch.");
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("payout_satna_threshold_irr", cancellationToken);
if (batch.Status == PayoutBatchStatus.Draft)
batch.TransitionTo(PayoutBatchStatus.Processing, now);
// Only unpaid payouts are (re)submitted — an already-paid row is skipped by the status machine, never
// re-sent. A zero-net payout (fully netted against clawback) has no transfer but still realizes recovery.
var unpaid = batch.Payouts.Where(p => p.Status != PayoutStatus.Paid).ToList();
var transferable = unpaid.Where(p => p.Amount > 0).ToList();
var resultsByPayout = new Dictionary<long, PayoutInstructionResult>();
if (transferable.Count > 0)
{
var instructions = transferable
.Select(p => PayoutSettlement.ToInstruction(p, satnaThreshold))
.ToList();
var submit = await bankTransfer.SubmitPayoutBatchAsync(
batch.Id, instructions, idempotencyKey: $"payout-batch:{batch.Id}", cancellationToken);
resultsByPayout = submit.Results.ToDictionary(r => r.PayoutId);
}
foreach (var payout in unpaid)
{
if (payout.Amount > 0)
{
var result = resultsByPayout.GetValueOrDefault(payout.Id);
if (result is null || result.Status == BankTransferStatus.Failed)
{
payout.MarkFailed(result?.FailureReason ?? "provider_declined");
continue;
}
payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}");
if (result.Status == BankTransferStatus.Paid)
payout.MarkPaid(now);
}
else
{
// Fully netted — no cash leaves, but the withheld earnings realize the clawback recovery now.
payout.MarkSubmitted($"netted:{batch.Id}:{payout.Id}");
payout.MarkPaid(now);
}
// Only a settled (paid) payout posts the ledger + nets clawbacks — a still-submitted row waits for the
// reconciliation callback (the real rail), a failed one does neither.
if (payout.Status == PayoutStatus.Paid)
{
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
}
}
batch.RecomputeSettlement(now);
await unitOfWork.CommitAsync();
return OperationResult<ExecutePayoutBatchResult>.SuccessResult(Summarize(batch));
}
private static ExecutePayoutBatchResult Summarize(NursePayoutBatch batch)
{
var paid = batch.Payouts.Where(p => p.Status == PayoutStatus.Paid).ToList();
var failed = batch.Payouts.Count(p => p.Status == PayoutStatus.Failed);
var totalPaid = paid.Sum(p => p.Amount);
return new ExecutePayoutBatchResult(batch.Id, batch.Status, paid.Count, failed, totalPaid.ToString());
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
public sealed class ExecutePayoutBatchCommandValidator : AbstractValidator<ExecutePayoutBatchCommand>
{
public ExecutePayoutBatchCommandValidator()
{
RuleFor(x => x.BatchId).GreaterThan(0);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
/// <summary>Submits a draft (or partially-failed) batch to the bank rail: transitions it to <c>processing</c>,
/// sends one instruction per unpaid payout (PAYA/SATNA by the config threshold), posts the balanced payout ledger
/// group out of <c>nurse_payable</c>, nets recovered clawbacks, and settles the batch <c>completed</c> or
/// <c>partially_failed</c>. Idempotent — a retried call never re-sends an already-paid transfer or re-posts the
/// ledger.</summary>
public record ExecutePayoutBatchCommand(long BatchId) : IRequest<OperationResult<ExecutePayoutBatchResult>>;
@@ -0,0 +1,160 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Payouts;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
/// <summary>
/// Builds a weekly payout batch. Under <c>lock(payout:batch)</c> (so two runs can't grab the same bookings), it
/// holiday-shifts the period end + processing date, selects the eligible unpaid bookings, groups them per nurse,
/// and materializes payouts + booking links in one unit of work. The <b>BuildNursePayouts</b> and
/// <b>LinkPayoutBookings</b> steps from the phase are cohesive private steps here (mirroring b11's
/// <c>CreateRefund</c>). Netting caps clawbacks at whole recoverable amounts; a nurse without a verified primary
/// IBAN is skipped with a recorded reason, never silently dropped. The <c>booking_id</c> UNIQUE link is the
/// backstop that makes a re-run over an overlapping window unable to re-select an already-paid booking.
/// </summary>
internal sealed class GeneratePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IHolidayCalendar holidays,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
ICurrentUser currentUser)
: IRequestHandler<GeneratePayoutBatchCommand, OperationResult<GeneratePayoutBatchResult>>
{
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
// Holiday-aware shifting: a batch landing on a bank-closed Nowruz day must move to the next business day,
// or PAYA/SATNA fails.
var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken);
var processingDate = await holidays.NextBusinessDay(DateOnly.FromDateTime(now), cancellationToken);
var requireBnplSettlement = await platformConfig.GetConfig<bool>("require_bnpl_settlement_for_payout", cancellationToken);
var eligible = await unitOfWork.PayoutRepository.GetEligibleBookingsAsync(
request.PeriodStart, periodEnd, now, requireBnplSettlement, cancellationToken);
if (eligible.Count == 0)
return OperationResult<GeneratePayoutBatchResult>.FailureResult(
"period", "No payout-eligible bookings in this window.");
var batch = new NursePayoutBatch
{
PeriodStart = request.PeriodStart,
PeriodEnd = periodEnd,
ProcessingDate = processingDate,
InitiatedByAdminId = adminId
};
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
var names = await unitOfWork.PayoutRepository.GetNurseNamesAsync(nurseIds, cancellationToken);
var skipped = new List<SkippedNurseDto>();
long batchTotal = 0;
var payoutCount = 0;
foreach (var group in eligible.GroupBy(e => e.NurseId).OrderBy(g => g.Key))
{
var nurseId = group.Key;
var bookings = group.ToList();
var gross = bookings.Sum(b => b.PayoutAmountIrr);
// First-payout gate: only a verified primary IBAN may receive a transfer. No account → skip, recorded.
var account = await unitOfWork.PayoutRepository.GetVerifiedPrimaryAccountAsync(nurseId, cancellationToken);
if (account is null)
{
skipped.Add(new SkippedNurseDto(
nurseId, names.GetValueOrDefault(nurseId), gross.ToString(), "no_verified_primary_iban"));
continue;
}
var clawbackApplied = await ComputeNettableClawbackAsync(nurseId, gross, cancellationToken);
var net = gross - clawbackApplied;
var payout = new NursePayout
{
NurseId = nurseId,
BankAccountId = account.BankAccountId,
IbanSnapshot = account.Iban, // encrypted at rest by the EF converter on save
GrossEarningsIrr = gross,
ClawbackAppliedIrr = clawbackApplied,
NetAmountIrr = net,
Amount = net,
BookingCount = bookings.Count
};
foreach (var b in bookings)
payout.BookingLinks.Add(new NursePayoutBookingLink
{
BookingId = b.BookingId,
PayoutAmountIrr = b.PayoutAmountIrr
});
batch.Payouts.Add(payout);
batchTotal += net;
payoutCount++;
}
if (payoutCount == 0)
return OperationResult<GeneratePayoutBatchResult>.FailureResult(
"nurse", "No eligible nurse has a verified primary IBAN to be paid.");
// total_amount = Σ net_amount_irr; payout_count = COUNT(payouts) — the batch invariant, frozen here.
batch.SetTotals(batchTotal, payoutCount);
await unitOfWork.PayoutRepository.AddBatchAsync(batch, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
// The booking_id UNIQUE backstop: a booking was linked by a concurrent run despite the lock. Never
// double-pay — surface a conflict rather than aborting into a half-written batch.
await unitOfWork.RollBackAsync();
return OperationResult<GeneratePayoutBatchResult>.ConflictResult(
"Another payout run already claimed one of these bookings.");
}
var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(
batch.Id, page: 1, pageSize: Math.Max(payoutCount, 1), cancellationToken);
return OperationResult<GeneratePayoutBatchResult>.SuccessResult(
new GeneratePayoutBatchResult(detail!.Batch, detail.Payouts, skipped));
}
/// <summary>
/// The clawback netting: recovers whole <c>pending</c> clawbacks (oldest first) that fit within the nurse's
/// earnings this batch — never a negative net, never a partial recovery of a single clawback row. A clawback
/// larger than this batch's earnings stays fully <c>pending</c> and recovers from a later, larger batch.
/// </summary>
private async Task<long> ComputeNettableClawbackAsync(long nurseId, long gross, CancellationToken cancellationToken)
{
var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(nurseId, cancellationToken);
long applied = 0;
foreach (var clawback in pending)
{
if (applied + clawback.AmountIrr > gross)
break;
applied += clawback.AmountIrr;
}
return applied;
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
public sealed class GeneratePayoutBatchCommandValidator : AbstractValidator<GeneratePayoutBatchCommand>
{
public GeneratePayoutBatchCommandValidator()
{
RuleFor(x => x.PeriodStart)
.LessThanOrEqualTo(x => x.PeriodEnd)
.WithMessage("period_start must be on or before period_end.");
// A payout window must be closed — never batch a future period.
RuleFor(x => x.PeriodEnd)
.Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date))
.WithMessage("period_end cannot be in the future.");
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
/// <summary>Opens a <c>draft</c> payout batch for a window: shifts the period end + processing date off bank-closed
/// days, selects the payout-eligible unpaid bookings, and materializes one payout per nurse (netting pending
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
@@ -0,0 +1,41 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
/// <summary>Records a reconciled bank rejection on a payout. No ledger movement — a rejected transfer means no
/// money left, so there is nothing to reverse. Re-settles the parent batch so its status reflects the failure.</summary>
internal sealed class MarkPayoutFailedCommandHandler(
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<MarkPayoutFailedCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(MarkPayoutFailedCommand request, CancellationToken cancellationToken)
{
var now = dateTimeProvider.UtcNow.UtcDateTime;
var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken);
if (stub is null)
return OperationResult<bool>.NotFoundResult("Payout not found.");
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken);
var payout = batch!.Payouts.First(p => p.Id == request.PayoutId);
// A paid payout is a confirmed, irreversible transfer — it can never be marked failed.
if (payout.Status == PayoutStatus.Paid)
return OperationResult<bool>.ConflictResult("A paid payout cannot be marked failed.");
// Idempotent.
if (payout.Status == PayoutStatus.Failed)
return OperationResult<bool>.SuccessResult(true);
payout.MarkFailed(request.FailureReason);
batch.RecomputeSettlement(now);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
public sealed class MarkPayoutFailedCommandValidator : AbstractValidator<MarkPayoutFailedCommand>
{
public MarkPayoutFailedCommandValidator()
{
RuleFor(x => x.PayoutId).GreaterThan(0);
RuleFor(x => x.FailureReason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
/// <summary>Records a reconciled bank rejection on a payout — sets <c>failed</c> with the reason. Posts <b>no</b>
/// ledger movement (no money left the platform). Used when the rail reports a transfer bounced after submit.</summary>
public record MarkPayoutFailedCommand(long PayoutId, string FailureReason) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,82 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
/// <summary>
/// Re-submits one <c>failed</c> payout. Holiday-aware — it refuses on a bank-closed day (PAYA/SATNA would fail).
/// On acceptance it drives the same settlement as the batch execute (ledger post + clawback netting, both
/// idempotent) and re-settles the parent batch (<c>partially_failed → completed</c> when it was the last failure).
/// </summary>
internal sealed class RetryFailedPayoutCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IBankTransferProvider bankTransfer,
IHolidayCalendar holidays,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RetryFailedPayoutCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(RetryFailedPayoutCommand request, CancellationToken cancellationToken)
{
var now = dateTimeProvider.UtcNow.UtcDateTime;
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken);
if (stub is null)
return OperationResult<bool>.NotFoundResult("Payout not found.");
// Load the full batch (tracked, with all payouts) so the retry can re-settle the batch status; EF identity
// resolution returns the same tracked payout instance.
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken);
var payout = batch!.Payouts.First(p => p.Id == request.PayoutId);
// Idempotent: an already-paid payout needs no retry.
if (payout.Status == PayoutStatus.Paid)
return OperationResult<bool>.SuccessResult(true);
if (payout.Status != PayoutStatus.Failed)
return OperationResult<bool>.ConflictResult("Only a failed payout can be retried.");
// Holiday-aware: a real PAYA/SATNA transfer won't settle on a bank-closed day (or the Friday weekend).
var today = DateOnly.FromDateTime(now);
var nextOpen = await holidays.NextBusinessDay(today, cancellationToken);
if (nextOpen != today)
return OperationResult<bool>.FailureResult(
"processing_date", "Banks are closed today; retry on the next business day.");
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("payout_satna_threshold_irr", cancellationToken);
var submit = await bankTransfer.SubmitPayoutBatchAsync(
batch.Id, [PayoutSettlement.ToInstruction(payout, satnaThreshold)],
idempotencyKey: $"payout:{payout.Id}:retry", cancellationToken);
var result = submit.Results.FirstOrDefault(r => r.PayoutId == payout.Id);
if (result is null || result.Status == BankTransferStatus.Failed)
{
payout.MarkFailed(result?.FailureReason ?? "provider_declined");
await unitOfWork.CommitAsync();
return OperationResult<bool>.FailureResult("channel", "The bank rail declined the transfer again.");
}
payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}");
if (result.Status == BankTransferStatus.Paid)
{
payout.MarkPaid(now);
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
}
batch.RecomputeSettlement(now);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
public sealed class RetryFailedPayoutCommandValidator : AbstractValidator<RetryFailedPayoutCommand>
{
public RetryFailedPayoutCommandValidator()
{
RuleFor(x => x.PayoutId).GreaterThan(0);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
/// <summary>Re-submits a single <c>failed</c> payout to the bank rail (holiday-aware — never on a bank-closed
/// day). On success it posts the payout ledger + nets clawbacks like the first execute and re-settles the batch;
/// idempotent on the same key so a retried retry never double-sends.</summary>
public record RetryFailedPayoutCommand(long PayoutId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,58 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
namespace Baya.Application.Features.Payouts;
/// <summary>
/// The shared money-settlement steps for a paid payout, used by both <c>ExecutePayoutBatch</c> and
/// <c>RetryFailedPayout</c> so the ledger posting + clawback netting live in exactly one place (mirroring b12's
/// extracted <c>BookingConversion</c>). Both operations are idempotent: the ledger-exists guard blocks a second
/// payout group, and a <c>recovered</c> clawback is never re-marked or re-posted.
/// </summary>
internal static class PayoutSettlement
{
/// <summary>Builds the rail instruction — SATNA above the config threshold, else PAYA. The tracked payout's
/// <see cref="NursePayout.IbanSnapshot"/> is decrypted by the EF converter on load.</summary>
public static PayoutInstruction ToInstruction(NursePayout payout, long satnaThreshold)
=> new(payout.Id, payout.IbanSnapshot, payout.Amount,
payout.Amount >= satnaThreshold ? BankTransferMethod.Satna : BankTransferMethod.Paya);
/// <summary>DEBIT <c>nurse_payable</c> / CREDIT <c>escrow_held</c> for the paid net — skipped when the payout
/// is fully netted (net 0) or a group already exists (retry idempotency).</summary>
public static async Task PostPayoutLedgerAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken)
{
if (payout.Amount <= 0)
return;
if (await unitOfWork.PayoutRepository.LedgerGroupExistsForPayoutAsync(payout.Id, cancellationToken))
return;
var legs = LedgerPosting.NursePayout(payout.NurseId, payout.Amount, payout.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
}
/// <summary>Realizes the payout's frozen <c>clawback_applied_irr</c>: recovers whole pending clawbacks (oldest
/// first, matching the build's greedy cap) — marks each <c>recovered</c> + posts DEBIT <c>nurse_payable</c> /
/// CREDIT <c>nurse_clawback_receivable</c>. Idempotent: on a retry the clawbacks are already <c>recovered</c>.</summary>
public static async Task RecoverClawbacksAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken)
{
if (payout.ClawbackAppliedIrr <= 0)
return;
var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(payout.NurseId, cancellationToken);
var remaining = payout.ClawbackAppliedIrr;
foreach (var clawback in pending)
{
if (clawback.AmountIrr > remaining)
break;
clawback.Recover(payout.Id, now);
var legs = LedgerPosting.ClawbackRecovery(clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
remaining -= clawback.AmountIrr;
}
}
}
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
internal sealed class ComputeEligibleEarningsQueryHandler(
IUnitOfWork unitOfWork,
IHolidayCalendar holidays,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ComputeEligibleEarningsQuery, OperationResult<PagedResult<EligibleNurseEarningsDto>>>
{
public async ValueTask<OperationResult<PagedResult<EligibleNurseEarningsDto>>> Handle(
ComputeEligibleEarningsQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var now = dateTimeProvider.UtcNow.UtcDateTime;
// Preview the exact set a generate would take — the period end is holiday-shifted the same way.
var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken);
var requireBnplSettlement = await platformConfig.GetConfig<bool>("require_bnpl_settlement_for_payout", cancellationToken);
var result = await unitOfWork.PayoutRepository.GetEligiblePreviewAsync(
request.PeriodStart, periodEnd, now, requireBnplSettlement, page, pageSize, cancellationToken);
return OperationResult<PagedResult<EligibleNurseEarningsDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
public sealed class ComputeEligibleEarningsQueryValidator : AbstractValidator<ComputeEligibleEarningsQuery>
{
public ComputeEligibleEarningsQueryValidator()
{
RuleFor(x => x.PeriodStart)
.LessThanOrEqualTo(x => x.PeriodEnd)
.WithMessage("period_start must be on or before period_end.");
// A payout window must be closed — never preview a future period.
RuleFor(x => x.PeriodEnd)
.Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date))
.WithMessage("period_end cannot be in the future.");
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
/// <summary>Admin preview of the payout-eligible, unpaid earnings for a window, grouped by nurse — the dry-run
/// before generating a batch. Only completed bookings whose dispute window has closed and that aren't already paid
/// appear; each nurse's pending clawback is netted and a nurse without a verified primary IBAN is flagged.
/// Paginated.</summary>
public record ComputeEligibleEarningsQuery(DateOnly PeriodStart, DateOnly PeriodEnd, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<EligibleNurseEarningsDto>>>;
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail;
internal sealed class GetBatchDetailQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetBatchDetailQuery, OperationResult<PayoutBatchDetailDto>>
{
public async ValueTask<OperationResult<PayoutBatchDetailDto>> Handle(
GetBatchDetailQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 200 ? 50 : request.PageSize;
var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(request.BatchId, page, pageSize, cancellationToken);
return detail is null
? OperationResult<PayoutBatchDetailDto>.NotFoundResult("Payout batch not found.")
: OperationResult<PayoutBatchDetailDto>.SuccessResult(detail);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail;
/// <summary>Admin batch detail — the header plus its paginated payouts (status, net, masked IBAN + transfer
/// reference) and the bookings each payout covers.</summary>
public record GetBatchDetailQuery(long BatchId, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PayoutBatchDetailDto>>;
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
internal sealed class GetNursePayoutHistoryQueryHandler(
IUnitOfWork unitOfWork,
ICurrentUser currentUser)
: IRequestHandler<GetNursePayoutHistoryQuery, OperationResult<PagedResult<NursePayoutHistoryDto>>>
{
public async ValueTask<OperationResult<PagedResult<NursePayoutHistoryDto>>> Handle(
GetNursePayoutHistoryQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<NursePayoutHistoryDto>>.UnauthorizedResult("Not authenticated.");
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
// Tenancy: resolve the caller's own nurse profile — a caller who is not a nurse simply has no payouts.
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<PagedResult<NursePayoutHistoryDto>>.SuccessResult(
new PagedResult<NursePayoutHistoryDto>([], 0, page, pageSize));
var result = await unitOfWork.PayoutRepository.GetNurseHistoryAsync(id, page, pageSize, cancellationToken);
return OperationResult<PagedResult<NursePayoutHistoryDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
/// <summary>The signed-in nurse's own payout history (tenancy-scoped to <c>ICurrentUser</c>) — status, net,
/// masked IBAN + transfer reference, any clawback applied, and the batch window. Feeds f12's earnings screen.</summary>
public record GetNursePayoutHistoryQuery(int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<NursePayoutHistoryDto>>>;
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
internal sealed class ListPayoutBatchesQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListPayoutBatchesQuery, OperationResult<PagedResult<PayoutBatchDto>>>
{
public async ValueTask<OperationResult<PagedResult<PayoutBatchDto>>> Handle(
ListPayoutBatchesQuery request, CancellationToken cancellationToken)
{
var page = request.Page < 1 ? 1 : request.Page;
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
var result = await unitOfWork.PayoutRepository.ListBatchesAsync(request.Status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<PayoutBatchDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
/// <summary>Admin reconciliation list of payout batches — projected + paginated, optional status filter.</summary>
public record ListPayoutBatchesQuery(string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<PayoutBatchDto>>>;
@@ -0,0 +1,87 @@
#nullable enable
namespace Baya.Application.Models.Payouts;
/// <summary>One raw eligible booking the batch build consumes: which nurse earned it and this booking's payout
/// portion (IRR). Grouped by nurse in the handler to compute <c>gross_earnings_irr</c>.</summary>
public record EligibleBookingRow(long NurseId, long BookingId, long PayoutAmountIrr);
/// <summary>The eligibility preview row — per-nurse earnings for the window, the pending clawback that would be
/// netted, the resulting net, and whether the nurse has a verified primary IBAN to receive it (a nurse without
/// one is <b>flagged</b>, not silently dropped). Money crosses the wire as digit strings.</summary>
public record EligibleNurseEarningsDto(
long NurseId,
string? NurseName,
int BookingCount,
string GrossEarningsIrr,
string ClawbackAppliedIrr,
string NetAmountIrr,
bool HasVerifiedPrimaryIban);
/// <summary>A batch header — periods (holiday-shifted), totals, status, and reconciliation timestamps.
/// Money is a digit string.</summary>
public record PayoutBatchDto(
long Id,
DateOnly PeriodStart,
DateOnly PeriodEnd,
DateOnly ProcessingDate,
string TotalAmount,
int PayoutCount,
string Status,
int InitiatedByAdminId,
DateTime? ProcessedAt,
string? FailureNotes,
DateTimeOffset CreatedAt);
/// <summary>The booking a payout covers — with the per-booking amount and (future) session id. Money is a digit
/// string.</summary>
public record PayoutBookingLinkDto(long BookingId, long? SessionId, string PayoutAmountIrr);
/// <summary>One payout in a batch detail — the decomposed amounts, status, the <b>masked</b> IBAN + transfer
/// reference, and the bookings it covers. Money is a digit string.</summary>
public record PayoutDto(
long Id,
long NurseId,
string? NurseName,
string MaskedIban,
string GrossEarningsIrr,
string ClawbackAppliedIrr,
string NetAmountIrr,
string Amount,
int BookingCount,
string Status,
string? TransferReference,
DateTime? PaidAt,
string? FailureReason,
IReadOnlyList<PayoutBookingLinkDto> Bookings);
/// <summary>A batch header plus its paginated payouts — the admin reconciliation detail view.</summary>
public record PayoutBatchDetailDto(PayoutBatchDto Batch, IReadOnlyList<PayoutDto> Payouts, int Total, int Page, int PageSize);
/// <summary>A nurse skipped during a batch build, with the reason — never silently dropped (the common case is
/// "no verified primary IBAN"). Money is a digit string.</summary>
public record SkippedNurseDto(long NurseId, string? NurseName, string GrossEarningsIrr, string Reason);
/// <summary>What <c>GeneratePayoutBatchCommand</c> returns — the draft batch, the materialized payouts for admin
/// preview, and the nurses skipped (with reasons).</summary>
public record GeneratePayoutBatchResult(
PayoutBatchDto Batch,
IReadOnlyList<PayoutDto> Payouts,
IReadOnlyList<SkippedNurseDto> Skipped);
/// <summary>What <c>ExecutePayoutBatchCommand</c> returns — the settled batch status and per-outcome counts.</summary>
public record ExecutePayoutBatchResult(long BatchId, string Status, int PaidCount, int FailedCount, string TotalPaid);
/// <summary>A nurse's own payout-history row (tenancy-scoped) — status, net, the <b>masked</b> IBAN + reference,
/// the clawback that was applied, and the batch window it belongs to. Money crosses the wire as digit strings.</summary>
public record NursePayoutHistoryDto(
long Id,
long BatchId,
string Status,
string GrossEarningsIrr,
string ClawbackAppliedIrr,
string NetAmountIrr,
string MaskedIban,
string? TransferReference,
DateTime? PaidAt,
DateOnly PeriodStart,
DateOnly PeriodEnd);
@@ -160,6 +160,58 @@ public static class LedgerPosting
];
}
/// <summary>
/// The <b>payout group</b> (b13): <c>DEBIT nurse_payable / CREDIT escrow_held</c> for the amount actually
/// transferred to the nurse, under one fresh <see cref="LedgerEntry.TransactionGroupId"/>. Draining the
/// <c>nurse_payable</c> accrual to a real bank transfer is the one irreversible money-out step; the balance
/// (the signed sum over <c>nurse_payable</c> legs) drops by exactly what was paid. Posted once per payout —
/// the payout status machine + the <c>nurse_payout_booking_links</c> UNIQUE make a retried execute a no-op.
/// The clawback netted into the payout is <b>not</b> a leg here: the receivable was already booked by b11 and
/// is cleared by marking the <c>nurse_clawbacks</c> row <c>recovered</c> (the net amount is simply lower).
/// </summary>
public static IReadOnlyList<LedgerEntry> NursePayout(
long nurseId,
long amountIrr,
long payoutId,
DateTime createdAt)
{
if (amountIrr <= 0)
throw new InvalidOperationException("A payout ledger group requires a positive amount.");
var group = Guid.NewGuid();
return
[
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, null, LedgerSourceRefType.NursePayout, payoutId, createdAt),
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, amountIrr, null, null, LedgerSourceRefType.NursePayout, payoutId, createdAt)
];
}
/// <summary>
/// The clawback <b>recovery</b> netting (b13): when a payout withholds a nurse's <c>pending</c> clawback, the
/// withheld earnings clear the receivable — <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> for
/// the recovered amount, under one group. Together with the payout group (which debits <c>nurse_payable</c>
/// by the paid net), this drains the nurse's <c>nurse_payable</c> by the full gross and zeroes the receivable,
/// so the derived balances reconcile. Posted once per recovered clawback (the <c>recovered</c> status makes a
/// retry a no-op).
/// </summary>
public static IReadOnlyList<LedgerEntry> ClawbackRecovery(
long bookingId,
long nurseId,
long amountIrr,
long clawbackId,
DateTime createdAt)
{
if (amountIrr <= 0)
throw new InvalidOperationException("A clawback-recovery group requires a positive amount.");
var group = Guid.NewGuid();
return
[
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt),
Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt)
];
}
/// <summary>
/// The clawback <b>write-off</b> correction: <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> for
/// the amount, when an admin declares the receivable uncollectable. A new balancing group — never an edit.
@@ -181,7 +233,7 @@ public static class LedgerPosting
private static LedgerEntry Leg(
Guid group, string account, string direction, long amount, long? nurse,
long bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
long? bookingId, string sourceType, long sourceId, DateTime createdAt) => new()
{
TransactionGroupId = group,
AccountType = account,
@@ -0,0 +1,93 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// One row per nurse per batch — the exact amount transferred, the frozen IBAN snapshot, and the bank transfer
/// reference for reconciliation. The nurse's earnings for the window are <see cref="GrossEarningsIrr"/>; any
/// <c>pending</c> clawback the nurse owes back is netted into <see cref="ClawbackAppliedIrr"/> so the platform
/// never overpays a nurse with a receivable.
/// <para>
/// <b>Invariant:</b> <see cref="NetAmountIrr"/> = <see cref="GrossEarningsIrr"/>
/// <see cref="ClawbackAppliedIrr"/>; all amounts ≥ 0; <see cref="NetAmountIrr"/> ≥ 0 (a clawback exceeding
/// earnings nets to <b>zero this batch</b>, the remainder staying <c>pending</c> for the next — never a negative
/// transfer). <see cref="Amount"/> is what actually moves; it equals <see cref="NetAmountIrr"/> on success.
/// Money is IRR <c>BIGINT</c>, no floats. <see cref="IbanSnapshot"/> is encrypted at rest and frozen at build
/// time from the nurse's verified primary account. Paid-ness is derived from a
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
/// </para>
/// </summary>
public class NursePayout : BaseEntity<long>
{
public long BatchId { get; set; }
public NursePayoutBatch Batch { get; set; } = null!;
public long NurseId { get; set; }
/// <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>
public string IbanSnapshot { get; set; } = null!;
/// <summary>Σ eligible booking payouts for the window (IRR).</summary>
public long GrossEarningsIrr { get; set; }
/// <summary>Pending clawbacks netted this batch (IRR, ≥ 0, capped at <see cref="GrossEarningsIrr"/>).</summary>
public long ClawbackAppliedIrr { get; set; }
/// <summary>Derived: <see cref="GrossEarningsIrr"/> <see cref="ClawbackAppliedIrr"/> (IRR, ≥ 0).</summary>
public long NetAmountIrr { get; set; }
/// <summary>Actually transferred net (IRR) — equals <see cref="NetAmountIrr"/> on success.</summary>
public long Amount { get; set; }
public int BookingCount { get; set; }
/// <summary>Guarded — a <see cref="PayoutStatus"/> code, mutated only through the mark-* methods.</summary>
public string Status { get; private set; } = PayoutStatus.Pending;
/// <summary>The bank track id (PAYA/SATNA), set at submit — kept for reconciliation.</summary>
public string? TransferReference { get; private set; }
public DateTime? PaidAt { get; private set; }
public string? FailureReason { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<NursePayoutBookingLink> BookingLinks { get; set; } = new List<NursePayoutBookingLink>();
public bool CanTransitionTo(string target) => PayoutStatusTransitions.CanTransition(Status, target);
private void Transition(string target)
{
if (!PayoutStatusTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal payout transition {Status} → {target}.");
Status = target;
}
/// <summary>Records the bank track id and transitions <c>pending|failed → submitted</c>. Clears any prior
/// failure so a retry starts clean.</summary>
public void MarkSubmitted(string transferReference)
{
Transition(PayoutStatus.Submitted);
TransferReference = transferReference;
FailureReason = null;
}
/// <summary>Confirms the transfer and transitions <c>submitted → paid</c> — the irreversible movement.</summary>
public void MarkPaid(DateTime now)
{
Transition(PayoutStatus.Paid);
PaidAt = now;
}
/// <summary>Records a rail rejection and transitions to <c>failed</c> (from pending or submitted).</summary>
public void MarkFailed(string reason)
{
Transition(PayoutStatus.Failed);
FailureReason = reason;
}
}
@@ -0,0 +1,88 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// A weekly aggregation of amounts owed for completed, payout-eligible, unpaid bookings — the operational unit of
/// the payout run, matching the PAYA settlement cycle. An admin (later, a scheduled job) opens a batch; it
/// materializes one <see cref="NursePayout"/> per nurse with earnings in the window, then submits them all to the
/// bank rail in one go.
/// <para>
/// <b>Holiday-aware:</b> <see cref="PeriodEnd"/> and <see cref="ProcessingDate"/> are shifted off bank-closed days
/// (via <c>IHolidayCalendar</c>) to the next business day — a batch landing on a multi-day Nowruz closure would
/// otherwise fail, since PAYA/SATNA does not settle on closed days. <b>Invariant:</b>
/// <see cref="TotalAmount"/> = Σ(<c>nurse_payouts.net_amount_irr</c>) and <see cref="PayoutCount"/> =
/// COUNT(payouts) — set by the handler when the rows are materialized and asserted by a verified invariant.
/// Money is IRR <c>BIGINT</c>, no floats.
/// </para>
/// </summary>
public class NursePayoutBatch : BaseEntity<long>
{
public DateOnly PeriodStart { get; set; }
/// <summary>Window end — shifted off <c>is_bank_closed</c> days to the next business day.</summary>
public DateOnly PeriodEnd { get; set; }
/// <summary>The date the transfers are submitted — shifted off <c>is_bank_closed</c> days.</summary>
public DateOnly ProcessingDate { get; set; }
/// <summary>Σ(<c>net_amount_irr</c>) across this batch's payouts (IRR). Set at materialization.</summary>
public long TotalAmount { get; set; }
public int PayoutCount { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
public string Status { get; private set; } = PayoutBatchStatus.Draft;
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
public int InitiatedByAdminId { get; set; }
public DateTime? ProcessedAt { get; private set; }
public string? FailureNotes { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<NursePayout> Payouts { get; set; } = new List<NursePayout>();
public bool CanTransitionTo(string target) => PayoutBatchTransitions.CanTransition(Status, target);
/// <summary>Applies a guarded status change and, on a settling edge, stamps <see cref="ProcessedAt"/>. Reaching
/// an illegal edge is a programming error (callers pre-check), so it fails fast rather than corrupting state.</summary>
public void TransitionTo(string target, DateTime now, string? failureNotes = null)
{
if (!PayoutBatchTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal payout-batch transition {Status} → {target}.");
Status = target;
if (target is PayoutBatchStatus.Completed or PayoutBatchStatus.PartiallyFailed or PayoutBatchStatus.Failed)
ProcessedAt = now;
if (failureNotes is not null)
FailureNotes = failureNotes;
}
/// <summary>Freezes the batch totals when the payouts are materialized (the CHECK-mirroring invariant).</summary>
public void SetTotals(long totalAmount, int payoutCount)
{
TotalAmount = totalAmount;
PayoutCount = payoutCount;
}
/// <summary>Re-derives the batch's terminal status from its payouts after an execute or a retry: all paid →
/// <c>completed</c>; some failed but some paid → <c>partially_failed</c>; all failed → <c>failed</c>. A no-op
/// when the resulting edge isn't allowed from the current status (e.g. already <c>partially_failed</c> with a
/// still-failed row). Requires the <see cref="Payouts"/> collection to be loaded.</summary>
public void RecomputeSettlement(DateTime now)
{
var anyFailed = Payouts.Any(p => p.Status == PayoutStatus.Failed);
var anyPaid = Payouts.Any(p => p.Status == PayoutStatus.Paid);
var target = !anyFailed
? PayoutBatchStatus.Completed
: anyPaid ? PayoutBatchStatus.PartiallyFailed : PayoutBatchStatus.Failed;
if (CanTransitionTo(target))
TransitionTo(target, now, target == PayoutBatchStatus.Failed ? "All payouts failed at the bank rail." : null);
}
}
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The join from a payout to the specific booking it covers — and the platform's strongest correctness feature:
/// <see cref="BookingId"/> carries a <b>UNIQUE</b> index so a booking can be paid in <b>exactly one</b> payout
/// across all batches, ever. A duplicate insert <i>is</i> the already-paid signal (the handler catches it, treats
/// the booking as not-eligible, and continues) — the structural anti-double-pay guard, not just a pre-check.
/// <para>
/// The <see cref="BookingId"/> UNIQUE is <b>unconditional</b> (not filtered on soft-delete): the link is a
/// permanent record of an irreversible transfer, so even a removed row must never re-open a booking for
/// re-payment. <see cref="SessionId"/> is nullable for a future per-session accrual model; today one link per
/// booking carries the whole booking payout. Money is IRR <c>BIGINT</c>.
/// </para>
/// </summary>
public class NursePayoutBookingLink : BaseEntity<long>
{
public long PayoutId { get; set; }
/// <summary><b>UNIQUE</b> (unconditional) across every batch — the hard one-payout-per-booking guard.</summary>
public long BookingId { get; set; }
/// <summary>Set only when paying a per-session accrual; null for whole-booking payment.</summary>
public long? SessionId { get; set; }
/// <summary>The portion of this booking (or session) paid in this payout (IRR).</summary>
public long PayoutAmountIrr { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,26 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The closed <c>nurse_payout_batches.status</c> code set. A batch opens in <see cref="Draft"/> (materialized
/// but not yet submitted to the bank), moves to <see cref="Processing"/> when the transfers are submitted, and
/// ends <see cref="Completed"/> (all paid), <see cref="PartiallyFailed"/> (some rejected — retryable), or
/// <see cref="Failed"/> (the whole submit failed). Persisted as these stable snake_case codes; the allowed edges
/// live in <see cref="PayoutBatchTransitions"/>.
/// </summary>
public static class PayoutBatchStatus
{
/// <summary>Materialized (payouts + links built) but not yet submitted — the admin preview state.</summary>
public const string Draft = "draft";
/// <summary>The bank submit is in flight / partially applied.</summary>
public const string Processing = "processing";
/// <summary>At least one payout was rejected by the rail; the rest paid. Retry the failed rows.</summary>
public const string PartiallyFailed = "partially_failed";
/// <summary>Every payout in the batch was paid. Terminal.</summary>
public const string Completed = "completed";
/// <summary>The whole submit failed (e.g. the rail was unreachable / a closed day). Terminal for the run.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,25 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The allowed-edge table for the <see cref="PayoutBatchStatus"/> machine. A batch opens in
/// <see cref="PayoutBatchStatus.Draft"/>, is submitted (<see cref="PayoutBatchStatus.Processing"/>), then settles
/// to a terminal outcome. <see cref="PayoutBatchStatus.PartiallyFailed"/> is re-enterable: a retry that clears the
/// last failed payout flips it to <see cref="PayoutBatchStatus.Completed"/>.
/// </summary>
public static class PayoutBatchTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[PayoutBatchStatus.Draft] = [PayoutBatchStatus.Processing, PayoutBatchStatus.Failed],
[PayoutBatchStatus.Processing] =
[PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed, PayoutBatchStatus.Failed],
// A retry can clear the last failed payout and complete the batch.
[PayoutBatchStatus.PartiallyFailed] = [PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed],
[PayoutBatchStatus.Completed] = [],
[PayoutBatchStatus.Failed] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -0,0 +1,24 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The closed <c>nurse_payouts.status</c> code set — a <b>forward-only</b> lifecycle that (with the
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE and the batch lock) makes a retried execute never
/// double-send an irreversible transfer. A payout is materialized <see cref="Pending"/>, moves to
/// <see cref="Submitted"/> when the bank accepts the instruction, and to <see cref="Paid"/> once the transfer
/// track id is confirmed; a rail rejection lands it in <see cref="Failed"/>, from which a retry re-submits.
/// Persisted as these stable snake_case codes; the allowed edges live in <see cref="PayoutStatusTransitions"/>.
/// </summary>
public static class PayoutStatus
{
/// <summary>Materialized into the draft batch but not yet submitted to the rail.</summary>
public const string Pending = "pending";
/// <summary>The bank accepted the transfer instruction (a PAYA/SATNA track id was issued).</summary>
public const string Submitted = "submitted";
/// <summary>The transfer is confirmed paid — an irreversible IBAN movement. Terminal on success.</summary>
public const string Paid = "paid";
/// <summary>The rail rejected the transfer; a retry re-submits the same instruction.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,25 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The forward-only allowed-edge table for the <see cref="PayoutStatus"/> machine (mirrors
/// <c>BnplTransitions</c>/<c>RefundTransitions</c>). Every write goes through <see cref="NursePayout"/>'s
/// cohesive mark-* methods, which assert the edge here — so a replayed execute that would re-drive an
/// already-<see cref="PayoutStatus.Paid"/> row is rejected before it can re-send an irreversible transfer or
/// re-post the ledger. <see cref="PayoutStatus.Failed"/> is the only re-enterable state (a retry re-submits).
/// </summary>
public static class PayoutStatusTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[PayoutStatus.Pending] = [PayoutStatus.Submitted, PayoutStatus.Failed],
[PayoutStatus.Submitted] = [PayoutStatus.Paid, PayoutStatus.Failed],
// A rejected transfer can be re-submitted.
[PayoutStatus.Failed] = [PayoutStatus.Submitted],
// Terminal on success — no outgoing edge (paid is an irreversible transfer).
[PayoutStatus.Paid] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -39,6 +39,18 @@ public class NurseClawback : BaseEntity<long>
public bool IsPending => Status == ClawbackStatus.Pending;
/// <summary>Netted out of a payout batch (b13): records the recovering payout + resolution. The balancing
/// <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> posting is the payout handler's job; this
/// records the workflow outcome. Only a <c>pending</c> clawback can be recovered.</summary>
public void Recover(long payoutId, DateTime now)
{
if (Status != ClawbackStatus.Pending)
throw new InvalidOperationException($"Only a pending clawback can be recovered (was {Status}).");
Status = ClawbackStatus.Recovered;
RecoveredInPayoutId = payoutId;
ResolvedAt = now;
}
/// <summary>Admin declares the receivable uncollectable. The balancing <c>bad_debt</c> posting is the
/// handler's job; this records the workflow outcome.</summary>
public void WriteOff(string notes, DateTime now)