Files
baya-monorepo/dev/contracts/domains/bnpl.md
T
2026-07-13 11:26:39 +03:30

165 lines
9.5 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.
# 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 ~710 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.