Files
baya-monorepo/dev/phases/frontend/frontend-phase-9-b10.md
T
2026-06-28 21:59:59 +03:30

27 KiB
Raw Blame History

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 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.
  • 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; 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. 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 and ../_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-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 (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.mdmoney 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 — 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 — 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.htmlC6 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 — 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_paymentconfirmed.
    • 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).
    • Invoiceinvoice_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, 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. Gatenpm 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 (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.