4.9 KiB
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/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
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_types are never exposed to the
customer — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601.
Enums used
paymentstatus (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_paymentbooking request owned by the caller. Abookingsrow 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-Keyheader — 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:
400bad id,401unauth,404request not found / not the caller's,409already paid / not awaiting payment / payment window lapsed,400no 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_eventsfirst on(provider, external_event_id)and no-ops on a duplicate; an invalid signature is storedignoredand 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 alock(booking:{id}:payment)with the DB uniques as the authoritative backstop. - Success
200(data):{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }(duplicate: trueon a replayed event).
GET api/v1/nurses/{nurseId}/payable_balance
- Purpose: the IRR balance currently owed a nurse — the signed sum over
nurse_payableledger 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 (
403otherwise). - 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
initiatethe redirect is shown; the booking appears once the PSP callback confirms. - The checkout shows gross + commission/VAT breakdown only — never the internal
account_types. - Payment is idempotent end-to-end: a retried
initiate(sameIdempotency-Key) reuses the attempt; a replayed webhook is a no-op; a repeatinitiateafter capture is a409.