Files
baya-monorepo/dev/shared-working-context/reports/frontend-phase-9-report.md
T
2026-07-13 22:52:57 +03:30

14 KiB
Raw Blame History

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 (reuses booking.accepted_badge), nurse/service/schedule mini-summary, the payment-window CountdownTimer, the served reconciling breakdown (هزینه خدمت / کارمزد بالین‌یار / مالیات بر ارزش افزوده / مبلغ کل) via PriceBreakdown, the verbatim EscrowNotice, «ادامه پرداخت ←» (terracotta secondary), and the BNPL seam — a disabled outlined «یا پرداخت اقساطی» + "coming soon" caption gated by BNPL_ENABLED for 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/gatewaytest-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 — fires useConfirmGatewayReturn once per mount (ref-guarded; a refresh replays it and converges idempotently), then the pending-callback poll. Succeeded → invalidate + router.replace to 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]/invoiceinvoiceNumber + Shamsi issue date, PriceBreakdown rows where the service line is the exact integer remainder (gross commission VAT, via parseIrr BigInt) 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_*), and pdfUrl download 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 through formatIrrToToman; dev-guard console.errors if rows ≠ total (test proves rows render, total = Σ rows, and the guard fires on mismatch).
  • EscrowNotice — wraps AppAlert (info severity, --bal-primary text on --bal-primary-soft, lock icon). Its test mocks next-intl to read the real fa.json, pinning the mandated copy verbatim: a rewording fails the suite.
  • PaymentStatusBadge — full PaymentTransactionStatusStatusChip kind map (Record typed, so an enum change breaks the build); labels from payment.pstatus_*.

Extensions to prior phases (in place, per operating rules)

  • services/bookingRequests: client-augmented bookingId: number | null on BookingRequestDto (REQ-017 twin of REQ-013's variantPrice; real client maps it to null), and the mock-only mockMarkBookingRequestConverted(id, bookingId) capture bridge.
  • services/bookings/apis/mockApi.ts: mock-only mockInsertConvertedBooking(seed) — inserts a confirmed single-session booking (ids 6001+/80001+, distinct from the 5001/5002 seeds).
  • C5 (bookings/request/[id]): the converted terminal card now deep-links the booking when bookingId is present (list fallback otherwise).
  • constants/routes.ts: CHECKOUT_GATEWAY/RETURN/CONFIRMATION + bookingInvoicePath().
  • i18n: new payment namespace — 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:

  1. Status enum is pending|succeeded|failed (b10 payment_transactions.status). Client unions match.
  2. 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.
  3. 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.
  4. 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.
  5. 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 (their converted/seeded-booking states were previously unconnected): capture flips the f7 request to converted + stamps bookingId, 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 × rate per b11) so service + commission + vat = total exactly and gross = balinyaarCommission + payout holds. 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).

  1. 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. /en flips dir, translates, still Toman.
  2. Pay: «ادامه پرداخت ←» → spinner → the آزمایشی gateway → «پرداخت موفق» → return surface briefly shows «در حال تایید پرداخت…» → confirmation screen.
  3. 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.
  4. Invoice: «دانلود فاکتور» → number INV-…, Shamsi date, service/commission/VAT-on-commission/total reconciling to C6, مودیان «در انتظار ثبت», print button (mock serves no pdfUrl so the print path runs).
  5. 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's gatewayReferenceCode suffix).
  6. Window expiry: wait out the 30-min window (or re-enter later) → C6/return show «مهلت پرداخت به پایان رسید» and C5 shows its terminal card; initiate after expiry → 409 handled as state, not error.
  7. 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}/status is live in b11 and unconsumed; refundableAmountIrr/cancellationRefundPercentage already ride on BookingDetailDto.
  • f11 (BNPL): flip BNPL_ENABLED and wire the C6 secondary to D1. The b12 contract (checkout_bnpl/eligibility|initiate|{id} + Idempotency-Key header) parallels this domain's shapes; InvoiceDto.bnplCommissionIrr is 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 failed outcome 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 a refetch() that bypasses enabled and would request checkout_summary/undefined.
  • Unpriced request fails loudly: the mock throws 409 unpriced_request instead of silently serving a reconciling 0-rial checkout when variantPrice is null (REQ-013 edge).
  • i18n/UX: inline initiate errors always use the localized copy (never raw ApiError.message); en cta_pay arrow points → (fa keeps ←); fa error_body matches 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: fa common.brand reads «بالین یار» — 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 flips data-mui-color-scheme to light around window.print() (restored on afterprint) so a dark-mode user prints paper colors.
  • Contract hygiene: the domain barrel is hooks-only again (isTerminalPaymentOutcome moved to types.ts, mirroring isTerminalBookingRequestStatus); the gateway harness scopes dir="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.