# Backend Phase 13 — Weekly nurse payouts (mocked bank transfer)

> **Mission:** pay nurses what they have earned. Build the weekly payout engine that aggregates
> payout-**eligible**, unpaid bookings/sessions into a `nurse_payout_batches` run, fans them out to **one
> `nurse_payouts` row per nurse** (netting any pending clawback the nurse owes back), links each booking
> under a **`UNIQUE` guard so it can never be paid twice**, snapshots the nurse's verified primary IBAN,
> submits the transfers through a **mocked PAYA/SATNA bank rail**, and posts the outbound `nurse_payable`
> ledger movement — all **holiday-aware** so a Nowruz-landing batch shifts off bank-closed days. This is
> the last money-out phase; after this a nurse's earnings are real.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (ledger / `nurse_payable`), [b11](./backend-phase-11.md) (clawbacks), [b9](./backend-phase-9.md) (booking/session eligibility, dispute window), [b3](./backend-phase-3.md) (nurse bank accounts), [b1](./backend-phase-1.md) (`iranian_holidays`) · **Unlocks:** nurse earnings; frontend **f12-b13**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.

---

## 1. Context — where this sits

This is **backend phase b13**, the final money-out leg of the payments arc (b10 ledger → b11
refunds/clawbacks/invoices → b12 BNPL → **b13 payouts**). The platform never custodies cash: "escrow" is
an internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and a nurse's owed balance lives in `ledger_entries` as the `nurse_payable` account. This phase **drains
that accrual to a real bank transfer**, once per booking, only after the dispute window has closed — the
one irreversible step in the whole money flow. Because an Iranian PAYA/SATNA transfer cannot be charged
back, eligibility gating and the clawback fallback (b11) are what protect the platform from overpaying.

**What already exists (do not rebuild) — built by prior phases:**
- **The ledger & `nurse_payable` accrual** — [b10](./backend-phase-10.md) built `ledger_entries`
  (append-only, balanced, `transaction_group_id`, the six `account_type`s incl. `escrow_held`,
  `nurse_payable`, `nurse_clawback_receivable`), `payment_transactions`, `payment_webhook_events`,
  the card-capture posting (`DEBIT escrow_held` / `CREDIT platform_revenue` + `nurse_payable`), the
  `IDistributedLock` Redis-lock pattern on the money path, and `GetNursePayableBalance` (sum of
  `nurse_payable` legs). **Reuse the ledger posting helper and the lock — do not re-implement them.**
- **Clawbacks** — [b11](./backend-phase-11.md) built `nurse_clawbacks` (`nurse_id`, `booking_id`,
  `refund_id`, `original_payout_id`, `amount_irr`, `status` ∈ `pending|recovered|written_off`,
  `recovered_in_payout_id`, `resolved_at`) and the `nurse_clawback_receivable` ledger leg. This phase
  **nets `pending` clawbacks into a payout and marks them `recovered`** — it does not create them.
- **Bookings, sessions & the dispute window** — [b9](./backend-phase-9.md) built `bookings` (the
  three-amount split `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`),
  `booking_sessions` (per-visit `visit_payout_amount`, `payout_eligible_at`), `visit_verifications`
  (EVV), and the `dispute_window_ends_at` set on completion (`completed_at + config(dispute_window_hours, 72)`).
  **The `payout_released` boolean was deliberately CUT — never reintroduce it.**
- **Nurse bank accounts** — [b3](./backend-phase-3.md) built `nurse_bank_accounts` (`iban` enc,
  `iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
  `account_holder_from_bank`, `ownership_vendor_ref`) and the `IBankAccountOwnershipVerifier` seam. This
  phase **reads the verified primary account and snapshots its IBAN** — it does not register or verify.
- **`iranian_holidays`** — [b1](./backend-phase-1.md) seeded the holiday calendar (`holiday_date`,
  `is_bank_closed`) behind the **`IHolidayCalendar`** seam. **Reuse `IHolidayCalendar`** for date shifting.
- **The platform config accessor** — [b1](./backend-phase-1.md)'s typed, cached `platform_configs`
  reader. Read `dispute_window_hours` (already used by b9) and any payout-window config through it; never
  hardcode.
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
  **`martinothamar/Mediator`**, `IFieldEncryptor` (for `iban_snapshot`), `ICurrentUser` + audit
  interceptor, rate limiting, `IDateTimeProvider`.

**What this phase introduces:** the three payout tables, the eligibility/build/link/execute capabilities,
and **one new seam — `IBankTransferProvider`** (the mocked PAYA/SATNA rail). The weekly **cron scheduler is
DEFERRED** — batches are triggered manually by an admin endpoint now (see §3, `SchedulePayoutJob` DEFERRED).

## 2. Required reading (do this first)

- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
  [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
  especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
  ledger, idempotent money writes, Redis lock on the money path).
- [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — **the business rules**:
  weekly batches, EVV + dispute-window gating, one-payout-per-booking, clawback netting, holiday-aware
  scheduling, verified-primary-IBAN destination, MVP vs DEFERRED (no on-demand withdrawal).
- [`product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) —
  **Q2 "who pays the nurse, and when"**: the nurse payout is `gross_price_irr − balinyaar_commission_irr`,
  identical and on the identical weekly timing whether the family paid by card or BNPL; the BNPL
  provider's commission **never** touches the nurse; the optional `settled_at` timing guard.
- [`product/data-model/07-payouts.md`](../../../product/data-model/07-payouts.md) — **the canonical
  schema** for `nurse_payout_batches`, `nurse_payouts` (incl. the `gross_earnings_irr` /
  `clawback_applied_irr` / `net_amount_irr` additions), and `nurse_payout_booking_links` (the `booking_id`
  UNIQUE guard). Mirror these field names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **payout ledger
  posting** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`) and the clawback leg.
- **Code to mirror:** b10's ledger posting helper + `IDistributedLock` usage, the `payment_webhook_events`
  idempotency pattern, and any `Features/Payments/**` command structure; b11's `nurse_clawbacks` config &
  `Features/Refunds/**`; b9's `bookings`/`booking_sessions` configs and the eligibility columns; b3's
  `nurse_bank_accounts` config; b1's `IHolidayCalendar` and the typed config accessor.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, envelope).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md`, `…-11.md`,
  `…-9.md`, `…-3.md`, `…-1.md`, and `reports/mocks-registry.md` (seam rows you reuse/add).

## 3. Scope — build this

All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Payouts/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Payouts/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/PayoutsConfig/`; one EF migration for the three tables.

### 3.1 Entities + migration

**`nurse_payout_batches`** [CORE] — weekly aggregation, admin/job-initiated, holiday-aware.
- Fields: `id`, `period_start`, `period_end` (holiday-shifted off `is_bank_closed` days),
  `processing_date` (holiday-shifted), `total_amount` (BIGINT), `payout_count` (int),
  `status` (enum, see below), `initiated_by_admin_id` (FK `users`), `processed_at` (nullable),
  `failure_notes` (nullable), audit fields.
- **CHECK / invariant:** `total_amount = Σ(nurse_payouts.net_amount_irr)` for the batch — enforce in the
  handler when materializing rows; add a DB CHECK where SQL Server allows (else a verified invariant in
  `ExecutePayoutBatch`). `payout_count = COUNT(nurse_payouts)`.
- Relations: 1:N → `nurse_payouts`.

**`nurse_payouts`** [CORE] — one row per nurse per batch.
- Fields: `id`, `batch_id` (FK), `nurse_id` (FK `nurse_profiles`), `bank_account_id` (FK
  `nurse_bank_accounts`), `iban_snapshot` (**encrypted** via `IFieldEncryptor`, frozen at build time),
  `gross_earnings_irr` (BIGINT — Σ eligible booking/session payouts), `clawback_applied_irr` (BIGINT —
  pending clawbacks netted this batch, ≥ 0), `net_amount_irr` (BIGINT — `gross_earnings_irr −
  clawback_applied_irr`), `amount` (BIGINT — actually transferred net; equals `net_amount_irr` on
  success), `booking_count` (int), `status` (enum), `transfer_reference` (nullable — the bank track id),
  `paid_at` (nullable), `failure_reason` (nullable), audit fields.
- **Invariant (handler + CHECK where possible):** `net_amount_irr = gross_earnings_irr −
  clawback_applied_irr`; all amounts ≥ 0; `net_amount_irr ≥ 0` (a nurse whose clawback exceeds earnings
  nets to **zero this batch with the remainder staying `pending`** — see §5; never produce a negative
  transfer).
- Relations: N:1 → `nurse_payout_batches`, `nurse_profiles`, `nurse_bank_accounts`; 1:N →
  `nurse_payout_booking_links`; referenced by `nurse_clawbacks.recovered_in_payout_id`.

**`nurse_payout_booking_links`** [CORE] — the structural anti-double-pay guard.
- Fields: `id`, `payout_id` (FK `nurse_payouts`), `booking_id` (FK `bookings`) **`UNIQUE`**,
  `session_id` (nullable FK `booking_sessions` — set when paying per-session accrual),
  `payout_amount_irr` (BIGINT — the portion of this booking/session in this payout), audit fields.
- **The `booking_id` UNIQUE index is the hard guard** — a booking can be linked to exactly one payout
  across all batches, ever. **Do not drop it.** (When paying per-session, the unique guard is on
  `(booking_id, session_id)` so each *session* is paid once — confirm against b9's session model; the
  booking-level `booking_id` UNIQUE still holds for single-session bookings.)
- Relations: N:1 → `nurse_payouts`; 1:1 → `bookings` (and per-session → `booking_sessions`).

**Status enums** (define as proper enums, persist as string/byte per project convention):
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed`.
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed`.

### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)

| Capability | Type | Route (admin/nurse) | What it does |
| --- | --- | --- | --- |
| **`ComputeEligibleEarningsQuery`** | Query | `GET api/v1/admin_payouts/eligible?period_start=&period_end=` | Projects (AsNoTracking + `.Select`) the **payout-eligible, unpaid** bookings/sessions for the window and groups them by nurse, returning a preview (per-nurse `gross_earnings_irr`, pending `clawback_applied_irr`, `net_amount_irr`, booking count). Eligible = `status='completed'` **AND** `dispute_window_ends_at < now` (per-session: `payout_eligible_at < now`) **AND no open dispute AND** not already in a `nurse_payout_booking_links` row. Paginated. |
| **`GeneratePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches` | Opens a `nurse_payout_batches` row in `draft`. Computes `period_end`/`processing_date` and **shifts them off `is_bank_closed` days via `IHolidayCalendar`** to the next business day. Selects the eligible set (same predicate as the query) under a `lock(payout:batch)` so two runs can't grab the same bookings. Orchestrates `BuildNursePayouts` + `LinkPayoutBookings` inside one unit of work. Returns the draft batch with materialized payouts for admin preview. **Idempotent:** re-running for an overlapping window cannot re-select an already-linked booking (the UNIQUE link is the backstop). |
| **`BuildNursePayouts`** | Command (internal step) | — | Groups the eligible bookings per nurse; computes `gross_earnings_irr = Σ(gross_price_irr − balinyaar_commission_irr)` (per-session: `Σ visit_payout_amount`); reads the nurse's **`pending` `nurse_clawbacks`**, nets them into `clawback_applied_irr` (capped at `gross_earnings_irr`), sets `net_amount_irr` and `amount`; **snapshots `iban_snapshot`** from the nurse's **verified primary** `nurse_bank_accounts` (`is_primary=1 AND is_verified=1 AND matched_national_id=1`). A nurse with no verified primary account is **skipped with a recorded reason** (not silently dropped). |
| **`LinkPayoutBookings`** | Command (internal step) | — | Inserts `nurse_payout_booking_links` rows under the `booking_id` UNIQUE constraint. **A duplicate-key violation is the already-paid guard** — catch it, treat that booking as not-eligible, and continue (never let it abort the batch or double-pay). |
| **`ExecutePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches/{id}/process` | Transitions `draft → processing`. Under `lock(payout:batch)` (and `lock(nurse:{id}:payout)` per row), submits the batch to **`IBankTransferProvider.SubmitPayoutBatchAsync`** with one `PayoutInstruction` per payout (nurse IBAN, `net_amount_irr`, PAYA/SATNA method), stores each `transfer_reference`, transitions payouts to `submitted` then `paid`, **posts the payout ledger group** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`, per payout, balanced, append-only) via b10's helper, and **marks each netted clawback `recovered`** with `recovered_in_payout_id` + `resolved_at`. Batch ends `completed` (all paid) or `partially_failed` (some failed). **Idempotent:** carries an `idempotencyKey` so a retried call never re-submits an already-`paid` payout or double-posts the ledger. |
| **`RetryFailedPayoutCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/retry` | Re-submits a single `failed` payout (holiday-aware: won't submit on a closed day), updating `transfer_reference`/`status`. Idempotent on the same key. |
| **`MarkPayoutFailedCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/mark_failed` | Records `failure_reason`/`failure_notes`, sets `status='failed'`. Used on a reconciled bank rejection. Does **not** post a ledger movement (no money left). |
| **`GetBatchDetailQuery`** | Query | `GET api/v1/admin_payouts/batches/{id}` | Batch header + its payouts (status, net, transfer_reference) + per-payout linked bookings. Projected, paginated. |
| **`ListPayoutBatchesQuery`** | Query | `GET api/v1/admin_payouts/batches?status=&page=&page_size=` | Admin reconciliation list. Projected + paginated. |
| **`GetNursePayoutHistoryQuery`** | Query | `GET api/v1/nurse_payouts/history?page=&page_size=` | The **nurse's own** payouts (tenancy-scoped to `ICurrentUser`): status, `net_amount_irr`, `transfer_reference`, `paid_at`, masked IBAN, any clawback applied. Projected + paginated. Feeds f12's earnings screen. |

- **Controllers:** `AdminPayoutsController` (admin policy, payout-sensitive endpoints **rate-limited**)
  and `NursePayoutsController` (nurse policy, tenancy-scoped). Both `sealed : BaseController`, inject
  `ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes,
  `CancellationToken` threaded.
- **Validators:** FluentValidation on `GeneratePayoutBatchCommand` (period_start ≤ period_end, not in the
  future) and the id-bearing commands.

### 3.3 DEFERRED (build the seam/flag, not the feature)
- **`SchedulePayoutJob`** — the recurring **weekly cron trigger** (PAYA-aligned). DEFERRED: batches are
  admin-triggered now. Leave a clean entry point (the `GeneratePayoutBatchCommand` the cron will call) and
  a config key for the cadence; note it in the report. (Roadmap: a hosted scheduler later.)
- **On-demand / instant nurse withdrawal**, **per-nurse configurable payout frequency**, **automated
  clawback recovery beyond next-batch netting** — DEFERRED per [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) §(c).
- The **optional BNPL `settled_at` timing guard** (don't pay before BNPL cash is actually received) —
  expose it as a **config flag** (`require_bnpl_settlement_for_payout`, default off) and apply it in the
  eligibility predicate when set; do not hard-couple payouts to BNPL settlement. Note in the report.

## 4. Mocks & seams in this phase

| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBankTransferProvider`** | **introduced here** | `SubmitPayoutBatchAsync(PayoutBatchId, IReadOnlyList<PayoutInstruction>, idempotencyKey, ct)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference`, status `Submitted` then `Paid` for all rows (**no money moves**); `GetPayoutStatusAsync(externalBatchRef, ct)` echoes `Paid`. **PAYA vs SATNA selection:** mock honours the `method` on each `PayoutInstruction` (choose SATNA for high-value rows above a config threshold, else PAYA) and records it. A config switch can force a deterministic **failure** (closed-day / insufficient-provider-balance) so `partially_failed`/retry paths are testable. | **add a new row** (🟡) |
| `IHolidayCalendar` | reuse from **b1** | static seeded `iranian_holidays`; used to shift `period_end`/`processing_date` off `is_bank_closed` days. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `iban_snapshot`, never logs plaintext. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(payout:batch)` + `lock(nurse:{id}:payout)`. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor. | reuse row |

The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later); a
real `JibitBankTransferProvider` / `VandarPayoutProvider` selection is config-driven, **never** an
`if (mock)` branch in a handler. Append the `IBankTransferProvider` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — Jibit transferor / Vandar
payout API, registered source settlement account, each nurse's verified Sheba, PAYA-vs-SATNA selection,
batch caps/minimums, the reconciliation callback that flips `submitted → paid/failed`).

## 5. Critical rules you must not get wrong

**Money correctness is sacred — the following must hold verbatim:**

- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`gross_earnings_irr`, `clawback_applied_irr`,
  `net_amount_irr`, `amount`, `payout_amount_irr`, `total_amount`) is `long`/`BIGINT`. No float path.
- **One payout per booking — `nurse_payout_booking_links.booking_id` UNIQUE is the hard guard; never pay
  a booking in two batches.** A duplicate insert is the already-paid signal; treat it as not-eligible and
  continue. Do not bypass the constraint across batches.
- **Payout eligibility requires `dispute_window_ends_at` (or per-session `payout_eligible_at`) passed AND
  no open dispute — never pay on `completed` alone.** EVV completion alone is not enough; the dispute
  window must have closed.
- **Net prior clawbacks before transfer:** `net_amount = gross_earnings − clawback`, and **mark recovered
  clawbacks** (`status='recovered'`, `recovered_in_payout_id`, `resolved_at`). Don't overpay a nurse who
  owes money back. If pending clawbacks exceed this batch's earnings, net to **zero** (never negative) and
  leave the remainder `pending` for the next batch.
- **`'paid'` derives from a `nurse_payout_booking_links` link row + a ledger movement out of
  `nurse_payable` — the `payout_released` boolean is gone.** Never reintroduce it; never infer paid-state
  from a status flag alone.
- **Append-only, balanced ledger.** The payout posts `DEBIT nurse_payable nurse_payout_amount` /
  `CREDIT escrow_held nurse_payout_amount` per payout, in one `transaction_group_id`, Σdebit = Σcredit,
  via b10's helper. Never UPDATE/DELETE a ledger row; corrections are new balancing postings. The nurse's
  payable balance is **derived from the ledger and may go negative** (clawbacks) — don't clamp it to zero.
- **Holiday-aware shifting:** a Nowruz batch must move off bank-closed days. Shift `period_end` and
  `processing_date` to the next `is_bank_closed=0` day via `IHolidayCalendar`, or PAYA/SATNA fails.
- **`total_amount = Σ payouts`** must hold per batch (CHECK / verified invariant); `payout_count =
  COUNT(payouts)`.
- **Gross = commission + payout:** the payout amount is `gross_price_irr − balinyaar_commission_irr` (the
  booking's own split), **never** a BNPL provider's `settled_amount_irr`; `bnpl_commission_irr` is a
  platform expense and **never touches the nurse**. The nurse's pay is invariant to payment method.
- **Webhook / transfer idempotency:** the bank submit carries an `idempotencyKey` and the payout `status`
  state machine (`pending → submitted → paid`) is forward-only, so a retried `ExecutePayoutBatchCommand`
  **never double-sends an irreversible transfer** or double-posts the ledger. Redis `lock(payout:batch)`
  is the fast first line; the status state machine + the link UNIQUE are the authoritative backstop.
- **First payout is gated on the nurse's `matched_national_id`** (b3): only a verified primary IBAN
  (`is_primary=1 AND is_verified=1 AND matched_national_id=1`) may receive a transfer. Snapshot that IBAN
  into `iban_snapshot` (encrypted) and store the `transfer_reference` for reconciliation.
- **Real bank transfers are effectively irreversible** — which is *why* payout is dispute-window-gated and
  refund-after-payout falls back to a clawback (b11), not a transfer reversal. Treat the execute step as
  the point of no return.
- **Tenancy:** `GetNursePayoutHistoryQuery` is scoped to the authenticated nurse via `ICurrentUser`; a
  nurse can never read another nurse's payouts. Admin endpoints sit behind the admin policy and are
  rate-limited.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The three tables (`nurse_payout_batches`, `nurse_payouts`, `nurse_payout_booking_links`) exist via
      one migration, each with its `IEntityTypeConfiguration<T>`, the `booking_id` UNIQUE index, the
      `net_amount_irr = gross_earnings_irr − clawback_applied_irr` and `total_amount = Σ payouts`
      invariants, encrypted `iban_snapshot`, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
      validators), with `AdminPayoutsController` + `NursePayoutsController`.
- [ ] **`IBankTransferProvider`** introduced (Application interface, Infrastructure mock, DI registration
      via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Eligibility predicate is correct (completed + dispute-window/`payout_eligible_at` passed + no open
      dispute + not already linked); clawback netting + `recovered` marking works; the payout ledger group
      posts balanced out of `nurse_payable`; holiday shifting works.
- [ ] Handler unit tests (NSubstitute) for eligibility selection, clawback netting, the duplicate-link
      guard, ledger posting, and holiday shifting; ≥1 `WebApplicationFactory` integration test per
      controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings;
      `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Payouts/**` area is reflected in the **Project map** in
      `server/CLAUDE.md`; the `IBankTransferProvider` seam noted where seams are documented.
- [ ] The contract `dev/contracts/domains/payouts.md` written and the `swagger.json` snapshot republished.

## 7. How to test (what a human can verify after this phase)

Seed (or reuse from prior phases) a few **completed** bookings: some with `dispute_window_ends_at` in the
**past** (eligible), some in the **future** (not yet), one **disputed**, and one with a **pending
clawback** on the nurse. Ensure one nurse has a verified primary IBAN and one does not.

1. **Eligibility preview** — `GET api/v1/admin_payouts/eligible?period_start=…&period_end=…` →
   **only** the completed-and-dispute-window-closed, unpaid bookings appear, grouped by nurse; the
   future-window and disputed bookings are **excluded**; the nurse without a verified IBAN is flagged.
2. **Generate a batch** — `POST api/v1/admin_payouts/batches` → a `draft` batch with one `nurse_payouts`
   row per eligible nurse; the nurse with a **pending clawback** shows `clawback_applied_irr > 0` and
   `net_amount_irr = gross_earnings_irr − clawback_applied_irr`; `total_amount = Σ net_amount_irr`;
   `iban_snapshot` populated (encrypted).
3. **Double-pay guard** — attempt to generate a second batch covering the **same** bookings → those
   bookings are not re-selected (the `booking_id` UNIQUE link blocks it); no booking appears in two
   payouts.
4. **Holiday shift** — set `processing_date` to land on a seeded `is_bank_closed=1` Nowruz day → the batch
   `period_end`/`processing_date` is shifted to the next business day.
5. **Execute** — `POST api/v1/admin_payouts/batches/{id}/process` → payouts go `submitted → paid` with a
   `transfer_reference`; the **ledger** shows a balanced `DEBIT nurse_payable` / `CREDIT escrow_held` per
   payout (verify `GetNursePayableBalance` drops by the paid amount); the **netted clawback is marked
   `recovered`** with `recovered_in_payout_id` set.
6. **Idempotency** — re-`process` the same batch → no second transfer, no second ledger posting (statuses
   already `paid`).
7. **Failure / retry** — flip the mock to force a failure → batch ends `partially_failed`; `POST
   …/{payout_id}/retry` re-submits and (with the mock back to success) flips to `paid`.
8. **Nurse history** — `GET api/v1/nurse_payouts/history` as the nurse → their payouts with masked IBAN,
   net amount, transfer reference, and clawback explanation; **another nurse's payouts are not visible**.

## 8. Hand off & document (close the phase)

- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
  `Features/Payouts/**` area + the `IBankTransferProvider` seam); if you discover/confirm a rule the
  product docs don't capture (e.g. the clawback-exceeds-earnings → net-to-zero behaviour, or the
  `require_bnpl_settlement_for_payout` flag default), record it in
  [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/payouts.md`** (per
  [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the admin payout
  endpoints (eligible/preview, create batch, process, retry, mark-failed, batch detail, list) and the
  nurse `…/history` endpoint; the `PayoutBatchStatus` / `PayoutStatus` enums; the batch/payout/link DTO
  shapes (IRR `BIGINT`, **masked** `iban_snapshot`); auth/rate-limit/idempotency notes; the
  one-payout-per-booking and dispute-window-gating side-effects. Republish the `swagger.json` snapshot per
  [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f12-b13**
  consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-13.md` (the
  payout engine is live, what f12 can now build — nurse earnings/payout history, admin payout console —
  which endpoints/contracts are live, that the bank rail is mocked behind `IBankTransferProvider`), append
  to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-13-report.md` (what was
  built, **what is now testable and exactly how** per §7, what is mocked + how to make it real,
  contracts produced, follow-ups: the cron scheduler, the BNPL `settled_at` guard, on-demand withdrawal),
  and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankTransferProvider` row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
  one-payout-per-booking UNIQUE guard, the clawback-netting + `recovered_in_payout_id` flow, the
  `'paid'`-derives-from-link+ledger rule (no `payout_released`), holiday-aware shifting, and the
  `IBankTransferProvider` seam — with a one-line pointer in `MEMORY.md`.
