# Flow — Nurse earnings and payouts > Last verified: 2026-08-02 against commit `c841bde` **Actor(s):** nurse (read-only) · admin/finance (runs the batch) · the weekly scheduler · **Status:** mocked **Client:** mock · **Server:** partial (all 4 nurse reads verified live; every admin op 403s) **Business source:** [product/business/10-payouts.md](../../product/business/10-payouts.md) **Integration:** [docs/integration/domains/payouts.md](../integration/domains/payouts.md) ## What it does A nurse wants one answer: *how much am I owed, and when does it land?* Money accrues per completed booking, becomes eligible once the 72-hour dispute window closes, and is swept weekly into a payout batch against the nurse's verified primary IBAN. Admin/finance opens the batch and — as the one irreversible, human-approved step on the platform — submits it to the bank rail. **This is the sharpest "real server, mocked client" case in the product.** All four nurse endpoints are live and return real data (probed below); `USE_PAYOUTS_MOCK = true` (`client/src/services/payouts/constants.ts:13`) means all six routed screens plus the nurse-dashboard widget render module-level fixtures instead. This confirms hardening issue [H-09](../../archive/post-phase/hardening/issues.md). **"Live" is not "correct":** two of the four reads are substantively wrong even at 200 — a booking with an unpaid payout is reported `paid`, and the headline net does not reconcile with the four buckets (see gaps). Flipping the flag exposes both. ## Screens | Step | Route | Component / notes | | --- | --- | --- | | 1 | `/fa/nurse/finance` | Group root. The single headline number: signed net payable, never clamped (`NurseFinanceScreen.tsx:66-68,76` — `parseIrr` → `BigInt`, then `isOwed ? -net : net` for magnitude + an `error` tone. **BigInt negation, not `Math.abs`** — IRR never touches a float) | | 2 | `/fa/nurse/earnings` | `EarningsBalanceHeader` four buckets + weekly/dispute explainer + state-segmented list (`all`/`pending`/`eligible`/`paid`/`clawback_applied`), `PAYOUTS_PAGE_SIZE = 10` | | 3 | `/fa/nurse/earnings/payouts` | `PayoutHistoryRow` list, newest first. No retry control — deliberate; retry is an admin action | | 4 | `/fa/nurse/earnings/payouts/[id]` | Reconciliation detail: `gross − clawback = net`, amount transferred, masked IBAN, transfer reference, `failureReasonLabelKey()`, and the bookings covered | | 5 | `/fa/admin/payouts` | Batch list + a Jalali period picker → preview dialog → confirm "run". **The confirm calls `POST admin_payouts/batches` (generate), not process** | | 6 | `/fa/admin/payouts/[batchId]` | Paginated payout rows, retry a `failed` payout, record a transfer reference | | — | (none) | The nurse dashboard `/fa/nurse` also reads the balance (`NurseDashboardScreen.tsx:220`) | Admin screens are additionally hidden behind `canPayout` (`super_admin`/`admin`/`finance`) — a UI hint only. ## API Shapes, enums and verdicts live in [payouts.md](../integration/domains/payouts.md) — not restated here. | Call | Endpoint | Live probe (2026-08-02, tokens from `tokens.env`) | | --- | --- | --- | | balance | `GET /api/v1/nurse_payouts/earnings_balance` | **200** nurse 1 → `0 / 0 / 3187500 / 212500`, net **`8500000`**; nurse 3 → all zeros | | earnings list | `GET /api/v1/nurse_payouts/earnings` | **200** nurse 1 → 3 items (bookings 3, 4, 8), states `paid`, `paid`, `clawback_applied` | | history | `GET /api/v1/nurse_payouts/history` | **200** nurse 1 → 2 payouts; **`failureReason` IS on the wire** (null here) | | payout detail | `GET /api/v1/nurse_payouts/{id}` | **200** payout 3 → `pending`, net `1487500`, batch 3 `draft`, `initiatedByAdminId: null` | | tenancy | same, nurse 2 (`09120000002`) reading payout 1 | **404** "Payout not found." — correct (a 403 would confirm the row). A **non-nurse** caller instead gets **403** "Only a nurse can read their payout." (`GetNursePayoutDetailQuery.Handler.cs:22,26,30`) | | eligible / batches / process / retry / mark_failed | `admin_payouts/*` | **403** "Authorization Error" for **both** `09120000020` (super_admin) and `09120000021` (finance) — see the RBAC gap below | | ledger balance | `GET /api/v1/nurses/{id}/payable_balance` | **200** → `8500000`. Not consumed by the client; tenancy is enforced in the handler (`GetNursePayableBalanceQuery.Handler.cs:22-29`) | | webhook | `POST /api/v1/webhooks/payouts/{provider}` | server-only reconciliation callback; not client-reachable | Client chain, verified link by link: page → `services/payouts` barrel (`index.ts`) → `hooks/useNurseEarningsBalance.ts:12` → `apis/index.ts:10` (the one seam, `USE_PAYOUTS_MOCK ? mock : client`) → `apis/clientApi.ts:163` → `clientFetch` → `NursePayoutsController.cs:33` → `GetNurseEarningsBalanceQueryHandler`. Every link exists. **The seam selector picks the mock**, so the real link is never traversed at runtime. ## Rules that must hold | Rule | Value | Source | | --- | --- | --- | | Payout cadence | weekly, config `nurse_payout_interval_days = 7` | [business/10](../../product/business/10-payouts.md) §(d1) | | Eligibility gate | `completed` **and** `dispute_window_ends_at < now` (72 h) **and** no active refund on the booking | [business/10](../../product/business/10-payouts.md) §(a) + §(d1) | | One payout per booking | `UNIQUE(booking_id)` on `nurse_payout_booking_links` — the DB is the authority, not an `if` | [business/10](../../product/business/10-payouts.md) §(a), §(d) | | **Two different "net"s — do not conflate** | a *payout's* `net_amount = gross − clawback_applied` is clamped **≥ 0**; the *nurse's* `netPayableBalanceIrr` is the ledger sum and is **SIGNED, never clamped** — showing 0 for a debt lies | clamped: §(d1) · signed: [business/10](../../product/business/10-payouts.md) §(a) ("derived from the ledger — it may go negative") | | Clawback netting | **whole clawbacks only**, oldest-first, capped at the batch gross; a clawback bigger than the batch stays fully `pending` | [business/10](../../product/business/10-payouts.md) §(d1) | | IBAN gate | verified **primary** IBAN gates *payment*, not accrual; a nurse without one is skipped with a recorded reason (`EligibleNurseEarningsDto.hasVerifiedPrimaryIban`, `GeneratePayoutBatchResult.skipped`) | [business/02](../../product/business/02-nurse-verification.md) step 6 | | PAYA vs SATNA | net ≥ `1,000,000,000` IRR ⇒ SATNA, config `payout_satna_threshold_irr` | the **seed** (`PlatformConfigConfig.cs:53`); [business/10](../../product/business/10-payouts.md) §(d1) names the config but carries no number | | Holiday shifting | period end + processing date shift server-side via `IHolidayCalendar`; **the client never computes one** | [business/10](../../product/business/10-payouts.md) §(a) | | **Generation is automatic, processing is not** | `WeeklyPayoutGenerationJob` opens a `draft`; the irreversible `process` is always an explicit admin action | [server/CLAUDE.md](../../server/CLAUDE.md) hard rule 12 | The scheduler half is **verified working in the live DB**: batch 3 was created `2026-07-29T02:58:32Z` with `initiatedByAdminId: null` and status `draft` — a system-initiated batch, exactly as `WeeklyPayoutGenerationJob.cs:48` sends `SystemInitiated = true` and `AdminPayoutsController.cs:47` forces it to `false` for API callers. **Note the business doc is stale here:** §(d1) still says "the weekly cron trigger is DEFERRED — batches are admin-triggered". Refinement phase 7 shipped it; the running server is the authority, not that line. ## How to test 1. Log in as **09120000001** (nurse 1, the seeded nurse with real payout rows) — see [testing-setup.md](testing-setup.md). 2. Open `/fa/nurse/finance`. **Expect (today):** the mock fixture balance, not `8,500,000` IRR. The masked IBAN on the following screens reads `IR••••••••••••••••••4821` (`payouts/apis/mockApi.ts:51`) — the live one ends `9012`. Seeing `4821` is the fastest way to prove you are looking at fixtures. 3. Open `/fa/nurse/earnings`, cycle the five tabs. **Expect (today):** four fixture rows for bookings 5001–5004. Tapping "view booking" on one 404s — those ids do not exist in the DB. 4. Open `/fa/nurse/earnings/payouts` → a row → the detail. **Expect (today):** fixture reconciliation, including a `failed` payout with a reason the real path would have discarded (see gaps). 5. **To see the truth instead**, curl the four nurse endpoints with nurse 1's bearer (§API above). **Expect:** balance `0 / 0 / 3187500 / 212500`, net `8500000`; payout 3 `pending` in `draft` batch 3. 6. Admin half, as **09120000021** (finance): `/fa/admin/payouts`. **Expect:** the mock batch list renders fine, but the same call against the API is **403**. Flipping the flag turns this console into a permission wall. 7. Set `MOCK_SCENARIO = 'clawback_heavy'` (`payouts/constants.ts:23`) and reload to exercise the negative-balance ("owed back") treatment. **Expect:** a red/`error`-toned magnitude on `/fa/nurse/finance`, never `0`. **Seeded-world caveat:** the world is 7 days stale, so nothing is `pending` or `eligible` — nurse 1's buckets are `0 / 0`. There is no way to observe a fresh accrual without re-seeding. Batch 3 stays `draft` forever because the process step both 403s and has no UI. ## Known gaps - `USE_PAYOUTS_MOCK = true` (`payouts/constants.ts:13`) suppresses four working nurse endpoints — H-09 confirmed, six screens + the dashboard widget show fixtures. - The mock's booking ids `5001–5004` (`payouts/apis/mockApi.ts:54`) deep-link into the now-real bookings screens; "view booking" 404s. - `toHistoryItem` hardcodes `failureReason: null` (`payouts/apis/clientApi.ts:60`) although the live wire carries it — on flip, a failed payout loses its reason in the nurse's history. - `previewPayoutBatch` (`payouts/apis/clientApi.ts:205-227`) sums net amounts client-side and fabricates `processingDate = periodEnd`, `holidayShifted: false`, `skipped: []` — the client computing money and a payout date, against client hard rule 18. - **No `process` operation exists anywhere in `PayoutsApi`.** The irreversible step has no UI on either path; `/fa/admin/payouts`'s confirm button calls `POST admin_payouts/batches` (generate). - The mock's `runPayoutBatch` returns batch `status: 'processing'` with payouts `submitted` (`payouts/apis/mockApi.ts:637,653`) while the real generate returns `draft` — the demo shows money moving that the real path never moves, collapsing generate and process into one click. - Every `admin_payouts/*` op returns **403** for the seeded `super_admin`/`finance` accounts (`DynamicPermissionService.CanAccess` grants on the literal role `admin`) — no one can process a batch today. - Draft batch 3 (system-generated, 1 payout, **1,487,500 IRR** to nurse 1) is therefore unpayable, indefinitely. - `DeriveEarningsState` (`PayoutRepository.cs:275-284`) returns `paid` for any un-clawed-back booking that merely **has a payout link**, regardless of that payout's status (the checks are `clawback → payout → dispute window`, and the payout branch never reads `Status`) — booking 3 renders as «پرداخت‌شده» while `paidAt: null`, `transferReference: null` and payout 3 is `pending`. The nurse is told they were paid when no money moved. - Consequence of the above: booking 3's `1,700,000` IRR falls into **no** bucket — not `pending`, not `eligible` (`GetNurseEarningsBalanceQuery.Handler.cs:33-39`), and not `paidTotal`, which sums only `Status == Paid` payouts (`PayoutRepository.cs:286-289`). - The four buckets and the headline `netPayableBalanceIrr` come from different sources (booking projection vs. the `nurse_payable` ledger sum) and do **not** reconcile: net `8,500,000` against buckets of `0` pending / `0` eligible / `3,187,500` paid / `212,500` clawback outstanding. Which is authoritative is undocumented, and the unreconciled number is the one on `/fa/nurse/finance` and the nurse dashboard. - `recordTransferReference` targets `admin_payouts/{id}/transfer_reference` (`payouts/apis/clientApi.ts:266`), which does not exist — the batch-detail reconcile field 404s on flip (REQ-036). - The client sends `Idempotency-Key` on generate/retry (`payouts/apis/clientApi.ts:233,259`); `AdminPayoutsController` never reads it — decorative, and misleading to a reader. - `POST admin_payouts/{id}/mark_failed` exists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console. - `holidayShifted` is always `false` (`payouts/apis/clientApi.ts:123`) because `PayoutBatchDto` carries no flag — the batch detail cannot say a date was shifted off a bank-closed day. - No payout forecast (REQ-053) — the nurse dashboard's "next batch" line renders nothing on the real path. - `failureReasons.ts:7` maps exactly one code (`invalid_sheba`); every other bank-rail reason falls back to the generic label. - Stale doc-blocks assert the opposite of reality: `payouts/constants.ts:5-11` and `payouts/apis/clientApi.ts:149-161` both claim `earnings_balance`, `earnings` and `{id}` do not exist server-side. All three returned 200.