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

343 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 9 — Checkout, card payment & invoice
> **Mission:** turn an accepted booking request into paid, confirmed money on the rails. Build the
> **C6 خلاصه و پرداخت** summary screen (the "✓ پرستار تایید کرد" badge, the reconciling
> service-cost / commission / tax / total breakdown, and the load-bearing escrow trust notice), then
> the **card payment** flow — initiate → mock gateway redirect → return → pending callback →
> succeeded → booking flips to **confirmed** — followed by the **confirmation** screen and a
> downloadable **invoice** with the VAT-on-commission line. This is the family's first real money
> moment in Balinyaar; the breakdown must reconcile to the rial and the escrow copy must build trust,
> because the whole anti-disintermediation thesis rests on payment happening *on-platform*.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) (booking
> detail / sessions / EVV) + backend phase **b10** (Payments core — the contract you consume) ·
> **Unlocks:** [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md) (refunds & cancellation),
> [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md) (BNPL checkout)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the payment seam of the customer journey. By the end of **f7-b8** the family can send a
booking request and a nurse can accept it; by the end of **f8-b9** there is a booking-detail screen,
session list, and EVV. What's missing is the bridge the wireframe calls **C6**: once the nurse has
accepted (`accepted_awaiting_payment`), the family sees the price, pays by card, and the
`booking_request` converts into a money-bearing `booking` that reaches **confirmed**. Backend **b10**
just shipped the money core (ledger, transactions, webhook idempotency, card capture → confirm →
convert) behind the `IPaymentProvider`/`IWebhookVerifier` seams; this phase is its frontend
counterpart. After this, **f10** can cancel/refund and **f11** can offer BNPL as an alternative to
the full-card path you build here.
**What already exists (do not rebuild) — from prior phases:**
- **The foundation (f0)** — the three actor shells + route groups, the `services/{domain}` + TanStack
Query caching pattern (copy the `auth` service shape), the contracts→types pattern, the **money/format
util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) that
**every** price in this phase renders through, the shared composite **price-breakdown** and
**status-chip** components, the i18n namespaces (including `payment`), and the RTL baseline. See
[`frontend-phase-0.md`](./frontend-phase-0.md).
- **Auth & role routing (f1-b2)** — `AuthContext` with roles; the customer app shell + 5-tab bottom nav.
- **Booking request flow (f7-b8)** — the request form (C4) and **awaiting-acceptance (C5)** status
tracker. C6 is the *next* node after that tracker's "در انتظار تایید پرستار" step resolves to
accepted; reuse C5's status-tracker component and the `booking`/`booking_requests` service shapes.
- **Booking detail & sessions (f8-b9)** — the `services/booking` domain (detail/list queries, status
timeline, the three-amount split already present on the booking payload), the booking status chip,
and the booking-detail route this phase links *back to* after confirmation. **Reuse this service**;
do not create a parallel booking service.
> **Out of scope here (DEFERRED):** the **BNPL** method/plan/eligibility/contract screens (D1D5) — that
> is [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md); this phase builds **only** the full-card
> path and must leave a clean "یا پرداخت اقساطی" seam on C6/D1 for f11 to attach to. **Cancellation &
> refund** (policy fee disclosure, refund status, BNPL ETA) is
> [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md). The admin-side refund console is **f15**. The
> مودیان (e-invoicing) registration state is a backend concern — surface it read-only if the contract
> exposes it, never drive it.
## 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, the gate, the contract/handoff lanes.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary,
`services/{domain}` + Query caching, the toast contract: do **not** toast 401/403/5xx in hooks, only
domain 4xx; cookies/constants rules; MUI v9 only). Non-negotiable.
- **Invoke the `frontend-designer` skill** — the design/brand contract (palette, the **terracotta**
financial accent vs **teal** brand, tokens, typography, the `App*` library, the mobile RTL shell, the
hard UI rules). **All visual work on C6, the redirect/pending/confirmation states, and the invoice goes
through it.** Do not hand-style money screens off-token.
- **The contract you consume:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(produced by **b10**) — the request/response shapes, routes, status codes, and the **payment status
enum** for initiate / verify / confirm / get-transaction. If the **invoice** shape lives in a separate
doc, also read `../../contracts/domains/refunds-invoices.md` (from **b11**) — if that file is not yet
published, mock the invoice behind the `services/payment` seam and file the gap (see §4 and §8).
Always cross-check against the published `../../contracts/openapi/README.md` / `swagger.json` snapshot
for exact casing — **derive types from the contract, never guess shapes.**
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — **money
is IRR rials as a string of digits on the wire**; parse with the f0 integer-safe helper, format to Toman
for display, never do float math; `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
enums are stable string codes (map to i18n labels, never hardcode a display label off the code).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult`/`ApiResult` envelope (`clientFetch` already unwraps it), `snake_case` URL segments,
status codes (`409` = idempotency/state-machine conflict — handle it as "already paid / in progress",
not an error toast), and the **idempotency key** requirement on the money-path POST.
- **Product truth (read before designing the breakdown/escrow copy):**
- [`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md) —
merchant-of-record, the three-amount split, **escrow is an internal ledger state, not held cash**,
VAT applies to **commission only**. This is *why* the C6 copy reads the way it does.
- [`../../../product/payments/index.md`](../../../product/payments/index.md) — the fintech overview;
card → PSP → Shaparak → IBAN rails, the تسهیم split, the "PSP received ≠ cash in bank" timing reality
that justifies a **pending-callback** UI state rather than assuming instant success.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **C6** is the exact
screen you implement (the badge, the breakdown rows, the escrow notice, the "ادامه پرداخت ←" CTA);
confirm the labels and RTL layout against it before building.
- **Code to mirror:** `client/src/services/auth/*` (the `types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts` shape every domain copies) and the f8 `services/booking` service; the f0
**price-breakdown** and **status-chip** components; the f0 money util in `src/utils/`.
## 3. Scope — build this
Everything below lives under `client/` (the customer app shell). Build a `services/payment` domain, the
hooks, the C6 + payment-state + confirmation + invoice screens, and the small composites they need —
each visual surface produced via the **frontend-designer** skill, RTL-first, both locales.
### 3.1 `services/payment` domain (the data layer)
Mirror the `auth`/`booking` service shape exactly. Types come from
[`payments.md`](../../contracts/domains/payments.md) — do not invent fields.
- **`types.ts`** — string-literal unions + DTOs derived from the contract. Expect (confirm exact names
against the published swagger):
- `PaymentTransactionStatus` = `initiated` | `pending` | `succeeded` | `failed` | `cancelled` (the
contract's `payment_transactions.status` enum).
- `BookingStatus` reused from `services/booking` — the value that flips `pending_payment``confirmed`.
- `CheckoutSummary` — the C6 payload: `booking_id`/`booking_request_id`, `nurse` mini-info, the
**three amounts** (`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount` as IRR
digit-strings), a derived/served **service cost** and **tax (VAT) line** (`vat_irr` +
`vat_rate`), `total_irr` (= `gross_price_irr`), and the escrow flag/copy keys. If the breakdown
rows aren't all served, compute display rows **only** from served amounts via the f0 integer-safe
helper — never float-derive tax client-side; if a needed line is missing, request it (§8).
- `InitiatePaymentRequest` (`booking_id` / `booking_request_id`, `gateway` selector, an
**idempotency key**) and `InitiatePaymentResult` (`transaction_id`, `redirect_url`,
`gateway_reference_code`).
- `VerifyPaymentResult` / `PaymentTransaction` (status, `gateway_reference_code`, amount, the
`booking_id` it confirmed).
- `Invoice``invoice_number`, issued-at, the line items including the **VAT-on-commission** line,
`download_url`/`pdf_url` if served (else a client-rendered receipt).
- **`keys.ts`** — a key factory: `paymentKeys.all`, `paymentKeys.summary(bookingRequestId)`,
`paymentKeys.transaction(transactionId)`, `paymentKeys.invoice(bookingId)`.
- **`apis/clientApi.ts`** — a `PaymentClientApi` namespace wrapping `clientFetch` (one call per
endpoint). Map to the b10 routes from the contract (illustrative `snake_case` slugs — use the
published ones):
- `getCheckoutSummary(bookingRequestId)``GET .../payment/get_checkout_summary`.
- `initiatePayment(req)``POST .../payment/initiate_payment` (sends the **idempotency key**).
- `verifyPayment({ gateway_reference_code, transaction_id })``POST .../payment/verify_payment`
(the **server-side re-check** on gateway return — never trust the redirect query alone).
- `getTransaction(transactionId)``GET .../payment/get_transaction` (the callback-poll target).
- `getInvoice(bookingId)``GET .../payment/get_invoice` (or the refunds-invoices route).
- **Only** add a `serverApi.ts` if an RSC needs to prefetch the summary; otherwise client-only.
- **`hooks/` — one hook per file:**
- `useCheckoutSummary(bookingRequestId)``useQuery`, `staleTime` short (prices can change before
pay), keyed `paymentKeys.summary(...)`.
- `useInitiatePayment()``useMutation`; **generates and reuses one idempotency key** for the
attempt (stable across retries — see §5); on success returns the `redirect_url`.
- `useVerifyPayment()``useMutation`; called on gateway return; on `succeeded`
**`invalidateQueries`** for the booking detail/list (so the booking flips to confirmed without a
refetch storm) and for the transaction key.
- `usePaymentTransaction(transactionId, { enabled })``useQuery` for the **pending-callback poll**;
use `refetchInterval` with **backoff** and **stop on terminal status** (`succeeded`/`failed`/
`cancelled`) — see §5. Do **not** aggressive-poll.
- `useInvoice(bookingId)``useQuery`, longer `staleTime` (an issued invoice is immutable).
- `index.ts` re-exports the **hooks only** (per `client/CLAUDE.md` — no `types`/`keys`/`apis` in the
barrel).
### 3.2 Screens & routes (under the customer shell, inside `[locale]/(private-routes)`)
Decide route segments that read cleanly under the existing role-scoped customer group (e.g. a
`checkout` segment keyed by the booking-request id). No layout above `[locale]`; respect the RSC/client
boundary.
- **C6 · خلاصه و پرداخت (Summary & pay)** — the checkout screen. Composes:
- the **"✓ پرستار تایید کرد"** acceptance badge (reuse the f0 **status-chip**, success token),
- the nurse mini-summary (name, service, schedule) pulled from the booking,
- the **price breakdown** via the f0 **price-breakdown** composite — rows: **هزینه خدمت** (service
cost, e.g. "۸ ساعت"), **کارمزد بالین‌یار** (platform commission), **مالیات (VAT)** (tax), and the
bold **مبلغ کل** (total). Every amount rendered through the f0 money util; the visible rows must
**reconcile to the total** (§5).
- the **escrow notice** — a distinct trust callout (reuse `AppAlert`/an info surface, teal/info
token, **not** an error color): the load-bearing copy
**«مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود»** (i18n key in both
locales). This copy is product-mandated trust UX — keep it verbatim in `fa`, with a faithful `en`
translation.
- the primary CTA **«ادامه پرداخت ←»** (drives `useInitiatePayment` → redirect), plus a disabled-state
**«یا پرداخت اقساطی»** seam stub that f11 wires to D1 (render it as a clearly-deferred secondary,
not a dead button — gate behind a `bnplEnabled` flag defaulting off).
- **Card payment states** — a single payment-state surface (page or modal) driving the wireframe's
documented checkout states **initiating → redirect-to-gateway → pending-callback → succeeded→confirmed
→ failed/retry**:
- **initiating** — CTA shows a spinner while `useInitiatePayment` runs.
- **redirect** — on `redirect_url`, navigate to the **mock gateway** (the b10 mock returns a redirect
URL; in dev this is a local mock-gateway page that immediately returns success — build a tiny
**mock-gateway return page** under the checkout segment so the round-trip is real without a PSP).
- **return / pending-callback** — on return, read `gateway_reference_code`/`transaction_id` from the
query, fire `useVerifyPayment`, and if status is `pending` show a **"در حال تایید پرداخت…"** state
backed by `usePaymentTransaction` polling with backoff until terminal.
- **succeeded → confirmed** — on `succeeded`, invalidate booking queries and route to the confirmation
screen.
- **failed / retry** — on `failed`/`cancelled`, show a retry affordance (re-initiate generates a
**new** idempotency key for the new attempt) and a "بازگشت به خلاصه" link.
- **Confirmation screen** — success state: "پرداخت با موفقیت انجام شد", the booking now **confirmed**,
a summary card, a **«مشاهده رزرو»** link back to the f8 booking-detail route, and a **«دانلود فاکتور»**
link to the invoice.
- **Invoice view / download** — renders `useInvoice(bookingId)`: header (`invoice_number`, issued
Shamsi date), line items, and the **VAT line on the commission** explicitly shown (per product rule:
VAT is on Balinyaar's commission, not the nurse's earnings). If the contract serves a `pdf_url`/
`download_url`, the download button hits it; otherwise render a clean printable receipt
(`window.print()` styled view) — no float math, every figure via the money util. Surface the
مودیان state read-only (e.g. «در انتظار ثبت») **only if** the contract exposes it.
### 3.3 Shared composites (build/extend at the shared level)
- **`PriceBreakdown`** — if f0 stubbed it, finish it here as the shared composite (`src/components/…`)
with a co-located `*.test.tsx`: props are typed display rows + a total; it formats nothing itself
beyond receiving already-formatted strings or raw IRR + the money util — keep money formatting in one
place. A test asserts the rows render and the total equals the sum of the served amounts.
- **`EscrowNotice`** — a small shared callout composite wrapping `AppAlert` with the mandated copy key,
so f10/f11 reuse the identical trust message. Co-located test asserts it renders the key.
- **`PaymentStatusBadge`** — map `PaymentTransactionStatus` → an i18n label + the f0 status-chip
variant. Co-located test for each status.
- Keep page-only composition (the confirmation layout, the mock-gateway return page) in the page.
### 3.4 i18n
Add all C6 / payment-state / confirmation / invoice strings to the **`payment`** namespace in **both**
`messages/en.json` and `messages/fa.json`, in sync, RTL-first. Status/enum codes map to label keys —
never hardcode a label off a code. The escrow copy and the breakdown row labels (هزینه خدمت / کارمزد
بالین‌یار / مالیات / مبلغ کل) are keys.
## 4. Mocks & seams in this phase
- **No new cross-cutting seam is owned here.** The PSP/gateway, تسهیم split, and webhook verification
are **backend** seams (`IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`) introduced
in **b10** — the frontend never talks to a PSP directly; it consumes the b10 contract. Reuse the
`services/{domain}` mock-`clientApi` seam pattern from **f0**.
- **The frontend-side mock (only if a shape is missing):** if `payments.md` (or `refunds-invoices.md`
for the **invoice**) is not yet published when you build, implement a **mock `clientApi`** behind the
`services/payment` seam — real-shaped responses (a `redirect_url` pointing at your local mock-gateway
return page, a transaction that goes `initiated → pending → succeeded`, an invoice with a VAT line) —
swap to the real `clientFetch` calls once the contract lands. **Record it** in your frontend report and
the mock registry, and **append the missing shape** to
`dev/shared-working-context/frontend/requests/for-backend.md` (operating-rules §6).
- **The dev mock-gateway return page** is a *test harness*, not a product feature: it exists so the
redirect→return round-trip is exercisable without a PSP. Keep it behind the checkout segment and note
it as test-only in the report.
## 5. Critical rules you must not get wrong
- **Money is IRR `BIGINT`, no floats.** Every amount on the wire is an **IRR digit-string**; parse it
with the f0 **integer-safe** helper and format to **Toman** for display through the f0 money util — do
**no float math** anywhere on the money path, in the DB, in the API, or in the client.
- **The breakdown must reconcile.** `gross = commission + payout`, and the **displayed rows must sum to
the displayed total** (service cost + platform commission + tax = total) using integer-safe addition —
never render a breakdown that doesn't add up, and never compute tax with a float rate client-side
(show the served `vat_irr`; if absent, request it, don't derive it loosely).
- **VAT is on the commission, not the nurse's earnings.** The invoice's VAT line is computed on
Balinyaar's commission (the taxable supply) — label and place it accordingly; never imply the nurse is
taxed.
- **The escrow copy is load-bearing trust UX.** Render the mandated notice verbatim («مبلغ به‌صورت امانی
نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود») as an info/trust callout, never an error tone,
on C6 — it is *why* the family pays on-platform.
- **Webhook/callback idempotency is the backend's guarantee — don't fight it.** Send **one stable
idempotency key** per payment attempt and **reuse it across retries of the same attempt** (a new
attempt gets a new key). A `409` on initiate/verify means "already in progress / already captured" —
treat it as a benign state convergence (re-fetch the transaction and continue to confirmation), **not**
an error toast. The server enforces one succeeded transaction per booking; the UI must never try to
double-capture.
- **Do not poll aggressively.** The pending-callback state uses `usePaymentTransaction` with
`refetchInterval` **backoff** (e.g. start ~2s, grow, cap; bounded total attempts) and **stops on any
terminal status**; never a tight loop. "PSP received ≠ cash in bank," so a pending state is normal —
reflect it calmly, don't hammer the endpoint.
- **Confirmation flips the booking, by cache invalidation.** On `succeeded`, `invalidateQueries` the
`services/booking` detail/list keys so the booking shows **confirmed** — do not refetch everything and
do not duplicate booking state in `services/payment`.
- **Caching & re-renders.** Deliberate `queryKey`/`staleTime`; the summary is short-lived, an issued
invoice is immutable (long `staleTime`). Use `select`/stable refs so the polling state doesn't re-render
the whole breakdown.
- **RTL + both locales + tokens.** `fa` default & RTL; every string in both locale files; colours from
`tokens.css` (terracotta for financial accents, info/teal for the escrow callout — never hardcoded);
MUI v9 API only; MUI primitives stay MUI, composites live shared.
- **Boundary & fetch discipline.** Fetch only via `clientFetch` through `services/payment`; no raw
`fetch()`; no toast for 401/403/5xx in hooks (only domain 4xx like a gateway-declined message).
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] **C6** renders the acceptance badge, the reconciling **service cost / commission / tax / total**
breakdown (all via the f0 money util), and the verbatim **escrow notice**; the «ادامه پرداخت ←»
CTA initiates payment.
- [ ] The **card flow** drives all five documented states (initiating → redirect → pending-callback →
succeeded→confirmed → failed/retry) end-to-end against the mock gateway; the **idempotency key** is
stable per attempt; `409` converges instead of erroring.
- [ ] On success the booking flips to **confirmed** via `invalidateQueries` (verified in React Query
Devtools — no over-fetch), and the **confirmation** screen links back to booking detail and to the
invoice.
- [ ] The **invoice** view renders the line items with the **VAT-on-commission** line and downloads
(served `pdf_url`) or prints a clean receipt; every figure via the money util, no float math.
- [ ] The pending-callback poll uses **backoff** and **stops on terminal status** — no aggressive loop.
- [ ] `PriceBreakdown`, `EscrowNotice`, `PaymentStatusBadge` are shared and each has a co-located
`*.test.tsx`; the breakdown test asserts rows sum to total.
- [ ] `payment` strings in **both** `en.json`/`fa.json`, in sync; RTL verified; colours from tokens.
- [ ] `npm run check` green; `npm run test:ci` green (shared components touched); `client/CLAUDE.md`
*Project Structure* updated for the new `services/payment` domain, checkout route segment, and new
shared components.
- [ ] Types derive from the published contract; any gap is appended to
`dev/shared-working-context/frontend/requests/for-backend.md` and mocked behind the
`services/payment` seam meanwhile (recorded in the report + mock registry).
## 7. How to test (what a human can verify after this phase)
Prereq: a booking request that the nurse has **accepted** (`accepted_awaiting_payment`) — create one via
the f7 request flow + nurse accept, or seed it. Run `npm run dev`.
1. **Open C6** from the accepted request (the C5 tracker's "پرداخت و تایید نهایی" step) → the screen
shows the **«✓ پرستار تایید کرد»** badge, the **breakdown** (هزینه خدمت / کارمزد بالین‌یار / مالیات /
مبلغ کل) where the rows **sum to the total**, and the **escrow notice** in an info tone. Switch locale
`dir` flips, all strings translate, amounts still format as Toman.
2. **Pay (mock redirect)** → tap «ادامه پرداخت ←»: CTA spins (initiating) → you are redirected to the
**mock gateway** → it returns to the checkout → **verify** runs; if pending you briefly see "در حال
تایید پرداخت…" (poll with backoff) → it resolves **succeeded** → the **confirmation** screen appears.
3. **Booking flips to confirmed** → follow «مشاهده رزرو» to the f8 booking detail: status is now
**confirmed** (no full refetch — confirm via React Query Devtools that only the booking keys were
invalidated).
4. **Download the invoice** → from confirmation tap «دانلود فاکتور»: the invoice shows the
`invoice_number`, line items, and the **VAT line on the commission**; download (or print) works; every
figure matches C6 to the rial.
5. **Idempotency / retry** → re-trigger pay on the same attempt (double-tap / refresh on return): no
double-capture — a `409`/already-succeeded converges to the confirmation, not an error toast. A
**new** attempt after a simulated failure issues a new idempotency key.
6. **Gate**`npm run check` and `npm run test:ci` pass; the `PriceBreakdown` test proves rows sum to
total; the `PaymentStatusBadge` test covers each status.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* for the new `services/payment` domain, the
checkout route segment, and the new shared components (`PriceBreakdown` if promoted, `EscrowNotice`,
`PaymentStatusBadge`). Note the `payment` i18n namespace usage. Don't reintroduce removed scaffolding.
- **Contract consumed:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(b10) for checkout-summary / initiate / verify / get-transaction, and the invoice part of
`../../contracts/domains/refunds-invoices.md` (b11) **if available**. Types come from the published
swagger — don't guess. **Append any gap** (missing breakdown line, missing `vat_irr`/`vat_rate`,
missing `redirect_url`, missing invoice shape, مودیان state field) to
`dev/shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append to `dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-9-report.md` (what was built, **what is now testable
and exactly how** — the steps in §7, what is mocked client-side (the gateway return harness / any
unmet shape) and how it swaps to real, contracts consumed, follow-ups for f10/f11). Update
`dev/shared-working-context/reports/mocks-registry.md` for the `services/payment` mock `clientApi` and
the dev mock-gateway page (seam, what's faked, config, how to make it real once the contract lands).
- **Memory:** save a `project` memory note for the checkout state machine (initiating → redirect →
pending-callback → succeeded → failed/retry), the **idempotency-key-per-attempt** rule, the
**backoff poll, no aggressive loop** decision, and the **escrow-copy-is-verbatim-trust-UX** constraint,
with a one-line `MEMORY.md` pointer.