# 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.