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

**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.

## What was built
- **`payouts` schema, 3 tables** (one migration `NursePayoutEngine`):
  - `NursePayoutBatches` — weekly aggregation; `period_end`/`processing_date` holiday-shifted; `total_amount` /
    `payout_count`; `status` (`draft|processing|partially_failed|completed|failed`); `initiated_by_admin_id` FK.
  - `NursePayouts` — one per nurse per batch; DB CHECK `net = gross − clawback` + all ≥ 0; **encrypted
    `iban_snapshot`** (EF converter) frozen from the verified primary account; `status` (`pending|submitted|paid|failed`,
    forward-only); `transfer_reference`, `paid_at`, `failure_reason`.
  - `NursePayoutBookingLinks` — **unconditional `UNIQUE(booking_id)`** (the one-payout-per-booking-ever guard);
    nullable `session_id` for a future per-session model.
- **Domain:** `PayoutBatchStatus`/`PayoutStatus` + `*Transitions`; `LedgerPosting.NursePayout` (DEBIT nurse_payable /
  CREDIT escrow_held) + `LedgerPosting.ClawbackRecovery` (DEBIT nurse_payable / CREDIT nurse_clawback_receivable);
  `NurseClawback.Recover(payoutId, now)`.
- **Application:** `Features/Payouts/{Commands|Queries}` — `ComputeEligibleEarnings`, `GeneratePayoutBatch`
  (build+link inline), `ExecutePayoutBatch`, `RetryFailedPayout`, `MarkPayoutFailed`, `GetBatchDetail`,
  `ListPayoutBatches`, `GetNursePayoutHistory`; shared `PayoutSettlement` (ledger post + clawback netting).
  `IPayoutRepository` on `IUnitOfWork`.
- **Infrastructure:** `PayoutRepository`, `PayoutsConfig/*`, `MockBankTransferProvider` (+ `BankTransferOptions`),
  the authoritative `NursePayoutLinkStatusService` (swapped for the deleted interim `NursePayoutStatusService`),
  `iban_snapshot` encryption wired in `ApplicationDbContext`, 2 new `platform_configs` seeds.
- **API:** `AdminPayoutsController` (admin, rate-limited) + `NursePayoutsController` (nurse, tenancy-scoped).

## What is now testable and exactly how (per §7 of the phase)
Seed a few **completed** bookings via the admin flow: some with `dispute_window_ends_at` in the past (eligible), some
future (not yet), one disputed, one with a pending clawback; one nurse with a verified primary IBAN, one without.
1. **Eligibility preview** — `GET admin_payouts/eligible?periodStart=&periodEnd=` → only completed + dispute-window-
   closed, unpaid bookings appear, grouped by nurse; future + disputed excluded; the no-IBAN nurse flagged
   (`hasVerifiedPrimaryIban=false`). *(unit: `Preview_includes_only_closed_window_and_flags_missing_iban`)*
2. **Generate a batch** — `POST admin_payouts/batches` → a `draft` batch, one payout per eligible nurse; the nurse
   with a pending clawback shows `clawbackAppliedIrr>0` and `net = gross − clawback`; `total_amount = Σ net`;
   `iban_snapshot` populated (encrypted, served masked). *(unit: `Generate_materializes_one_payout_per_nurse_and_nets_clawback`,
   `Generate_skips_nurse_without_verified_primary_iban_with_reason`)*
3. **Double-pay guard** — a second generate over the same window doesn't re-select the linked bookings.
   *(unit: `Double_pay_guard_second_generate_does_not_reselect_linked_bookings`)*
4. **Holiday shift** — `period_end`/`processing_date` shift off a seeded bank-closed day.
   *(unit: `Holiday_shifts_period_end_and_processing_date`)*
5. **Execute** — `POST admin_payouts/batches/{id}/process` → payouts go `paid` with a `transfer_reference`; the ledger
   shows balanced `DEBIT nurse_payable / CREDIT escrow_held` per payout (the payable balance drops by the paid amount);
   a netted clawback is marked `recovered` with `recovered_in_payout_id`. *(unit:
   `Execute_posts_balanced_payout_ledger_and_drains_payable`, `Execute_recovers_clawback_and_posts_recovery_leg`)*
6. **Idempotency** — re-process → no second transfer / ledger group. *(unit: `Reprocess_is_idempotent_no_second_ledger_group`)*
7. **Failure / retry** — force a rail failure → `partially_failed`; `retry` (rail back to success) → `paid`, batch
   `completed`. *(unit: `Partial_failure_then_retry_completes_the_batch`)*
8. **Nurse history** — `GET nurse_payouts/history` as the nurse → their payouts (masked IBAN, net, reference);
   another nurse's are invisible. *(api: `NursePayoutsApiTests`)*

API happy-path/401/400: `AdminPayoutsApiTests` (generate→process pays the nurse; 401 unauth; 400 bad period; list)
and `NursePayoutsApiTests` (401 unauth; own paid payout with masked IBAN).

## What is mocked + how to make it real
- **`IBankTransferProvider`** (🟡) — PAYA/SATNA rail. Make real = a Jibit/Vandar/Sadad payout adapter with a
  registered source settlement account, per-nurse verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, and
  the async reconciliation callback that flips `submitted → paid/failed`. Config keys `Seams:BankTransfer:*`.
- **`INursePayoutStatus`** (🟢) — now the real link-based lookup (`NursePayoutLinkStatusService`).
- Reused mocks: `IHolidayCalendar`, `IFieldEncryptor`, `IDistributedLock`, `ICacheService`.

## Contracts produced
- `dev/contracts/domains/payouts.md` (new) · `dev/contracts/openapi/swagger.v1.json` refreshed (7 payout paths).

## Confirmed rules recorded (product/business/10-payouts.md §d1)
- Clawback netting recovers **whole** clawbacks up to a batch's earnings (never negative net, never a partial single
  clawback); a clawback larger than a batch's earnings waits for a later batch. Recovery is a real ledger movement.
- A booking with an active refund is held out of payouts (the operational reading of "no open dispute").
- `payout_satna_threshold_irr` picks PAYA vs SATNA; `require_bnpl_settlement_for_payout` (default off) gates BNPL.

## Follow-ups (deferred)
- The weekly **cron scheduler** (PAYA-aligned) — entry point is `GeneratePayoutBatchCommand`; cadence in
  `nurse_payout_interval_days`. On-demand/instant withdrawal; per-nurse payout frequency; automated clawback recovery
  beyond next-batch netting; the BNPL `settled_at` timing guard (flag shipped, off).

## Gate
`dotnet build Baya.sln` — 0 errors, 0 new code warnings (only pre-existing NU1510/NETSDK1057/NU1903).
`dotnet test Baya.sln` — 329 pass (223 foundation + 102 api + 4 identity), 0 fail.
