Files
baya-monorepo/archive/post-phase/ui/audit/checkout-money.md
T
2026-08-02 18:48:32 +03:30

19 KiB
Raw Blame History

Checkout & money surfaces (customer side)

Current state

The card flow is a four-screen chain: C6 checkout (bookings/checkout/page.tsx) renders an acceptance StatusChip, an EngagementSummary mini-card (variant/nurse/patient/Shamsi date), a terracotta CountdownTimer for the payment deadline, the reconciling PriceBreakdown (service cost × visit count / commission / VAT / total), the shared EscrowNotice, and a terracotta contained pay CTA plus an outlined BNPL branch button. checkout/return/page.tsx fires an idempotent gateway-return report then polls usePaymentOutcome, rendering a pending StateCard (CircularProgress + PaymentStatusBadge + manual «بررسی دوباره»), a designed failure card with retry/back, and a window-expired card; success hands off to checkout/confirmation/page.tsx (64px verified icon, total-paid panel, view-booking + invoice CTAs, optional «پرداخت‌شده با اقساط» line). BNPL is a 4-step wizard (checkout/bnpl/page.tsx + MethodStep/PlanStep/EligibilityStep/ScheduleStep) with a MUI StepperHeader, ButtonBase selection cards (BnplPlanCard, provider rows with two-letter glyph logo stand-ins), consent-gated eligibility and contract steps, and its own return surface; checkout/bnpl/gateway/page.tsx is a dev provider harness still shipped in the route tree. The invoice page (bookings/[id]/invoice/page.tsx) reuses PriceBreakdown, shows invoice number (dir=ltr), Shamsi issue date and read-only مودیان status, and prints via a visibility-scoped print area with a dark→light token flip. /wallet is a thin shell around WalletInstallments — provider-reported BNPL plans only (terracotta outstanding-balance card, InstallmentScheduleRow due list, provider-ownership note).

All money passes through the BigInt-safe utils/money.ts (formatIrrToToman → Intl fa-IR Persian digits + grouping; Toman display-only, Rial digit-strings on the wire; a dev-mode guard in PriceBreakdown asserts rows sum to the total). Styling is token-disciplined (var(--bal-*) everywhere, no hard-coded hexes found in this area, both schemes defined in tokens.css), RTL hygiene is genuinely good (logical textAlign:'start', insetInlineStart, LTR-forced countdown clock and invoice number). The weaknesses are compositional rather than mechanical: every screen is a flat single column of elevation={0} bordered Papers with h6-as-h1 headings, amount+«تومان» is hand-composed ad hoc on six-plus surfaces (no shared Money primitive beyond the catalog-specific PriceDisplay), and the trust moments (total, escrow, confirmation receipt) are visually underweighted for a payment product.

Problems (18)

  • [high] client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx — The top-level Wallet bottom-nav tab (labelled «کیف‌پول» / title «کیف‌پول و اقساط») contains only BNPL installment plans — no payment history, no refund status, no receipts, no balance. For every customer who paid by card (the default path, BNPL is mock-gated) this is a permanently empty tab showing «طرح اقساط فعالی ندارید», which both under-delivers on the 'wallet' promise and wastes 1 of 5 nav slots on the money product's most trust-sensitive surface.
    • evidence: WalletPage renders only (wallet/page.tsx:9); empty state at WalletInstallments.tsx:50-51 is a PlaceholderScreen about installments only
  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx — The post-payment confirmation has no payment reference: no transaction/tracking code (کد پیگیری), no payment date-time, no payment method, no booking number. Iranian users screenshot payment receipts and expect a reference number to quote in disputes; here the receipt panel is just amount + variant + nurse name. Worse, the amount panel is fetched via useCheckoutSummary with no error/loading handling — if that fetch fails the paid amount silently disappears (lines 63-87 render nothing on !summary) and the 'receipt' is just a title and two buttons.
    • evidence: lines 51-113: only totalIrr, variantLabel, nurseName rendered; {summary ? (...) : null} with no fallback
  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx — Weak visual hierarchy for a payment page: the page h1 is a plain variant="h6" (line 159), the total — the single most important number — is a small subtitle2 row inside the breakdown card (PriceBreakdown.tsx:62), and the pay CTA is the last element of a scroll column after countdown + breakdown + escrow notice, not sticky and not paired with the amount. On mobile (the primary experience) the user must scroll past everything to find the button, and nothing on screen answers 'how much am I about to pay' at the moment of tapping pay.
    • evidence: checkout/page.tsx:155-226 — flat Stack; CTA at lines 203-212 with no sticky container and no amount on/near the button
  • [medium] client/src/components/PriceBreakdown/PriceBreakdown.tsx — Currency-unit ambiguity — the classic Iranian Toman/Rial trust hazard: individual breakdown rows render bare grouped numbers with no «تومان» label (line 53), only the total row appends the unit (line 63). InstallmentScheduleRow.tsx:57 omits the currency label entirely on every installment amount, while BnplPlanCard and MethodStep do show it. Amounts are wire-Rials rendered as Toman, so an unlabelled number is exactly the case users second-guess.
    • evidence: PriceBreakdown.tsx:52-55 rows have no currency label; InstallmentScheduleRow.tsx:57 {formatIrrToToman(row.amountIrr, locale)} with no unit
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/return/page.tsx — Mislabelled CTA in the invalid-link state: the button reads t('pay_with_card') («پرداخت با کارت») but navigates to the bookings list, not a card checkout — the label promises a payment action the click cannot perform.
    • evidence: lines 100-104: onClick={() => router.replace(/${locale}${ROUTES.BOOKINGS})} with label {t('pay_with_card')}
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx — During the pay-initiate busy state (isPending/isSuccess) only the card CTA is disabled — the «پرداخت اقساطی» BNPL button stays fully tappable (no disabled={busy}), so a user can launch the BNPL wizard while a card payment initiation/redirect is in flight, racing two payment paths for the same request.
    • evidence: lines 214-224: BNPL AppButton has onClick router.push but no disabled prop; busy (line 148) is applied only to the pay button (line 207)
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx — Financial-provider selection uses two-letter text glyphs (DG/SP/TA/…) as logo stand-ins in a 40×28 tinted box. Choosing a credit provider from fake wordmark chips reads as unfinished and undermines exactly the trust the BNPL step needs; the code itself marks them as stand-ins awaiting real assets.
    • evidence: lines 17-24 PROVIDER_GLYPH map + comment 'real logos land with the provider assets'
  • [medium] client/src/components/BnplPlanCard/BnplPlanCard.tsx — The down payment is communicated only as a percentage plus a LinearProgress bar (lines 77-98) — a static fact styled as a loading indicator — and the actual down-payment amount in Toman is never shown anywhere in the plan card or D2 step; the user must mentally compute percent × total to know what they'll pay today. The plan's total repayment cost (with fee) is likewise absent from the card.
    • evidence: lines 79-96: percent label + <LinearProgress variant="determinate" value={asPercent(...)}>, no Toman figure
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx — The «مبلغ کل» header card shows the first plan's total before any selection and silently swaps to the selected plan's total on tap (fee plans differ from interest-free ones) — an amount that changes without explanation, with no label saying which plan it reflects and no fee delta called out.
    • evidence: lines 54-56: const shownPlan = plans.find(...) ?? plans[0]; feeding the 'total_amount' header
  • [medium] client/messages/fa.json — The brand is spelled two different ways in fa: common.brand = «بالین یار» (plain space) while payment.issuer_platform = «بالین‌یار» (ZWNJ, the product-docs spelling). An inconsistently spelled brand name — on money surfaces and a fiscal invoice of all places — is a direct trust leak; the invoice page even carries a code comment acknowledging the mismatch instead of fixing it.
    • evidence: fa.json common.brand «بالین یار» vs payment.issuer_platform «بالین‌یار»; invoice/page.tsx:148-149 comment
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx — The invoice is print-capable but not audit-worthy: no customer name, no service/nurse identification, no service date, no booking reference, no payment method/transaction id, and no seller fiscal identity (tax/economic ID, address) that a real Iranian VAT invoice carries — just number, issue date, three lines and a مودیان chip. As the document families keep for reimbursement/dispute it is too thin.
    • evidence: lines 138-184: header + MetaRow(invoiceNumber, issuedAt) + 3-row PriceBreakdown + moadian chip is the entire document
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/return/page.tsx — The payment-pending state stacks three redundant signals of the same fact — title «در حال تایید پرداخت…», a generic CircularProgress, and a PaymentStatusBadge 'pending' chip (lines 139-147) — a spinner-centric default rather than a designed wait state; the failure state similarly doubles an error icon with a 'failed' chip (lines 113-115). Functional, but reads default-MUI at the flow's most anxious moment.
    • evidence: lines 139-147 pending StateCard; lines 112-135 failure StateCard
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx — MessageCard is copy-pasted verbatim into bnpl/page.tsx (lines 178-209 there) and StateCard is duplicated between checkout/return and bnpl/return — four private near-identical terminal-state cards across one flow, guaranteeing visual drift in the surface where consistency signals reliability.
    • evidence: checkout/page.tsx:255-286 vs bnpl/page.tsx:178-209; return/page.tsx:151-180 vs bnpl/return/page.tsx:158-187
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx — A dev-only provider-handoff harness (dashed-border card with 'pay success / pay fail' buttons) ships inside the customer route tree and is reachable by URL in production builds; the equivalent card-gateway harness was already deleted in refinement, this one remains.
    • evidence: file header comment 'a test harness, not a product feature'; USE_BNPL_MOCK=true in services/bnpl/constants.ts:18
  • [low] client/messages/fa.json — Directional arrow glyphs are baked into the translated CTA copy (fa cta_pay ends with «←», en with «→») instead of an icon slot on the button — brittle typography that any copy edit or font change degrades, and inconsistent with every other CTA in the flow which uses AppButton startIcon.
    • evidence: payment.cta_pay: «ادامه پرداخت ←» (fa) / 'Continue to payment →' (en)
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx — The provider credit check — plausibly a multi-second external call — gives no in-progress feedback beyond a disabled button (line 163): no spinner, no label change (C6's pay button at least swaps to «در حال شروع…»), leaving the user staring at a dead form. The prefilled mobile field also uses disabled (low-contrast, skipped by screen readers) where read-only presentation would be clearer.
    • evidence: lines 140-146 disabled PhoneNumberField; line 163 disabled={!consent || check.isPending} with static label
  • [low] client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx — Money-surface column widths are inconsistent: the wallet self-constrains to maxWidth 560 (line 24) while checkout/confirmation/invoice stretch to the shell's full 800px (CONTENT_MAX_WIDTH, components/config.ts:4) with edge-to-edge CTAs — the same flow renders at two different reading widths on desktop.
    • evidence: WalletInstallments.tsx:24 maxWidth: 560 vs CustomerLayout.tsx:70 CONTENT_MAX_WIDTH=800
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx — The main payable state offers no back/cancel affordance — the customer shell's TopBar has no back arrow and only the error/expired branches render navigation, so a user who wants to re-read the request before paying must rely on browser back or abandon via bottom nav under a ticking payment countdown.
    • evidence: lines 155-226 render no link back to the request; CustomerLayout TopBar startNode is a support icon only

Opportunities (8)

  • Trust-forward checkout redesign: sticky pay bar + identity moment (impact: high, effort: medium) — Restructure C6 around the two questions users ask at payment: 'who am I paying for' and 'how much'. Give the EngagementSummary the nurse's avatar + verified TrustBadge (the components exist elsewhere in the app), lift the total out of the breakdown into a prominent h4 figure, and pin a sticky bottom pay bar (total + «پرداخت» button) above the customer shell's bottom nav so amount and action are always co-located on mobile. Add the expected Iranian trust marks near the CTA: gateway/Shaparak logos and a lock + «پرداخت امن از طریق درگاه بانکی» line so users know a bank gateway redirect is coming.
  • Real receipt on confirmation: reference code, timestamp, share (impact: high, effort: small) — Turn the confirmation into a screenshot-worthy receipt card: transaction/tracking code (کد پیگیری) in a copyable dir=ltr row, Shamsi payment date-time, payment method, booking number, and the escrow reassurance restated ('مبلغ به‌صورت امانی نگه‌داری می‌شود'). Add a share/save action and an 'SMS receipt sent' note. This is also where a 'what happens next' 2-step strip (nurse notified → visit day check-in) would extend trust past the payment.
  • Make /wallet the customer money hub (impact: high, effort: large) — The tab already exists and is empty for most users — fill it: payment history (all card + BNPL transactions with PaymentStatusBadge), refund entries reusing the existing RefundStatusCard, a receipts/invoices list deep-linking to the invoice page, and the current installments section beneath. This converts a dead nav slot into the single place families verify 'where my money went' — the core promise of an escrow marketplace.
  • Escrow explainer beyond one sentence (impact: high, effort: small) — EscrowNotice is one mandated sentence. Add an optional expandable 'چطور کار می‌کند' with a 3-step visual (پرداخت → امانت نزد بالین‌یار → آزادسازی پس از تایید پایان ویزیت) plus the cancellation/refund implication, linked from checkout and confirmation. Escrow is the platform's reason-to-pay-on-platform; one alert line under-sells it at the exact moment of maximum skepticism.
  • Shared primitive (impact: medium, effort: small) — Introduce one Money component (amount + «تومان» + size/tone/emphasis variants, optional strike-through for fee comparisons) and replace the six-plus ad-hoc formatIrrToToman(...) {tc('currency_toman')} compositions (confirmation, MethodStep, PlanStep, EligibilityStep, WalletInstallments, BnplPlanCard, PriceBreakdown rows, InstallmentScheduleRow). Guarantees the currency label is never dropped and money typography is identical everywhere.
  • Honest BNPL plan comparison (impact: medium, effort: medium) — On each plan card show the concrete Toman figures users actually decide with: down payment amount due today, monthly amount, and total repayment (with the fee delta vs interest-free made explicit, e.g. '+۴۵۰٬۰۰۰ تومان کارمزد'). Replace the LinearProgress down-payment bar with a plain labelled amount row. Consider a compact compare view when a provider offers 3+ plans.
  • Designed payment wait/result states (impact: medium, effort: medium) — Replace the spinner+chip+title pending card with a staged wait state (e.g. a 2-node progress: 'بازگشت از درگاه ✓ → در انتظار تایید بانک' with a calm animated indicator and expected duration), and give success a small warm moment (brand-toned check animation). Extract MessageCard/StateCard into one shared PaymentStateCard so card and BNPL flows can't drift.
  • Fiscal-grade invoice + A4 print stylesheet (impact: medium, effort: medium) — Extend the invoice with buyer name, service description + visit date(s), booking and transaction references, payment method, seller fiscal identity, and the مودیان tax reference once registered; add an @media print A4 layout (margins, footer with issue metadata) so the printed artifact looks like a document rather than a cropped web card.

Keep (do not regress)

  • The money pipeline itself: BigInt-safe utils/money.ts, Rial digit-strings on the wire, Toman display-only, Intl fa-IR Persian digits + grouping — no float money math anywhere in the area, and PriceBreakdown's dev-mode guard that displayed rows must reconcile to the total to the rial (PriceBreakdown.tsx:35-42).
  • EscrowNotice as a single shared, product-mandated component (with a thoughtful dark-scheme token comment) reused verbatim across surfaces instead of re-written copy.
  • State coverage discipline: every screen has a skeleton, designed error+retry, and empty states (no providers, no plans, empty wallet); benign 409s converge silently to the outcome read instead of surfacing scary toasts; idempotency-key-per-attempt on both pay and BNPL issue.
  • CountdownTimer: server-frozen deadline, self-contained 1s tick, LTR-forced clock with tabular-nums so HH:MM:SS survives RTL.
  • Token discipline and dark-mode care: no hard-coded hexes in the whole area, all colors via scheme-aware --bal-* tokens, and the invoice's data-mui-color-scheme flip so dark-mode users print on paper colors (invoice/page.tsx:101-113).
  • RTL hygiene: logical properties throughout (textAlign:'start', insetInlineStart in print rules), dir='ltr' on the invoice number and national-id input — no marginLeft/left hazards found in these files.
  • Terracotta used exactly as intended — the single money accent (pay CTA, BNPL selection borders/tints, outstanding-balance card, breakdown total) against calm teal/neutral chrome.
  • BNPL honesty architecture: ownership notes at the point of choice and in the contract step ('the agreement is customer ↔ provider'), consent checkboxes gating both credit check and contract, and every decline path offering the card fallback — never a dead end (EligibilityStep DeclinedPanel).
  • Invoice print mechanics: visibility-scoped print area so only the receipt prints, buttons excluded, position anchored with insetInlineStart.
  • VAT transparency: the invoice explicitly labels VAT as 'on Balinyaar's commission' with the served rate rendered as a percent — an unusually honest fee disclosure worth preserving through any redesign.