frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
+150
View File
@@ -0,0 +1,150 @@
# 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).