frontend phase 11
This commit is contained in:
+16
-5
@@ -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 ~7–10-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 ~7–10 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` 7–10-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 (D1–D5): 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 D1–D4 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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "جدول"
|
||||
}
|
||||
}
|
||||
|
||||
+222
@@ -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;
|
||||
+206
@@ -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;
|
||||
+108
@@ -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;
|
||||
+174
@@ -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;
|
||||
+73
@@ -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 (D1–D4) — 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>
|
||||
);
|
||||
}
|
||||
+187
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-2
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`)),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 D1–D5 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(),
|
||||
};
|
||||
}
|
||||
@@ -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 D1–D5 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 D1–D5 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[]>;
|
||||
}
|
||||
@@ -17,10 +17,10 @@
|
||||
export const USE_PAYMENT_MOCK = true;
|
||||
|
||||
/**
|
||||
* f11 wires the BNPL method screens (D1–D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
|
||||
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
|
||||
* f11 wired the BNPL method screens (D1–D5): 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;
|
||||
|
||||
Reference in New Issue
Block a user