# 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<EligibleNurseEarningsDto>`.
- **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<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:** `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?, **null = system-initiated / scheduled batch** — refinement-phase-7), `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.

---

## Refinement phase 3 additions (REQ-025 — nurse earnings)

- **`GET api/v1/nurse_payouts/earnings_balance`** → `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr,
  clawbackOutstandingIrr, netPayableBalanceIrr }`. `netPayableBalanceIrr` is the **ledger-derived, SIGNED**
  nurse_payable balance (may be negative = "owed back"; never clamped); `paidTotalIrr` is lifetime, not in the net.
- **`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=`** → `PagedResult<NurseEarningsItem>`; `state`
  (`pending|eligible|paid|clawback_applied`) is **derived server-side** from `bookings.status` +
  `dispute_window_ends_at < now` + the payout link + any clawback. Filterable by `state`.
- **`GET api/v1/nurse_payouts/{id}`** → nurse-scoped payout detail (batch window + covered bookings).
- **`NursePayoutHistoryDto`** gains `failureReason`.
