# 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 (D1–D5) — 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.