13 KiB
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.tsx — provider-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_admin — GET /admin_bnpl/1 probed |
Chain traced for the two live customer calls: bnpl/page.tsx:47 → useBnplOptions → services/bnpl/apis/index.ts
(ternary on USE_BNPL_MOCK) → apis/clientApi.ts:53/76 → clientFetch → CheckoutBnplController.cs:34/39 →
CheckBnplEligibilityQuery.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).
GET /api/v1/checkout_bnpl/by_request/11with Sara's token. Expect:200, orderid: 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.07because the seeder usedbnpl_provider_commission_rate.- Same row shows the revert leg:
revertedAmountIrr "1600000"(50% tier),revertTransactionId "demo-revert-txn-1",refundChannel "bnpl_revert",expectedCustomerRefundEta "2026-08-09", andproviderCommissionReversedAmount: null. GET /api/v1/checkout_bnpl/1as 09120000010 → 404, not 403. That is the tenancy rule (hard rule 20).- 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. D1–D5 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 D1–D4
end-to-end requires re-seeding, fixing gap 1, and giving the mock wizard a payable request.
Known gaps
- No
payment_gatewaysrow of typebnplis ever created —SeedPaymentGatewaysAsync(ServiceCollectionExtensions.cs:140-151) seeds onlyStandard, soInitiateBnplOrderCommand.Handler.cs:42-44andCheckBnplEligibilityQuery.Handler.cs:45-47always fail with400 "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 D1–D5 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:198renders the CTA onBNPL_ENABLEDalone and pushes a real booking-request id into the mock wizard.bnpl/apis/mockApi.ts:5-8hard-importsbookingRequestsMockApiandmockInsertConvertedBooking(consumed at:262, :289, :358, :378, :405, :424, :463, :484) — bypassing the seam, so the flipUSE_BOOKING_REQUESTS_MOCK = falsenever applies. That store seeds ids 1 and 2 only (nextId = 1), bothpending_nurse_response, and itssweep()ages them toexpired_no_response; nothing in a real-primary UI ever accepts them. So a real id 404s and ids 1/2 hit thepage.tsx:88guard — D1–D5 never render. The6001+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-103and its header block:38-41both claimGET 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) derivesrequestStatusclient-side asorder.status === 'settled' ? 'converted' : 'accepted_awaiting_payment'. Afailed/reverted/cancelledorder is mislabelled awaiting-payment, so the return page'swindowExpiredbranch (bnpl/return/page.tsx:77) can never fire on the real path.- All four
AdminBnplControllerendpoints return 403 for the seededsuper_admin(09120000020) andfinance(09120000021) accounts — probed live onGET /admin_bnpl/1andPOST /admin_bnpl/1/verify(RBAC gap) — and no admin console consumes them —admin_bnpl/{id}/revertis unreachable from any UI; the reversal is driven from cancellation-and-refunds.md instead. providerCommissionReversedAmountisnullon 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:42writesEligibilityStatus = "approved", which is not in the closedBnplEligibilityStatusset (eligible/not_eligible/ceiling_exceeded) nor in the client unionbnpl/types.ts:28.- The D3 KYC inputs are half-wired: the client sends
{ nationalId, mobile, consent }(clientApi.ts:62) butCheckBnplEligibilityQuery.Handler.cs:49-52uses 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 reachsettled— settlement is webhook-driven by design (HandleBnplCallbackCommand.Handler.cs). /fa/bookings/checkout/bnpl/gatewayis an orphan on the real path — referenced only bymockApi.ts:342andnotFound()-gated outsideNODE_ENV=development.