14 KiB
Frontend phase 9 report — Checkout, card payment & invoice (consumes b10 + the invoice slice of b11)
Status: complete · gate green (npm run check, npm run test:ci — 204 tests, production build) · 2026-07-10
Scope shipped: the C6 خلاصه و پرداخت checkout, the card-payment state machine (initiate → redirect →
pending-callback → succeeded→confirmed / failed→retry), the confirmation screen, the invoice view, the
services/payment domain, and three shared money composites.
1. What was built
services/payment (new domain — mirrors the auth/bookings shape)
| File | What it is |
|---|---|
types.ts |
Contract-derived DTOs + the PaymentApi seam. PaymentTransactionStatus = pending|succeeded|failed (the b10 enum — the phase file's illustrative initiated/cancelled states do not exist on the wire and were not used). CheckoutSummaryDto (REQ-016 shape), InitiatePaymentResult (b10 swagger), PaymentOutcomeDto (poll target), InvoiceDto (b11 swagger, flat totals — no line-items array exists in the contract). |
keys.ts |
paymentKeys.summary(requestId) / .outcome(requestId) / .invoice(bookingId). |
constants.ts |
USE_PAYMENT_MOCK=true (why documented in-file), BNPL_ENABLED=false (the f11 seam gate), poll backoff tuning, checkout query-param names, mock money rates. |
apis/clientApi.ts |
Real impl: initiate = POST api/v1/bookings/{id}/payments + Idempotency-Key header (no body — contract-exact); invoice = GET api/v1/invoices/{bookingId}; summary targets the REQ-016 proposed slug; outcome maps GET booking_requests/get/{id} (converted→succeeded, payment_deadline_expired→failed, else pending — REQ-017). |
apis/mockApi.ts |
Mock-primary state machine — see §3. |
invalidations.ts |
invalidateAfterPaymentSuccess — the one post-capture cache transition (request detail/lists + bookings lists/detail + this request's summary/outcome). Exact keys, never a blanket refetch. |
hooks/ |
useCheckoutSummary (short stale), useInitiatePayment (caller owns the per-attempt key), useConfirmGatewayReturn (primes the outcome key; invalidates on immediate success), usePaymentOutcome (geometric-backoff poll: 2s → ×1.5 → cap 15s, stops on terminal outcome or 40 attempts; exports isTerminalPaymentOutcome), useInvoice (immutable → long stale; 404 = "not issued", not retried). |
Screens (customer shell)
- C6
/bookings/checkout?request_id=— «✓ پرستار تایید کرد» badge (reusesbooking.accepted_badge), nurse/service/schedule mini-summary, the payment-windowCountdownTimer, the served reconciling breakdown (هزینه خدمت / کارمزد بالینیار / مالیات بر ارزش افزوده / مبلغ کل) viaPriceBreakdown, the verbatimEscrowNotice, «ادامه پرداخت ←» (terracottasecondary), and the BNPL seam — a disabled outlined «یا پرداخت اقساطی» + "coming soon" caption gated byBNPL_ENABLEDfor f11 to wire to D1. Non-payable statuses render convergence cards (already-paid → outcome; window-expired / other terminal → back to C5). - Gateway harness
/bookings/checkout/gateway— test-only fake PSP (labelled آزمایشی): success + failure buttons so both return branches are drivable without a gateway. The phase file suggested auto-success; buttons were chosen instead so §7 step 5 (failed attempt → new idempotency key) is testable by a human. - Return
/bookings/checkout/return— firesuseConfirmGatewayReturnonce per mount (ref-guarded; a refresh replays it and converges idempotently), then the pending-callback poll. Succeeded → invalidate +router.replaceto confirmation (once, ref-guarded, no double invalidation between mutation and poll paths); failed → retry (back to a fresh C6 mount = new attempt, new key); window lapsed → back to C5. A manual «بررسی دوباره» covers the bounded poll giving up. - Confirmation
/bookings/checkout/confirmation— success state, amount-paid card, «مشاهده رزرو» →/bookings/{bookingId}(falls back to the list without a bookingId — REQ-017), «دانلود فاکتور» →/bookings/{bookingId}/invoice(hidden without a bookingId). - Invoice
/bookings/[id]/invoice—invoiceNumber+ Shamsi issue date,PriceBreakdownrows where the service line is the exact integer remainder (gross − commission − VAT, viaparseIrrBigInt) so the lines reconcile by construction, the VAT line labelled «مالیات بر ارزش افزوده (بر کارمزد بالینیار)» (product rule: the nurse is never implied to be taxed), read-only مودیان state chip (moadian_*), andpdfUrldownload or a print receipt (window.print()+ a print-scoped visibility rule isolating the invoice card). 404 renders «فاکتور هنوز صادر نشده است» (REQ-018).
Shared composites (each with a co-located test)
PriceBreakdown— typed rows + total, all IRR digit-strings throughformatIrrToToman; dev-guardconsole.errors if rows ≠ total (test proves rows render, total = Σ rows, and the guard fires on mismatch).EscrowNotice— wrapsAppAlert(info severity,--bal-primarytext on--bal-primary-soft, lock icon). Its test mocks next-intl to read the realfa.json, pinning the mandated copy verbatim: a rewording fails the suite.PaymentStatusBadge— fullPaymentTransactionStatus→StatusChipkind map (Recordtyped, so an enum change breaks the build); labels frompayment.pstatus_*.
Extensions to prior phases (in place, per operating rules)
services/bookingRequests: client-augmentedbookingId: number | nullonBookingRequestDto(REQ-017 twin of REQ-013'svariantPrice; real client maps it tonull), and the mock-onlymockMarkBookingRequestConverted(id, bookingId)capture bridge.services/bookings/apis/mockApi.ts: mock-onlymockInsertConvertedBooking(seed)— inserts a confirmed single-session booking (ids 6001+/80001+, distinct from the 5001/5002 seeds).- C5 (
bookings/request/[id]): theconvertedterminal card now deep-links the booking whenbookingIdis present (list fallback otherwise). constants/routes.ts:CHECKOUT_GATEWAY/RETURN/CONFIRMATION+bookingInvoicePath().- i18n: new
paymentnamespace — 53 keys, both locales, inserted textually (no reformat of existing lines).
2. Contract deltas the implementation honors (vs the phase file's illustrative design)
The phase file sketched getCheckoutSummary/verifyPayment/getTransaction endpoints and an
initiated…cancelled status enum. The published contract wins:
- Status enum is
pending|succeeded|failed(b10payment_transactions.status). Client unions match. - There is no client verify/transaction endpoint. The server re-verifies inside the webhook handler;
"verify on return" is therefore modelled as
confirmGatewayReturn(real impl = an outcome read — the PSP already hit the webhook before redirecting) + the outcome poll. Filed as REQ-017. - There is no checkout-summary endpoint and the b8 request DTO is money-free — the C6 breakdown cannot be served today. Filed as REQ-016; mocked behind the seam; the real client targets the proposed slug.
- The invoice is flat totals (
grossIrr/platformCommissionIrr/vatRate/vatIrr…, no line-items array) and only exists after the admin-only issue action. Filed as REQ-018; the UI has a not-issued state. - Idempotency is a header (
Idempotency-Key), not a body field — the contract's exact casing.
3. Mocks in this phase (recorded in mocks-registry.md)
paymentMockApi(mock-primary) — the missing conversion trigger between the f7 and f8 mock stores (theirconverted/seeded-booking states were previously unconnected): capture flips the f7 request toconverted+ stampsbookingId, inserts a confirmed f8 booking, and auto-issues the b11-shaped invoice. Money split uses integer parts-per-10000 BigInt math (12% fee, 10% VAT,vat = commission_net × rateper b11) soservice + commission + vat = totalexactly andgross = balinyaarCommission + payoutholds. Idempotency mirrors b10: same-key retry reuses the attempt, post-capture initiate →409, replayed returns converge.- The mock-gateway page — test harness only (see registry row for the deletion story).
- Why mock-primary: upstream ids are mock-primary (f7), REQ-016/017 are unserved, and nothing fires
the PSP webhook in dev (b10's "webhook simulator" is a manual server-side POST — after a real initiate,
nothing would ever confirm). Swap = deliver REQ-016/017/018 + real upstreams, then
USE_PAYMENT_MOCK=false.
4. What is now testable, exactly (mock path — npm run dev)
Prereq: an accepted_awaiting_payment request — either seed-driven (open /fa/nurse/requests, accept a
seeded pending request) or full-flow (C4 create → nurse accept). Same browser tab throughout (module-singleton mocks).
- C6: from C5's «ادامه پرداخت» (or
/fa/bookings/checkout?request_id={id}) — badge, mini-summary, 30-min countdown, breakdown that sums to the rial (2,800,000×1 IRR gross → service 2,436,000 + commission 336,000 − VAT split), escrow notice in info tone./enflipsdir, translates, still Toman. - Pay: «ادامه پرداخت ←» → spinner → the آزمایشی gateway → «پرداخت موفق» → return surface briefly shows «در حال تایید پرداخت…» → confirmation screen.
- Booking flips: «مشاهده رزرو» lands on
/bookings/{id}showing confirmed (React Query Devtools: only request-detail/lists, bookings lists/detail, and this request's payment keys invalidated). C5 now shows the converted card deep-linking the same booking; the bookings list has the new row. - Invoice: «دانلود فاکتور» → number
INV-…, Shamsi date, service/commission/VAT-on-commission/total reconciling to C6, مودیان «در انتظار ثبت», print button (mock serves nopdfUrlso the print path runs). - Idempotency/retry: double-tap pay (one attempt, same key — mock returns the same transaction);
re-initiate after success →
409→ converges to confirmation, no error toast. Gateway «شبیهسازی پرداخت ناموفق» → failed card → «تلاش دوباره» → fresh C6 mount issues a new key (observable in the mock'sgatewayReferenceCodesuffix). - Window expiry: wait out the 30-min window (or re-enter later) → C6/return show «مهلت پرداخت به
پایان رسید» and C5 shows its terminal card; initiate after expiry →
409handled as state, not error. - Invoice not-issued state:
/fa/bookings/5001/invoice(a seeded booking that never went through checkout) → «فاکتور هنوز صادر نشده است».
5. Follow-ups for the next phases
- f10 (refunds/cancellation): reuse
PriceBreakdown(fee disclosure),EscrowNotice(identical trust copy),PaymentStatusBadge;RefundStatusDto/refunds/{id}/statusis live in b11 and unconsumed;refundableAmountIrr/cancellationRefundPercentagealready ride onBookingDetailDto. - f11 (BNPL): flip
BNPL_ENABLEDand wire the C6 secondary to D1. The b12 contract (checkout_bnpl/eligibility|initiate|{id}+Idempotency-Keyheader) parallels this domain's shapes;InvoiceDto.bnplCommissionIrris already typed. The gateway-harness pattern extends to the BNPL redirect. - Backend: REQ-016 (checkout summary), REQ-017 (outcome/bookingId — until then the real poll can't
distinguish declined from slow, and the confirmation can't deep-link), REQ-018 (invoice reachable
post-capture). Also note: the PSP's return-URL config must deep-link
/{locale}/bookings/checkout/return.
6. Post-review hardening (multi-agent adversarial review before close)
A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
- Stale-outcome guard (major): the return surface now trusts the outcome cache only after this
mount's return report settles — a previous attempt's cached
failedoutcome can no longer flash a false «پرداخت ناموفق» (with a live retry) while the current attempt's capture is in flight. - No dead-end retries: malformed
request_id/booking-id links render a navigation card instead of arefetch()that bypassesenabledand would requestcheckout_summary/undefined. - Unpriced request fails loudly: the mock throws
409 unpriced_requestinstead of silently serving a reconciling 0-rial checkout whenvariantPriceis null (REQ-013 edge). - i18n/UX: inline initiate errors always use the localized copy (never raw
ApiError.message); encta_payarrow points → (fa keeps ←); faerror_bodymatches the app's «بارگذاری … ممکن نشد» pattern; the C6 service-cost row carries the quantity (row_service_cost_with_count); the invoice issuer line uses the product spelling «بالینیار» (note: facommon.brandreads «بلینیار» — a pre-existing wordmark/product-spelling divergence worth a product decision). - Dark scheme: EscrowNotice text/border use
--bal-primary(the info token is an alert background and is illegible as dark-mode text); the print button temporarily flipsdata-mui-color-schemeto light aroundwindow.print()(restored onafterprint) so a dark-mode user prints paper colors. - Contract hygiene: the domain barrel is hooks-only again (
isTerminalPaymentOutcomemoved totypes.ts, mirroringisTerminalBookingRequestStatus); the gateway harness scopesdir="ltr"to the reference code, not the Persian label; the invoice VAT percent formats fractional rates (maximumFractionDigits: 2).
7. Gate
npm run check green · npm run test:ci green (46 suites, 204 tests — +9: PriceBreakdown 4, EscrowNotice 2,
PaymentStatusBadge 3) · npm run build green. en.json/fa.json in sync (53-key payment namespace).
client/CLAUDE.md Project Structure updated (checkout subtree, invoice route, services/payment, three
components, payment namespace entry). Gotcha for future phases: BigInt literals (0n) don't compile
(tsconfig target ES2017) — use the BigInt(...) constructor like utils/money.ts.