# 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/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../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_failed` has some paid + some failed (retryable). - `PayoutStatus`: `pending` | `submitted` | `paid` | `failed` — the per-payout lifecycle (forward-only; `paid` is an irreversible transfer with no outgoing edge; `failed` re-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`. - **Failure cases:** `400` periodStart > periodEnd or periodEnd in the future; `401` unauthenticated; `403` non-admin. - **Notes:** Eligible = booking `status='completed'` AND `dispute_window_ends_at < now` AND no active refund AND not already paid. The `periodEnd` is 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 `draft` batch: 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_id` UNIQUE 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:** `400` invalid period; `401`/`403`; a plain failure when **no eligible bookings** in the window or **no eligible nurse has a verified primary IBAN**; `409` a concurrent run already claimed one of the bookings (the UNIQUE backstop). - **Notes:** `period_end`/`processing_date` are shifted off bank-closed days via `IHolidayCalendar`. `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`; `404` batch not found; `409` the batch already `failed` (open a new one). A re-process of a `completed` batch is an idempotent `200`. - **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 the `nurse_clawbacks` row `recovered`. Batch ends `completed` (all paid) or `partially_failed` (some failed). PAYA vs SATNA is chosen by `payout_satna_threshold_irr`. ### `POST api/v1/admin_payouts/{payoutId}/retry` - **Purpose:** Re-submit a single `failed` payout (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:** `400` a `processing_date` failure when banks are closed today, or a `channel` failure when the rail declines again; `401`/`403`; `404` payout not found; `409` the payout is not `failed`. An already-`paid` payout returns an idempotent `200`. - **Notes:** On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (`partially_failed → completed` when 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:** `400` empty reason; `401`/`403`; `404` not found; `409` the payout is `paid` (a confirmed transfer can't be failed). An already-`failed` payout is an idempotent `200`. ### `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`; `404` not found. ### `GET api/v1/admin_payouts/batches` - **Purpose:** Admin reconciliation list of batches. - **Auth:** admin · **Rate-limited:** yes. - **Query:** `status` (optional `PayoutBatchStatus`), `page` (default 1), `pageSize` (default 20, max 100). - **Success `200` (`data`):** `PagedResult`. ### `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`. - **Failure cases:** `401` unauthenticated. 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 a `payout_released` boolean — paid-ness derives from a link row + the ledger. - **One payout per booking, forever** via the `nurse_payout_booking_links.booking_id` UNIQUE. - **Bank rail** is mocked behind `IBankTransferProvider` (PAYA/SATNA) — no real transfer. ## Changelog - b13 — initial contract.