# 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": "<token>" }`. 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).

---

## Refinement phase 3 additions (REQ-022/023/024)

- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
  same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
  KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
  the settled order was already present on the DTO.
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
  the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
  Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
