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

119 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/business/09-installments-bnpl.md) · [product/payments/bnpl-landscape.md](../../product/payments/bnpl-landscape.md)
**Integration:** [docs/integration/domains/bnpl.md](../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`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — 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`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/bnpl/page.tsx) 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](../integration/domains/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](../../product/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](../../product/business/09-installments-bnpl.md) §(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](../../product/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](../../product/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](../../product/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](../../product/business/09-installments-bnpl.md) §(a) |
| `installment_count` (`4`) is **informational** and never drives money | | [business/09](../../product/business/09-installments-bnpl.md) §(a) |
| MoR is config (`bnpl_merchant_of_record` = `platform`) | read in both handlers | [business/13](../../product/business/13-tax-invoicing-and-legal.md) §(a) |
| A booking with an active refund is **held out of every payout batch** | INV-16 | [business/10](../../product/business/10-payouts.md) §(d1) |
## How to test
Log in as **09120000011** (customer Sara, the BNPL customer) — see [testing-setup.md](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 **09120000010** → **404**, 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](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](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`.