27 KiB
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_referenceandpaid_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(booking detail · sessions · EVV completed-work view) + the b13 payouts contract (dev/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. 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): the nurse app shell and its route group/segment, role-aware nav fromAuthContext, theservices/{domain}+ TanStack Query caching pattern (copy theauthservice shape), the contracts→types.tsstep, the shared composite components (status chip, card, stepper/progress header), the money/format util insrc/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): 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-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, 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.mdis 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.mdand../_shared/frontend-conventions-checklist.md— how you work and the tick-list this phase is graded against.../../../client/CLAUDE.md— the engineering contract (RSC/client boundary, theservices/{domain}shape, TanStack Query caching/invalidation, i18n in both locales, tokens-based colours, RTL, theApp*library). Non-negotiable.- Invoke the
frontend-designerskill before any visual work — it is the design/brand contract (palette, tokens, typography, theApp*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— the weekly batch model, the EVV + dispute-window eligibility gate, the payout amount =gross_price_irr − balinyaar_commission_irrrule, clawback netting (gross_earnings,clawback_applied,net_amount), one-payout-per-booking, holiday-aware scheduling, verified-IBAN payout withtransfer_reference. This is why each state exists.../../../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— the b13 nurse-read endpoints and shapes. Also read the cross-cutting conventions:../../contracts/conventions/api-conventions.md(envelope, snake_case routes, paginationpage/page_size, status codes) and../../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 canonicaltypes.ts/keys.ts/apis/clientApi.ts/hooks/use*.ts/index.tsshape 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 frompayouts.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_batchescontext 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.
- an earnings-summary shape — the four-bucket roll-up:
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— aPayoutsClientApinamespace wrappingclientFetchfor 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.tsonly 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(useQueryby id). All read-onlyuseQuery— there are no mutations in this phase (nurse never writes payout state). Set a deliberatestaleTime(earnings move slowly — a generousstaleTime, e.g. minutes, avoids needless refetch).index.ts— re-export the hooks only (nottypes/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) + thetransfer_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).
- pending → "in escrow · dispute window open" with the time-to-eligible derived from
- 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'snurse_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 thetransfer_reference. A failed payout shows itsfailure_reasonas 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-shiftedperiod_start/period_end, Shamsi),processed_at, the money decomposition (gross_earnings_irr−clawback_applied_irr=net_amount_irr;amountactually transferred), thetransfer_reference, and the list of bookings this payout covered (thenurse_payout_booking_linksrows), 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
clientFetchinservices/payouts/apis— never rawfetch(). - 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 generousstaleTimeis correct here (earnings change on a weekly cadence, not per second). - No needless refetch / re-render: subscribe to slices with
selectwhere 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
payoutKeysthen. Optionally prefetch the summary in the nurse-shell RSC for a no-flash first paint (viaserverApi.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, 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. This phase only displays the maskediban_snapshoton 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
PayoutsClientApibehind the sameservices/payoutsseam (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(you request it; you never edit backend files), and - record the mock in your phase report + the
mocks-registry.mdso it's swapped for the realclientApicleanly once b13 lands (per operating-rules §6–7). The hooks/screens stay unchanged on swap — only theapis/clientApi.tsimplementation flips.
- append the missing/needed shape to
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, plus this phase's specifics:
services/payouts/exists in theauth-service shape (types.tsfrom the contract,keys.ts,apis/clientApi.ts, read-onlyhooks/use*.ts, hooks-onlyindex.ts); no rawfetch().- 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), thetransfer_reference,paid_at(Shamsi), the status chip, a failed payout'sfailure_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,
staleTimeset 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.jsonin sync; RTL-correct; colours from tokens (state chips off the semantic tokens).npm run checkgreen;npm run test:cigreen for the shared components added (earnings row, payout row, balance header each have a co-located test).client/CLAUDE.mdProject Structure updated for the newservices/payoutsdomain and any new shared components / nurse route segment; thefrontend-designerskill 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:
- 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. - 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_referenceandpaid_at(Shamsi), and it appears in payout history; the detail lists the exact booking(s) the payout covered. - 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).
- Failed payout. A fixture payout with status
failedshows itsfailure_reasonas a read-only banner in history/detail; no retry control is present for the nurse. - 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. - 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. - Gate:
npm run checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs to update:
client/CLAUDE.mdProject Structure — add theservices/payoutsdomain, the new shared earnings/payout/balance components, and any new nurse route segment. If you discover/decide any business rule theproduct/docs don't capture (e.g. an eligible-window estimate shown to the nurse), record it in../../../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— derivetypes.tsfrom 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 todev/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; writedev/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 theservices/payoutsseam and how it swaps to the real b13clientApi, contracts consumed, follow-ups (the deferred admin console + bank-account UI). Updatedev/shared-working-context/reports/mocks-registry.mdif you mockedPayoutsClientApi. - Memory: save a
projectmemory 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-lineMEMORY.mdpointer. Don't record what the code/docs already make obvious.