72 lines
4.9 KiB
Markdown
72 lines
4.9 KiB
Markdown
# Contract — Payments core: ledger, transactions, webhooks & card capture (backend phase b10)
|
||
|
||
> One-line: the inbound money rail — start a card payment against an accepted request, a PSP webhook confirms
|
||
> it, the balanced card-capture ledger group posts, and the booking confirms. 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-b10 · **Frontend consumer:** frontend-phase-f9-b10
|
||
|
||
All money is **IRR Rials, integer, on the wire as a string of digits** (`"23300000"`). The card-capture ledger
|
||
group is always **balanced** (Σ debit = Σ credit). **Internal `account_type`s are never exposed to the
|
||
customer** — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601.
|
||
|
||
## Enums used
|
||
- `payment` status (`payment_transactions.status`): `pending` | `succeeded` | `failed`.
|
||
- `payment_gateways.type`: `standard` (card IPG) | `bnpl`.
|
||
- `payment_webhook_events.processing_status`: `received` | `processed` | `failed` | `ignored`.
|
||
- `account_type` (internal, never on the customer wire): `escrow_held` | `platform_revenue` | `nurse_payable`
|
||
| `refund_payable` | `bnpl_fee_expense` | `psp_fee_expense` | `nurse_clawback_receivable` | `bad_debt`.
|
||
b10 posts only the first three (card capture); the rest are reserved for b11/b12/b13.
|
||
|
||
## Endpoints
|
||
|
||
### `POST api/v1/bookings/{bookingRequestId}/payments`
|
||
- **Purpose:** start a card payment for an `accepted_awaiting_payment` booking request owned by the caller.
|
||
A `bookings` row exists only on capture (b9), so payment is initiated against the **request**; the amount
|
||
charged is the request's **frozen gross** (variant price × session count), never client-supplied.
|
||
- **Auth:** authenticated (the owning customer) · **Rate-limited:** yes (sensitive) · **Idempotency:** send an
|
||
**`Idempotency-Key`** header — a retried start reuses the same attempt/reference.
|
||
- **Request body:** none (the id is in the route; the key is a header).
|
||
- **Success `200` (`data`):** `{ "transactionId": 42, "redirectUrl": "https://…", "gatewayReferenceCode": "…" }`.
|
||
No ledger rows yet; the booking is not created yet.
|
||
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` already paid /
|
||
not awaiting payment / payment window lapsed, `400` no active gateway configured.
|
||
|
||
### `POST api/v1/webhooks/payments/{provider}`
|
||
- **Purpose:** the inbound PSP/BNPL callback — verify-then-dedup-then-mutate.
|
||
- **Auth:** **none (signature-authenticated)**, anonymous to the auth pipeline · **Rate-limited:** yes (global
|
||
per-IP) · **Idempotent:** yes, at-least-once tolerant.
|
||
- **Request:** the raw provider callback body (stored verbatim in `payload_json`); signature material in headers.
|
||
- **Behaviour:** upserts `payment_webhook_events` **first** on `(provider, external_event_id)` and **no-ops on
|
||
a duplicate**; an **invalid signature** is stored `ignored` and mutates nothing; on a **new success event**
|
||
it re-verifies server-side (never trusts the callback alone), then captures — posts the balanced card-capture
|
||
group and creates/confirms the booking — all under a `lock(booking:{id}:payment)` with the DB uniques as the
|
||
authoritative backstop.
|
||
- **Success `200` (`data`):** `{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }`
|
||
(`duplicate: true` on a replayed event).
|
||
|
||
### `GET api/v1/nurses/{nurseId}/payable_balance`
|
||
- **Purpose:** the IRR balance currently owed a nurse — the **signed sum** over `nurse_payable` ledger legs
|
||
(credit adds, debit subtracts), **derived, never a stored column**. This is what b13 payouts read.
|
||
- **Auth:** authenticated — the **nurse themself or an admin/finance role** (`403` otherwise).
|
||
- **Success `200` (`data`):** `{ "nurseId": 7, "balanceIrr": "19805000" }`.
|
||
|
||
## The card-capture ledger group (posted on webhook confirm)
|
||
One `transaction_group_id`, `amount_irr` positive with `direction` carrying the sign, Σdebit = Σcredit:
|
||
|
||
```
|
||
DEBIT escrow_held gross_price_irr (e.g. 23300000)
|
||
CREDIT platform_revenue balinyaar_commission_irr (e.g. 3495000)
|
||
CREDIT nurse_payable nurse_payout_amount (e.g. 19805000, nurse_id set)
|
||
```
|
||
|
||
## Load-bearing rules the client must honour
|
||
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
|
||
- **A booking is created & confirmed on capture** (the webhook), not on initiate — after `initiate` the
|
||
redirect is shown; the booking appears once the PSP callback confirms.
|
||
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
|
||
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
||
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|