add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
@@ -0,0 +1,334 @@
# 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.