backend phase 10

This commit is contained in:
hamid
2026-07-06 21:17:00 +03:30
parent 12c7e51c32
commit aae056b4e5
70 changed files with 8124 additions and 73 deletions
+71
View File
@@ -0,0 +1,71 @@
# 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`.