# 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 commit `d3ec723` and 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):** `GeneratePayoutBatchCommand` gained **`systemInitiated: boolean`**, alongside the
  existing `periodStart`/`periodEnd` dates. 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 `draft` batch. `IRecurringJob` + `RecurringJobSchedulerHostedService`
  do this weekly, in-process (refinement-phase-7).
- The **irreversible `process` step 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

- **`UNIQUE` on booking id: one payout per booking, ever.** The DB constraint is the authority; the handler
  does not rely on an `if`.
- **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](refunds.md).
- **Payout dates are resolved against the bank-holiday calendar server-side.** The client never computes
  one — see [admin.md](admin.md). `nurse_payout_interval_days` and `payout_satna_threshold_irr` are config;
  the threshold selects PAYA vs SATNA.
- **A verified primary IBAN gates payment, not accrual.** `EligibleNurseEarningsDto.hasVerifiedPrimaryIban`
  is what the console checks; a nurse without one accrues a balance and is not paid. See
  [nurse.md](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](../api-contract.md#idempotency).

## 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 |
