# 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/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` — 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]/invoice` — `invoiceNumber` + 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.error`s 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 `PaymentTransactionStatus` → `StatusChip` 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`.
