Files
baya-monorepo/archive/docs/flows/bnpl-installments.md
T
2026-08-02 20:01:31 +03:30

13 KiB
Raw Blame History

Flow — BNPL installments

Last verified: 2026-08-02 against commit c841bde

Actor(s): customer · Status: mocked Client: mock · Server: partial Business source: product/business/09-installments-bnpl.md · product/payments/bnpl-landscape.md Integration: docs/integration/domains/bnpl.md

What it does

The second checkout rail off C6: instead of paying by card, the family finances the booking through a BNPL provider (SnappPay, Digipay, …). The provider pays Balinyaar one full lump, net of its own commission, and carries the customer's installments itself — Balinyaar finances nothing and tracks no repayments. From the booking's point of view a settled BNPL order is identical to a card payment that landed net-of-fee.

Screens

Step Route Component / notes
CTA /fa/bookings/checkout «پرداخت اقساطی» button, checkout/page.tsx:198-204 — gated only on BNPL_ENABLED (payment/constants.ts:23), never on the mock flags
D1 provider /fa/bookings/checkout/bnpl MethodStep — one stateful wizard, ?request_id=; provider list from useBnplOptions. Unreachable today — the status gate at page.tsx:88 bails before it, see gap 3
D2 plan same route PlanStep — per-plan monthly / down-payment / total
D3 eligibility same route EligibilityStep — national id + mobile + consent, then the provider's verdict
D4 schedule same route ScheduleStep — repayment table + contract, then issueBnplToken → handoff
handoff /fa/bookings/checkout/bnpl/gateway Dev harness only (notFound() outside NODE_ENV=development); only the mock points here
return /fa/bookings/checkout/bnpl/return useAcceptBnplSchedule fires once per mount, then a bounded settle poll → confirmation / retry / card fall-back / window-lapse
receipt /fa/bookings/checkout/confirmation?method=bnpl The reused card confirmation, relabelled «پرداخت‌شده با اقساط»
D5 wallet /fa/wallet → «اقساط» tab WalletInstallments.tsxprovider-reported status, never ledger-derived

API

Shapes and enums live in bnpl.md — not restated here.

Call Endpoint Verified
D3 eligibility POST /api/v1/checkout_bnpl/eligibility live; 409 "This request is not awaiting payment." on request 19
D4 initiate POST /api/v1/checkout_bnpl/initiate · Idempotency-Key live but 400 "No active BNPL gateway is configured." — see gap 1
order by own id GET /api/v1/checkout_bnpl/{id} 200 for order 1 as 09120000011; 404 as 09120000010 (tenancy)
order by request GET /api/v1/checkout_bnpl/by_request/{id} 200 for request 11 — the client comment calling it a phantom is stale
D1/D2 options GET /api/v1/checkout_bnpl/options/{id} 404 — phantom (REQ-022)
D4 schedule GET /api/v1/checkout_bnpl/schedule/{id} 404 — phantom (REQ-022)
D5 wallet list GET /api/v1/checkout_bnpl/wallet_installments 404 — phantom (REQ-024)
provider callback POST /api/v1/webhooks_bnpl/{provider} server-only; nothing fires it in dev
admin verify/settle/revert/get /api/v1/admin_bnpl/{id}* 403 for seeded super_adminGET /admin_bnpl/1 probed

Chain traced for the two live customer calls: bnpl/page.tsx:47useBnplOptionsservices/bnpl/apis/index.ts (ternary on USE_BNPL_MOCK) → apis/clientApi.ts:53/76clientFetchCheckoutBnplController.cs:34/39CheckBnplEligibilityQuery.Handler.cs / InitiateBnplOrderCommand.Handler.cs. Every link exists; the seam selector picks the mock, so the real chain is never exercised from the UI.

Rules that must hold

Rule Value Source
BNPL is full-upfront — the provider bears 100% of default risk, Balinyaar tracks no installments GT-3 overview/platform-summary.md
The card payment is recorded net of the provider fee: settled + bnplCommission == orderAmount, rejected otherwise SettleBnplOrderCommand.Handler.cs:72-74 + DB CK_BnplTransactions_SettleSplit business/09 §(b)
The commission is read from the actual settlement, never from config bnpl_provider_commission_rate (0.07) is a baseline platform_config row (PlatformConfigConfig.cs:40, shipped in InitialMarketplaceBaseline), and the only reader in the repo is the demo seeder — no money path consults it payments/bnpl-landscape.md §5
The nurse payout is invariant to payment method — from the booking split, never from settled_amount INV-13; SettleBnplOrder…Handler.cs:91-92 passes conversion.PayoutIrr payments/cancellation-and-payout.md §7
BNPL commission is a platform expense (bnpl_fee_expense leg), never the nurse's LedgerPosting.BnplSettle payments/escrow-ledger.md
BnplStatus is forward-only: eligible → token_issued → verified → settled → reverted BnplTransitions.cs; only cohesive domain methods mutate status server hard rule 13
Webhook idempotency before money moves — dedup on (provider_code, external_event_id) first HandleBnplCallbackCommand.Handler.cs:37-42,79-88 INV-10
Money flows only customer ↔ provider ↔ Balinyaar; the provider owns the unwind INV-12 business/09 §(a)
installment_count (4) is informational and never drives money business/09 §(a)
MoR is config (bnpl_merchant_of_record = platform) read in both handlers business/13 §(a)
A booking with an active refund is held out of every payout batch INV-16 business/10 §(d1)

How to test

Log in as 09120000011 (customer Sara, the BNPL customer) — see testing-setup.md.

A. Inspect the seeded BNPL order (this is the only part that works today).

  1. GET /api/v1/checkout_bnpl/by_request/11 with Sara's token. Expect: 200, order id: 1, bookingId: 6, providerCode: "snapppay", status: "reverted", orderAmountIrr "3200000", settledAmountIrr "2976000", bnplCommissionIrr "224000". The PASS is the arithmetic: 2976000 + 224000 == 3200000 — the net-of-fee invariant, live. 224000 / 3200000 = 0.07 because the seeder used bnpl_provider_commission_rate.
  2. Same row shows the revert leg: revertedAmountIrr "1600000" (50% tier), revertTransactionId "demo-revert-txn-1", refundChannel "bnpl_revert", expectedCustomerRefundEta "2026-08-09", and providerCommissionReversedAmount: null.
  3. GET /api/v1/checkout_bnpl/1 as 09120000010404, not 403. That is the tenancy rule (hard rule 20).
  4. Cross-check the payout hold: nurse 1 (09120000001) has an outstanding clawback and booking 6 is excluded from every batch — see nurse-earnings-and-payouts.md.

B. The wizard (D1→D5) — a dead end at every id. Open /fa/bookings/checkout/bnpl?request_id=1. Expect: not the four steps — you get the "pay with card" fallback card. request_id=1 and =2 are the only rows the BNPL mock's request store holds, both seeded pending_nurse_response and swept to expired_no_response minutes later, and page.tsx:88 bails for anything ≠ accepted_awaiting_payment. Any other id throws 404 inside the mock. D1D5 cannot be walked today by any route — so there is no reproducible test of the BNPL UI, mock or real. Reaching it the intended way (the C6 CTA) passes a real request id and 404s inside the mock — see gap 3.

C. Prove the rail is dead server-side. POST /api/v1/checkout_bnpl/initiate with any request id. Expect: 400 "No active BNPL gateway is configured." No workaround exists from the API — a payment_gateways row with Type = Bnpl, IsActive = 1 must be inserted first.

The seeded world can no longer support a fresh BNPL checkout either. Probed live: booking_requests/list returns 13 rows for 09120000010 and 5 for 09120000011, and none is accepted_awaiting_payment (a mix of converted, payment_deadline_expired, expired_no_response, cancelled_by_customer, rejected_by_nurse). So even with a gateway row every eligibility call returns 409 — request 11 answers "This booking has already been paid.", request 19 "This request is not awaiting payment." Testing D1D4 end-to-end requires re-seeding, fixing gap 1, and giving the mock wizard a payable request.

Known gaps

  • No payment_gateways row of type bnpl is ever created — SeedPaymentGatewaysAsync (ServiceCollectionExtensions.cs:140-151) seeds only Standard, so InitiateBnplOrderCommand.Handler.cs:42-44 and CheckBnplEligibilityQuery.Handler.cs:45-47 always fail with 400 "No active BNPL gateway is configured." The whole rail is unreachable on the live dev server. Verified live.
  • USE_BNPL_MOCK = true (bnpl/constants.ts:18) — every D1D5 screen renders fixture data; no customer-visible BNPL number on screen comes from the server.
  • The wizard is a dead end for every id — H-07's cross-domain mock edge, but the symptom is deadness, not fabrication. checkout/page.tsx:198 renders the CTA on BNPL_ENABLED alone and pushes a real booking-request id into the mock wizard. bnpl/apis/mockApi.ts:5-8 hard-imports bookingRequestsMockApi and mockInsertConvertedBooking (consumed at :262, :289, :358, :378, :405, :424, :463, :484) — bypassing the seam, so the flip USE_BOOKING_REQUESTS_MOCK = false never applies. That store seeds ids 1 and 2 only (nextId = 1), both pending_nurse_response, and its sweep() ages them to expired_no_response; nothing in a real-primary UI ever accepts them. So a real id 404s and ids 1/2 hit the page.tsx:88 guard — D1D5 never render. The 6001+ client-only booking H-07 warned about is currently unreachable, because the status gate fires before any settle; the guard is the only thing preventing it.
  • Three client ops have no server route and 404 live: getBnplOptions (clientApi.ts:53), getBnplSchedule (:69), getWalletInstallments (:107) — REQ-022/024. D1/D2's plan list, D4's schedule and the wallet «اقساط» tab therefore have no real source at all.
  • bnpl/apis/clientApi.ts:101-103 and its header block :38-41 both claim GET checkout_bnpl/by_request/{id} is a proposed slug that 404s. It exists (CheckoutBnplController.ByRequest, in the swagger) and returns 200 — stale comments blocking a partial flip.
  • acceptBnplSchedule (clientApi.ts:93) derives requestStatus client-side as order.status === 'settled' ? 'converted' : 'accepted_awaiting_payment'. A failed/reverted/cancelled order is mislabelled awaiting-payment, so the return page's windowExpired branch (bnpl/return/page.tsx:77) can never fire on the real path.
  • All four AdminBnplController endpoints return 403 for the seeded super_admin (09120000020) and finance (09120000021) accounts — probed live on GET /admin_bnpl/1 and POST /admin_bnpl/1/verify (RBAC gap) — and no admin console consumes them — admin_bnpl/{id}/revert is unreachable from any UI; the reversal is driven from cancellation-and-refunds.md instead.
  • providerCommissionReversedAmount is null on the seeded reverted order — the reconciliation figure most providers never send. Nothing in the UI or admin surfaces the resulting commission shortfall.
  • DemoLifecycleSeeder.Money.cs:42 writes EligibilityStatus = "approved", which is not in the closed BnplEligibilityStatus set (eligible/not_eligible/ceiling_exceeded) nor in the client union bnpl/types.ts:28.
  • The D3 KYC inputs are half-wired: the client sends { nationalId, mobile, consent } (clientApi.ts:62) but CheckBnplEligibilityQuery.Handler.cs:49-52 uses only the mobile. Legal consent is collected and discarded (REQ-023).
  • Nothing fires POST /webhooks_bnpl/{provider} in dev, so even a successfully initiated real order would never reach settled — settlement is webhook-driven by design (HandleBnplCallbackCommand.Handler.cs).
  • /fa/bookings/checkout/bnpl/gateway is an orphan on the real path — referenced only by mockApi.ts:342 and notFound()-gated outside NODE_ENV=development.