# 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`, 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}//`; entities in `Baya.Domain/Entities/Payouts/`; one `IEntityTypeConfiguration` 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, 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`, 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`.