Files
baya-monorepo/dev/contracts/domains/payments.md
T
2026-07-06 21:17:00 +03:30

4.9 KiB
Raw Blame History

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

  • 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_types.
  • 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.