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

335 lines
27 KiB
Markdown
Raw Permalink 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.
# 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 §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](../_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.