9.5 KiB
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/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
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_types 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 buteligiblethe client falls back to card.provider_code:snapppay|digipay|tara|torobpay— selects the provider adapter.refund_channel(on the revert's refund): alwaysbnpl_reverthere (seerefunds-invoices.md).
Endpoints
POST api/v1/checkout_bnpl/eligibility
- Purpose: check whether the caller can finance an
accepted_awaiting_paymentbooking request with a provider, and record the outcome on a created/updatedbnpl_transactionsrow (statuseligible). - Auth: authenticated (customer, tenancy-scoped) · Rate-limited: yes (sensitive) · Idempotency key: no
- Request body:
{ "bookingRequestId": 42, "providerCode": "snapppay" } - Success
200payload (data):{ "eligibilityStatus": "eligible", "isEligible": true, "installmentCount": 4, "planSummary": "4 interest-free installments, 0% interest, provider-financed.", "creditCeilingIrr": "2000000000" } - Failure cases:
400invalidprovider_code/ non-positive id;401unauthenticated;404request not found or not owned by the caller (tenancy — a cross-customer request is indistinguishable from missing);409already 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-Keyheader (a retried initiate reuses the same token). - Request body:
{ "bookingRequestId": 42, "providerCode": "snapppay" } - Success
200payload (data):{ "bnplTransactionId": 7, "paymentTransactionId": 15, "status": "token_issued", "externalPaymentToken": "mock-bnpl-token-10000000-bnpl-br-42", "redirectUrl": "https://provider.example/checkout/…" } - Failure cases:
400invalid input;401unauthenticated;404request not found / not owned;409already paid / not awaiting payment / payment window lapsed / order no longer startable;400provider 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 underlock(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: theBnplOrderStatusshape (below). - Failure cases:
401;404not 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 inpayment_webhook_eventsbefore 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: containsverif→ verify,settl→ settle,revert/refund→ revert. - Success
200payload (data):{ "processingStatus": "processed", "duplicate": false } - Notes: always
200(at-least-once tolerant). A bad signature is storedignored; a duplicate is a no-op (duplicate: true); an unknown token isfailed(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;404order not found;409wrong state (e.g. settle before verify);400provider 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 plusDEBIT bnpl_fee_expense / CREDIT escrow_held, one balanced group, so escrow reflects the net cash); confirms the parentpayment_transaction→ converts the booking. The nurse'snurse_payableaccrual equals the card-path amount (payout invariant to payment method). Runs underlock(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_percentagefor a full revert){ "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" } - Success
200payload (data):{ "bnplTransactionId": 7, "refundId": 3, "status": "reverted", "revertTransactionId": "…", "revertedAmountIrr": "8000000", "expectedCustomerRefundEta": "2026-08-24" } - Failure cases:
401/403;404not found;409not settled / already reverted;400provider refused. - Notes: creates a
refundsrow withrefund_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: theBnplOrderStatusshape (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)
balinyaaradded to theprovider_codeenum (in-house plan; identical net-of-fee mechanics, resolves to the same adapter). The set is nowsnapppay|digipay|tara|torobpay|balinyaar.POST checkout_bnpl/eligibilityaccepts 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;bookingIdon 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.