13 KiB
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 Integration: docs/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. "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 — 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 §(d1) |
| Eligibility gate | completed and dispute_window_ends_at < now (72 h) and no active refund on the booking |
business/10 §(a) + §(d1) |
| One payout per booking | UNIQUE(booking_id) on nurse_payout_booking_links — the DB is the authority, not an if |
business/10 §(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 §(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 §(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 step 6 |
| PAYA vs SATNA | net ≥ 1,000,000,000 IRR ⇒ SATNA, config payout_satna_threshold_irr |
the seed (PlatformConfigConfig.cs:53); business/10 §(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 §(a) |
| Generation is automatic, processing is not | WeeklyPayoutGenerationJob opens a draft; the irreversible process is always an explicit admin action |
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
- Log in as 09120000001 (nurse 1, the seeded nurse with real payout rows) — see testing-setup.md.
- Open
/fa/nurse/finance. Expect (today): the mock fixture balance, not8,500,000IRR. The masked IBAN on the following screens readsIR••••••••••••••••••4821(payouts/apis/mockApi.ts:51) — the live one ends9012. Seeing4821is the fastest way to prove you are looking at fixtures. - 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. - Open
/fa/nurse/earnings/payouts→ a row → the detail. Expect (today): fixture reconciliation, including afailedpayout with a reason the real path would have discarded (see gaps). - To see the truth instead, curl the four nurse endpoints with nurse 1's bearer (§API above). Expect:
balance
0 / 0 / 3187500 / 212500, net8500000; payout 3pendingindraftbatch 3. - 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. - 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, never0.
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. toHistoryItemhardcodesfailureReason: 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 fabricatesprocessingDate = periodEnd,holidayShifted: false,skipped: []— the client computing money and a payout date, against client hard rule 18.- No
processoperation exists anywhere inPayoutsApi. The irreversible step has no UI on either path;/fa/admin/payouts's confirm button callsPOST admin_payouts/batches(generate). - The mock's
runPayoutBatchreturns batchstatus: 'processing'with payoutssubmitted(payouts/apis/mockApi.ts:637,653) while the real generate returnsdraft— 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 seededsuper_admin/financeaccounts (DynamicPermissionService.CanAccessgrants on the literal roleadmin) — 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) returnspaidfor any un-clawed-back booking that merely has a payout link, regardless of that payout's status (the checks areclawback → payout → dispute window, and the payout branch never readsStatus) — booking 3 renders as «پرداختشده» whilepaidAt: null,transferReference: nulland payout 3 ispending. The nurse is told they were paid when no money moved.- Consequence of the above: booking 3's
1,700,000IRR falls into no bucket — notpending, noteligible(GetNurseEarningsBalanceQuery.Handler.cs:33-39), and notpaidTotal, which sums onlyStatus == Paidpayouts (PayoutRepository.cs:286-289). - The four buckets and the headline
netPayableBalanceIrrcome from different sources (booking projection vs. thenurse_payableledger sum) and do not reconcile: net8,500,000against buckets of0pending /0eligible /3,187,500paid /212,500clawback outstanding. Which is authoritative is undocumented, and the unreconciled number is the one on/fa/nurse/financeand the nurse dashboard. recordTransferReferencetargetsadmin_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-Keyon generate/retry (payouts/apis/clientApi.ts:233,259);AdminPayoutsControllernever reads it — decorative, and misleading to a reader. POST admin_payouts/{id}/mark_failedexists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console.holidayShiftedis alwaysfalse(payouts/apis/clientApi.ts:123) becausePayoutBatchDtocarries 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:7maps 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-11andpayouts/apis/clientApi.ts:149-161both claimearnings_balance,earningsand{id}do not exist server-side. All three returned 200.