Files
baya-monorepo/dev/contracts/domains/bnpl.md
T
2026-07-09 03:05:14 +03:30

8.6 KiB
Raw Blame History

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 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).

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:
    { "bookingRequestId": 42, "providerCode": "snapppay" }
    
  • Success 200 payload (data):
    {
      "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:
    { "bookingRequestId": 42, "providerCode": "snapppay" }
    
  • Success 200 payload (data):
    {
      "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):
    { "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_transactionconverts 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)
    { "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" }
    
  • Success 200 payload (data):
    {
      "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

  • BnplOrderStatusid (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).