Files
2026-06-28 21:59:59 +03:30

306 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.