# Frontend Phase 12 — Nurse earnings & payout history > **Mission:** give a nurse a clear, trustworthy view of the money they have earned and when it > arrives. Build the **nurse earnings** screen that distinguishes the four money states a nurse cares > about — **pending** (still in escrow, dispute window open), **eligible** (cleared, awaiting the weekly > batch), **paid** (transferred, with a `transfer_reference` and `paid_at`), and **clawback-applied** > (a refunded-after-payout amount netted out of the total) — plus the **payout history** list and a > **batch detail** view. Money is read-only here (nurses don't trigger transfers); the job is to render > the ledger-derived numbers correctly, explain the weekly cadence and the dispute-window gate in plain > Persian, and never confuse "earned" with "paid". > > **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking > detail · sessions · EVV completed-work view) + the **b13** payouts contract > ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md)) · **Unlocks:** — > (last money-path frontend phase; the nurse earnings surface other phases link into) > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits This is the **last money-path frontend phase**. The customer-side money flows are already built (checkout/payment in f9, refund/cancellation in f10, BNPL in f11); this phase finally closes the loop on the **nurse** side — *"I did the work, where is my money?"*. A nurse completes visits (EVV check-out in f8), the booking enters a **72h dispute window**, then the amount becomes eligible for the **weekly payout batch**, which an admin runs (b13). Once the batch processes, the nurse sees the transfer landed against their verified primary IBAN. This phase renders all of that as a read-only nurse view — no nurse ever initiates a transfer, retries a payout, or runs a batch (those are admin actions in b13 / f15). **What already exists (do not rebuild) — link the prior phases:** - **Foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the **nurse app shell** and its route group/segment, role-aware nav from `AuthContext`, the `services/{domain}` + TanStack Query caching pattern (copy the `auth` service shape), the contracts→`types.ts` step, the shared composite components (status chip, card, stepper/progress header), the **money/format util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display), and the i18n namespace conventions. **Reuse the money util and the status chip — do not re-implement them.** - **Booking detail · sessions · EVV** ([`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md)): the nurse's view of completed visits, the per-session schedule, the EVV check-in/out flow, and the booking status timeline. Earnings rows **link back to** these booking/session screens; do not duplicate the booking detail here — deep-link to it. - **Checkout & refund money UI** ([`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md), [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)): the price-breakdown / money-display conventions on the customer side. Match the same Toman-display + IRR-string handling; **reuse the same money util**, don't fork a second formatter. - **The contract** ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md), produced by **b13**): the exact request/response shapes, routes, status codes, and enum codes for the nurse earnings & payout endpoints. This is the **source of truth for types** — do not guess shapes. > **Read note:** the file [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) is the prior nurse > phase you build on; if it is not yet on disk when you run, rely on its handoff > (`dev/shared-working-context/reports/frontend-phase-8-report.md`) and the nurse-shell facts from f0. ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work and the tick-list this phase is graded against. - [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary, the `services/{domain}` shape, TanStack Query caching/invalidation, i18n in both locales, tokens-based colours, RTL, the `App*` library). Non-negotiable. - **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract (palette, tokens, typography, the `App*` library, status-chip styling, the money-display look, empty/loading/error treatments, RTL mirroring). **All UI in this phase goes through it.** The four earnings states must be visually distinct and instantly readable; the designer skill owns how. - **Business truth — read before designing anything:** - [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — the weekly batch model, the **EVV + dispute-window eligibility gate**, the **payout amount = `gross_price_irr − balinyaar_commission_irr`** rule, clawback netting (`gross_earnings`, `clawback_applied`, `net_amount`), one-payout-per-booking, holiday-aware scheduling, verified-IBAN payout with `transfer_reference`. This is *why* each state exists. - [`../../../product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) — Q2 ("who pays the nurse, and when"): the nurse is paid by **Balinyaar** on the **normal weekly schedule after the dispute window closes**, the **same amount whether the family paid by card or BNPL** (the BNPL provider commission is a Balinyaar expense, **never** deducted from the nurse). The worked example (gross 5,000,000 → nurse 4,250,000) is the copy you explain to the nurse. - **The contract you consume:** [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md) — the b13 nurse-read endpoints and shapes. Also read the cross-cutting conventions: [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes, pagination `page`/`page_size`, status codes) and [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (**IRR as integer string on the wire**, parse with integer-safe helpers, Toman is display-only, UTC timestamps → Shamsi on the client, enums as stable string codes). - **Code to mirror:** `client/src/services/auth/*` (the canonical `types.ts`/`keys.ts`/ `apis/clientApi.ts`/`hooks/use*.ts`/`index.ts` shape every new domain copies) and the f0 money/format util + status-chip component. The nurse earnings list pattern (paginated, status-filtered) mirrors the nurse booking list from f8. ## 3. Scope — build this Build the **`payouts` domain service** (nurse read) and the **two nurse screens** it feeds. Everything admin-side (create/process/retry a batch, the clawback write-off queue, eligible-earnings preview) is **(DEFERRED)** to the admin console — see §3.5. ### 3.1 `services/payouts` domain (nurse read) Copy the `auth` service shape into `client/src/services/payouts/`: - **`types.ts`** — string-literal union types and response shapes **derived from [`payouts.md`](../../contracts/domains/payouts.md)** (not guessed). Expect at least: - an **earnings-summary** shape — the four-bucket roll-up: `pending_total_irr`, `eligible_total_irr`, `paid_total_irr`, `clawback_outstanding_irr`, and a derived **payable/net balance** (which **may be negative** when clawbacks exceed earnings — model it as a signed string, never clamp to zero). - an **earnings-item** shape — one row per completed booking/session contributing to earnings: `booking_id` (+ enough to deep-link), `gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount` (= gross − commission), an **earnings state** enum (`pending` | `eligible` | `paid` | `clawback_applied`), `dispute_window_ends_at` (for the pending countdown/explanation), `payout_eligible_at`, and — when paid — `paid_at` + `transfer_reference` + `nurse_payout_id` / `batch_id`. - a **payout (history) item** shape — one row per `nurse_payouts`: `id`, `batch_id`, `gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr` (= gross − clawback), `amount` (actually transferred), `iban_snapshot` (masked, last-4 only), `transfer_reference`, a **payout status** enum (`pending` | `processing` | `paid` | `failed`), `paid_at`, `failure_reason`. - a **batch detail** shape — the `nurse_payout_batches` context for one payout: `period_start`/ `period_end` (holiday-shifted), `status`, `total_amount`, `payout_count`, `processed_at`, and the list of **booking links** (`nurse_payout_booking_links`) so the nurse sees exactly which bookings a payout covered. - paginated list envelopes (`items` + `total` + `page`/`page_size`) per the api-conventions. - **Use string-literal unions for every enum**; never hardcode a display label off a code — labels are i18n keys. - **`keys.ts`** — a query-key factory: `payoutKeys.all`, `payoutKeys.earningsSummary()`, `payoutKeys.earningsList(filters)` (key includes the state filter + page so each filter caches separately), `payoutKeys.history(page)`, `payoutKeys.batchDetail(payoutId|batchId)`. - **`apis/clientApi.ts`** — a `PayoutsClientApi` namespace wrapping `clientFetch` for the nurse-read routes (exact paths from the contract; expected from b13): - `GET .../get_nurse_earnings_balance` → earnings summary (the four buckets + net balance). - `GET .../get_nurse_earnings?state=&page=&page_size=` → paginated earnings items, filterable by state. - `GET .../get_nurse_payout_history?page=&page_size=` → paginated payout history. - `GET .../get_nurse_payout/{id}` (or batch detail) → one payout + its batch context + booking links. - (Routes are `snake_case`; derive the exact segments from the published contract — don't assume.) - Add a `serverApi.ts` **only** if an RSC prefetches the summary for first-paint (optional; see 3.4). - **`hooks/`** — one hook per file: `useNurseEarningsBalance.ts` (`useQuery`), `useNurseEarnings.ts` (`useQuery`, takes the state filter + page), `useNursePayoutHistory.ts` (`useQuery`, paged), `useNursePayoutDetail.ts` (`useQuery` by id). All **read-only `useQuery`** — there are **no mutations** in this phase (nurse never writes payout state). Set a deliberate `staleTime` (earnings move slowly — a generous `staleTime`, e.g. minutes, avoids needless refetch). - **`index.ts`** — re-export the **hooks only** (not `types`/`keys`/`apis`), per the client barrel rule. ### 3.2 Nurse earnings screen The nurse's money home, in the nurse shell. Composition: - A **balance header** (the f0 money util formats every amount; Toman display, Shamsi where dates show): the **net payable balance** prominently, with the four-bucket breakdown beneath — **pending / eligible / paid (lifetime) / clawback outstanding**. When the net balance is **negative** (clawbacks exceed earnings), show an explicit **"owed back" state** (don't render a bare minus sign as if it were a positive amount) — the designer skill owns the visual. - A short, plain-Persian **explainer** of the cadence and gate (an info callout / collapsible "how payouts work"): *paid in weekly batches; an amount becomes eligible only after the visit is verified and the 72-hour dispute window closes; the same amount whether the family paid by card or installments.* This copy comes straight from `product/business/10-payouts.md` + `cancellation-and-payout.md` — both i18n keys, both locales. - A **state-segmented earnings list** (tabs/segmented control filtering by earnings state → `useNurseEarnings(state, page)`), each item a **shared earnings-row component** showing the booking reference, the **three-amount breakdown** (gross / commission / nurse payout) via the price-breakdown primitive, the **earnings-state chip** (reuse the f0 status chip), and the state-specific affordance: - **pending** → "in escrow · dispute window open" with the time-to-eligible derived from `dispute_window_ends_at` (display-only; the *server* decides eligibility — never compute eligibility client-side, only render the countdown). - **eligible** → "cleared · awaiting the next weekly batch" (+ an estimated window if the contract supplies one; otherwise generic copy — never invent a date). - **paid** → "paid" with `paid_at` (Shamsi) + the `transfer_reference`; links to the payout detail. - **clawback_applied** → a **net explanation**: the original earned amount, the clawback amount, and the resulting net — so the nurse understands *why* a paid total is lower than expected (a booking was refunded after payout). Link to the refund/booking context. - Each row **deep-links** to the booking/session detail from f8 (don't rebuild it). - **Empty / loading / error** states for the list (loading skeletons; "no earnings yet" empty; a retry affordance on error — but **don't** toast 401/403/5xx in the hook; the fetch layer already does). ### 3.3 Payout history list + batch detail - **Payout history** — a paginated list (`useNursePayoutHistory`) of the nurse's `nurse_payouts`, newest first: per row the **net amount transferred**, the **payout-status chip** (`pending`/`processing`/`paid`/`failed`), `paid_at` (Shamsi), the masked IBAN (last-4 only — it is an encrypted/masked field), and the `transfer_reference`. A **failed** payout shows its `failure_reason` as an informational banner (read-only — the nurse cannot retry; retry is an admin action). - **Payout / batch detail** — one payout expanded (`useNursePayoutDetail`): the batch period (holiday-shifted `period_start`/`period_end`, Shamsi), `processed_at`, the **money decomposition** (`gross_earnings_irr` − `clawback_applied_irr` = `net_amount_irr`; `amount` actually transferred), the `transfer_reference`, and the **list of bookings this payout covered** (the `nurse_payout_booking_links` rows), each deep-linking to its booking detail. This is the nurse's reconciliation view — "this transfer paid for these specific visits." - **Empty / loading / error** for both (loading skeletons; "no payouts yet" empty; error retry). ### 3.4 Caching & data-flow rules (this is graded) - All reads go through **`clientFetch` in `services/payouts/apis`** — never raw `fetch()`. - **TanStack Query with deliberate keys + `staleTime`**: the summary and lists key separately (state filter + page are part of the key) so switching the state tab or paging never refetches data already in cache. A generous `staleTime` is correct here (earnings change on a weekly cadence, not per second). - **No needless refetch / re-render**: subscribe to slices with `select` where a screen needs only part of the payload; keep the state-filter tab state colocated low; stable references for row callbacks. - **No mutations** ⇒ no invalidation logic to write this phase; if a future phase adds a nurse action, it invalidates `payoutKeys` then. Optionally **prefetch the summary in the nurse-shell RSC** for a no-flash first paint (via `serverApi.ts` + `initialData`/hydration) — only if it removes a real round-trip and respects the RSC/client boundary. ### 3.5 Out of scope (DEFERRED — do not build here) - **Admin payout console** — create/process/retry batch, the eligible-earnings preview wizard, the clawback write-off queue, per-payout failure retry → **(DEFERRED** to [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md), the admin & partner console). - **Nurse bank-account add/verify (استعلام شبا) UI** — the add-IBAN → pending-verification → verified/ failed flow → **(DEFERRED**; built in the nurse onboarding/profile phase [`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md). This phase only *displays* the masked `iban_snapshot` on a payout; it never edits bank accounts.) - **On-demand / instant withdrawal**, per-nurse payout-frequency settings → **(DEFERRED** product-side; MVP is weekly batches only — see `product/business/10-payouts.md` (c)). - **Computing eligibility or payout dates on the client** → never; the **server** owns eligibility and holiday-shifted dates. The client only renders what the contract returns (see §5). ## 4. Mocks & seams in this phase - **No new seam is introduced here.** This phase consumes the b13 nurse-read endpoints; the bank-transfer rail (`IBankTransferProvider`) and IBAN-ownership (`IIbanOwnershipVerifier`) seams live **server-side** and were introduced in **b13** — the frontend never touches them. - **If the b13 contract isn't merged when you run** (or a needed shape is missing): build a **mock `PayoutsClientApi`** behind the **same `services/payouts` seam** (the namespace object the hooks call), returning real-shaped fixtures that cover **all four earnings states** + a **negative net balance** (clawback > earnings) + a **failed** payout, so every UI state is exercisable. Then: - append the missing/needed shape to [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) (you **request** it; you never edit backend files), and - record the mock in your phase report + the [`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it's swapped for the real `clientApi` cleanly once b13 lands (per operating-rules §6–7). The hooks/screens stay unchanged on swap — only the `apis/clientApi.ts` implementation flips. ## 5. Critical rules you must not get wrong **Money correctness (verbatim, the sacred invariants across b9–b13):** money is **IRR `BIGINT`, no floats** — parse the wire integer string with the integer-safe util, never `Number()`/float math; **Toman is display-only**. The three booking amounts always satisfy **gross = commission + payout** (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`); render the breakdown so it sums. The ledger is an **append-only, balanced double-entry ledger** — the nurse's **payable balance is derived from the ledger and may go negative** (don't clamp it to zero); a clawback **nets**, it does not auto-reverse. Payout gating is **dispute-window gating**: an amount is eligible only after EVV completion **AND** `dispute_window_ends_at < now()` — never show "eligible"/"paid" for an amount still in its dispute window, and **never compute eligibility on the client**. **One payout per booking** (`nurse_payout_booking_links.booking_id` is UNIQUE) — a booking appears in exactly one payout; the batch detail's booking links reflect that, don't double-count. **Webhook idempotency** is a server concern, but its consequence on the client is real: never assume a settlement/transfer is instant — render the status the contract returns (`pending`/`processing`/`paid`/`failed`), not an optimistic "done". **Payout-amount rule (do not get this wrong):** the nurse payout is **`gross_price_irr − balinyaar_commission_irr`**, identical whether the family paid by **card or BNPL**. The **BNPL provider commission (`bnpl_commission_irr`) is a Balinyaar expense and is NEVER deducted from the nurse** — never surface it on the nurse earnings screen, never subtract it from a nurse amount. The nurse's number is payment-method-invariant. **Read-only & authority:** the nurse view is **strictly read-only** — no transfer, no retry, no batch action, no eligibility computation. The **server is the only authority** on eligibility, holiday-shifted dates, and amounts; the client renders the contract's values and only ever *displays* a countdown derived from `dispute_window_ends_at` (cosmetic, never a gate). **PII / masking:** `iban_snapshot` is an encrypted/masked field — show **last-4 only**, never a full IBAN; `transfer_reference` is an opaque string shown for reconciliation. Don't log full sensitive values. **Frontend invariants:** respect the **RSC/client boundary** (no `next/headers`/`next-intl/server`/ `@/lib/cookies/server` in client components); **design RTL-first**, `fa` default, **every string in both `en.json` and `fa.json`** in sync; **colours from `tokens.css`** (the four state chips use the `--bal-{success,warning,info,error}` semantic tokens, never hardcoded hex); **MUI v9 API only**, pre-built themes only; **MUI primitives stay MUI**, shared composites (earnings row, payout row, balance header) live at the **shared** level with a co-located `*.test.tsx`, not inline in the page. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus this phase's specifics: - [ ] `services/payouts/` exists in the `auth`-service shape (`types.ts` from the **contract**, `keys.ts`, `apis/clientApi.ts`, read-only `hooks/use*.ts`, hooks-only `index.ts`); no raw `fetch()`. - [ ] The **nurse earnings screen** renders the **net payable balance** (correct when **negative**), the four-bucket breakdown, the cadence/dispute-window explainer (both locales), and a **state-segmented earnings list** whose four states (**pending / eligible / paid / clawback_applied**) are visually distinct, each with the three-amount breakdown and the correct state affordance, deep-linking to the f8 booking detail. - [ ] The **payout history** list + **payout/batch detail** render the net decomposition (`gross_earnings − clawback_applied = net_amount`), the masked IBAN (last-4), the `transfer_reference`, `paid_at` (Shamsi), the status chip, a **failed** payout's `failure_reason`, and the **list of bookings each payout covered**. - [ ] **Empty / loading / error** states exist for both lists and the detail; hooks don't toast 401/403/5xx. - [ ] Caching is deliberate: state-filter + page are part of the query key, `staleTime` set sensibly, no needless refetch on tab/page switch; minimal re-renders. - [ ] All money via the **f0 money util** (IRR-string integer-safe parse → Toman display); the three amounts sum; no float math anywhere. - [ ] `en.json`/`fa.json` in sync; RTL-correct; colours from tokens (state chips off the semantic tokens). - [ ] `npm run check` green; `npm run test:ci` green for the shared components added (earnings row, payout row, balance header each have a co-located test). - [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/payouts` domain and any new shared components / nurse route segment; the `frontend-designer` skill was invoked for the visual work. ## 7. How to test (what a human can verify after this phase) Prereq: the b13 nurse-read endpoints are reachable (`npm run dev` against the API), **or** the mock `PayoutsClientApi` (§4) is active with fixtures covering every state. Then: 1. **Pending earnings.** As a nurse with a **just-completed** booking (EVV checked out, inside the 72h dispute window), open the earnings screen → the amount shows under **pending / "in escrow · dispute window open"** with a countdown derived from `dispute_window_ends_at`; the **net balance** includes it as pending, not as paid. *Expected:* no "eligible"/"paid" label while the window is open. 2. **Becomes eligible, then paid.** After the dispute window passes (or with a fixture past it), the item moves to **eligible / "awaiting the weekly batch"**. After a **(mock) batch processes** (b13 admin action / fixture), it shows as **paid** with a **`transfer_reference`** and **`paid_at`** (Shamsi), and it appears in **payout history**; the detail lists the exact booking(s) the payout covered. 3. **Clawback nets the total.** With a fixture where a booking was **refunded after payout**, the earnings row shows **clawback_applied** with the net explanation (original − clawback = net), and the **net payable balance** reflects the netting — when the clawback exceeds earnings, the balance renders as an explicit **negative / "owed back"** state (not a bare minus). 4. **Failed payout.** A fixture payout with status `failed` shows its `failure_reason` as a read-only banner in history/detail; **no retry control is present** for the nurse. 5. **Money correctness.** Spot-check a row: `gross − commission = nurse payout`; the displayed Toman equals the IRR string ÷ 10; no BNPL provider commission appears anywhere on the nurse view; the amount is identical for a card-funded vs BNPL-funded booking of the same gross. 6. **i18n / RTL / caching.** Switch `fa`↔`en` → all labels translate, layout mirrors correctly. Switch the state tabs and page the lists → React Query Devtools shows separate cache entries per filter/page and **no refetch** of data already loaded. 7. **Gate:** `npm run check` and `npm run test:ci` pass. ## 8. Hand off & document (close the phase) - **Docs to update:** `client/CLAUDE.md` *Project Structure* — add the `services/payouts` domain, the new shared earnings/payout/balance components, and any new nurse route segment. If you discover/decide any business rule the `product/` docs don't capture (e.g. an eligible-window estimate shown to the nurse), record it in [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't invent rules; record decisions and flag uncertain ones in your report. - **Contract:** *consume* [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md) — derive `types.ts` from it, do **not** guess shapes. Any missing/needed shape (e.g. the four-bucket summary, the eligible-window estimate, the booking-links payload on batch detail) is **appended** to [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) — you request it; the backend delivers it in a later change. - **Handoff & report:** append your phase summary to [`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write [`dev/shared-working-context/reports/frontend-phase-12-report.md`](../../shared-working-context/reports/frontend-phase-12-report.md) — what was built, **what is now testable and exactly how** (the §7 steps), what is mocked behind the `services/payouts` seam and how it swaps to the real b13 `clientApi`, contracts consumed, follow-ups (the deferred admin console + bank-account UI). Update [`dev/shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) if you mocked `PayoutsClientApi`. - **Memory:** save a `project` memory note for any non-obvious decision this phase made (the four earnings-state model + how each maps to a contract field, the negative-balance "owed back" treatment, the read-only nurse-view boundary vs admin actions), with a one-line `MEMORY.md` pointer. Don't record what the code/docs already make obvious.