6.2 KiB
payouts — weekly nurse settlement
Client seam
client/src/services/payouts/·USE_PAYOUTS_MOCK = true(mock is primary) · 13 server ops Last verified: 2026-07-30 against commitd3ec723and swagger.v1.json (2026-07-29).
Money going out, weekly, in batches. The nurse's view is read-only; the admin's view is the one place in the platform where an irreversible transfer is triggered by hand.
Endpoints
| Method | Path | Auth | Verdict |
|---|---|---|---|
| GET | /api/v1/nurse_payouts/earnings |
[Authorize] |
wired · paginated — per-booking earnings |
| GET | /api/v1/nurse_payouts/earnings_balance |
[Authorize] |
wired — the four-bucket balance |
| GET | /api/v1/nurse_payouts/history |
[Authorize] |
wired · paginated |
| GET | /api/v1/nurse_payouts/{id} |
[Authorize] |
wired — payout detail |
| GET | /api/v1/nurses/{nurseId}/payable_balance |
[Authorize] |
unwired — the nurse reads earnings_balance instead |
| GET | /api/v1/admin_payouts/eligible |
admin · sensitive |
wired · paginated |
| GET | /api/v1/admin_payouts/batches |
admin · sensitive |
wired · paginated |
| POST | /api/v1/admin_payouts/batches |
admin · sensitive |
unwired — generation is automatic (below) |
| GET | /api/v1/admin_payouts/batches/{id} |
admin · sensitive |
wired · paginated (page/pageSize) |
| POST | /api/v1/admin_payouts/batches/{id}/process |
admin · sensitive |
unwired — the irreversible step; no console button yet |
| POST | /api/v1/admin_payouts/{payoutId}/retry |
admin · sensitive |
wired |
| POST | /api/v1/admin_payouts/{payoutId}/mark_failed |
admin · sensitive |
unwired |
| POST | /api/v1/webhooks/payouts/{provider} |
anonymous · webhook |
server-only — the transferor's reconciliation callback |
This domain absorbed the entire wire-level drift between the 2026-07-13 contract freeze and the 2026-07-29 snapshot — one added endpoint and one changed schema, both here:
- Added (C-6):
POST /api/v1/webhooks/payouts/{provider}— the transferor's reconciliation callback. - Changed (C-7):
GeneratePayoutBatchCommandgainedsystemInitiated: boolean, alongside the existingperiodStart/periodEnddates. That is the refinement-phase-7 scheduler flag: it distinguishes a batch the recurring job generated from one an admin generated, which is what keeps the "generation is automatic, processing is not" rule auditable.
Phantom — 1
| Client call | REQ | Note |
|---|---|---|
POST /api/v1/admin_payouts/{id}/transfer_reference |
REQ-036 | Deferred. The reference is readable on PayoutDto.transferReference; what is missing is a route to record one manually |
Generation is automatic; processing is not
This is a hard platform rule, not a convention:
- A scheduled job may generate a
draftbatch.IRecurringJob+RecurringJobSchedulerHostedServicedo this weekly, in-process (refinement-phase-7). - The irreversible
processstep is always an explicit admin action. No job, no schedule, no retry loop may trigger a real transfer.
Which is why POST batches is unwired (the job does it) and POST batches/{id}/process is unwired (the
console has no button yet — that is the gap, and it is deliberate that nothing automated fills it).
The money rules
UNIQUEon booking id: one payout per booking, ever. The DB constraint is the authority; the handler does not rely on anif.- The net balance is SIGNED and must never be clamped to zero. A nurse with a clawback larger than
their eligible earnings has a negative
netAmountIrr. Rendering it as 0 tells them they have nothing owing when in fact they owe. The client displays the signed value. - Clawback netting is whole-clawback greedy, never partial: a clawback either fits in this batch or waits for the next. See refunds.md.
- Payout dates are resolved against the bank-holiday calendar server-side. The client never computes
one — see admin.md.
nurse_payout_interval_daysandpayout_satna_threshold_irrare config; the threshold selects PAYA vs SATNA. - A verified primary IBAN gates payment, not accrual.
EligibleNurseEarningsDto.hasVerifiedPrimaryIbanis what the console checks; a nurse without one accrues a balance and is not paid. See nurse.md. - The IBAN is always masked on every read model, nurse-side and admin-side alike.
A resolved drift
payouts/apis/clientApi.ts states that NursePayoutHistoryDto "carries no failureReason — that field
lives on the admin-only PayoutDto … until REQ-025 adds it". The live wire has it: failureReason is
on NursePayoutHistoryDto and NursePayoutDetailDto and PayoutDto. The comment predates the
delivery; a failed payout can show its reason to the nurse today.
The client also sends Idempotency-Key on process/retry. The server does not read it there — only
PaymentsController and CheckoutBnplController do. Harmless (those writes are idempotent by
constraint), but the header is decorative on this domain. See
../api-contract.md.
Enums
| Vocabulary | Values |
|---|---|
PayoutStatus |
pending submitted paid failed |
PayoutBatchStatus |
draft processing partially_failed completed failed |
EarningsState |
pending eligible paid clawback_applied |
PayoutStatus and PayoutBatchStatus are verified identical to Entities/Payouts/*.cs and are
forward-only through PayoutStatusTransitions. partially_failed is a real batch outcome — some
destinations settled, some did not — and drives the single-payout retry; Seams:BankTransfer:FailIban
exists to make it testable.
Open REQs
| REQ | Status | Effect |
|---|---|---|
| REQ-025 | delivered | The four-bucket balance, per-booking earnings list and payout detail are all served (including failureReason, above) |
| REQ-036 | deferred | No single-payout preview, no holidayShifted flag, no record-transfer-reference route → 1 phantom |
| REQ-053 | open | No payout forecast (next batch date + expected eligible amount). The nurse dashboard's forecast line renders nothing on the real path |