Files
baya-monorepo/archive/docs/flows/nurse-earnings-and-payouts.md
T
2026-08-02 20:01:31 +03:30

13 KiB
Raw Blame History

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,76parseIrrBigInt, 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 2008500000. 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:12apis/index.ts:10 (the one seam, USE_PAYOUTS_MOCK ? mock : client) → apis/clientApi.ts:163clientFetchNursePayoutsController.cs:33GetNurseEarningsBalanceQueryHandler. 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

  1. Log in as 09120000001 (nurse 1, the seeded nurse with real payout rows) — see 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 50015004. 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 50015004 (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.