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

119 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.