# Contract — BNPL provider-financed installments (backend phase b12) > One-line: the "pay with installments" checkout alternative. A family checks eligibility, starts a BNPL order > and is handed off to the provider; the provider callback (or an admin) verifies + settles it — which, in our > books, is **a card payment that lands net-of-fee** (the provider pays the full booking amount in one lump minus > its merchant commission and owns 100% of the customer's installments + default risk). Admins can revert. Assumes > [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../openapi/README.md). **Status:** live as of backend-phase-b12 · **Frontend consumer:** frontend-phase-f11-b12 All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). We do **not** model the customer's repayment schedule — `installment_count` is informational (default 4). Timestamps are UTC ISO-8601; `settled_at` is **nullable** (settlement is contract-defined and not instant); `expected_customer_refund_eta` is a **date** (`"2026-08-24"`). Internal ledger `account_type`s are never exposed. ## Enums used - `bnpl_status` (`bnpl_transactions.status`): `eligible` | `token_issued` | `verified` | `settled` | `reverted` | `cancelled` | `failed`. **Forward-only** (`eligible → token_issued → verified → settled → reverted`); a replayed callback that would re-drive a completed transition is an idempotent no-op. - `bnpl_eligibility_status` (`bnpl_transactions.eligibility_status`): `eligible` | `not_eligible` | `ceiling_exceeded`. On anything but `eligible` the client falls back to card. - `provider_code`: `snapppay` | `digipay` | `tara` | `torobpay` — selects the provider adapter. - `refund_channel` (on the revert's refund): always `bnpl_revert` here (see [`refunds-invoices.md`](refunds-invoices.md)). ## Endpoints ### `POST api/v1/checkout_bnpl/eligibility` - **Purpose:** check whether the caller can finance an `accepted_awaiting_payment` booking request with a provider, and record the outcome on a created/updated `bnpl_transactions` row (status `eligible`). - **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:** no - **Request body:** ```json { "bookingRequestId": 42, "providerCode": "snapppay" } ``` - **Success `200` payload (`data`):** ```json { "eligibilityStatus": "eligible", "isEligible": true, "installmentCount": 4, "planSummary": "4 interest-free installments, 0% interest, provider-financed.", "creditCeilingIrr": "2000000000" } ``` - **Failure cases:** `400` invalid `provider_code` / non-positive id; `401` unauthenticated; `404` request not found **or not owned by the caller** (tenancy — a cross-customer request is indistinguishable from missing); `409` already paid / not awaiting payment. - **Notes:** the order amount is the request's frozen gross (variant price × session count), never client-supplied. ### `POST api/v1/checkout_bnpl/initiate` - **Purpose:** start the BNPL order — issue the provider payment token + redirect and walk `eligible → token_issued`. - **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:** `Idempotency-Key` header (a retried initiate reuses the same token). - **Request body:** ```json { "bookingRequestId": 42, "providerCode": "snapppay" } ``` - **Success `200` payload (`data`):** ```json { "bnplTransactionId": 7, "paymentTransactionId": 15, "status": "token_issued", "externalPaymentToken": "mock-bnpl-token-10000000-bnpl-br-42", "redirectUrl": "https://provider.example/checkout/…" } ``` - **Failure cases:** `400` invalid input; `401` unauthenticated; `404` request not found / not owned; `409` already paid / not awaiting payment / payment window lapsed / order no longer startable; `400` provider declined the order. - **Notes:** the row is **1:1** with a `payment_transaction` (`UNIQUE(payment_transaction_id)`); a second initiate reuses the same row. Runs under `lock(booking-request:{id}:payment)`. ### `GET api/v1/checkout_bnpl/{id}` - **Purpose:** the customer reads **their own** BNPL order. - **Auth:** authenticated (tenancy-scoped) · **Rate-limited:** yes (sensitive) - **Success `200`:** the `BnplOrderStatus` shape (below). - **Failure cases:** `401`; `404` not found **or another customer's** order (clean not-found). ### `POST api/v1/webhooks_bnpl/{provider}` - **Purpose:** inbound provider callback — verify/settle/revert an order by event type. - **Auth:** anonymous, **signature-authenticated** · **Rate-limited:** yes (per-IP) · **Idempotency key:** `(provider_code, external_event_id)` deduped in `payment_webhook_events` before any money moves. - **Request body:** raw provider payload; the mock verifier reads `{ "external_event_id": "...", "event_type": "order.settled", "gateway_reference_code": "" }`. Event type routes: contains `verif` → verify, `settl` → settle, `revert`/`refund` → revert. - **Success `200` payload (`data`):** ```json { "processingStatus": "processed", "duplicate": false } ``` - **Notes:** always `200` (at-least-once tolerant). A bad signature is stored `ignored`; a duplicate is a no-op (`duplicate: true`); an unknown token is `failed` (retryable). A replayed settle never double-posts the ledger (webhook dedup + the forward-only state guard). ### `POST api/v1/admin_bnpl/{id}/verify` · `POST api/v1/admin_bnpl/{id}/settle` - **Purpose:** manually drive verify / settle (also driven by the callback). - **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) - **Success `200`:** `true`. - **Failure cases:** `401`/`403`; `404` order not found; `409` wrong state (e.g. settle before verify); `400` provider declined / settlement does not reconcile. - **Settle side effects:** records `settled_amount_irr` = `order − commission`, `bnpl_commission_irr`, `settled_at` (nullable — read from the settlement); posts the **net-of-fee ledger group** (card-capture legs **plus** `DEBIT bnpl_fee_expense / CREDIT escrow_held`, one balanced group, so escrow reflects the **net** cash); confirms the parent `payment_transaction` → **converts the booking**. The nurse's `nurse_payable` accrual equals the card-path amount (payout **invariant to payment method**). Runs under `lock(bnpl:{id}:settle)`. ### `POST api/v1/admin_bnpl/{id}/revert` - **Purpose:** reverse a settled BNPL order through the provider. - **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) - **Request body:** (all optional; omit `refund_percentage` for a full revert) ```json { "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" } ``` - **Success `200` payload (`data`):** ```json { "bnplTransactionId": 7, "refundId": 3, "status": "reverted", "revertTransactionId": "…", "revertedAmountIrr": "8000000", "expectedCustomerRefundEta": "2026-08-24" } ``` - **Failure cases:** `401`/`403`; `404` not found; `409` not settled / already reverted; `400` provider refused. - **Notes:** creates a `refunds` row with `refund_channel='bnpl_revert'` and posts the reversal ledger via the b11 refund path (fee + payout legs; a clawback if the nurse was already paid). Money flows **customer ↔ provider ↔ Balinyaar** only; the customer cash-back is async ~7–10 business days (`expected_customer_refund_eta`). A partial (`refund_percentage < 1`) maps to the provider's update-to-strictly-lower verb. ### `GET api/v1/admin_bnpl/{id}` - **Purpose:** admin reads any BNPL order. - **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) - **Success `200`:** the `BnplOrderStatus` shape (below). ## Shared shapes - `BnplOrderStatus` — `id` (long), `paymentTransactionId` (long), `bookingId` (long?, set at settle), `providerCode` (string), `status` (`bnpl_status`), `eligibilityStatus` (`bnpl_eligibility_status`?), `orderAmountIrr` (digit string), `settledAmountIrr` (digit string?), `bnplCommissionIrr` (digit string?), `currency` (string, `IRR`), `installmentCount` (int, informational), `settledAt` (datetime?, **nullable — not instant**), `revertTransactionId` (string?), `revertedAmountIrr` (digit string?), `revertedAt` (datetime?), `providerCommissionReversedAmount` (digit string?), `refundChannel` (string?, `bnpl_revert`), `expectedCustomerRefundEta` (date?), `createdAt` (datetime). ## Changelog - b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).