27 KiB
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(booking detail / sessions / EVV) + backend phase b10 (Payments core — the contract you consume) · Unlocks:frontend-phase-10-b11.md(refunds & cancellation),frontend-phase-11-b12.md(BNPL checkout) Before you start, read../_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 theauthservice shape), the contracts→types pattern, the money/format util insrc/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 (includingpayment), and the RTL baseline. Seefrontend-phase-0.md. - Auth & role routing (f1-b2) —
AuthContextwith 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_requestsservice shapes. - Booking detail & sessions (f8-b9) — the
services/bookingdomain (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; 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) isfrontend-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.mdand../_shared/frontend-conventions-checklist.md— how you work, the gate, the contract/handoff lanes.../../../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-designerskill — the design/brand contract (palette, the terracotta financial accent vs teal brand, tokens, typography, theApp*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(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 theservices/paymentseam and file the gap (see §4 and §8). Always cross-check against the published../../contracts/openapi/README.md/swagger.jsonsnapshot for exact casing — derive types from the contract, never guess shapes. ../../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— theOperationResult/ApiResultenvelope (clientFetchalready unwraps it),snake_caseURL 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— 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— 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— 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/*(thetypes.ts/keys.ts/apis/clientApi.ts/hooks/use*.ts/index.tsshape every domain copies) and the f8services/bookingservice; the f0 price-breakdown and status-chip components; the f0 money util insrc/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 — 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'spayment_transactions.statusenum).BookingStatusreused fromservices/booking— the value that flipspending_payment→confirmed.CheckoutSummary— the C6 payload:booking_id/booking_request_id,nursemini-info, the three amounts (gross_price_irr,balinyaar_commission_irr,nurse_payout_amountas 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,gatewayselector, an idempotency key) andInitiatePaymentResult(transaction_id,redirect_url,gateway_reference_code).VerifyPaymentResult/PaymentTransaction(status,gateway_reference_code, amount, thebooking_idit confirmed).Invoice—invoice_number, issued-at, the line items including the VAT-on-commission line,download_url/pdf_urlif 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— aPaymentClientApinamespace wrappingclientFetch(one call per endpoint). Map to the b10 routes from the contract (illustrativesnake_caseslugs — 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.tsif an RSC needs to prefetch the summary; otherwise client-only.
hooks/— one hook per file:useCheckoutSummary(bookingRequestId)—useQuery,staleTimeshort (prices can change before pay), keyedpaymentKeys.summary(...).useInitiatePayment()—useMutation; generates and reuses one idempotency key for the attempt (stable across retries — see §5); on success returns theredirect_url.useVerifyPayment()—useMutation; called on gateway return; onsucceededinvalidateQueriesfor the booking detail/list (so the booking flips to confirmed without a refetch storm) and for the transaction key.usePaymentTransaction(transactionId, { enabled })—useQueryfor the pending-callback poll; userefetchIntervalwith backoff and stop on terminal status (succeeded/failed/cancelled) — see §5. Do not aggressive-poll.useInvoice(bookingId)—useQuery, longerstaleTime(an issued invoice is immutable).index.tsre-exports the hooks only (perclient/CLAUDE.md— notypes/keys/apisin 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 infa, with a faithfulentranslation. - 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 abnplEnabledflag 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
useInitiatePaymentruns. - 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_idfrom the query, fireuseVerifyPayment, and if status ispendingshow a "در حال تایید پرداخت…" state backed byusePaymentTransactionpolling 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.
- initiating — CTA shows a spinner while
- 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 apdf_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 wrappingAppAlertwith the mandated copy key, so f10/f11 reuse the identical trust message. Co-located test asserts it renders the key.PaymentStatusBadge— mapPaymentTransactionStatus→ 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 theservices/{domain}mock-clientApiseam pattern from f0. - The frontend-side mock (only if a shape is missing): if
payments.md(orrefunds-invoices.mdfor the invoice) is not yet published when you build, implement a mockclientApibehind theservices/paymentseam — real-shaped responses (aredirect_urlpointing at your local mock-gateway return page, a transaction that goesinitiated → pending → succeeded, an invoice with a VAT line) — swap to the realclientFetchcalls once the contract lands. Record it in your frontend report and the mock registry, and append the missing shape todev/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 servedvat_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
409on 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
usePaymentTransactionwithrefetchIntervalbackoff (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,invalidateQueriestheservices/bookingdetail/list keys so the booking shows confirmed — do not refetch everything and do not duplicate booking state inservices/payment. - Caching & re-renders. Deliberate
queryKey/staleTime; the summary is short-lived, an issued invoice is immutable (longstaleTime). Useselect/stable refs so the polling state doesn't re-render the whole breakdown. - RTL + both locales + tokens.
fadefault & RTL; every string in both locale files; colours fromtokens.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
clientFetchthroughservices/payment; no rawfetch(); 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, 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;
409converges 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,PaymentStatusBadgeare shared and each has a co-located*.test.tsx; the breakdown test asserts rows sum to total.paymentstrings in bothen.json/fa.json, in sync; RTL verified; colours from tokens.npm run checkgreen;npm run test:cigreen (shared components touched);client/CLAUDE.mdProject Structure updated for the newservices/paymentdomain, 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.mdand mocked behind theservices/paymentseam 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.
- 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
→
dirflips, all strings translate, amounts still format as Toman. - 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.
- 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).
- 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. - 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. - Gate →
npm run checkandnpm run test:cipass; thePriceBreakdowntest proves rows sum to total; thePaymentStatusBadgetest covers each status.
8. Hand off & document (close the phase)
- Docs: update
client/CLAUDE.mdProject Structure for the newservices/paymentdomain, the checkout route segment, and the new shared components (PriceBreakdownif promoted,EscrowNotice,PaymentStatusBadge). Note thepaymenti18n namespace usage. Don't reintroduce removed scaffolding. - Contract consumed:
../../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, missingvat_irr/vat_rate, missingredirect_url, missing invoice shape, مودیان state field) todev/shared-working-context/frontend/requests/for-backend.md. - Handoff & report: append to
dev/shared-working-context/frontend/STATUS.md; writedev/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). Updatedev/shared-working-context/reports/mocks-registry.mdfor theservices/paymentmockclientApiand the dev mock-gateway page (seam, what's faked, config, how to make it real once the contract lands). - Memory: save a
projectmemory 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-lineMEMORY.mdpointer.