# 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`.

---

## Refinement phase 3 additions (REQ-018)

- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
  capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
  booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
