# bnpl — provider-financed installments

> Client seam `client/src/services/bnpl/` · `USE_BNPL_MOCK = true` (**mock is primary**) · 9 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

The second checkout rail. **Balinyaar does not finance anything** — a provider (SnappPay, Digipay, …) pays
the platform net of its commission and carries the customer's installments itself. Card checkout is
[payment.md](payment.md).

## Endpoints

| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| POST | `/api/v1/checkout_bnpl/eligibility` | `[Authorize]` · `sensitive` | wired |
| POST | `/api/v1/checkout_bnpl/initiate` | `[Authorize]` · `sensitive` | wired · **`Idempotency-Key`** |
| GET | `/api/v1/checkout_bnpl/{id}` | `[Authorize]` · `sensitive` | wired |
| GET | `/api/v1/checkout_bnpl/by_request/{bookingRequestId}` | `[Authorize]` · `sensitive` | wired |
| POST | `/api/v1/webhooks_bnpl/{provider}` | **anonymous** · `webhook` 120/min | server-only — the provider calls it |
| GET | `/api/v1/admin_bnpl/{id}` | admin · `sensitive` | **unwired** — no console screen |
| POST | `/api/v1/admin_bnpl/{id}/verify` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_bnpl/{id}/settle` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_bnpl/{id}/revert` | admin · `sensitive` | **unwired** — the reversal is driven from [refunds.md](refunds.md) instead |

### Phantom — 3

All three are REQ-022's deferred half. Written real-shaped so the swap is one line.

| Client call | REQ | Note |
| --- | --- | --- |
| `GET /api/v1/checkout_bnpl/options/{bookingRequestId}` | REQ-022 | The D1/D2 provider + plan list, per-plan monthly / down-payment / total |
| `GET /api/v1/checkout_bnpl/schedule/{id}` | REQ-022 | The D4 repayment schedule |
| `GET /api/v1/checkout_bnpl/wallet_installments` | REQ-024 | The D5 wallet installment list |

REQ-022 was **partially** delivered: `balinyaar` was added to the `provider_code` enum; `options` and
`schedule` were deferred. `BnplEligibilityDto` carries a single `planSummary` + `installmentCount`, not a
list of plans — which is exactly why the client needs `options`.

## The money shape

Two facts that make BNPL different from card, and both are easy to get wrong:

1. **The card payment is recorded net of the provider's fee.** The provider deducts its commission before
   remitting, so the platform receives `orderAmount − bnplCommission`. `settledAmountIrr` and
   `bnplCommissionIrr` are both on `BnplOrderStatusDto`, and the handler reads the **actual deducted
   amount from the settlement response** — never a rate from config. `Seams:Bnpl:CommissionRate` tunes the
   *mock* only.
2. **Settlement is not necessarily instant.** `settledAt` is nullable, modelling the deferred / T+1–3 /
   weekly reality. A null `settledAt` on a `settled` order is normal, not an inconsistency.

`BnplStatus` is **forward-only**. A reversal is `reverted`, with `revertTransactionId`,
`revertedAmountIrr`, `revertedAt` and — when the provider returns it — `providerCommissionReversedAmount`,
which the reconciliation needs and which most providers do not send. See [refunds.md](refunds.md) for the
`bnpl_revert` refund channel.

## Shape rules the JSON does not express

- **Eligibility accepts the credit-check inputs** `{ nationalId, mobile, consent }` (REQ-023, delivered).
  Consent is **required** when the KYC inquiry runs — it is a legal precondition, not a checkbox.
- **`eligibilityStatus` distinguishes three outcomes**, and the third is not a failure:
  `not_eligible` (provider declined) vs `ceiling_exceeded` (order above `creditCeilingIrr` — offer card
  instead) vs `eligible`. The UI must fall back to card, not show an error, on either negative.
- **`bookingId` is on the settled order** (REQ-024, confirmed) so the wallet can link an installment plan
  to its booking.
- **D5 installment status is provider-reported, not ledger-derived.** The platform does not track the
  customer's repayment; whatever the provider says is the truth. Never compute an installment state from
  Balinyaar's own ledger.
- **`currency` is on the wire and matters.** `Seams:Bnpl:WireCurrency` is `IRR` by default; SnappPay and
  Digipay speak Rial. Conversion happens **only** inside the adapter via `ICurrencyNormalizer`.
- Provider credentials proper live in the encrypted `payment_gateways.config_json`; only non-secret
  connection facts (base URL, sandbox flag, merchant handle) come from `Seams:Bnpl:Providers`.
- `Seams:Bnpl:NotEligibleMobile` (`09120000099`) is the designated test mobile that returns
  `not_eligible`, so the fall-back-to-card path is testable.

## Enums

| Vocabulary | Values |
| --- | --- |
| `BnplStatus` | `eligible` `token_issued` `verified` `settled` `reverted` `cancelled` `failed` |
| `BnplEligibilityStatus` | `eligible` `not_eligible` `ceiling_exceeded` |
| `ProviderCode` | `snapppay` `digipay` `tara` `torobpay` `balinyaar` |
| `BnplInstallmentStatus` *(D5, provider-reported)* | `paid` `due_soon` `upcoming` `overdue` |
| `BnplHandoffOutcome` *(client, from the return URL)* | `success` `failure` |

The first three are verified identical to `Entities/Bnpl/BnplStatus.cs`,
`BnplEligibilityStatus.cs` and `BnplProviderCodes.cs`. Note `snapppay` has **three** `p`s.

## Open REQs

| REQ | Status | Effect |
| --- | --- | --- |
| REQ-022 | partially delivered | `options` and `schedule` deferred → 3 phantom routes. D1/D2/D4 are mock-only |
| REQ-024 | partially delivered | `bookingId` confirmed present; the wallet installment list is deferred |
