8.9 KiB
8.9 KiB
Contract — Payouts (backend phase b13)
The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout history. Assumes
../conventions/api-conventions.md+../conventions/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
Status: live as of backend-phase-b13 · Frontend consumer: frontend-phase-f12-b13
All money is IRR BIGINT and crosses the wire as a digit string ("8500000"). Dates are yyyy-MM-dd.
List query params are camelCase (page, pageSize, status, periodStart, periodEnd) — not snake_case.
The response envelope is the standard { data, … }; the shapes below are the data.
Enums used
PayoutBatchStatus:draft|processing|partially_failed|completed|failed— the batch lifecycle. A draft is materialized but unsubmitted;partially_failedhas some paid + some failed (retryable).PayoutStatus:pending|submitted|paid|failed— the per-payout lifecycle (forward-only;paidis an irreversible transfer with no outgoing edge;failedre-submits on retry).
Endpoints
GET api/v1/admin_payouts/eligible
- Purpose: Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch).
- Auth: admin (dynamic-permission policy) · Rate-limited: yes · Idempotency key: n/a (read).
- Query params:
periodStart(date, required),periodEnd(date, required, ≤ today, ≥ periodStart),page(default 1),pageSize(default 20, max 100). - Success
200(data):PagedResult<EligibleNurseEarningsDto>. - Failure cases:
400periodStart > periodEnd or periodEnd in the future;401unauthenticated;403non-admin. - Notes: Eligible = booking
status='completed'ANDdispute_window_ends_at < nowAND no active refund AND not already paid. TheperiodEndis holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is flagged (hasVerifiedPrimaryIban=false), not dropped. Pending clawbacks are netted into the preview.
POST api/v1/admin_payouts/batches
- Purpose: Open a
draftbatch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. No money moves. - Auth: admin · Rate-limited: yes · Idempotency: the
booking_idUNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking. - Request body:
{ "periodStart": "2026-06-01", "periodEnd": "2026-06-30" } - Success
200(data):GeneratePayoutBatchResult— the draft batch, its materialized payouts, and the nurses skipped (with reasons). - Failure cases:
400invalid period;401/403; a plain failure when no eligible bookings in the window or no eligible nurse has a verified primary IBAN;409a concurrent run already claimed one of the bookings (the UNIQUE backstop). - Notes:
period_end/processing_dateare shifted off bank-closed days viaIHolidayCalendar.total_amount = Σ net_amount_irr,payout_count = COUNT(payouts).
POST api/v1/admin_payouts/batches/{id}/process
- Purpose: Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step.
- Auth: admin · Rate-limited: yes · Idempotency key: yes (
payout-batch:{id}; a retried process never re-sends a paid payout or re-posts the ledger). - Path params:
id(long) — the batch id. Body: none. - Success
200(data):ExecutePayoutBatchResult. - Failure cases:
401/403;404batch not found;409the batch alreadyfailed(open a new one). A re-process of acompletedbatch is an idempotent200. - Notes: Per accepted transfer it posts
DEBIT nurse_payable / CREDIT escrow_held(paid net) and, for a netted clawback,DEBIT nurse_payable / CREDIT nurse_clawback_receivable+ marks thenurse_clawbacksrowrecovered. Batch endscompleted(all paid) orpartially_failed(some failed). PAYA vs SATNA is chosen bypayout_satna_threshold_irr.
POST api/v1/admin_payouts/{payoutId}/retry
- Purpose: Re-submit a single
failedpayout (holiday-aware). - Auth: admin · Rate-limited: yes · Idempotency key: yes (
payout:{id}:retry). - Path params:
payoutId(long). Body: none. - Success
200(data):true. - Failure cases:
400aprocessing_datefailure when banks are closed today, or achannelfailure when the rail declines again;401/403;404payout not found;409the payout is notfailed. An already-paidpayout returns an idempotent200. - Notes: On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (
partially_failed → completedwhen it was the last failure).
POST api/v1/admin_payouts/{payoutId}/mark_failed
- Purpose: Record a reconciled bank rejection on a payout — no ledger movement (no money left).
- Auth: admin · Rate-limited: yes.
- Path params:
payoutId(long). Request body:{ "failureReason": "invalid_sheba" } - Success
200(data):true. - Failure cases:
400empty reason;401/403;404not found;409the payout ispaid(a confirmed transfer can't be failed). An already-failedpayout is an idempotent200.
GET api/v1/admin_payouts/batches/{id}
- Purpose: Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers.
- Auth: admin · Rate-limited: yes.
- Path params:
id(long). Query:page(default 1),pageSize(default 50, max 200). - Success
200(data):PayoutBatchDetailDto. - Failure cases:
401/403;404not found.
GET api/v1/admin_payouts/batches
- Purpose: Admin reconciliation list of batches.
- Auth: admin · Rate-limited: yes.
- Query:
status(optionalPayoutBatchStatus),page(default 1),pageSize(default 20, max 100). - Success
200(data):PagedResult<PayoutBatchDto>.
GET api/v1/nurse_payouts/history
- Purpose: The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window.
- Auth: authenticated (nurse) · Rate-limited: no.
- Query:
page(default 1),pageSize(default 20, max 100). - Success
200(data):PagedResult<NursePayoutHistoryDto>. - Failure cases:
401unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data).
Shared shapes
EligibleNurseEarningsDto:nurseId(long),nurseName(string?),bookingCount(int),grossEarningsIrr(string),clawbackAppliedIrr(string),netAmountIrr(string),hasVerifiedPrimaryIban(bool).PayoutBatchDto:id(long),periodStart/periodEnd/processingDate(date),totalAmount(string),payoutCount(int),status(PayoutBatchStatus),initiatedByAdminId(int),processedAt(datetime?),failureNotes(string?),createdAt(datetime).PayoutDto:id(long),nurseId(long),nurseName(string?),maskedIban(string, last-4 only),grossEarningsIrr/clawbackAppliedIrr/netAmountIrr/amount(string),bookingCount(int),status(PayoutStatus),transferReference(string?),paidAt(datetime?),failureReason(string?),bookings(PayoutBookingLinkDto[]).PayoutBookingLinkDto:bookingId(long),sessionId(long?),payoutAmountIrr(string).PayoutBatchDetailDto:batch(PayoutBatchDto),payouts(PayoutDto[]),total(int),page(int),pageSize(int).SkippedNurseDto:nurseId(long),nurseName(string?),grossEarningsIrr(string),reason(string, e.g.no_verified_primary_iban).GeneratePayoutBatchResult:batch(PayoutBatchDto),payouts(PayoutDto[]),skipped(SkippedNurseDto[]).ExecutePayoutBatchResult:batchId(long),status(PayoutBatchStatus),paidCount(int),failedCount(int),totalPaid(string).NursePayoutHistoryDto:id(long),batchId(long),status(PayoutStatus),grossEarningsIrr/clawbackAppliedIrr/netAmountIrr(string),maskedIban(string),transferReference(string?),paidAt(datetime?),periodStart/periodEnd(date).
Side effects
- Ledger: process/retry post balanced groups out of
nurse_payable(payout + clawback-recovery). Never apayout_releasedboolean — paid-ness derives from a link row + the ledger. - One payout per booking, forever via the
nurse_payout_booking_links.booking_idUNIQUE. - Bank rail is mocked behind
IBankTransferProvider(PAYA/SATNA) — no real transfer.
Changelog
- b13 — initial contract.