26 KiB
Frontend Phase 11 — BNPL checkout (installments)
Mission: give the family an alternative to full-card payment at checkout — pay a booking in installments through a provider (دیجیپی / اسنپپی / اقساط بالینیار). Build the five BNPL screens from the wireframe (D1 method → D2 plan → D3 eligibility → D4 contract/schedule → D5 wallet status), wired to the b12 BNPL endpoints, styled in the financial terracotta language the design system reserves for money/installments. The load-bearing product truth you must encode in the UI: the installment repayment is owned by the provider, not Balinyaar — the provider pays Balinyaar the full amount up-front and bears 100% of customer-default risk, so Balinyaar shows the installment status it is told about; it does not run the schedule. D5 is provider-reported status, never a Balinyaar-managed ledger.
Track: frontend · Depends on:
frontend-phase-9-b10.md(checkout & payment) + the b12 BNPL contract (dev/contracts/domains/bnpl.md) · Unlocks: nothing downstream depends on it (BNPL is an alternate checkout branch) Before you start, read../_shared/agent-operating-rules.md. It is not optional.
1. Context — where this sits
We are at the alternate-checkout branch of the customer money path. By f9 a family can already see the price breakdown (C6 خلاصه و پرداخت) and pay a confirmed booking by card. This phase adds the second exit off C6: instead of paying the full amount on a card, the family chooses اقساط (installments) and is taken through a provider's BNPL flow — pick a provider, pick a plan, pass a credit check, accept a repayment schedule, pay the down-payment — after which the booking confirms exactly as the card path confirms it (the provider has paid Balinyaar in full). The family then tracks repayment from the کیفپول (Wallet) tab.
The single rule that shapes every screen: in Balinyaar's books a BNPL order is identical to a card payment that lands net-of-fee in one inbound settlement (product/business/09). The customer's 4-installment (or 3/6/12-month) repayment is decoupled from Balinyaar's escrow/EVV/payout cycle. So the Wallet screen (D5) renders a provider-reported balance and due-date list — not a ledger Balinyaar owns or can settle. The copy must make that ownership clear without scaring the user.
What already exists (do not rebuild) — from prior phases:
- Foundations (
frontend-phase-0.md): the three actor shells, the customer 5-tab bottom nav (خانه · رزروها · بیماران · کیفپول/Wallet · پروفایل), theservices/{domain}+ TanStack Query pattern (akeys.tsfactory,apis/clientApi.ts, one-hook-per-file, mutation-invalidates-cache), the money/format util (formatIrrToToman, integer-safe IRR parse, Shamsi date display) insrc/utils/, the shared composites (stepper/progress header, status chip, price-breakdown), the i18n namespaces — including a reservedbnplnamespace — and the RTL baseline. Reuse all of it; do not re-derive the data pattern. - Checkout & payment (
frontend-phase-9-b10.md): the C6 summary-&-pay screen with the commission/tax breakdown and the escrow notice, the card-payment redirect flow, the confirmation screen, and the booking-confirmed state. This phase adds a branch to C6's payment-method step and reuses C6's confirmation/booking-confirmed UI once the down-payment clears — it does not build a second confirmation. - Refund & cancellation (
frontend-phase-10-b11.md): the cancellation flow and the customer refund-status view, including the BNPL revert ETA copy ("~7–10 business days, provider-owned"). When a BNPL booking is cancelled, the refund status surface from f10 is what the user sees — do not build a BNPL-specific refund screen here; that path belongs to f10. This phase owns only the forward checkout (D1–D4) and the status view (D5).
Branch note: BNPL is gated to bookings above a configurable threshold (e.g. total/duration) — a config flag, not a feature, per scope notes. If the contract exposes an
bnplEligibleflag on the booking/checkout summary, hide the "اقساط" option when it's false; otherwise show it always and let D3 eligibility be the gate. Do not invent a client-side threshold rule.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/frontend-conventions-checklist.md.client/CLAUDE.md— the engineering contract (RSC/client boundary, layouts, i18n, theme, cookies, the fetch services, anti-patterns). You add aservices/bnpldomain and screens under the customer shell; nothing above[locale].- Invoke the
frontend-designerskill before any visual work. It is the design/brand contract. For this phase it carries the rule that matters most: money/installment surfaces use the terracotta financial accent (--bal-terracotta#d98c6a, the wireframe's "installments/financial" legend colour) — D1–D5 are terracotta-accented, distinct from the teal of the core booking flow. Ask it for the installment-plan card, the eligibility-result panel, the repayment-schedule table, and the outstanding-balance Wallet card. - Product truth:
product/business/09-installments-bnpl.md— the full-upfront / provider-bears-risk / decoupled-repayment model. This is why D5 is provider-reported.product/payments/bnpl-landscape.md— the provider comparison: SnappPay (4 interest-free), Digipay (3/6/12 + 4-installment), Torob Pay (25% down, 6.6%), Balinyaar in-house plan. Use these for the provider/plan copy and fee/down-payment shapes D1/D2 show. Note: settlement timing is not instant and commission is per-contract — never hardcode a fee in the client; render whatever the contract returns.
- Wireframe:
product/wireframes/index.html, Section D (D1–D5) — the exact screens, RTL Persian, terracotta financial accent. D1 روش پرداخت, D2 انتخاب طرح اقساط, D3 اعتبارسنجی, D4 تایید طرح و قرارداد, D5 پیگیری اقساط (in Wallet). D5 carries the bottom tab nav (Wallet active); D1–D4 are mid-flow (no tab bar). - Contract to consume:
dev/contracts/domains/bnpl.md(from b12) — the request/response shapes, routes, the BNPLstatusenum codes, and the eligibility result shape. Plus../../contracts/conventions/money-and-types.md(IRR-as-string, Toman display, enums-as-codes, UTC + Shamsi) andapi-conventions.md(the envelope). Types come from the contract — do not guess shapes. - The existing
src/services/payment/*from f9 (the checkout service) andsrc/services/auth/*— the exact service pattern (types.ts/keys.ts/apis/clientApi.ts/hooks/use*.ts/index.ts) your newservices/bnplcopies; and the C6 screen you branch from.
3. Scope — build this
A new services/bnpl domain + the five wireframe screens, all under the customer shell, all
terracotta-financial. Build the forward checkout (D1–D4) and the Wallet status view (D5).
3.1 services/bnpl (the data layer — copy the f0 pattern)
Under src/services/bnpl/:
types.ts— derived frombnpl.md. At minimum:BnplProvider(providerCodee.g.digipay/snapppay/balinyaar, display name, supported plans),BnplPlanOption(termMonths3/6/12 orinstallmentCount4,feePercent,downPaymentPercent,monthlyAmountIrr,totalIrr— all IRR amounts as string),BnplEligibilityResult(eligibilityStatus:eligible/not_eligible/ceiling_exceeded,creditCeilingIrr),BnplSchedule(downPaymentIrr,dueDate+amountIrrper installment),BnplTransaction(statusstate-machine code:eligible/token_issued/verified/settled/reverted/cancelled/failed,installmentCount,outstandingBalanceIrr,installments[]with per-rowstatusanddueDate). All money is the IRR-string type from the money-and-types contract.keys.ts— a query-key factory:bnplKeys.providers(bookingId),bnplKeys.eligibility(bookingId),bnplKeys.transaction(bookingId)/bnplKeys.walletStatus().apis/clientApi.tswrappingclientFetch—getBnplOptions(bookingId),checkEligibility({ bookingId, nationalId, mobile, consent }),issueToken({ bookingId, providerCode, planSelection }),acceptSchedule({ bookingId, ... })/ pay-down-payment,getBnplTransaction(bookingId), andgetWalletInstallments(). Map these to the b12 routes from the contract (POST /checkout/bnpl/eligibility,POST /checkout/bnpl/token, the provider-handoff/verify return, and the read endpoints). If a route or shape is missing from the contract, see §4.hooks/— one hook per file:useBnplOptions(query),useCheckEligibility(mutation),useIssueBnplToken(mutation),useAcceptBnplSchedule(mutation, invalidates the booking + checkout query so the confirmed booking is not refetched stale),useBnplTransaction(query),useWalletInstallments(query). DeliberatestaleTime(eligibility/options are short-lived; wallet-status is moderate). Don't toast 401/403/5xx — only domain 4xx (ineligible, ceiling-exceeded, token-expired) get a message.index.tsbarrel.
3.2 D1 · روش پرداخت (Payment method) — the branch off C6
The payment-method chooser the family reaches from C6 (Summary & pay). Shows the payable amount (reuse the f0 price-breakdown / money util — Toman display), then the method options:
- پرداخت کامل با کارت (full card) — selecting it returns to / continues the f9 card flow (do not rebuild it).
- Installment providers (terracotta-accented option cards, each with provider branding/label and a
one-line plan summary): دیجیپی (3–12 installments), اسنپپی (۴ قسط بدون بهره / 4 interest-free),
اقساط بالینیار (in-house plan). Render the provider list from
useBnplOptions— do not hardcode the provider set or fees; the contract is the source. Primary action: "ادامه با {provider}".
States: options-loading (skeleton), loaded, empty/none-eligible (only card shown), error (retry / fall back to card). If the booking is not BNPL-eligible (§1 branch note), the installment options are hidden and only card shows.
3.3 D2 · انتخاب طرح اقساط (Choose plan)
For the chosen provider, the plan selector. Shows the total amount and the plan options the contract
returned — e.g. ۳ ماهه (بدون کارمزد), ۶ ماهه (کارمزد ۴٪), ۱۲ ماهه (کارمزد ۹٪) — each rendering
its monthly amount and (where present) down-payment % (پیشپرداخت ۲۰٪). A single-select plan card
group (terracotta), plus a down-payment indicator. Every amount comes through the money util from the
contract's IRR strings — the client computes nothing about money beyond formatting; if the contract gives
per-plan monthlyAmountIrr use it, otherwise show only what the contract provides. Primary action: "ادامه".
States: loading, loaded, none (no plans for this provider → back to D1).
3.4 D3 · اعتبارسنجی (Credit eligibility)
The provider credit check. Fields: کد ملی (national ID), موبایل (mobile, prefilled from the session),
and a consent checkbox — "با استعلام اعتبارسنجی … موافقم" — which gates the submit (no consent →
disabled). On submit, call useCheckEligibility. Result panel:
- approved → "اعتبار شما تایید شد" + the returned credit ceiling (سقف اعتبار, money util). Action: "تایید و ادامه" → D4.
- not_eligible / ceiling_exceeded → a clear declined panel with a "پرداخت با کارت" fall-back to the f9 card flow. (Ceiling-exceeded copy: the booking total exceeds the available credit.)
- error/timeout → retry or fall back to card.
National-ID input validation is client-side format only (10 digits) — the real check is the provider's; surface its result, don't pre-judge. Reuse the f0 phone-field for the mobile display.
3.5 D4 · تایید طرح و قرارداد (Schedule & contract)
The repayment schedule + contract acceptance. Renders the repayment table from the contract's schedule:
a پیشپرداخت (down-payment) — today row, then قسط ۱…N rows each with a Shamsi due date and an
amount (money util). A terms/contract acceptance checkbox that gates the final action. Primary
action: "تایید نهایی و پرداخت پیشپرداخت" → useIssueBnplToken / useAcceptBnplSchedule, which performs
the provider handoff (redirect or in-app token state — follow whatever the contract specifies, mirroring
f9's card redirect handling) and pays the down-payment. On success: the booking confirms — route to the
f9 confirmation / booking-confirmed UI (reused, not rebuilt), now reflecting "paid via installments".
States: schedule-loading, handoff/redirect in-progress (spinner + "در حال انتقال به {provider}"), success
(→ confirmation), declined/expired-token (→ retry or card).
The contract-acceptance copy must reflect the ownership truth: the installment agreement is between the customer and the provider (provider-financed, provider bears risk); Balinyaar is the merchant being paid in full. Get this exact copy from the frontend-designer skill / product docs, in both locales.
3.6 D5 · پیگیری اقساط (Installment status — in Wallet)
The Wallet-tab view of an active installment plan (bottom tab nav, Wallet active). It reads
useWalletInstallments and renders a provider-reported status:
- Outstanding-balance card (مانده بدهی, money util) — terracotta.
- Next-installment date + an "پرداخت زودهنگام" (early pay) affordance — but early-pay is a provider action, not a Balinyaar transaction: link out / hand off to the provider; do not build a Balinyaar payment for it.
- Due-date list — one row per installment with a status chip (reuse the f0 status chip): پرداختشده (paid) / سررسید نزدیک (due soon) / آینده (future), each with a Shamsi due date and amount.
- A short "وضعیت اقساط نزد {provider} ثبت میشود" / provider-owned note so the user understands Balinyaar is displaying, not managing, this schedule.
States: no-active-plan (empty Wallet installments section), loading, error (provider status unavailable → "وضعیت اقساط در دسترس نیست"). If the f12 nurse-earnings Wallet content also lands here later, keep D5 a self-contained section under the Wallet route.
3.7 i18n & tokens
Every user-visible string is a key in the bnpl namespace (seeded in f0) in both messages/en.json
and messages/fa.json, in sync, fa default & RTL-first. Provider names render from the contract but
their surrounding copy is i18n. Colours from tokens.css (the terracotta financial accent via the
designer's tokens) — never hardcoded hex in sx.
Out of scope (DEFERRED — do not build here)
- BNPL refund / revert UI — the cancellation + refund-status surface (incl. the BNPL "~7–10 business
days, provider-owned" ETA) is f10 (
frontend-phase-10-b11.md). Don't duplicate it. - Admin BNPL revert/cancel console — admin-side BNPL ops live in f15
(
frontend-phase-15-b15.md). - Multiple-provider routing / tranched settlement — b12 ships one provider mock; treat the provider list as data, but don't build provider-comparison or multi-provider reconciliation UI.
- Customer per-installment webhook / default handling — there is none on Balinyaar's side; the provider owns it (D5 is read-only status).
4. Mocks & seams in this phase
No new client seam family is introduced here — you reuse the services/{domain} seam pattern from
frontend-phase-0.md. The BNPL provider integration itself is mocked on the
backend behind IBnplProvider (b12) — the frontend just consumes the b12 endpoints.
- If the b12 contract is published and live: wire
services/bnpl/apis/clientApi.tsto the real routes; no client mock needed. - If b12 is not yet merged when you run: build a mock
clientApibehind the sameservices/bnplseam (per operating-rules §6) that returns deterministic shapes — a provider list, an always-eligibleeligibility result with a sample ceiling, a sample 6-month plan + schedule, and a sample D5 status with a paid/due/future mix — so D1–D5 are fully exercisable. Record the mock in your report and as a row inreports/mocks-registry.md; it is swapped for the realclientApi(one file) once b12 lands. - Contract gaps: any shape the contract doesn't provide that D1–D5 need (e.g. per-plan
monthlyAmountIrr, the schedule rows, the D5 provider-reportedinstallments[], abnplEligibleflag on the booking) → append a request todev/shared-working-context/frontend/requests/for-backend.mdand mock behind the seam meanwhile. Never edit backend files.
5. Critical rules you must not get wrong
- The installment repayment is OWNED BY THE PROVIDER. Balinyaar shows status; it does not run the schedule. D5 renders provider-reported balance/due-dates/status — it is not a Balinyaar-managed ledger and Balinyaar never settles a customer installment. "Early pay" hands off to the provider.
- The provider pays Balinyaar the full amount up-front and bears 100% of customer-default risk — the contract-acceptance copy (D4) and the D5 ownership note must reflect that the installment agreement is customer ↔ provider, interest-free-to-customer, provider-financed. Do not imply Balinyaar lends.
- Money correctness (verbatim, the payments-track invariants that bind the client): all internal money
is IRR
BIGINT, no floats anywhere — the client receives IRR as strings and must use the money util (formatIrrToToman/ integer-safe parse) for every amount; neverNumber()-coerce an IRR string or do arithmetic on money in the client. The split is gross = commission + payout; the ledger is append-only and balanced; settlement is reconciled via webhook idempotency; payout is one per booking and dispute-window-gated. The client never computes commission, fee, ceiling, or schedule amounts — it renders whatever the contract returns (commission and BNPL fee are per-contract config, never hardcoded in the UI). - A confirmed BNPL booking is, to Balinyaar, a card payment that landed net-of-fee — after the down-payment clears, reuse the f9 booking-confirmed/confirmation UI; do not build a parallel confirmation, and do not show the customer a Balinyaar-side installment ledger.
- Eligibility & consent are gates. D3 submit is disabled without the consent checkbox; D4 final action is disabled without contract acceptance. Decline / ceiling-exceeded must offer the card fall-back (f9), never a dead end.
- RSC/client boundary — payment interactions are client components; don't pull
next-intl/serveror server cookies into them. Caching: setqueryKey/staleTimedeliberately; the accept-schedule mutation invalidates the booking/checkout queries so the confirmed booking isn't refetched stale and the wallet status reflects the new plan. - RTL-first,
fadefault, both locales in sync; terracotta financial accent from tokens (not teal, not hardcoded hex); MUI v9 primitives reused (no re-implemented Button/Card); shareable composites at the shared level. Keepnpm run checkgreen throughout.
6. Definition of Done
The shared definition-of-done.md, plus:
services/bnplexists following the f0 pattern (types from the b12 contract,keys.ts,clientApi, one-hook-per-file, deliberate caching + mutation invalidation).- D1–D5 are built under the customer shell, terracotta-financial (frontend-designer-driven), with all states (loading / loaded / empty / declined / error) handled; D5 lives under the Wallet tab.
- The branch off C6 works: choosing اقساط leads into D1–D4 and, on a cleared down-payment, routes to the reused f9 confirmation with the booking confirmed; the card fall-back is reachable from decline/ceiling-exceeded/error.
- D5 is presented as provider-reported status with the ownership note; early-pay hands off to the provider; no Balinyaar-side installment ledger is implied anywhere.
- Every money value renders through the money util; nothing about money is computed client-side; no hardcoded fee/ceiling/provider set.
bnplstrings in bothen.json/fa.json, in sync, RTL-correct; colours from tokens.npm run checkgreen;npm run test:cigreen for any shared composite you add/touch (e.g. a reusable installment-schedule row/card or plan-option card gets a co-located*.test.tsx).client/CLAUDE.mdProject Structure updated if you add a route group/folder for the BNPL screens or theservices/bnpldomain.
7. How to test (what a human can verify after this phase)
Run npm run dev, sign in as a customer, reach a confirmed booking's checkout (C6) — using the f9
flow (or the seam mock if b12 isn't merged):
- On C6, choose اقساط → D1 shows the payable amount + provider options (دیجیپی / اسنپپی / اقساط بالینیار) loaded from the contract/mock, terracotta-accented. Pick a provider → "ادامه با …".
- D2 shows the plan options (e.g. ۳/۶/۱۲ ماهه) with monthly amount + down-payment for the chosen provider; select a plan → "ادامه". Verify amounts render in Toman via the money util.
- D3 — enter کد ملی, confirm the prefilled mobile, tick consent (submit stays disabled until you
do), submit → mock approves with a credit ceiling → "تایید و ادامه". Then verify the declined
path (mock a
not_eligible/ceiling_exceeded) shows the panel and the card fall-back. - D4 — the repayment table shows پیشپرداخت (today) + قسط rows with Shamsi due dates and amounts; accept the contract (final action disabled until ticked) → "تایید نهایی و پرداخت پیشپرداخت" → provider handoff → the booking confirms and you land on the f9 confirmation marked as paid via installments.
- Open the کیفپول (Wallet) tab → D5 shows the provider-reported outstanding balance, next due date, the per-installment due list with status chips (پرداختشده / سررسید نزدیک / آینده), the ownership note, and an early-pay hand-off (not a Balinyaar payment).
- Switch locale to
en→ all BNPL strings translate, layout flips correctly (RTL ⇄ LTR); dark mode intact. npm run checkand (if a shared composite changed)npm run test:cipass.
This becomes the "what can be tested" section of your report.
8. Hand off & document (close the phase)
- Docs: update
client/CLAUDE.mdProject Structure for the BNPL route(s) and theservices/bnpldomain; if you establish a reusable installment-schedule/plan-option composite, note it. If you discover a BNPL business-rule gap while building (e.g. the eligibility/ceiling copy, the threshold flag), record the decision inproduct/business/09-installments-bnpl.md— don't invent rules; flag uncertain ones in your report. - Contract: consume
dev/contracts/domains/bnpl.md(b12) for all types/routes — do not guess shapes. Any gap (per-plan monthly amount, schedule rows, D5installments[], abnplEligibleflag) goes todev/shared-working-context/frontend/requests/for-backend.md. - Handoff & report: append your phase summary to
shared-working-context/frontend/STATUS.md; writereports/frontend-phase-11-report.md(the D1–D5 screens +services/bnplbuilt; what is now testable and exactly how — the C6→D1→…→D4→confirm→D5 walkthrough; what is mocked behind the seam and how it swaps to the real b12clientApi; the contract consumed + any gaps filed; follow-ups for f15 admin BNPL). Add/refresh the BNPL row inreports/mocks-registry.mdif you mocked the client seam. - Memory: save a
projectmemory note for the non-obvious decision that D5 is provider-reported, not a Balinyaar-managed ledger, and that BNPL confirmation reuses the f9 confirmation — with aMEMORY.mdpointer, so a future agent doesn't build a Balinyaar installment ledger or a duplicate confirmation screen.