frontend phase 11
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Frontend Phase 11 — BNPL installment checkout (D1–D5) — report
|
||||
|
||||
**Track:** frontend · **Depends on:** f9 (checkout & payment) + the b12 BNPL contract · **Date:** 2026-07-10
|
||||
**Gate:** `npm run check` green · `npm run test:ci` green (223 tests, +9) · `npm run build` green with
|
||||
`NEXT_PUBLIC_API_URL` set (the build's missing-`.env` prerender failure is pre-existing/environmental and
|
||||
affects unrelated pages).
|
||||
|
||||
## What was built
|
||||
|
||||
The **second exit off C6**: instead of paying the full card amount, the family chooses **اقساط** and is taken
|
||||
through a provider BNPL flow (pick provider → pick plan → credit check → accept schedule → pay down-payment),
|
||||
after which **the booking confirms exactly as the card path** (the provider paid Balinyaar in full). Repayment
|
||||
is then tracked from the **کیفپول (Wallet)** tab.
|
||||
|
||||
### `services/bnpl` (new domain, f0 pattern)
|
||||
- **`types.ts`** — the b12 enums (`BnplStatus`/`BnplEligibilityStatus`/`ProviderCode`), `BnplProvider` +
|
||||
`BnplPlanOption` (per-plan served monthly/down-payment/total IRR strings), `BnplEligibilityResult`,
|
||||
`BnplSchedule` + `BnplInstallmentRow`, `BnplOrderStatus`, `WalletInstallmentPlan` (D5 provider-reported),
|
||||
the `BnplApi` seam, and helpers (`isTerminalBnplStatus`, `isBnplSettlementSuccess`, `installmentStatusKind`).
|
||||
**All money is the IRR digit-string type.**
|
||||
- **`keys.ts`** — `bnplKeys.options/eligibility/schedule/order/walletInstallments`.
|
||||
- **`apis/`** — `clientApi.ts` (real, maps b12 `eligibility`/`initiate`/order-by-id 1:1; options/schedule/
|
||||
wallet/by-request-order are REQ-022/024 gaps on proposed slugs), `mockApi.ts` (**PRIMARY**), selecting
|
||||
`index.ts` (`USE_BNPL_MOCK`).
|
||||
- **`hooks/`** — `useBnplOptions`/`useCheckEligibility`/`useBnplSchedule`/`useIssueBnplToken`/
|
||||
`useAcceptBnplSchedule`(invalidates booking+checkout+wallet)/`useBnplOrder`(bounded backoff poll)/
|
||||
`useWalletInstallments`. `invalidations.ts` reuses the f9 `invalidateAfterPaymentSuccess` + the wallet key.
|
||||
|
||||
### Screens (customer shell, terracotta financial accent = `--bal-secondary`)
|
||||
- **D1 روش پرداخت** (`checkout/bnpl/page.tsx` + `MethodStep.tsx`) — payable amount + full-card option
|
||||
(returns to the f9 card flow) + provider option cards **from `useBnplOptions`** (never hardcoded) + an
|
||||
ownership disclosure at the point of choice. A stateful wizard (`StepperHeader`) drives D1→D4.
|
||||
- **D2 انتخاب طرح** (`PlanStep.tsx`) — single-select `BnplPlanCard` group (term/fee/served monthly/down-payment).
|
||||
- **D3 اعتبارسنجی** (`EligibilityStep.tsx`) — کد ملی (10-digit client format) + prefilled موبایل (session) +
|
||||
**consent-gated** submit → `useCheckEligibility` → approved (credit ceiling) / declined+ceiling-exceeded
|
||||
(declined panel **+ card fall-back**).
|
||||
- **D4 تایید طرح و قرارداد** (`ScheduleStep.tsx`) — served repayment table (`InstallmentScheduleRow`) +
|
||||
the ownership-truth note + a **contract-acceptance-gated** final action → `useIssueBnplToken` handoff.
|
||||
- **Provider handoff** (`bnpl/gateway/page.tsx` harness → `bnpl/return/page.tsx`) — settle (down-payment
|
||||
cleared) → invalidate → **the reused f9 confirmation** (`?method=bnpl` adds «پرداختشده با اقساط»).
|
||||
- **D5 پیگیری اقساط** (`wallet/page.tsx` → `WalletInstallments.tsx`) — provider-reported outstanding
|
||||
balance (terracotta card), next-installment + **early-pay provider hand-off** (opens the provider URL, not
|
||||
a Balinyaar payment), per-installment due list with status chips, and the provider-owned ownership note.
|
||||
|
||||
### Shared composites (tested)
|
||||
- **`BnplPlanCard`** — D2 plan option (terracotta selectable).
|
||||
- **`InstallmentScheduleRow`** — one repayment row, reused by D4 schedule + D5 wallet due list.
|
||||
|
||||
### Wiring / changes
|
||||
- C6 (`checkout/page.tsx`): the «پرداخت اقساطی» button is enabled (`BNPL_ENABLED = true`) and navigates into
|
||||
the wizard with `?request_id=`.
|
||||
- The f9 confirmation renders the installments line on `?method=bnpl` (no parallel confirmation built).
|
||||
- New `ROUTES.CHECKOUT_BNPL[_GATEWAY|_RETURN]`, the `installments` AppIcon, and the `bnpl` i18n namespace
|
||||
(87 keys, both locales in sync, RTL-first, ICU-`number` params for Persian digits).
|
||||
|
||||
## What is now testable, and exactly how
|
||||
|
||||
Run `npm run dev`, sign in as a customer, reach a **confirmed** booking's checkout (C6) via the f9 flow (the
|
||||
seam mock closes the loop):
|
||||
1. **C6 → اقساط → D1**: payable amount + دیجیپی/اسنپپی/اقساط بالینیار (terracotta). Pick → «ادامه با …».
|
||||
2. **D2**: plans (۳/۶/۱۲ ماهه or ۴ قسط) with monthly + down-payment; amounts render in Toman via the money util.
|
||||
3. **D3**: enter کد ملی, tick consent (submit stays disabled until consent) → **mock approves** with a ceiling.
|
||||
**Declined path:** a national ID ending in `0` → `not_eligible` (declined panel + card fall-back);
|
||||
an order above the ceiling → `ceiling_exceeded`.
|
||||
4. **D4**: پیشپرداخت (امروز) + قسط rows with **Shamsi** due dates; accept the contract (final action disabled
|
||||
until ticked) → handoff harness → **the booking confirms**, landing on the f9 confirmation marked اقساطی.
|
||||
5. **کیفپول (Wallet) → D5**: provider-reported outstanding balance + next due + per-installment due list
|
||||
(پرداختشده / سررسید نزدیک / آینده), the ownership note, and an **early-pay hand-off** (opens the provider).
|
||||
6. Switch to `en` → all BNPL strings translate, RTL⇄LTR flips; dark mode intact.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
|
||||
`services/bnpl` is **mock-primary** (`USE_BNPL_MOCK = true`) — see the `BnplApi` row in
|
||||
[`mocks-registry.md`](./mocks-registry.md). The mock reads the frozen request gross from the f7 store, plays
|
||||
the provider (eligibility verdict, plan math, token/redirect), and on settle **reuses the f9 conversion
|
||||
bridge** (`mockInsertConvertedBooking` + `mockMarkBookingRequestConverted`) — a settled BNPL order is a card
|
||||
payment net-of-fee — then seeds a provider-reported Wallet plan. All money is served IRR digit-strings (the
|
||||
mock computes plan/schedule with integer BigInt math; components only format). Swap is a one-line flip once
|
||||
the upstream is real and the REQs land.
|
||||
|
||||
## Contracts consumed + gaps filed
|
||||
|
||||
Consumed [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (b12) for all types/routes.
|
||||
Filed to [`for-backend.md`](../frontend/requests/for-backend.md):
|
||||
- **REQ-022** — provider/plan options (D1/D2) + repayment schedule (D4); the contract serves neither and
|
||||
explicitly does not model the repayment schedule. Also: add `balinyaar` to the `provider_code` enum.
|
||||
- **REQ-023** — BNPL eligibility should accept the D3 KYC inputs (national ID / mobile / consent).
|
||||
- **REQ-024** — provider-reported Wallet installment status (D5), a customer-readable `bookingId` on the
|
||||
settled order, and a by-request order lookup for the return poll (`GET checkout_bnpl/{id}` is order-id-keyed).
|
||||
|
||||
## Adversarial review (multi-agent workflow)
|
||||
|
||||
Ran a 6-dimension review (money · ownership · contract · reuse/caching · gates/flow · i18n/RTL/theme) with an
|
||||
adversarial verify pass. Of the raised findings, **5 were confirmed and all fixed**; the rest were refuted
|
||||
(mock-allowed, non-reachable, or f9-parity conventions):
|
||||
1. Return-page **window-expired** branch had a mislabeled CTA + wrong copy → now reuses the f9
|
||||
`window_expired_*` + `back_to_request` keys (label matches the destination).
|
||||
2. Real `getBnplOrder` keyed by request id vs the contract's order-id path → the settle-on-return now reads
|
||||
the order **by its own id** (contract-correct); the by-request poll is honestly flagged as a REQ-024 gap.
|
||||
3. Return-page order poll fired one needless fetch on immediate success → gated on the late-settle case only.
|
||||
4. Dead i18n keys (`col_*`) deleted; the strongest ownership disclosure (`ownership_note`) is now rendered at D1.
|
||||
5. D1 provider-glyph text was near-illegible in dark mode → switched to the `--bal-secondary-dark` text token.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **f15 admin BNPL** — the admin revert/cancel console (b12 `admin_bnpl/*`) is out of scope here.
|
||||
- **BNPL refund/revert** — the customer cancellation + BNPL revert ETA surface is **f10** (not duplicated here).
|
||||
- When REQ-022/023/024 land, flip `USE_BNPL_MOCK = false` (one line); if `f12` nurse-earnings Wallet content
|
||||
lands, it sits beside the self-contained D5 section under `/wallet`.
|
||||
@@ -74,3 +74,5 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
||||
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 |
|
||||
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 |
|
||||
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~7–10-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
|
||||
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1–D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجیپی 3/6/12 · اسنپپی ۴ · اقساط بالینیار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجیپی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false` — `checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
|
||||
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائهدهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user