backend phase 13 & frontend phase 6
This commit is contained in:
@@ -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 < 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();
|
||||
}
|
||||
|
||||
+114
@@ -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());
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+160
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>;
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>>;
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>>;
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user