Files
2026-06-28 21:59:59 +03:30

27 KiB
Raw Permalink Blame History

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 (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 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): 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.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 and ../_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, 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 — 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 — 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, pagination page/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 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 (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, 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 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 (you request it; you never edit backend files), and
    • record the mock in your phase report + the mocks-registry.md so it's swapped for the real clientApi cleanly once b13 lands (per operating-rules §67). 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 b9b13): 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 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 faen → 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 — don't invent rules; record decisions and flag uncertain ones in your report.
  • Contract: consume dev/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 — 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; write dev/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 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.