frontend phase 11

This commit is contained in:
hamid
2026-07-10 13:55:42 +03:30
parent ccfa27aff6
commit 67c028562e
43 changed files with 3275 additions and 24 deletions
+16 -5
View File
@@ -134,13 +134,21 @@ client/
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + disabled BNPL seam (f11)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
│ │ │ │ ── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice)
│ │ │ │ ── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment)
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → approved(ceiling)/declined(+card)
│ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff
│ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later)
│ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
@@ -193,6 +201,8 @@ client/
│ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
│ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
@@ -249,6 +259,7 @@ client/
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -346,11 +357,11 @@ async function MyServerComponent() {
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*``bnpl_revert` 710-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'bnpl'` — the f11 BNPL installment checkout (D1D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
files): `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`,
`notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
files): `reviews`, `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
+89
View File
@@ -839,5 +839,94 @@
"eta_expected_label": "estimated by {date}",
"cancel_booking_cta": "Cancel booking",
"refund_section_title": "Refund"
},
"bnpl": {
"title": "Installment payment",
"ownership_note": "The provider pays Balinyaar the full amount at once and bears 100% of the customer's default risk; installment repayment is between you and the provider.",
"payable_amount": "Payable amount",
"total_amount": "Total amount",
"monthly": "Monthly",
"continue": "Continue",
"error_title": "Something went wrong",
"error_body": "We couldn't load the installment options. Please try again.",
"provider_digipay": "Digipay",
"provider_snapppay": "SnappPay",
"provider_balinyaar": "Balinyaar Installments",
"provider_tara": "Tara",
"provider_torobpay": "TorobPay",
"provider_tagline_digipay": "3 to 12 installments",
"provider_tagline_snapppay": "4 interest-free installments",
"provider_tagline_balinyaar": "In-house plan",
"provider_tagline_tara": "Installment plan",
"provider_tagline_torobpay": "Installment plan",
"method_title": "Payment method",
"method_card": "Pay in full (bank card)",
"method_card_hint": "One-time payment, instant booking confirmation",
"installments_heading": "Pay in installments",
"continue_with": "Continue with {provider}",
"no_providers_title": "Installments unavailable",
"no_providers_body": "Installment payment isn't available for this booking right now. You can pay by card.",
"plan_title": "{provider} installment plan",
"plan_term_months": "{months, number} months",
"plan_installments": "{count, number} installments",
"plan_interest_free": "Interest-free",
"plan_fee": "{percent, number}% fee",
"down_payment": "Down payment",
"down_payment_percent": "{percent, number}% down payment",
"no_plans_title": "No plans available",
"no_plans_body": "This provider has no installment plans for this booking.",
"back_to_providers": "Choose another provider",
"eligibility_title": "Credit eligibility",
"national_id_label": "National ID",
"national_id_placeholder": "10-digit national ID",
"national_id_invalid": "National ID must be 10 digits.",
"mobile_label": "Mobile number",
"consent_label": "I agree to a credit and eligibility inquiry by {provider}.",
"check_eligibility": "Check eligibility",
"approved_title": "You're approved",
"credit_ceiling_label": "Available credit ceiling",
"approve_continue": "Confirm & continue",
"declined_not_eligible_title": "Not approved",
"declined_not_eligible_body": "Unfortunately the provider didn't approve the required credit.",
"declined_ceiling_title": "Credit ceiling exceeded",
"declined_ceiling_body": "This booking's total exceeds your available credit.",
"pay_with_card": "Pay with card",
"eligibility_error": "The inquiry failed. Try again, or pay by card.",
"schedule_title": "Repayment schedule",
"today": "Today",
"installment_n": "Installment {n, number}",
"contract_note": "The installment agreement is between you and {provider}; Balinyaar is paid the full booking amount up-front.",
"contract_consent": "I have read and accept the installment terms and contract.",
"pay_down_payment": "Confirm & pay down payment",
"redirecting": "Redirecting to {provider}…",
"handoff_title": "Redirecting to the provider",
"handoff_body": "Complete the installment agreement with {provider} to confirm your booking.",
"handoff_pay_success": "Pay down payment (demo)",
"handoff_pay_fail": "Cancel",
"settling_title": "Confirming your installment plan",
"settling_body": "The provider is settling your order. This won't take long.",
"settle_failed_title": "Installment payment didn't complete",
"settle_failed_body": "The provider didn't complete the transaction. You can retry or pay by card.",
"retry_installments": "Retry installments",
"check_again": "Check again",
"wallet_title": "Wallet & installments",
"outstanding_balance": "Outstanding installment balance",
"next_installment": "Next installment",
"early_pay": "Pay early",
"due_dates": "Due dates",
"status_paid": "Paid",
"status_due_soon": "Due soon",
"status_upcoming": "Upcoming",
"status_overdue": "Overdue",
"provider_owned_note": "Your installment status is recorded and managed by {provider}, not Balinyaar.",
"wallet_empty_title": "No active installment plan",
"wallet_empty_body": "If you pay for a booking in installments, your installment status will appear here.",
"wallet_error_title": "Installment status unavailable",
"wallet_error_body": "We couldn't reach the provider's installment status. Please try again.",
"paid_via_installments": "Paid in installments via {provider}",
"step_provider": "Provider",
"step_plan": "Plan",
"step_eligibility": "Eligibility",
"step_schedule": "Schedule"
}
}
+89
View File
@@ -839,5 +839,94 @@
"eta_expected_label": "تا حدود {date}",
"cancel_booking_cta": "لغو رزرو",
"refund_section_title": "بازپرداخت"
},
"bnpl": {
"title": "پرداخت اقساطی",
"ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.",
"payable_amount": "مبلغ قابل پرداخت",
"total_amount": "مبلغ کل",
"monthly": "ماهانه",
"continue": "ادامه",
"error_title": "خطایی رخ داد",
"error_body": "بارگذاری گزینه‌های اقساط ممکن نشد. دوباره تلاش کنید.",
"provider_digipay": "دیجی‌پی",
"provider_snapppay": "اسنپ‌پی",
"provider_balinyaar": "اقساط بالین‌یار",
"provider_tara": "تارا",
"provider_torobpay": "ترب‌پی",
"provider_tagline_digipay": "۳ تا ۱۲ قسط",
"provider_tagline_snapppay": "۴ قسط بدون سود",
"provider_tagline_balinyaar": "طرح داخلی",
"provider_tagline_tara": "طرح اقساطی",
"provider_tagline_torobpay": "طرح اقساطی",
"method_title": "روش پرداخت",
"method_card": "پرداخت کامل (کارت بانکی)",
"method_card_hint": "پرداخت یک‌جا و تایید فوری رزرو",
"installments_heading": "پرداخت اقساطی",
"continue_with": "ادامه با {provider}",
"no_providers_title": "اقساط در دسترس نیست",
"no_providers_body": "در حال حاضر پرداخت اقساطی برای این رزرو ممکن نیست. می‌توانید با کارت پرداخت کنید.",
"plan_title": "طرح اقساط {provider}",
"plan_term_months": "{months, number} ماهه",
"plan_installments": "{count, number} قسط",
"plan_interest_free": "بدون سود",
"plan_fee": "کارمزد {percent, number}٪",
"down_payment": "پیش‌پرداخت",
"down_payment_percent": "پیش‌پرداخت {percent, number}٪",
"no_plans_title": "طرحی موجود نیست",
"no_plans_body": "این ارائه‌دهنده برای این رزرو طرح اقساطی ندارد.",
"back_to_providers": "انتخاب ارائه‌دهنده دیگر",
"eligibility_title": "اعتبارسنجی",
"national_id_label": "کد ملی",
"national_id_placeholder": "کد ملی ۱۰ رقمی",
"national_id_invalid": "کد ملی باید ۱۰ رقم باشد.",
"mobile_label": "شماره موبایل",
"consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.",
"check_eligibility": "استعلام اعتبار",
"approved_title": "اعتبار شما تایید شد",
"credit_ceiling_label": "سقف اعتبار قابل استفاده",
"approve_continue": "تایید و ادامه",
"declined_not_eligible_title": "اعتبارسنجی تایید نشد",
"declined_not_eligible_body": "متأسفانه ارائه‌دهنده اعتبار لازم را تایید نکرد.",
"declined_ceiling_title": "سقف اعتبار کافی نیست",
"declined_ceiling_body": "مبلغ این رزرو از سقف اعتبار قابل استفاده شما بیشتر است.",
"pay_with_card": "پرداخت با کارت",
"eligibility_error": "استعلام ناموفق بود. دوباره تلاش کنید یا با کارت پرداخت کنید.",
"schedule_title": "جدول بازپرداخت",
"today": "امروز",
"installment_n": "قسط {n, number}",
"contract_note": "قرارداد اقساط میان شما و {provider} است؛ بالین‌یار مبلغ کامل رزرو را یک‌جا دریافت می‌کند.",
"contract_consent": "شرایط و قرارداد اقساط را خوانده‌ام و می‌پذیرم.",
"pay_down_payment": "تایید نهایی و پرداخت پیش‌پرداخت",
"redirecting": "در حال انتقال به {provider}…",
"handoff_title": "در حال انتقال به ارائه‌دهنده",
"handoff_body": "برای تایید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.",
"handoff_pay_success": "پرداخت پیش‌پرداخت (نمایشی)",
"handoff_pay_fail": "انصراف",
"settling_title": "در حال تایید طرح اقساط شما",
"settling_body": "ارائه‌دهنده در حال تسویه سفارش است. طولی نمی‌کشد.",
"settle_failed_title": "پرداخت اقساطی کامل نشد",
"settle_failed_body": "ارائه‌دهنده تراکنش را کامل نکرد. می‌توانید دوباره تلاش کنید یا با کارت پرداخت کنید.",
"retry_installments": "تلاش دوباره برای اقساط",
"check_again": "بررسی دوباره",
"wallet_title": "کیف‌پول و اقساط",
"outstanding_balance": "مانده بدهی اقساط",
"next_installment": "قسط بعدی",
"early_pay": "پرداخت زودهنگام",
"due_dates": "سررسیدها",
"status_paid": "پرداخت‌شده",
"status_due_soon": "سررسید نزدیک",
"status_upcoming": "آینده",
"status_overdue": "معوق",
"provider_owned_note": "وضعیت اقساط نزد {provider} ثبت و مدیریت می‌شود، نه بالین‌یار.",
"wallet_empty_title": "طرح اقساط فعالی ندارید",
"wallet_empty_body": "اگر رزروی را اقساطی پرداخت کنید، وضعیت اقساط اینجا نمایش داده می‌شود.",
"wallet_error_title": "وضعیت اقساط در دسترس نیست",
"wallet_error_body": "دسترسی به وضعیت اقساط ارائه‌دهنده ممکن نشد. دوباره تلاش کنید.",
"paid_via_installments": "پرداخت‌شده به‌صورت اقساطی از طریق {provider}",
"step_provider": "ارائه‌دهنده",
"step_plan": "طرح",
"step_eligibility": "اعتبارسنجی",
"step_schedule": "جدول"
}
}
@@ -0,0 +1,222 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, PhoneNumberField } from '@/components';
import { digitsOnly, formatIrrToToman } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
/** Mobile prefilled from the session (may be empty if unknown). */
sessionMobile: string;
/** A prior approval to re-show on back-navigation from D4 (so the approved panel survives, not the form). */
initialResult?: BnplEligibilityResult | null;
onApproved: (result: BnplEligibilityResult) => void;
onPayWithCard: () => void;
}
/**
* D3 · اعتبارسنجی the provider credit check. کد ملی (client-side format only the real check is the
* provider's), موبایل (prefilled from the session, read-only), and a consent checkbox that **gates** the
* submit. On approval the credit ceiling + «تایید و ادامه» D4. On decline / ceiling-exceeded the
* declined panel + a card fall-back (never a dead end). The verdict is surfaced, never pre-judged.
*/
const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
bookingRequestId,
providerCode,
sessionMobile,
initialResult,
onApproved,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const [nationalId, setNationalId] = useState('');
const [consent, setConsent] = useState(false);
const [submitted, setSubmitted] = useState(false);
const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined;
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
const nationalIdError = submitted && !nationalIdValid;
const providerName = t(`provider_${providerCode}`);
const handleSubmit = () => {
setSubmitted(true);
if (!nationalIdValid || !consent) return;
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent });
};
// Approved — show the ceiling + advance.
if (result?.isEligible) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
textAlign: 'center',
}}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
{t('approved_title')}
</Typography>
{result.creditCeilingIrr ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('credit_ceiling_label')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
</Typography>
</>
) : null}
</Stack>
</Paper>
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)} sx={{ m: 0 }}>
{t('approve_continue')}
</AppButton>
</Stack>
);
}
// Declined (not_eligible / ceiling_exceeded) — a clear panel + the card fall-back.
if (result && !result.isEligible) {
const ceiling = result.eligibilityStatus === 'ceiling_exceeded';
return (
<DeclinedPanel
title={ceiling ? t('declined_ceiling_title') : t('declined_not_eligible_title')}
body={ceiling ? t('declined_ceiling_body') : t('declined_not_eligible_body')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
/>
);
}
// Error / timeout — retry or fall back to card.
if (check.isError) {
return (
<DeclinedPanel
title={t('eligibility_title')}
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit}
retryLabel={tc('retry')}
/>
);
}
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<TextField
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
value={nationalId}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))}
error={nationalIdError}
helperText={nationalIdError ? t('national_id_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
disabled
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
sx={{ m: 0 }}
>
{t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard} sx={{ m: 0 }}>
{t('pay_with_card')}
</AppButton>
</Stack>
</Stack>
);
};
function DeclinedPanel({
title,
body,
cardLabel,
onPayWithCard,
onRetry,
retryLabel,
}: {
title: string;
body: string;
cardLabel: string;
onPayWithCard: () => void;
onRetry?: () => void;
retryLabel?: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{onRetry && retryLabel ? (
<AppButton variant="outlined" color="secondary" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard} sx={{ m: 0 }}>
{cardLabel}
</AppButton>
</Stack>
</Stack>
);
}
export default EligibilityStep;
@@ -0,0 +1,206 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { formatIrrToToman } from '@/utils';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps {
options: BnplOptions;
selectedProvider: ProviderCode | null;
onSelectProvider: (code: ProviderCode) => void;
onContinue: () => void;
onPayWithCard: () => void;
}
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
digipay: 'DG',
snapppay: 'SP',
balinyaar: 'ب',
tara: 'TA',
torobpay: 'TP',
};
/**
* D1 · روش پرداخت the branch off C6. Shows the payable amount, the full-card option (returns to the f9
* card flow never rebuilt here), and the installment providers loaded **from the contract/mock** (never
* hardcoded). Primary action «ادامه با {provider}». Empty provider set only the card option.
*/
const MethodStep: FunctionComponent<MethodStepProps> = ({
options,
selectedProvider,
onSelectProvider,
onContinue,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const hasProviders = options.providers.length > 0;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('method_title')}
</Typography>
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Paper>
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
<ButtonBase
onClick={onPayWithCard}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
p: 1.75,
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon="payment" size={24} color="var(--bal-primary)" />
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('method_card')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('method_card_hint')}
</Typography>
</Stack>
</Stack>
</ButtonBase>
{hasProviders ? (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-secondary-dark)' }}>
{t('installments_heading')}
</Typography>
{/* Ownership disclosure at the point of choice: the provider finances & owns the repayment. */}
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.8 }}>
{t('ownership_note')}
</Typography>
<Stack sx={{ gap: 1 }}>
{options.providers.map((provider) => (
<ProviderOption
key={provider.providerCode}
provider={provider}
selected={selectedProvider === provider.providerCode}
onSelect={() => onSelectProvider(provider.providerCode)}
/>
))}
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedProvider == null}
onClick={onContinue}
sx={{ m: 0 }}
>
{selectedProvider
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
: t('continue')}
</AppButton>
</>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('no_providers_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('no_providers_body')}
</Typography>
</Paper>
)}
</Stack>
);
};
function ProviderOption({
provider,
selected,
onSelect,
}: {
provider: BnplProvider;
selected: boolean;
onSelect: () => void;
}) {
const t = useTranslations('bnpl');
return (
<ButtonBase
data-provider={provider.providerCode}
data-selected={selected}
aria-pressed={selected}
onClick={onSelect}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
borderWidth: selected ? 2 : 1,
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 28,
borderRadius: 1,
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: 11,
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{PROVIDER_GLYPH[provider.providerCode]}
</Box>
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t(`provider_tagline_${provider.providerCode}`)}
</Typography>
</Stack>
{/* Decorative radio indicator the whole card is the ButtonBase; a real <input> here would nest
interactive content inside a <button> (invalid HTML) and warn on checked-without-onChange. */}
<Box
aria-hidden
sx={{
width: 18,
height: 18,
borderRadius: '50%',
flex: 'none',
border: '2px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'var(--bal-divider)',
backgroundColor: selected ? 'var(--bal-secondary)' : 'transparent',
boxShadow: selected ? 'inset 0 0 0 3px var(--bal-bg-paper)' : 'none',
}}
/>
</Stack>
</ButtonBase>
);
}
export default MethodStep;
@@ -0,0 +1,108 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard } from '@/components';
import { formatIrrToToman } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps {
providerCode: ProviderCode;
plans: BnplPlanOption[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
onContinue: () => void;
onBack: () => void;
}
/**
* D2 · انتخاب طرح اقساط the plan selector for the chosen provider. Shows the total amount and the plan
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
* Every amount comes through the money util from served IRR strings the client computes nothing about
* money. Empty plans back to D1.
*/
const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode,
plans,
selectedPlanId,
onSelectPlan,
onContinue,
onBack,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
if (plans.length === 0) {
return (
<Stack sx={{ gap: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('no_plans_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('no_plans_body')}
</Typography>
</Paper>
<AppButton variant="outlined" color="primary" onClick={onBack} sx={{ m: 0 }}>
{t('back_to_providers')}
</AppButton>
</Stack>
);
}
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// Use the selected plan's total, falling back to the first plan's for the header before any selection.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0];
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_amount')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{plans.map((plan) => (
<BnplPlanCard
key={plan.planId}
plan={plan}
selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan}
/>
))}
</Stack>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedPlanId == null}
onClick={onContinue}
sx={{ m: 0 }}
>
{t('continue')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
export default PlanStep;
@@ -0,0 +1,174 @@
'use client';
import { FunctionComponent, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow } from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ApiError } from '@/lib/api/errors';
import { useBnplSchedule, useIssueBnplToken } from '@/services/bnpl';
import type { IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
interface ScheduleStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
onBack: () => void;
/** The provider handoff — the page follows `redirectUrl`. */
onIssued: (result: IssueBnplTokenResult) => void;
/** A `409` (already paid / in progress / window lapsed) — the page converges by reading the order. */
onConverged: () => void;
}
/**
* D4 · تایید طرح و قرارداد the repayment schedule + contract acceptance. Renders the **served** repayment
* rows (پیشپرداخت today + قسط ۱N with Shamsi due dates + amounts), the ownership-truth note (the
* agreement is customer provider; Balinyaar is paid in full), and a contract-acceptance checkbox that
* **gates** the final action. «تایید نهایی و پرداخت پیشپرداخت» issues the provider token and hands off
* (the page follows the redirect); on success the booking confirms exactly as the card path.
*/
const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
bookingRequestId,
providerCode,
planId,
onBack,
onIssued,
onConverged,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: schedule, isLoading, isError, refetch } = useBnplSchedule(bookingRequestId, providerCode, planId);
const issue = useIssueBnplToken();
const [accepted, setAccepted] = useState(false);
// One idempotency key per handoff attempt, reused across retries of that attempt (mirrors C6).
const attemptKeyRef = useRef<string | null>(null);
const providerName = t(`provider_${providerCode}`);
const busy = issue.isPending || issue.isSuccess;
const handleConfirm = () => {
attemptKeyRef.current ??= crypto.randomUUID();
issue.mutate(
{ bookingRequestId, providerCode, planId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => onIssued(result),
onError: (error) => {
if (error instanceof ApiError && error.status === 409) onConverged();
},
},
);
};
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('redirecting', { provider: providerName })}
</Typography>
</Stack>
</Paper>
);
}
if (isLoading) return <ScheduleSkeleton />;
if (isError || !schedule) {
return (
<Stack sx={{ gap: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t('error_body')}
</AppAlert>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
</Stack>
);
}
const inlineError =
issue.error && !(issue.error instanceof ApiError && issue.error.status === 409) ? t('settle_failed_body') : null;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('schedule_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
{schedule.rows.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} />
))}
</Stack>
{/* The ownership truth: the installment agreement is customer ↔ provider; Balinyaar is paid in full. */}
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-secondary-dark)" />
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', lineHeight: 1.9 }}>
{t('contract_note', { provider: providerName })}
</Typography>
</Stack>
</Paper>
<FormControlLabel
control={<Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('contract_consent')}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!accepted}
onClick={handleConfirm}
sx={{ m: 0, py: 1.25 }}
>
{t('pay_down_payment')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
function ScheduleSkeleton() {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="40%" height={28} />
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
export default ScheduleStep;
@@ -0,0 +1,73 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PLAN,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
} from '@/services/bnpl/constants';
import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
/**
* Dev provider-handoff harness a **test harness, not a product feature**. It stands in for the BNPL
* provider so the initiate redirect return round-trip is exercisable without a real provider: the
* mock's `redirectUrl` points here, and the pay/cancel buttons drive both outcome branches of the return
* surface (a real provider redirects back after the customer completes or abandons the agreement). On the
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/
export default function BnplGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplGatewayScreen />
</Suspense>
);
}
function BnplGatewayScreen() {
const t = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(BNPL_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(BNPL_QUERY_TRANSACTION_ID) ?? '';
const provider = (params.get(BNPL_QUERY_PROVIDER) ?? '') as ProviderCode | '';
const providerName = provider ? t(`provider_${provider}`) : t('installments_heading');
const returnWith = (outcome: BnplHandoffOutcome) => {
const query = new URLSearchParams({
[BNPL_QUERY_REQUEST_ID]: requestId,
[BNPL_QUERY_TRANSACTION_ID]: transactionId,
[BNPL_QUERY_PROVIDER]: provider,
[BNPL_QUERY_PLAN]: params.get(BNPL_QUERY_PLAN) ?? '',
[BNPL_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('handoff_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('handoff_body', { provider: providerName })}
</Typography>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('handoff_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('handoff_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,222 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl';
import { BNPL_QUERY_REQUEST_ID, BNPL_QUERY_TRANSACTION_ID } from '@/services/bnpl/constants';
import { CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import type { BnplEligibilityResult, IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
import MethodStep from './MethodStep';
import PlanStep from './PlanStep';
import EligibilityStep from './EligibilityStep';
import ScheduleStep from './ScheduleStep';
type WizardStep = 'provider' | 'plan' | 'eligibility' | 'schedule';
const STEP_ORDER: WizardStep[] = ['provider', 'plan', 'eligibility', 'schedule'];
/**
* BNPL installment checkout (D1D4) the alternate branch off C6. A single stateful wizard: D1 method /
* provider D2 plan D3 eligibility D4 schedule + contract, then the provider handoff. On a cleared
* down-payment the return surface routes to the **reused f9 confirmation** (the booking confirms exactly
* as the card path). Reached with `?request_id=`; `useSearchParams` needs a Suspense boundary.
*/
export default function BnplCheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplCheckoutScreen />
</Suspense>
);
}
function BnplCheckoutScreen() {
const t = useTranslations('bnpl');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const tp = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const [{ currentUser }] = useAuth();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: options, isLoading, isError, refetch } = useBnplOptions(validId ? requestId : undefined);
const [step, setStep] = useState<WizardStep>('provider');
const [providerCode, setProviderCode] = useState<ProviderCode | null>(null);
const [planId, setPlanId] = useState<string | null>(null);
const [eligibility, setEligibility] = useState<BnplEligibilityResult | null>(null);
const toCard = () => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`);
const toBookings = () => router.replace(`/${locale}${ROUTES.BOOKINGS}`);
const toRequest = () => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`);
if (!validId) {
return (
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} />
);
}
if (isError) {
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />;
}
if (isLoading || !options) return <WizardSkeleton />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={tp('already_paid_title')}
body={tp('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={toRequest}
/>
);
}
if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined}
ctaLabel={t('pay_with_card')}
onCta={toCard}
/>
);
}
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [BNPL_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`;
};
const onIssued = (result: IssueBnplTokenResult) => {
if (!result.redirectUrl) {
// Nothing to hand off to — read the order directly on the return surface.
router.push(returnUrl({ [BNPL_QUERY_TRANSACTION_ID]: String(result.bnplTransactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real provider page — a full navigation outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
};
const activeProvider = options.providers.find((p) => p.providerCode === providerCode);
return (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.25, alignItems: 'center', textAlign: 'center' }}>
<Typography variant="h6" component="h1">
{t('title')}
</Typography>
</Stack>
<StepperHeader
activeStep={STEP_ORDER.indexOf(step)}
steps={[t('step_provider'), t('step_plan'), t('step_eligibility'), t('step_schedule')]}
/>
{step === 'provider' ? (
<MethodStep
options={options}
selectedProvider={providerCode}
onSelectProvider={setProviderCode}
onContinue={() => setStep('plan')}
onPayWithCard={toCard}
/>
) : null}
{step === 'plan' && providerCode && activeProvider ? (
<PlanStep
providerCode={providerCode}
plans={activeProvider.plans}
selectedPlanId={planId}
onSelectPlan={setPlanId}
onContinue={() => setStep('eligibility')}
onBack={() => setStep('provider')}
/>
) : null}
{step === 'eligibility' && providerCode ? (
<EligibilityStep
bookingRequestId={requestId}
providerCode={providerCode}
sessionMobile={currentUser?.phone ?? ''}
initialResult={eligibility}
onApproved={(result) => {
setEligibility(result);
setStep('schedule');
}}
onPayWithCard={toCard}
/>
) : null}
{step === 'schedule' && providerCode && planId ? (
<ScheduleStep
bookingRequestId={requestId}
providerCode={providerCode}
planId={planId}
onBack={() => setStep('eligibility')}
onIssued={onIssued}
onConverged={() => router.replace(returnUrl())}
/>
) : null}
</Stack>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function WizardSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="50%" height={32} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,187 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
import { isBnplSettlementSuccess } from '@/services/bnpl/types';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_REQUEST_ID,
} from '@/services/payment/constants';
/**
* Return-from-provider surface drives the tail of the BNPL checkout: report the return
* (`useAcceptBnplSchedule`; the settle trigger in the mock, an order read on the real path), then a brief
* settle-pending state backed by the bounded order poll until terminal. On settlement: hand off to the
* **reused f9 confirmation** marked «paid via installments» (the booking confirmed exactly as the card
* path); on decline: a retry (a fresh D4 = a new attempt) or the card fall-back; on window-lapse: back to
* the request.
*/
export default function BnplReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplReturnScreen />
</Suspense>
);
}
function BnplReturnScreen() {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(BNPL_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const provider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const outcome = params.get(BNPL_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const accept = useAcceptBnplSchedule();
const { mutate: acceptMutate } = accept;
// Fire the settle report exactly once per mount — a refresh replays it (idempotent convergence).
const firedRef = useRef(false);
useEffect(() => {
if (firedRef.current || !validId) return;
firedRef.current = true;
acceptMutate({ bookingRequestId: requestId, bnplTransactionId: transactionId, outcome });
}, [acceptMutate, validId, requestId, transactionId, outcome]);
const settled = accept.isSuccess || accept.isError;
const acceptSawSuccess = accept.data ? isBnplSettlementSuccess(accept.data) : false;
// Poll only for the late-settle case: when the accept result is ALREADY a settlement success (the mock
// down-payment-cleared path), the navigation effect hands off immediately — reading the order would be a
// needless fetch. Poll only when accept resolved without a settlement (real provider callback still in flight).
const orderQuery = useBnplOrder(validId ? requestId : undefined, { enabled: settled && !acceptSawSuccess });
const order = settled ? orderQuery.data : undefined;
const succeeded = acceptSawSuccess || order?.status === 'settled';
const windowExpired = accept.data?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && (accept.data?.status === 'failed' || order?.status === 'failed');
// Hand off to the confirmation exactly once. The accept mutation already invalidated on immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const bookingId = accept.data?.bookingId ?? order?.bookingId ?? null;
useEffect(() => {
if (!succeeded || navigatedRef.current) return;
navigatedRef.current = true;
if (!acceptSawSuccess) {
invalidateAfterBnplSettlement(queryClient, requestId, bookingId);
}
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: String(requestId),
[CHECKOUT_QUERY_METHOD]: CHECKOUT_METHOD_BNPL,
});
if (bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(bookingId));
if (provider) query.set(BNPL_QUERY_PROVIDER, provider);
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, acceptSawSuccess, bookingId, queryClient, requestId, provider, router, locale]);
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('pay_with_card')}
</AppButton>
</StateCard>
);
}
if (windowExpired) {
// The payment window lapsed during the handoff — card payment is impossible now, so route to the
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{tp('back_to_request')}
</AppButton>
</StateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('retry_installments')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('pay_with_card')}
</AppButton>
</StateCard>
);
}
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()} sx={{ m: 0 }}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
}
@@ -8,12 +8,19 @@ import { bookingInvoicePath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCheckoutSummary } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import {
BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
/**
* Post-payment confirmation the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
* to the bookings list and the invoice link is hidden the invoice route needs the booking id.
* the invoice («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached
* with `?method=bnpl` it also renders a «پرداختشده با اقساط» line (a settled BNPL order is, to
* Balinyaar, a card payment net-of-fee there is no separate BNPL confirmation). Without a `booking_id`
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list.
*/
export default function CheckoutConfirmationPage() {
return (
@@ -26,6 +33,7 @@ export default function CheckoutConfirmationPage() {
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
@@ -33,6 +41,8 @@ function ConfirmationScreen() {
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
@@ -65,6 +75,13 @@ function ConfirmationScreen() {
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
{isBnpl ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 600, mt: 0.5 }}>
{tBnpl('paid_via_installments', {
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
})}
</Typography>
) : null}
</Stack>
</Paper>
) : null}
@@ -210,14 +210,17 @@ function CheckoutScreen() {
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
{t('bnpl_option')}
</AppButton>
{!BNPL_ENABLED ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{tc('coming_soon')}
</Typography>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
sx={{ m: 0 }}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
</Stack>
@@ -0,0 +1,138 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/**
* D5 · پیگیری اقساط the Wallet view of active installment plans. It reads `useWalletInstallments` and
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays,
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content
* can land beside it later.
*/
const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
<Typography variant="h6" component="h1">
{t('wallet_title')}
</Typography>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('wallet_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('wallet_error_body')}
</Typography>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0, mt: 1 }}>
{tc('retry')}
</AppButton>
</Stack>
</Paper>
) : !plans || plans.length === 0 ? (
<PlaceholderScreen icon="installments" title={t('wallet_empty_title')} description={t('wallet_empty_body')} />
) : (
<Stack sx={{ gap: 3 }}>
{plans.map((plan) => (
<InstallmentPlanSection key={plan.bnplTransactionId} plan={plan} />
))}
</Stack>
)}
</Stack>
);
};
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const providerName = t(`provider_${plan.providerCode}`);
const handleEarlyPay = () => {
// Early-pay is a PROVIDER action — hand off to the provider, never a Balinyaar payment.
if (plan.earlyPayUrl) window.open(plan.earlyPayUrl, '_blank', 'noopener,noreferrer');
};
return (
<Stack sx={{ gap: 1.5 }}>
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
</Typography>
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
{plan.serviceLabel}
</Typography>
{plan.nextDueDate ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-end', mt: 1.5, gap: 1.5 }}>
<Box>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
</Typography>
{plan.nextAmountIrr ? (
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
</Typography>
) : null}
</Box>
{plan.earlyPayUrl ? (
<AppButton
color="primary"
variant="contained"
size="small"
onClick={handleEarlyPay}
sx={{ m: 0, flex: 'none' }}
>
{t('early_pay')}
</AppButton>
) : null}
</Stack>
) : null}
</Paper>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('due_dates')}
</Typography>
<Stack sx={{ gap: 1 }}>
{plan.installments.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} showStatus />
))}
</Stack>
{/* Ownership note: Balinyaar displays, it does not manage, this provider-owned schedule. */}
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'flex-start', px: 0.5 }}>
<AppIcon icon="info" size={16} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('provider_owned_note', { provider: providerName })}
</Typography>
</Stack>
</Stack>
);
}
export default WalletInstallments;
@@ -1,8 +1,10 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import WalletInstallments from './WalletInstallments';
export default async function WalletPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="wallet" title={t('wallet')} description={tShell('placeholder_body')} />;
/**
* /wallet the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported,
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client
* component (TanStack Query); this page is the thin route shell.
*/
export default function WalletPage() {
return <WalletInstallments />;
}
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys (+ params) and locale = en so the money util groups with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
useLocale: () => 'en',
}));
import BnplPlanCard from './BnplPlanCard';
import type { BnplPlanOption } from '@/services/bnpl/types';
const FEE_PLAN: BnplPlanOption = {
planId: 'digipay_6m',
termMonths: 6,
installmentCount: 6,
feePercent: 0.04,
downPaymentPercent: 0.2,
monthlyAmountIrr: '4040000', // 404,000 Toman
downPaymentIrr: '4660000',
totalIrr: '23300000',
};
const INTEREST_FREE_PLAN: BnplPlanOption = {
planId: 'snapppay_4',
termMonths: null,
installmentCount: 4,
feePercent: 0,
downPaymentPercent: 0,
monthlyAmountIrr: '5825000',
downPaymentIrr: '0',
totalIrr: '23300000',
};
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
const onSelect = props.onSelect ?? jest.fn();
render(
<ThemeProvider>
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} />
</ThemeProvider>,
);
return { onSelect };
}
describe('<BnplPlanCard/> component', () => {
it('renders the served monthly amount as grouped Toman (never derived)', () => {
renderCard();
expect(screen.getByText(/404,000/)).toBeInTheDocument();
expect(screen.getByText('monthly')).toBeInTheDocument();
});
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => {
renderCard();
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4%
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument();
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => {
renderCard({ plan: INTEREST_FREE_PLAN });
expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('marks the selected card via aria-pressed + data-selected', () => {
renderCard({ selected: true });
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-pressed', 'true');
expect(card).toHaveAttribute('data-selected', 'true');
});
it('calls onSelect with the planId when clicked', async () => {
const user = userEvent.setup();
const { onSelect } = renderCard();
await user.click(screen.getByRole('button'));
expect(onSelect).toHaveBeenCalledWith('digipay_6m');
});
});
@@ -0,0 +1,104 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
import { formatIrrToToman } from '@/utils';
import type { BnplPlanOption } from '@/services/bnpl/types';
export interface BnplPlanCardProps {
plan: BnplPlanOption;
selected: boolean;
onSelect: (planId: string) => void;
}
/** A whole-percent from a 0..1 fraction (display only — never money math). */
const asPercent = (fraction: number): number => Math.round(fraction * 100);
/**
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util never
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only.
* @component BnplPlanCard
*/
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const termLabel =
plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free');
const hasDownPayment = plan.downPaymentPercent > 0;
return (
<ButtonBase
data-plan-id={plan.planId}
data-selected={selected}
aria-pressed={selected}
onClick={() => onSelect(plan.planId)}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2.5,
p: 1.75,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
borderWidth: selected ? 2 : 1,
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{termLabel}
</Typography>
<Typography
variant="caption"
sx={{ color: plan.feePercent > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary' }}
>
{feeLabel}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
{formatIrrToToman(plan.monthlyAmountIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('monthly')}
</Typography>
</Stack>
</Stack>
{hasDownPayment ? (
<Box>
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={asPercent(plan.downPaymentPercent)}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'var(--bal-divider)',
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
}}
/>
</Box>
) : null}
</Stack>
</ButtonBase>
);
};
export default BnplPlanCard;
@@ -0,0 +1,4 @@
import BnplPlanCard from './BnplPlanCard';
export { BnplPlanCard as default, BnplPlanCard };
export type { BnplPlanCardProps } from './BnplPlanCard';
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys (+ params); locale = en so the money util groups with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
useLocale: () => 'en',
}));
import InstallmentScheduleRow from './InstallmentScheduleRow';
import type { BnplInstallmentRow } from '@/services/bnpl/types';
const DOWN_PAYMENT: BnplInstallmentRow = {
sequence: 0,
kind: 'down_payment',
dueDate: '2026-07-10',
amountIrr: '4660000', // 466,000 Toman
};
const INSTALLMENT: BnplInstallmentRow = {
sequence: 2,
kind: 'installment',
dueDate: '2026-09-01',
amountIrr: '4040000', // 404,000 Toman
status: 'paid',
};
function renderRow(props: React.ComponentProps<typeof InstallmentScheduleRow>) {
return render(
<ThemeProvider>
<InstallmentScheduleRow {...props} />
</ThemeProvider>,
);
}
describe('<InstallmentScheduleRow/> component', () => {
it('renders the down-payment row: label, «today» due, and served amount', () => {
renderRow({ row: DOWN_PAYMENT });
expect(screen.getByText('down_payment')).toBeInTheDocument();
expect(screen.getByText('today')).toBeInTheDocument();
expect(screen.getByText(/466,000/)).toBeInTheDocument();
expect(screen.getByText('down_payment')).toBeInTheDocument();
});
it('renders an installment row with the numbered label and no status chip by default', () => {
const { container } = renderRow({ row: INSTALLMENT });
expect(screen.getByText('installment_n:{"n":2}')).toBeInTheDocument();
expect(screen.getByText(/404,000/)).toBeInTheDocument();
// showStatus defaults to false → no chip even when a status is present.
expect(container.querySelector('[data-status]')).toBeNull();
});
it('renders the provider-reported status chip when showStatus is set', () => {
const { container } = renderRow({ row: INSTALLMENT, showStatus: true });
// status 'paid' → StatusChip kind 'verified' + label key 'status_paid'.
expect(container.querySelector('[data-status="verified"]')).not.toBeNull();
expect(screen.getByText('status_paid')).toBeInTheDocument();
});
it('exposes the row kind for automation', () => {
const { container } = renderRow({ row: DOWN_PAYMENT });
expect(container.querySelector('[data-row-kind="down_payment"]')).not.toBeNull();
});
});
@@ -0,0 +1,68 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import StatusChip from '../StatusChip';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { installmentStatusKind, type BnplInstallmentRow, type BnplInstallmentStatus } from '@/services/bnpl/types';
export interface InstallmentScheduleRowProps {
row: BnplInstallmentRow;
/** Render the provider-reported status chip (D5 Wallet). The D4 schedule preview omits it. */
showStatus?: boolean;
}
/** i18n key for each provider-reported installment status (labels are keys off the code, never derived). */
const STATUS_LABEL_KEY: Record<BnplInstallmentStatus, string> = {
paid: 'status_paid',
due_soon: 'status_due_soon',
upcoming: 'status_upcoming',
overdue: 'status_overdue',
};
/**
* One repayment row the down-payment (due «امروز») or an installment (Shamsi due date), with the
* **served** amount (Toman via the money util). Reused by the D4 schedule table and the D5 Wallet due list;
* `showStatus` adds the provider-reported status chip (D5). No money is computed here display only.
* @component InstallmentScheduleRow
*/
const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = ({ row, showStatus = false }) => {
const t = useTranslations('bnpl');
const locale = useLocale();
const label =
row.kind === 'down_payment' ? t('down_payment') : t('installment_n', { n: row.sequence });
const dueLabel = row.kind === 'down_payment' ? t('today') : formatShamsiDate(`${row.dueDate}T00:00:00`, locale);
return (
<Paper
elevation={0}
data-row-kind={row.kind}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{dueLabel}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: row.kind === 'down_payment' ? 'var(--bal-secondary)' : undefined }}
>
{formatIrrToToman(row.amountIrr, locale)}
</Typography>
{showStatus && row.status ? (
<StatusChip status={installmentStatusKind(row.status)} label={t(STATUS_LABEL_KEY[row.status])} />
) : null}
</Stack>
</Stack>
</Paper>
);
};
export default InstallmentScheduleRow;
@@ -0,0 +1,4 @@
import InstallmentScheduleRow from './InstallmentScheduleRow';
export { InstallmentScheduleRow as default, InstallmentScheduleRow };
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
@@ -70,6 +70,8 @@ import ClinicalIcon from '@mui/icons-material/HealthAndSafetyOutlined';
import MedicationIcon from '@mui/icons-material/MedicationOutlined';
import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
import LockIcon from '@mui/icons-material/LockOutlined';
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
/**
* List of all available Icon names
@@ -150,4 +152,5 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
medication: MedicationIcon,
emergency: EmergencyIcon,
lock: LockIcon,
installments: InstallmentsIcon,
};
+6
View File
@@ -24,6 +24,8 @@ import BookingRequestSummaryCard from './BookingRequestSummaryCard';
import PriceBreakdown from './PriceBreakdown';
import EscrowNotice from './EscrowNotice';
import PaymentStatusBadge from './PaymentStatusBadge';
import BnplPlanCard from './BnplPlanCard';
import InstallmentScheduleRow from './InstallmentScheduleRow';
export {
UserInfo,
@@ -50,6 +52,8 @@ export {
PriceBreakdown,
EscrowNotice,
PaymentStatusBadge,
BnplPlanCard,
InstallmentScheduleRow,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -73,3 +77,5 @@ export type { CountdownTimerProps } from './CountdownTimer';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
export type { BnplPlanCardProps } from './BnplPlanCard';
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
+8
View File
@@ -26,6 +26,14 @@ export const ROUTES = {
CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice.
CHECKOUT_CONFIRMATION: '/bookings/checkout/confirmation',
// BNPL installment checkout (f11) — the alternate branch off C6: D1 method → D2 plan → D3 eligibility →
// D4 schedule wizard, reached with `?request_id=`. On a cleared down-payment it routes to the reused
// confirmation above (the booking confirms exactly as the card path).
CHECKOUT_BNPL: '/bookings/checkout/bnpl',
// Provider-handoff harness (test-only stand-in for the provider redirect; the mock initiate points here).
CHECKOUT_BNPL_GATEWAY: '/bookings/checkout/bnpl/gateway',
// Return-from-provider surface — settle (down-payment cleared) → confirmation.
CHECKOUT_BNPL_RETURN: '/bookings/checkout/bnpl/return',
PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses',
+108
View File
@@ -0,0 +1,108 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type {
AcceptBnplScheduleInput,
BnplApi,
BnplEligibilityResult,
BnplOptions,
BnplOrderStatus,
BnplSchedule,
BnplSettlementResult,
CheckEligibilityInput,
GetBnplScheduleInput,
IssueBnplTokenInput,
IssueBnplTokenResult,
WalletInstallmentPlan,
} from '../types';
const CHECKOUT_BNPL = '/api/v1/checkout_bnpl';
/** The header b12's `initiate` reads the per-attempt idempotency key from. */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Real HTTP implementation of the `BnplApi` seam. `checkEligibility` and `issueBnplToken` map the published
* b12 contract 1:1, and the order read **by its own id** (`GET checkout_bnpl/{id}`) is contract-correct; the
* rest cover contract gaps the frontend filed:
* - `getBnplOptions` (D1/D2 provider+plan list, per-plan monthly/down-payment/total) **REQ-022**: the
* contract serves no options endpoint. Targets a proposed slug; 404s until it lands.
* - `getBnplSchedule` (D4 repayment table) **REQ-022**: the contract explicitly does not model the
* customer's repayment schedule. Targets a proposed slug; 404s until it lands.
* - `acceptBnplSchedule` (settle-on-return) the real path settles inside the provider **webhook**
* (the client is never trusted), so on return there is nothing to submit; it reads the order status and
* maps it (`settled` success, `converted` request success). **REQ-024** asks for a customer-readable
* `bookingId` on the order.
* - `getWalletInstallments` (D5 provider-reported status) **REQ-024**: no wallet/installment-status read
* exists (the repayment schedule is provider-owned and out of the contract's scope). Targets a proposed
* slug; 404s until it lands.
* - `getBnplOrder(bookingRequestId)` (the return poll) **REQ-024**: `GET checkout_bnpl/{id}` is keyed by
* the ORDER id, not the request id, and there is no customer *by-request* order lookup; the poll holds
* the request id, not the order id. Targets a proposed by-request slug; 404s until it lands. (The
* settle-on-return path below reads the order **by its own id**, which IS contract-correct.)
*
* NOT the primary implementation this phase (`USE_BNPL_MOCK = true`) see `constants.ts` for why.
*/
/** Read a BNPL order by its own id — the contract-correct `GET checkout_bnpl/{id}` (id = bnplTransactionId). */
const readOrderById = async (orderId: number): Promise<BnplOrderStatus> =>
unwrap(await clientFetch<ApiEnvelope<BnplOrderStatus>>(`${CHECKOUT_BNPL}/${orderId}`));
export const bnplClientApi: BnplApi = {
getBnplOptions: async (bookingRequestId: number) =>
// REQ-022 proposed slug (owner-scoped). 404s until the backend delivers it — the domain stays mock-primary.
unwrap(await clientFetch<ApiEnvelope<BnplOptions>>(`${CHECKOUT_BNPL}/options/${bookingRequestId}`)),
checkEligibility: async ({ bookingRequestId, providerCode, nationalId, mobile, consent }: CheckEligibilityInput) =>
unwrap(
await clientFetch<ApiEnvelope<BnplEligibilityResult>>(`${CHECKOUT_BNPL}/eligibility`, {
method: 'POST',
// The contract body is `{ bookingRequestId, providerCode }`; the D3 KYC inputs (nationalId/mobile/
// consent) are sent for the provider credit check (REQ-023 — the contract eligibility does not yet
// accept them). Extra fields are ignored server-side until the KYC step is defined.
body: JSON.stringify({ bookingRequestId, providerCode, nationalId, mobile, consent }),
}),
),
getBnplSchedule: async ({ bookingRequestId, providerCode, planId }: GetBnplScheduleInput) =>
// REQ-022 proposed slug. 404s until the backend serves the repayment schedule.
unwrap(
await clientFetch<ApiEnvelope<BnplSchedule>>(
`${CHECKOUT_BNPL}/schedule/${bookingRequestId}?provider=${providerCode}&plan=${planId}`,
),
),
issueBnplToken: async ({ bookingRequestId, providerCode, idempotencyKey }: IssueBnplTokenInput) =>
unwrap(
await clientFetch<ApiEnvelope<IssueBnplTokenResult>>(`${CHECKOUT_BNPL}/initiate`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
body: JSON.stringify({ bookingRequestId, providerCode }),
}),
),
acceptBnplSchedule: async ({ bookingRequestId, bnplTransactionId }: AcceptBnplScheduleInput) => {
// The provider settled via webhook before redirecting back — nothing to submit; read the order by its
// OWN id (`GET checkout_bnpl/{id}`, carried back as `transaction_id`). Fall back to the by-request read
// only if the order id wasn't carried through the handoff.
const order =
bnplTransactionId != null ? await readOrderById(bnplTransactionId) : await bnplClientApi.getBnplOrder(bookingRequestId);
const result: BnplSettlementResult = {
bnplTransactionId: order.id,
bookingRequestId,
// REQ-024: the b12 order gives no request status; the return page falls back to the order status.
requestStatus: order.status === 'settled' ? 'converted' : 'accepted_awaiting_payment',
status: order.status,
bookingId: order.bookingId,
providerCode: order.providerCode,
};
return result;
},
getBnplOrder: async (bookingRequestId: number) =>
// REQ-024 gap (see header): b12 has no customer *by-request* order lookup; the poll needs one. Proposed slug.
unwrap(await clientFetch<ApiEnvelope<BnplOrderStatus>>(`${CHECKOUT_BNPL}/by_request/${bookingRequestId}`)),
getWalletInstallments: async () =>
// REQ-024 proposed slug (owner-scoped, provider-reported). 404s until the backend serves it.
unwrap(await clientFetch<ApiEnvelope<WalletInstallmentPlan[]>>(`${CHECKOUT_BNPL}/wallet_installments`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_BNPL_MOCK } from '../constants';
import type { BnplApi } from '../types';
import { bnplClientApi } from './clientApi';
import { bnplMockApi } from './mockApi';
/**
* The selected `BnplApi` implementation the single seam the hooks import. Selection is by config
* (`USE_BNPL_MOCK`), never by scattered `if (mock)` checks.
*/
export const bnplApi: BnplApi = USE_BNPL_MOCK ? bnplMockApi : bnplClientApi;
+539
View File
@@ -0,0 +1,539 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import {
bookingRequestsMockApi,
mockMarkBookingRequestConverted,
} from '@/services/bookingRequests/apis/mockApi';
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import { MOCK_PLATFORM_FEE_RATE, MOCK_VAT_RATE } from '@/services/payment/constants';
import {
BNPL_QUERY_PLAN,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
MOCK_CREDIT_CEILING_IRR,
MOCK_NOT_ELIGIBLE_LAST_DIGIT,
} from '../constants';
import type {
AcceptBnplScheduleInput,
BnplApi,
BnplEligibilityResult,
BnplInstallmentRow,
BnplInstallmentStatus,
BnplOptions,
BnplOrderStatus,
BnplPlanOption,
BnplProvider,
BnplSchedule,
BnplSettlementResult,
BnplStatus,
CheckEligibilityInput,
GetBnplScheduleInput,
IssueBnplTokenInput,
IssueBnplTokenResult,
ProviderCode,
WalletInstallmentPlan,
} from '../types';
const MOCK_LATENCY_MS = 350;
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
const SESSION_COUNT = 1;
// Integer-only rate math: fractions as parts-per-10000 so the money path never touches a float.
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
const RATE_SCALE = BigInt(10_000);
const ZERO = BigInt(0);
function fractionPpm(fraction: number): bigint {
return BigInt(Math.round(fraction * Number(RATE_SCALE)));
}
/** `YYYY-MM-DD` for a whole-month offset from today (mock schedule dates — runs client-side, `new Date()` is fine). */
function isoMonthsFromNow(months: number): string {
const d = new Date();
d.setMonth(d.getMonth() + months);
return d.toISOString().slice(0, 10);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* A plan template the provider-config a real BNPL adapter owns. `feePercent` is the customer-facing plan
* fee (0 = بدون سود / interest-free, matching the interest-free-to-customer model for the ۴-قسط plans);
* the wireframe shows دیجیپی's longer terms carrying a small fee (کارمزد ۴٪/۹٪). All amounts the client
* shows are computed HERE (the server's job) and served as digit-strings the client never derives them.
*/
interface PlanTemplate {
planId: string;
termMonths: number | null;
installmentCount: number;
feePercent: number;
downPaymentPercent: number;
}
interface ProviderTemplate {
providerCode: ProviderCode;
plans: PlanTemplate[];
}
// Provider/plan set as **data** (per phase §Out-of-scope: treat the provider list as data, no multi-provider
// routing UI). دیجی‌پی (۳/۶/۱۲ ماهه), اسنپ‌پی (۴ قسط بدون سود), اقساط بالین‌یار (طرح داخلی) — matching the
// wireframe. Shapes/fees follow product/payments/bnpl-landscape.md; never hardcode a fee in the UI.
const PROVIDER_TEMPLATES: ProviderTemplate[] = [
{
providerCode: 'digipay',
plans: [
{ planId: 'digipay_3m', termMonths: 3, installmentCount: 3, feePercent: 0, downPaymentPercent: 0.2 },
{ planId: 'digipay_6m', termMonths: 6, installmentCount: 6, feePercent: 0.04, downPaymentPercent: 0.2 },
{ planId: 'digipay_12m', termMonths: 12, installmentCount: 12, feePercent: 0.09, downPaymentPercent: 0.2 },
],
},
{
providerCode: 'snapppay',
plans: [{ planId: 'snapppay_4', termMonths: null, installmentCount: 4, feePercent: 0, downPaymentPercent: 0.25 }],
},
{
providerCode: 'balinyaar',
plans: [{ planId: 'balinyaar_4', termMonths: null, installmentCount: 4, feePercent: 0, downPaymentPercent: 0.25 }],
},
];
/** The frozen order gross the provider finances: variant price × session count, never client-supplied. */
function requestGross(request: BookingRequestDto): string {
if (request.variantPrice == null) {
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
}
return multiplyIrr(request.variantPrice, SESSION_COUNT);
}
/** Split a plan's amounts (all BigInt): total (= gross + customer-facing fee), down-payment, regular installment. */
function planAmounts(grossIrr: string, plan: PlanTemplate) {
const gross = parseIrr(grossIrr);
const total = gross + (gross * fractionPpm(plan.feePercent)) / RATE_SCALE;
const downPayment = (total * fractionPpm(plan.downPaymentPercent)) / RATE_SCALE;
const financed = total - downPayment;
const regular = plan.installmentCount > 0 ? financed / BigInt(plan.installmentCount) : ZERO;
// The final installment absorbs the integer-division remainder so the rows sum to `total` exactly.
const last = financed - regular * BigInt(Math.max(plan.installmentCount - 1, 0));
return { total, downPayment, regular, last };
}
function toPlanOption(grossIrr: string, plan: PlanTemplate): BnplPlanOption {
const { total, downPayment, regular } = planAmounts(grossIrr, plan);
return {
planId: plan.planId,
termMonths: plan.termMonths,
installmentCount: plan.installmentCount,
feePercent: plan.feePercent,
downPaymentPercent: plan.downPaymentPercent,
monthlyAmountIrr: regular.toString(),
downPaymentIrr: downPayment.toString(),
totalIrr: total.toString(),
};
}
/** Build the served repayment rows (down-payment today + N monthly installments; last absorbs the remainder). */
function buildScheduleRows(grossIrr: string, plan: PlanTemplate): BnplInstallmentRow[] {
const { downPayment, regular, last } = planAmounts(grossIrr, plan);
const rows: BnplInstallmentRow[] = [
{ sequence: 0, kind: 'down_payment', dueDate: todayIso(), amountIrr: downPayment.toString() },
];
for (let i = 1; i <= plan.installmentCount; i += 1) {
const amount = i === plan.installmentCount ? last : regular;
rows.push({ sequence: i, kind: 'installment', dueDate: isoMonthsFromNow(i), amountIrr: amount.toString() });
}
return rows;
}
function findPlan(providerCode: ProviderCode, planId: string): PlanTemplate {
const provider = PROVIDER_TEMPLATES.find((p) => p.providerCode === providerCode);
const plan = provider?.plans.find((p) => p.planId === planId);
if (!plan) throw new ApiError(400, 'Unknown BNPL plan', 'unknown_plan');
return plan;
}
// The b8 booking amounts a settled order confirms (a settled BNPL order = a card payment net-of-fee, so the
// nurse payout is invariant to method — same split as the f9 capture). Commission net + VAT = balinyaar cut.
const FEE_RATE_PPM = fractionPpm(MOCK_PLATFORM_FEE_RATE);
const VAT_RATE_PPM = fractionPpm(MOCK_VAT_RATE);
function bookingAmounts(grossIrr: string) {
const gross = parseIrr(grossIrr);
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
const payout = gross - commissionNet - vat;
return {
grossPriceIrr: gross.toString(),
balinyaarCommissionIrr: (commissionNet + vat).toString(),
nursePayoutAmount: payout.toString(),
pspFeeAmount: ((gross * BigInt(200)) / RATE_SCALE).toString(),
};
}
interface MockBnplTx {
bnplTransactionId: number;
paymentTransactionId: number;
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
idempotencyKey: string;
status: BnplStatus;
externalPaymentToken: string;
orderAmountIrr: string;
totalIrr: string;
installmentCount: number;
schedule: BnplInstallmentRow[];
bookingId: number | null;
createdAt: string;
}
// Module-level stores (one browser session) — same singleton pattern as the f7/f8/f9 mocks, so the C6
// branch, the return settle, and the Wallet all observe the same order.
let transactions: MockBnplTx[] = [];
let walletPlans: WalletInstallmentPlan[] = [];
let nextBnplTxId = 71_001;
let nextPaymentTxId = 72_001;
function latestTxFor(bookingRequestId: number): MockBnplTx | undefined {
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
}
const providerEarlyPayUrl = (providerCode: ProviderCode, txId: number): string =>
`https://provider.example/${providerCode}/installments/${txId}/early-pay`;
/**
* Seed one active plan so D5 shows a provider-reported outstanding balance out of the box (like the refunds
* mock seeds a failed refund). A settled دیجیپی ۶-ماهه: قسط ۱ پرداختشده, قسط ۲ سررسید نزدیک, ۳۶ آینده
* the paid/due/future mix the wireframe shows. Outstanding = Σ of the not-yet-paid rows.
*/
function seedWallet(): void {
const rows: BnplInstallmentRow[] = [
{ sequence: 0, kind: 'down_payment', dueDate: isoMonthsFromNow(-1), amountIrr: '4660000', status: 'paid' },
{ sequence: 1, kind: 'installment', dueDate: isoMonthsFromNow(-1), amountIrr: '4040000', status: 'paid' },
{ sequence: 2, kind: 'installment', dueDate: isoMonthsFromNow(0), amountIrr: '4040000', status: 'due_soon' },
{ sequence: 3, kind: 'installment', dueDate: isoMonthsFromNow(1), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 4, kind: 'installment', dueDate: isoMonthsFromNow(2), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 5, kind: 'installment', dueDate: isoMonthsFromNow(3), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 6, kind: 'installment', dueDate: isoMonthsFromNow(4), amountIrr: '4040000', status: 'upcoming' },
];
const outstanding = rows
.filter((r) => r.status !== 'paid')
.reduce((acc, r) => acc + parseIrr(r.amountIrr), ZERO);
const nextDue = rows.find((r) => r.status === 'due_soon') ?? rows.find((r) => r.status === 'upcoming') ?? null;
walletPlans = [
{
bnplTransactionId: 70_900,
bookingId: 5001,
providerCode: 'digipay',
status: 'settled',
serviceLabel: 'مراقبت سالمند — شیفت روز',
outstandingBalanceIrr: outstanding.toString(),
installmentCount: 6,
nextDueDate: nextDue?.dueDate ?? null,
nextAmountIrr: nextDue?.amountIrr ?? null,
installments: rows,
earlyPayUrl: providerEarlyPayUrl('digipay', 70_900),
createdAt: isoMonthsFromNow(-1),
},
];
}
seedWallet();
/** Provider-reported due status for a freshly settled plan: down-payment paid, next installment due-soon, rest upcoming. */
function toWalletRows(schedule: BnplInstallmentRow[]): BnplInstallmentRow[] {
return schedule.map((row) => {
let status: BnplInstallmentStatus;
if (row.kind === 'down_payment') status = 'paid';
else if (row.sequence === 1) status = 'due_soon';
else status = 'upcoming';
return { ...row, status };
});
}
/** The settle side effect: convert the request, insert the confirmed booking (net-of-fee), seed the Wallet plan. */
function settle(request: BookingRequestDto, tx: MockBnplTx): BnplSettlementResult {
const amounts = bookingAmounts(tx.orderAmountIrr);
const booking = mockInsertConvertedBooking({
bookingRequestId: request.id,
nurseId: request.nurseId,
nurseName: request.nurseName,
patientId: request.patientId,
patientName: request.patientName,
variantId: request.variantId,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
customerAddressId: request.customerAddressId,
addressSnapshotJson: JSON.stringify({
title: request.addressTitle,
city: request.cityNameFa,
district: request.districtNameFa,
line: request.addressLine,
postalCode: request.postalCode,
}),
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
pspFeeAmount: amounts.pspFeeAmount,
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
scheduledDate: request.requestedDate,
scheduledTimeStart: request.requestedTimeStart,
scheduledTimeEnd: request.requestedTimeEnd,
});
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
tx.status = 'settled';
tx.bookingId = booking.id;
const walletRows = toWalletRows(tx.schedule);
const outstanding = walletRows
.filter((r) => r.status !== 'paid')
.reduce((acc, r) => acc + parseIrr(r.amountIrr), ZERO);
const nextDue = walletRows.find((r) => r.status !== 'paid') ?? null;
walletPlans = [
{
bnplTransactionId: tx.bnplTransactionId,
bookingId: booking.id,
providerCode: tx.providerCode,
status: 'settled',
serviceLabel: request.variantLabel,
outstandingBalanceIrr: outstanding.toString(),
installmentCount: tx.installmentCount,
nextDueDate: nextDue?.dueDate ?? null,
nextAmountIrr: nextDue?.amountIrr ?? null,
installments: walletRows,
earlyPayUrl: providerEarlyPayUrl(tx.providerCode, tx.bnplTransactionId),
createdAt: new Date().toISOString(),
},
...walletPlans,
];
return toSettlementResult(converted, tx);
}
function toSettlementResult(request: BookingRequestDto, tx: MockBnplTx): BnplSettlementResult {
return {
bnplTransactionId: tx.bnplTransactionId,
bookingRequestId: request.id,
requestStatus: request.status,
status: tx.status,
bookingId: request.bookingId ?? tx.bookingId,
providerCode: tx.providerCode,
};
}
function toInitiateResult(tx: MockBnplTx): IssueBnplTokenResult {
const query = new URLSearchParams();
query.set(BNPL_QUERY_TRANSACTION_ID, String(tx.bnplTransactionId));
query.set(BNPL_QUERY_REQUEST_ID, String(tx.bookingRequestId));
query.set(BNPL_QUERY_PROVIDER, tx.providerCode);
query.set(BNPL_QUERY_PLAN, tx.planId);
return {
bnplTransactionId: tx.bnplTransactionId,
paymentTransactionId: tx.paymentTransactionId,
status: tx.status,
externalPaymentToken: tx.externalPaymentToken,
// App-relative (the wizard prepends the locale); the real provider returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_BNPL_GATEWAY}?${query.toString()}`,
};
}
/**
* In-memory mock behind the `BnplApi` seam the whole D1D5 installment surface b12 doesn't serve
* (no provider/plan options, no repayment schedule, no provider-reported Wallet status REQ-022/023/024).
* It reads the frozen request gross from the shared f7 store, plays the provider (eligibility verdict,
* plan math, token/redirect), and on settle (the down-payment-cleared analogue of the webhook) runs the
* SAME conversion bridge f9 uses a settled BNPL order is a card payment net-of-fee then seeds a
* provider-reported Wallet plan. Swap to the real `clientApi` once the upstream is real and the REQs land
* (`USE_BNPL_MOCK = false`).
*/
export const bnplMockApi: BnplApi = {
getBnplOptions: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const gross = requestGross(request);
const providers: BnplProvider[] = PROVIDER_TEMPLATES.map((p) => ({
providerCode: p.providerCode,
tagline: null,
plans: p.plans.map((plan) => toPlanOption(gross, plan)),
}));
const options: BnplOptions = {
bookingRequestId: request.id,
requestStatus: request.status,
orderAmountIrr: gross,
// The §1 branch flag — mock always eligible; D3 is the real gate. Never a client-side threshold.
bnplEligible: true,
providers,
};
return options;
},
checkEligibility: async ({ bookingRequestId, providerCode, nationalId }: CheckEligibilityInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const gross = parseIrr(requestGross(request));
const ceiling = parseIrr(MOCK_CREDIT_CEILING_IRR);
// Deterministic declined branches so a tester can exercise both (§7 step 3): last digit 0 → declined;
// an order above the ceiling → ceiling_exceeded. The real credit check is the provider's — the client
// only surfaces the verdict, never pre-judges it.
if (nationalId.endsWith(MOCK_NOT_ELIGIBLE_LAST_DIGIT)) {
return declined('not_eligible');
}
if (gross > ceiling) {
return declined('ceiling_exceeded');
}
const provider = PROVIDER_TEMPLATES.find((p) => p.providerCode === providerCode);
const installmentCount = provider?.plans[0]?.installmentCount ?? 4;
const result: BnplEligibilityResult = {
eligibilityStatus: 'eligible',
isEligible: true,
installmentCount,
planSummary: null,
creditCeilingIrr: MOCK_CREDIT_CEILING_IRR,
};
return result;
},
getBnplSchedule: async ({ bookingRequestId, providerCode, planId }: GetBnplScheduleInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const plan = findPlan(providerCode, planId);
const gross = requestGross(request);
const rows = buildScheduleRows(gross, plan);
const { total, downPayment } = planAmounts(gross, plan);
const schedule: BnplSchedule = {
bookingRequestId,
providerCode,
planId,
totalIrr: total.toString(),
downPaymentIrr: downPayment.toString(),
installmentCount: plan.installmentCount,
rows,
};
return schedule;
},
issueBnplToken: async ({ bookingRequestId, providerCode, planId, idempotencyKey }: IssueBnplTokenInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const existing = latestTxFor(bookingRequestId);
if (request.status === 'converted' || existing?.status === 'settled') {
throw new ApiError(409, 'Request already paid', 'already_paid');
}
if (request.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
// b12 idempotency: a retried initiate with the same key reuses the token/order.
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'token_issued') {
return toInitiateResult(existing);
}
const plan = findPlan(providerCode, planId);
const gross = requestGross(request);
const { total } = planAmounts(gross, plan);
const bnplTransactionId = nextBnplTxId++;
const tx: MockBnplTx = {
bnplTransactionId,
paymentTransactionId: nextPaymentTxId++,
bookingRequestId,
providerCode,
planId,
idempotencyKey,
status: 'token_issued',
externalPaymentToken: `mock-bnpl-token-${gross}-bnpl-br-${bookingRequestId}`,
orderAmountIrr: gross,
totalIrr: total.toString(),
installmentCount: plan.installmentCount,
schedule: buildScheduleRows(gross, plan),
bookingId: null,
createdAt: new Date().toISOString(),
};
transactions = [tx, ...transactions];
return toInitiateResult(tx);
},
acceptBnplSchedule: async ({ bookingRequestId, bnplTransactionId, outcome }: AcceptBnplScheduleInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const tx =
(bnplTransactionId != null ? transactions.find((t) => t.bnplTransactionId === bnplTransactionId) : undefined) ??
latestTxFor(bookingRequestId);
// Replays / double-taps converge (the webhook-dedup analogue): if settlement already happened, report it.
if (request.status === 'converted' || tx?.status === 'settled') {
return toSettlementResult(request, tx ?? fallbackTx(bookingRequestId));
}
if (!tx) throw new ApiError(404, 'BNPL order not found', 'not_found');
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
// Provider declined the order, or the payment window lapsed during the handoff.
tx.status = 'failed';
return toSettlementResult(request, tx);
}
return settle(request, tx);
},
getBnplOrder: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const tx = latestTxFor(bookingRequestId);
if (!tx) throw new ApiError(404, 'BNPL order not found', 'not_found');
const order: BnplOrderStatus = {
id: tx.bnplTransactionId,
paymentTransactionId: tx.paymentTransactionId,
bookingId: request.bookingId ?? tx.bookingId,
providerCode: tx.providerCode,
status: tx.status,
eligibilityStatus: 'eligible',
orderAmountIrr: tx.orderAmountIrr,
settledAmountIrr: tx.status === 'settled' ? bookingAmounts(tx.orderAmountIrr).nursePayoutAmount : null,
bnplCommissionIrr: tx.status === 'settled' ? bookingAmounts(tx.orderAmountIrr).balinyaarCommissionIrr : null,
currency: 'IRR',
installmentCount: tx.installmentCount,
settledAt: tx.status === 'settled' ? new Date().toISOString() : null,
createdAt: tx.createdAt,
};
return order;
},
getWalletInstallments: async () => {
await sleep(MOCK_LATENCY_MS);
return walletPlans.map((p) => ({ ...p, installments: p.installments.map((r) => ({ ...r })) }));
},
};
function declined(status: 'not_eligible' | 'ceiling_exceeded'): BnplEligibilityResult {
return {
eligibilityStatus: status,
isEligible: false,
installmentCount: 0,
planSummary: null,
creditCeilingIrr: null,
};
}
/** A minimal tx stand-in for a settlement replay where the request already converted but no tx row survives. */
function fallbackTx(bookingRequestId: number): MockBnplTx {
return {
bnplTransactionId: 0,
paymentTransactionId: 0,
bookingRequestId,
providerCode: 'digipay',
planId: 'digipay_6m',
idempotencyKey: '',
status: 'settled',
externalPaymentToken: '',
orderAmountIrr: '0',
totalIrr: '0',
installmentCount: 0,
schedule: [],
bookingId: null,
createdAt: new Date().toISOString(),
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* When true, the BNPL domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `BnplApi`
* seam.
*
* **Mock is primary this phase.** b12 ships the eligibility/initiate/webhook/settle endpoints server-side,
* but the D1D5 checkout cannot run real end-to-end from the client yet:
* - the accepted request being financed comes from the **mock-primary** `bookingRequests` store (f7), so a
* real `initiate` would reference an id that exists only in memory (same reason f9 payment is mock-primary);
* - the contract serves **no provider/plan options** (D1/D2), **no repayment schedule** (D4 the contract
* explicitly does not model the customer's repayment schedule), and **no provider-reported installment
* status** for the Wallet (D5) REQ-022/023/024;
* - nothing fires the provider webhook in dev, so a real order would never settle.
* The mock closes the loop: the settle (down-payment cleared) converts the f7 request, inserts a **confirmed**
* booking into the f8 store (the SAME bridge f9 uses a settled BNPL order is a card payment net-of-fee),
* and seeds a Wallet installment plan so C6 D1 D4 confirmation D5 demos end-to-end. Flip to
* `false` once the upstream domains are real and REQ-022/023/024 land no hook/component change.
*/
export const USE_BNPL_MOCK = true;
/** Options/eligibility/schedule are short-lived (prices/deadlines move between visits). */
export const BNPL_OPTIONS_STALE_TIME = 10 * 1000;
export const BNPL_SCHEDULE_STALE_TIME = 30 * 1000;
/** The provider-reported Wallet status is moderately fresh — a plan does not change second-to-second. */
export const BNPL_WALLET_STALE_TIME = 60 * 1000;
/** The settle poll after the provider return (mirrors f9's bounded backoff — «received ≠ settled»). */
export const BNPL_ORDER_POLL_BASE_MS = 2 * 1000;
export const BNPL_ORDER_POLL_GROWTH = 1.5;
export const BNPL_ORDER_POLL_MAX_MS = 15 * 1000;
export const BNPL_ORDER_POLL_MAX_ATTEMPTS = 40;
/** BNPL checkout deep-link query params (C6 hands off `request_id`; the handoff round-trip carries the rest). */
export const BNPL_QUERY_REQUEST_ID = 'request_id';
export const BNPL_QUERY_TRANSACTION_ID = 'transaction_id';
export const BNPL_QUERY_PROVIDER = 'provider';
export const BNPL_QUERY_PLAN = 'plan';
export const BNPL_QUERY_OUTCOME = 'outcome';
/** The `method` flag the reused f9 confirmation reads to render «پرداخت‌شده با اقساط». */
export const CHECKOUT_QUERY_METHOD = 'method';
export const CHECKOUT_METHOD_BNPL = 'bnpl';
/**
* National-ID format: 10 digits (client-side validation only the real credit check is the provider's;
* D3 surfaces the provider's verdict, it never pre-judges eligibility).
*/
export const NATIONAL_ID_LENGTH = 10;
export const NATIONAL_ID_PATTERN = /^\d{10}$/;
/**
* Mock-only credit ceiling + declined-path triggers (see `apis/mockApi.ts`). The ceiling is what a real
* provider would return per its own credit model; the client never derives it. Declined paths are keyed
* deterministically off the entered national ID so a tester can exercise both branches (§7 step 3).
*/
export const MOCK_CREDIT_CEILING_IRR = '2000000000'; // 200,000,000 Toman (matches the contract example)
/** A national ID whose last digit is 0 → `not_eligible`; otherwise eligible unless the order exceeds the ceiling. */
export const MOCK_NOT_ELIGIBLE_LAST_DIGIT = '0';
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { invalidateAfterBnplSettlement } from '../invalidations';
import { isBnplSettlementSuccess, type AcceptBnplScheduleInput } from '../types';
/**
* The settle-on-return mutation (the down-payment-cleared analogue of the provider webhook): reported once
* from the return surface. On a successful settlement it **invalidates the booking + checkout + Wallet
* queries** so the confirmed booking isn't refetched stale and D5 reflects the new plan (never a blanket
* refetch). The booking then confirms exactly as the card path the return page routes to the reused f9
* confirmation.
*/
export function useAcceptBnplSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AcceptBnplScheduleInput) => bnplApi.acceptBnplSchedule(input),
onSuccess: (result) => {
if (isBnplSettlementSuccess(result)) {
invalidateAfterBnplSettlement(queryClient, result.bookingRequestId, result.bookingId);
}
},
});
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_OPTIONS_STALE_TIME } from '../constants';
/**
* D1/D2 the payable amount + the provider/plan set for an accepted request. Short `staleTime`
* (the price/deadline can move between visits). Enabled only when an id is present.
*/
export function useBnplOptions(bookingRequestId: number | undefined) {
return useQuery({
queryKey: bnplKeys.options(bookingRequestId ?? -1),
queryFn: () => bnplApi.getBnplOptions(bookingRequestId as number),
enabled: bookingRequestId != null && bookingRequestId > 0,
staleTime: BNPL_OPTIONS_STALE_TIME,
});
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import {
BNPL_ORDER_POLL_BASE_MS,
BNPL_ORDER_POLL_GROWTH,
BNPL_ORDER_POLL_MAX_ATTEMPTS,
BNPL_ORDER_POLL_MAX_MS,
} from '../constants';
import { isTerminalBnplStatus } from '../types';
/**
* The BNPL order read + settle poll after the provider return. "Received ≠ settled" settlement is not
* instant, so this polls with **geometric backoff** (base ×growth cap) and **stops** on any terminal
* status (`settled`/`reverted`/`cancelled`/`failed`) or after a bounded number of attempts never a tight
* loop. The return surface offers a manual re-check once the budget is spent.
*/
export function useBnplOrder(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: bnplKeys.order(bookingRequestId ?? -1),
queryFn: () => bnplApi.getBnplOrder(bookingRequestId as number),
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
refetchInterval: (query) => {
const order = query.state.data;
if (order && isTerminalBnplStatus(order.status)) return false;
const attempt = query.state.dataUpdateCount;
if (attempt >= BNPL_ORDER_POLL_MAX_ATTEMPTS) return false;
return Math.min(BNPL_ORDER_POLL_BASE_MS * BNPL_ORDER_POLL_GROWTH ** attempt, BNPL_ORDER_POLL_MAX_MS);
},
});
}
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_SCHEDULE_STALE_TIME } from '../constants';
import type { ProviderCode } from '../types';
/**
* D4 the served repayment schedule (down-payment + N installments, amounts + due dates) for the chosen
* plan. **Amounts are server-served** (the client never derives a schedule amount); only Shamsi date
* display is a client concern. Enabled once provider + plan are chosen.
*/
export function useBnplSchedule(
bookingRequestId: number | undefined,
providerCode: ProviderCode | undefined,
planId: string | undefined,
) {
const enabled = bookingRequestId != null && bookingRequestId > 0 && providerCode != null && planId != null;
return useQuery({
queryKey: bnplKeys.schedule(bookingRequestId ?? -1, providerCode ?? '', planId ?? ''),
queryFn: () =>
bnplApi.getBnplSchedule({
bookingRequestId: bookingRequestId as number,
providerCode: providerCode as ProviderCode,
planId: planId as string,
}),
enabled,
staleTime: BNPL_SCHEDULE_STALE_TIME,
});
}
@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import type { CheckEligibilityInput } from '../types';
/**
* D3 the provider credit check. The consent checkbox gates the submit at the call site (no consent, no
* mutation). The result (`eligible` / `not_eligible` / `ceiling_exceeded`) drives the D3 panel; a declined
* verdict is not an error it renders the declined panel + the card fall-back, not a toast. The fetch
* layer owns 401/403/5xx; only domain 4xx (e.g. a token-expired) would surface inline via `mutation.error`.
*/
export function useCheckEligibility() {
return useMutation({
mutationFn: (input: CheckEligibilityInput) => bnplApi.checkEligibility(input),
});
}
@@ -0,0 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import type { IssueBnplTokenInput } from '../types';
/**
* D4 final action start the BNPL order and issue the provider token/redirect (contract `initiate`). The
* caller owns the **per-attempt idempotency key** (a retried initiate reuses the same token; a new attempt
* gets a new key), mirroring the f9 card initiate. On success the caller follows `redirectUrl` (the provider
* handoff). A `409` here means "already paid / already in progress / window lapsed" the wizard converges
* (reads the order) instead of toasting; other domain 4xx render inline.
*/
export function useIssueBnplToken() {
return useMutation({
mutationFn: (input: IssueBnplTokenInput) => bnplApi.issueBnplToken(input),
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_WALLET_STALE_TIME } from '../constants';
/**
* D5 the customer's active installment plans, **provider-reported** (outstanding balance, next due date,
* per-installment due list). Moderate `staleTime`: a plan does not change second-to-second, and it is
* invalidated on settlement. This is a **read of provider status**, never a Balinyaar-managed ledger
* "early pay" hands off to the provider (D5), it is not a Balinyaar transaction.
*/
export function useWalletInstallments() {
return useQuery({
queryKey: bnplKeys.walletInstallments(),
queryFn: () => bnplApi.getWalletInstallments(),
staleTime: BNPL_WALLET_STALE_TIME,
});
}
+11
View File
@@ -0,0 +1,11 @@
/**
* BNPL domain barrel re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis/constants directly from their files when needed.
*/
export { useBnplOptions } from './hooks/useBnplOptions';
export { useCheckEligibility } from './hooks/useCheckEligibility';
export { useBnplSchedule } from './hooks/useBnplSchedule';
export { useIssueBnplToken } from './hooks/useIssueBnplToken';
export { useAcceptBnplSchedule } from './hooks/useAcceptBnplSchedule';
export { useBnplOrder } from './hooks/useBnplOrder';
export { useWalletInstallments } from './hooks/useWalletInstallments';
+21
View File
@@ -0,0 +1,21 @@
import type { QueryClient } from '@tanstack/react-query';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import { bnplKeys } from './keys';
/**
* The cache transition a settled BNPL order causes: the request flipped `converted`, a confirmed booking
* now exists, and a new provider-reported installment plan is live. A settled BNPL order is, to Balinyaar,
* a card payment net-of-fee so it invalidates exactly the same booking/request/checkout keys the card
* capture does (reusing `invalidateAfterPaymentSuccess`), **plus** the BNPL order + the Wallet installment
* status (so D5 reflects the new plan) never a blanket refetch. Shared by `useAcceptBnplSchedule` and the
* return-page settle poll.
*/
export function invalidateAfterBnplSettlement(
queryClient: QueryClient,
bookingRequestId: number,
bookingId: number | null,
): void {
invalidateAfterPaymentSuccess(queryClient, bookingRequestId, bookingId);
queryClient.invalidateQueries({ queryKey: bnplKeys.order(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: bnplKeys.walletInstallments() });
}
+15
View File
@@ -0,0 +1,15 @@
/**
* React Query key factory for the BNPL domain (hierarchical, per the `services/{domain}` pattern).
* Options / eligibility / schedule / order all run against the accepted booking-request id; the Wallet
* installment status (D5) is a customer-wide read (no id).
*/
export const bnplKeys = {
all: ['bnpl'] as const,
options: (bookingRequestId: number) => [...bnplKeys.all, 'options', bookingRequestId] as const,
eligibility: (bookingRequestId: number) => [...bnplKeys.all, 'eligibility', bookingRequestId] as const,
schedule: (bookingRequestId: number, providerCode: string, planId: string) =>
[...bnplKeys.all, 'schedule', bookingRequestId, providerCode, planId] as const,
orders: () => [...bnplKeys.all, 'order'] as const,
order: (bookingRequestId: number) => [...bnplKeys.orders(), bookingRequestId] as const,
walletInstallments: () => [...bnplKeys.all, 'wallet-installments'] as const,
};
+264
View File
@@ -0,0 +1,264 @@
import type { BookingRequestStatus } from '@/services/bookingRequests/types';
import type { StatusKind } from '@/components/StatusChip/StatusChip';
/**
* BNPL domain the installment-checkout alternative to the f9 card flow (b12). Shapes mirror the
* published contract (`dev/contracts/domains/bnpl.md`; camelCase wire, `clientFetch` unwraps the
* `ApiEnvelope<T>`), extended with the D1D5 read surfaces the contract does not yet serve (filed as
* REQ-022/023/024 see `apis/clientApi.ts`).
*
* The load-bearing product truth every type here encodes
* ([product/business/09](../../../../product/business/09-installments-bnpl.md)):
* - **The installment repayment is OWNED BY THE PROVIDER, not Balinyaar.** The provider pays Balinyaar
* the full order amount up-front and bears 100% of the customer's default risk. In Balinyaar's books a
* settled BNPL order is **identical to a card payment that lands net-of-fee in one inbound settlement**.
* - So the Wallet view (D5) renders **provider-reported** status (`WalletInstallmentPlan`) never a
* Balinyaar-managed ledger, and Balinyaar never settles a customer installment ("early pay" hands off
* to the provider).
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
* `@/utils` BigInt helpers and display as Toman via `formatIrrToToman`. The client renders whatever the
* contract returns it never computes a fee, ceiling, monthly amount, or schedule amount itself
* (they are per-contract/per-provider config; the mock plays the server here).
*/
/** `bnpl_transactions.status` (b12 contract enum) — forward-only. */
export type BnplStatus = 'eligible' | 'token_issued' | 'verified' | 'settled' | 'reverted' | 'cancelled' | 'failed';
/** `bnpl_transactions.eligibility_status` (b12) — anything but `eligible` falls back to card. */
export type BnplEligibilityStatus = 'eligible' | 'not_eligible' | 'ceiling_exceeded';
/**
* `provider_code` selects the provider adapter. The b12 contract enum is
* `snapppay | digipay | tara | torobpay`; `balinyaar` is the in-house plan the wireframe shows
* (اقساط بالینیار) which the contract enum does not yet carry (flagged in REQ-022). Provider **display
* names** are i18n keys off this code (`bnpl.provider_{code}`) never derived from the code text.
*/
export type ProviderCode = 'snapppay' | 'digipay' | 'tara' | 'torobpay' | 'balinyaar';
/** What the provider handoff reports back on return (mock harness); the real provider signals via webhook. */
export type BnplHandoffOutcome = 'success' | 'failure';
/**
* A single installment plan a provider offers (D2). **All amounts are served IRR digit-strings** the
* client renders them, it never computes a monthly amount, down-payment, or total. `feePercent` /
* `downPaymentPercent` are informational fractions the plan card shows (`کارمزد ۴٪`, `پیش‌پرداخت ۲۰٪`).
*/
export interface BnplPlanOption {
/** Stable id for single-select + the handoff (e.g. `digipay_6m`). */
planId: string;
/** 3/6/12 for monthly plans; `null` for an N-installment plan (اسنپ‌پی ۴ قسط). */
termMonths: number | null;
installmentCount: number;
/** Provider fee as a fraction (0 = بدون سود / interest-free) — display only. */
feePercent: number;
/** Down-payment as a fraction of the total (0..1) — display only. */
downPaymentPercent: number;
/** Per-installment amount (IRR digit-string, served). */
monthlyAmountIrr: string;
/** The پیش‌پرداخت due today (IRR digit-string, served). */
downPaymentIrr: string;
/** What the customer repays in total across down-payment + installments (IRR digit-string, served). */
totalIrr: string;
}
/** A provider and the plans it offers for this order (D1/D2). Rendered from the contract — never hardcoded. */
export interface BnplProvider {
providerCode: ProviderCode;
/** Provider-reported one-line plan headline (informational; the surrounding copy is i18n). */
tagline: string | null;
plans: BnplPlanOption[];
}
/**
* The D1/D2 payload the payable amount + the provider/plan set. `bnplEligible` is the §1 branch-note
* flag: when `false` the installment options are hidden and only card shows (the mock always returns
* `true` and lets D3 eligibility be the real gate).
*/
export interface BnplOptions {
bookingRequestId: number;
requestStatus: BookingRequestStatus;
/** The payable gross (D1 «مبلغ قابل پرداخت») — IRR digit-string. */
orderAmountIrr: string;
bnplEligible: boolean;
providers: BnplProvider[];
}
/** D3 credit-check inputs. National-ID validation is client-side format only (10 digits); the real check is the provider's. */
export interface CheckEligibilityInput {
bookingRequestId: number;
providerCode: ProviderCode;
/** 10-digit national ID (کد ملی). */
nationalId: string;
/** Mobile, prefilled from the session. */
mobile: string;
/** Gates the submit — no consent, no request. */
consent: boolean;
}
/** D3 result — the provider's verdict + (on approval) the usable credit ceiling. */
export interface BnplEligibilityResult {
eligibilityStatus: BnplEligibilityStatus;
isEligible: boolean;
installmentCount: number;
/** Provider-served plan summary (informational; may be `null`). */
planSummary: string | null;
/** سقف اعتبار — IRR digit-string, or `null` when declined. */
creditCeilingIrr: string | null;
}
/** One row of the D4 repayment table / D5 due list. */
export interface BnplInstallmentRow {
/** 0 = پیش‌پرداخت (down-payment), 1..N = قسط. */
sequence: number;
kind: 'down_payment' | 'installment';
/** ISO date `YYYY-MM-DD` (Shamsi display is a client concern). The down-payment row renders «امروز». */
dueDate: string;
amountIrr: string;
/** Provider-reported per-row status — present in D5 (Wallet), absent in the D4 preview. */
status?: BnplInstallmentStatus;
}
/** The D4 repayment schedule — served, not client-derived (amounts + due dates are provider-owned). */
export interface BnplSchedule {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
totalIrr: string;
downPaymentIrr: string;
installmentCount: number;
/** `[down_payment, installment×N]`. */
rows: BnplInstallmentRow[];
}
export interface GetBnplScheduleInput {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
}
/** D4 final action — start the BNPL order + issue the provider token/redirect (contract `initiate`). */
export interface IssueBnplTokenInput {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
/** Stable per attempt (b12 `Idempotency-Key` header); a retried initiate reuses the same token. */
idempotencyKey: string;
}
/** `initiate` result (b12) — where to hand the customer off, and the provider references. */
export interface IssueBnplTokenResult {
bnplTransactionId: number;
paymentTransactionId: number;
status: BnplStatus;
externalPaymentToken: string | null;
/** Absolute provider URL on the real path; an app-relative provider-harness path from the mock. */
redirectUrl: string | null;
}
/** The provider-return settle input (down-payment cleared). The real path settles via webhook; on return the client reads the order. */
export interface AcceptBnplScheduleInput {
bookingRequestId: number;
bnplTransactionId: number | null;
outcome: BnplHandoffOutcome;
}
/**
* What the return-from-provider surface reports: the order after the settle attempt, plus which booking
* the settlement confirmed (client-augmented the b12 contract sets `bookingId` at settle, but the
* customer-readable link is filed as REQ-024). On success the booking confirms exactly as the card path.
*/
export interface BnplSettlementResult {
bnplTransactionId: number;
bookingRequestId: number;
requestStatus: BookingRequestStatus;
status: BnplStatus;
bookingId: number | null;
providerCode: ProviderCode;
}
/** `BnplOrderStatus` (b12 `GET checkout_bnpl/{id}`) — the customer's own order, read for the return poll. */
export interface BnplOrderStatus {
id: number;
paymentTransactionId: number;
bookingId: number | null;
providerCode: ProviderCode;
status: BnplStatus;
eligibilityStatus: BnplEligibilityStatus | null;
orderAmountIrr: string;
settledAmountIrr: string | null;
bnplCommissionIrr: string | null;
currency: string;
installmentCount: number;
/** Nullable — settlement is contract-defined and not instant. */
settledAt: string | null;
createdAt: string;
}
/** Provider-reported per-installment status (D5). */
export type BnplInstallmentStatus = 'paid' | 'due_soon' | 'upcoming' | 'overdue';
/**
* The D5 Wallet view of an active installment plan **provider-reported status, not a Balinyaar ledger**.
* The contract models none of this (installment_count is informational; the repayment schedule is
* explicitly out of scope), so the whole shape is a client/REQ-024 gap the mock fills.
*/
export interface WalletInstallmentPlan {
bnplTransactionId: number;
bookingId: number | null;
providerCode: ProviderCode;
status: BnplStatus;
/** Human context for the plan (e.g. the service/nurse the order paid for). */
serviceLabel: string;
/** مانده بدهی — IRR digit-string. */
outstandingBalanceIrr: string;
installmentCount: number;
/** قسط بعدی — ISO date, or `null` when nothing is outstanding. */
nextDueDate: string | null;
nextAmountIrr: string | null;
/** Provider-reported due list with per-row status. */
installments: BnplInstallmentRow[];
/** Hand-off link for پرداخت زودهنگام — early-pay is a **provider** action, never a Balinyaar transaction. */
earlyPayUrl: string | null;
createdAt: string;
}
/** Forward-only terminal check for the return poll. */
const TERMINAL_BNPL_STATUSES: readonly BnplStatus[] = ['settled', 'reverted', 'cancelled', 'failed'];
export function isTerminalBnplStatus(status: BnplStatus): boolean {
return TERMINAL_BNPL_STATUSES.includes(status);
}
/** A settled order (or a converted request) — the booking confirmed; nothing left to poll on the return. */
export function isBnplSettlementSuccess(result: BnplSettlementResult): boolean {
return result.status === 'settled' || result.requestStatus === 'converted';
}
/** Maps a provider-reported installment status to the shared StatusChip kind (D5 due list). */
export function installmentStatusKind(status: BnplInstallmentStatus): StatusKind {
switch (status) {
case 'paid':
return 'verified';
case 'due_soon':
return 'pending';
case 'overdue':
return 'rejected';
case 'upcoming':
default:
return 'neutral';
}
}
/**
* The BNPL API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_BNPL_MOCK`), never scattered `if (mock)` checks.
*/
export interface BnplApi {
getBnplOptions(bookingRequestId: number): Promise<BnplOptions>;
checkEligibility(input: CheckEligibilityInput): Promise<BnplEligibilityResult>;
getBnplSchedule(input: GetBnplScheduleInput): Promise<BnplSchedule>;
issueBnplToken(input: IssueBnplTokenInput): Promise<IssueBnplTokenResult>;
acceptBnplSchedule(input: AcceptBnplScheduleInput): Promise<BnplSettlementResult>;
getBnplOrder(bookingRequestId: number): Promise<BnplOrderStatus>;
getWalletInstallments(): Promise<WalletInstallmentPlan[]>;
}
+3 -3
View File
@@ -17,10 +17,10 @@
export const USE_PAYMENT_MOCK = true;
/**
* f11 wires the BNPL method screens (D1D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
* f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment
* wizard (`ROUTES.CHECKOUT_BNPL`) instead of rendering a disabled "coming soon" secondary.
*/
export const BNPL_ENABLED = false;
export const BNPL_ENABLED = true;
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;