# Flow — Checkout & payment

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** customer · **Status:** partial
**Client:** partial · **Server:** partial
**Business source:** [product/business/08-payments-and-escrow.md](../../product/business/08-payments-and-escrow.md) ·
[product/payments/escrow-ledger.md](../../product/payments/escrow-ledger.md) ·
[product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
**Integration:** [docs/integration/domains/payment.md](../integration/domains/payment.md) ·
[docs/integration/api-contract.md](../integration/api-contract.md)

## What it does

The customer's nurse accepted, a 30-minute payment window is running, and the family pays the whole service
price on a card. Balinyaar never creates the booking on "the user tapped pay" — the booking, the balanced
escrow ledger group and the commission invoice all come into existence inside the PSP's webhook, after the
server re-verifies the capture with the acquirer. Everything before that is a redirect.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| C6 summary & pay | `/fa/bookings/checkout?request_id=` | [`checkout/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — nurse identity card, served `PriceBreakdown`, `CountdownTimer` on the frozen `paymentDeadlineAt`, `EscrowExplainer`, `StickyActionBar`. Non-payable statuses render a `PaymentStateCard` instead of a CTA (`:92-124`) |
| Return from gateway | `/fa/bookings/checkout/return` | `useConfirmGatewayReturn` fires once per mount (`confirmFiredRef`), then the `StatusTimeline` pending-callback state backed by the backoff poll; terminal → `invalidateAfterPaymentSuccess` → confirmation |
| Receipt | `/fa/bookings/checkout/confirmation` | Paid total, copyable LTR کد پیگیری, Shamsi paid-at, method, booking deep-link, «دانلود فاکتور». Shared with the BNPL branch via `?method=bnpl` |
| Money hub | `/fa/wallet` | 4 tabs; «پرداخت‌ها»/«رسیدها» merge card + BNPL rows in `useWalletHistoryRows.ts` |
| Invoice | `/fa/bookings/[id]/invoice` | A4-printable commission invoice, VAT-on-commission line, مودیان status chip |

## API

| Call | Endpoint | Notes |
| --- | --- | --- |
| Checkout summary | `GET booking_requests/checkout_summary/{id}` | **Live — probed `200`.** `clientApi.ts:35-42` |
| Initiate | `POST bookings/{bookingRequestId}/payments` | `clientApi.ts:44-50`; **one of only two endpoints that read `Idempotency-Key`** (`PaymentsController.cs:34`; the other is `CheckoutBnplController.cs:41`) |
| Outcome poll | `GET booking_requests/get/{id}` | No transaction read exists; `clientApi.ts:56-80` maps request status → `succeeded`/`failed`/`pending` |
| Invoice | `GET invoices/{bookingId}` | `clientApi.ts:82-83` |
| PSP callback | `POST webhooks/payments/{provider}` | anonymous, server-only |
| Payment history | `GET bookings/payment_history` | **Phantom — probed `404`** (REQ-047) |

Shapes live in [payment.md](../integration/domains/payment.md); the request DTO in
[booking-requests.md](../integration/domains/booking-requests.md).

## Rules that must hold

| Rule | Value | Where |
| --- | --- | --- |
| Three-amount split | `gross = balinyaar_commission + nurse_payout`, all ≥ 0 | DB CHECK `CK_Bookings_AmountSplit` (`BookingConfig.cs:13-17`) — [business/08](../../product/business/08-payments-and-escrow.md) |
| Commission rate | `0.15` — a **seeded `platform_configs` default**, not a product mandate | key `platform_fee_rate`; read at conversion (`BookingConversion.cs:45`) |
| Rate is snapshotted | frozen onto `Bookings.PlatformFeeRate` at conversion, never re-read | `BookingFactory.Create`; a later rate change is never retroactive |
| VAT | `0.10`, **on Balinyaar's commission line only** — never the gross, never the payout | key `vat_rate`; [business/13](../../product/business/13-tax-invoicing-and-legal.md), [platform-summary GT-2](../../product/overview/platform-summary.md) |
| Payment window | `30` min, server-frozen onto `BookingRequests.PaymentDeadlineAt` | key `booking_payment_deadline_minutes`; [business/05](../../product/business/05-booking-and-scheduling.md) |
| Money on the wire | IRR integer — **digit string outbound, `int64` inbound**. Never `Number()`; parse with the BigInt helpers | `GetCheckoutSummaryQuery.Handler.cs:72` (`Str()`); client rule 18 |
| Ledger balances | `CardCapture` = DEBIT `escrow_held` gross / CREDIT `platform_revenue` commission + `nurse_payable` payout, one group | `LedgerPosting.cs:26-36` **throws** rather than persist an unbalanced group |
| Ledger is append-only | corrections are new balancing groups, never edits | `LedgerEntryConfig.cs`; [escrow-ledger.md](../../product/payments/escrow-ledger.md) |
| Escrow release | only after a **confirmed check-out** and a closed 72 h dispute window — never on `completed` alone | `Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs` (the booking-lifecycle flow file is not written yet — see [flows index](index.md)) |
| The platform holds no cash | «escrow» is a ledger state over funds custodied at the licensed provider (GT-1) | [platform-summary](../../product/overview/platform-summary.md) |
| Webhook idempotency | upsert `payment_webhook_events` on `(provider, external_event_id)` **first**, then re-verify with the acquirer, then confirm | `HandlePaymentWebhookCommand.Handler.cs:31-36, 76-85, 88` |
| One succeeded payment | filtered `UNIQUE(booking_id) WHERE status='succeeded'`; a unique-violation on confirm is idempotent **success** | `ConfirmPaymentAndPostLedgerCommand.Handler.cs:69-75` |
| `409` on initiate is benign | "already paid / not awaiting payment / window lapsed" — converge, never toast | handler `:35-43`; client `checkout/page.tsx:145-149` |

### ⚠ The same commission is taxed two different ways

Probed live on **booking 8 / request 13**, gross `250000`, commission `37500`:

| Surface | VAT | Method |
| --- | --- | --- |
| `checkout_summary/13` | **`3409`** | `commissionNet = round(commission / (1 + vatRate))`, VAT carved **out** (`GetCheckoutSummaryQuery.Handler.cs:44-46`) |
| `invoices/8` | **`3750`** | `vat = round(commission × vatRate)`, VAT **additive** (`IssueInvoiceCommand.Handler.cs:37-38`) |

Both reconcile internally (checkout: `212500 + 34091 + 3409 = 250000`; invoice: `37500 + 3750 = 41250`) but
they disagree by 341 IRR on one booking. No product file resolves it. Never state "VAT is additive/inclusive"
without naming the surface.

## How to test

Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
OTP and the demo accounts.

> **The pristine seed has nothing to pay for.** Every seeded request has aged past its window: ids 6,7,8,9,12,13
> are `converted`, 2 is `payment_deadline_expired`, 1 is `expired_no_response`, 3 is `rejected_by_nurse`. The
> seed contains **no** `accepted_awaiting_payment` request — you must make one (step 4) to reach a payable C6.
> **The live DB is not pristine:** this session's `booking_requests/list` for `…010` also returned **21**
> (`accepted_awaiting_payment`, live deadline) and **22** (`cancelled_by_customer`), both left by earlier
> walk-throughs. Re-list before assuming an id's state.

**Walk the read-only half (works today):**

1. Open `/fa/bookings/checkout?request_id=13`. **Expect:** the already-paid convergence card («این رزرو
   پرداخت شده است»), not a pay button — the summary returns `requestStatus: "converted"`.
2. Open `/fa/bookings/8/invoice`. **Expect:** `INV-0000000008`, VAT row labelled `۱۰٪`, مودیان chip
   «در انتظار» (`moadianStatus: "pending"` — the mock `IMoadianClient` returns `registered` **only** when
   `Seams:Moadian:ForceRegistered` is on, and it is off by default), no PDF button (`pdfUrl: null`).
3. Open `/fa/wallet` → «پرداخت‌ها». **Expect:** the empty state, *not* an error — `bookings/payment_history`
   404s and the BNPL half is mock-empty (`useWalletHistoryRows.ts`).

**Create something payable (the workaround):**

4. As `09120000010`: search → C4 → submit a booking request. As **`09120000001`** (زهرا عزیزی): accept it from
   the nurse inbox. **Expect:** status `accepted_awaiting_payment` and a 30-minute countdown. (If a leftover
   payable request is still inside its window — id `21` at this stamp — reuse it and skip to step 5.)
5. Return to `/fa/bookings/checkout?request_id=<new>`. **Expect:** the money breakdown, the live countdown,
   and an enabled «پرداخت».
6. **Do not tap «پرداخت» in the browser** — see the first gap. Instead capture from the CLI:
   `POST /api/v1/bookings/convert {"bookingRequestId": <new>}` with the customer's bearer.
   **Expect:** `200` with the booking detail, request → `converted`, and two notifications of *different*
   types (`booking_confirmed` to the customer, `booking_confirmed_nurse` to the nurse).
   **But:** this path posts **no ledger group, no `payment_transactions` row, no invoice and no coordination
   ticket** (`ConvertRequestToBookingCommand.Handler.cs` — it stops at `CommitAsync` + notify), so
   `GET /invoices/{newBookingId}` will `404`. Only the webhook path (`ConfirmPaymentAndPostLedger…:58-98`)
   produces the full money record.

**Live probe results at this stamp** (customer `…010`, every curl `--noproxy '*'`):

- `checkout_summary/13` → `200`; `vatIrr "3409"`, `grossPriceIrr "250000"`, `balinyaarCommissionIrr "37500"`.
- `checkout_summary/21` (a genuinely payable request) → `200`, `requestStatus "accepted_awaiting_payment"`.
- **`POST bookings/21/payments` + `Idempotency-Key` → `200`, `redirectUrl
  "https://mock-psp.local/pay/mock-ref-21-<key>"`.** The dead end below is probed on a payable request, not
  inferred from the 409 path.
- `POST bookings/13/payments` + `Idempotency-Key` → `409 "This booking has already been paid."`
- `bookings/payment_history` → `404` · `booking_requests/get/13` → `200` with `bookingId: 8` ·
  `invoices/8` → `200`; `vatIrr "3750"`, `totalIrr "41250"`, `moadianStatus "pending"`, `pdfUrl null`.

## Known gaps

- **Tapping «پرداخت» dead-ends the browser.** `MockPaymentProvider.cs:20` returns
  `https://mock-psp.local/pay/{ref}` — a non-existent host — and `checkout/page.tsx:137-140` does
  `window.location.assign` on any absolute URL. The customer lands on a DNS error and never returns.
  **Probed:** `POST bookings/21/payments` really does return that URL with a `200`. The local card-gateway
  harness page (`checkout/gateway/page.tsx`) was deleted in `64f6aa4`, so nothing catches the hop.
- **Nothing fires the PSP webhook locally**, so no *new* card payment reaches `ConfirmPaymentAndPostLedger` in
  dev: the `payment_transactions` row stays `pending`, no ledger group is posted, no invoice is issued.
  `bookings/convert` substitutes for the *booking*, not for the money. The succeeded transactions and invoices
  you can read today (booking 8) are **seeded**, not reproducible from the UI — so the flow's core invariant is
  observable only as data, never as behaviour.
- **A verified nurse is shown as unverified on the payment screen.** `CheckoutSummaryDto` (client
  `types.ts:46-47`) declares `nurseAvatarUrl` and `nurseVerified`; the server DTO constructs neither
  (`GetCheckoutSummaryQuery.Handler.cs:48-67`, absent from the probed payload), so `nurseVerified` is
  `undefined` → `<TrustBadge state="unverified">` at `checkout/page.tsx:326`. REQ-046.
- **`sessionCount` is served but nullable, and the client types it as non-null.** The server *does* emit the
  field (`"sessionCount": null` on requests 13 and 21 — `ctx.SessionCount` is `int?`, defaulted to `1` only
  for the internal `gross` maths); the client declares `sessionCount: number` (`types.ts:52`) and feeds it
  straight into the ICU label `row_service_cost_with_count` at `checkout/page.tsx:265`. UNVERIFIED how
  next-intl renders a null `count` — not reproduced in a browser this session.
- **The confirmation screen cannot deep-link to the booking.** `booking_requests/get/{id}` **does** serve
  `bookingId` (probed `8`), but `clientApi.ts:58` types the response as `Omit<…,'bookingId'>` and `:73`
  hardcodes `bookingId: null`. Hardening issue H-10. The receipt also always hides کد پیگیری and paid-at
  (`:76-77`).
- **The client's payment constants and comments are stale and mislead the reader:** `clientApi.ts:36-37` says
  `checkout_summary` "404s until the backend delivers it" (it returns `200`); `constants.ts:5-16` still says
  "Mock is primary this phase" and "the contract serves no checkout summary" while `USE_PAYMENT_MOCK = false`;
  and `constants.ts:53` hardcodes `MOCK_PLATFORM_FEE_RATE = 0.12` against the server's seeded `0.15`. Only the
  last is money-shaped, and it is unreachable while the mock is off.
- **The invoice screen derives a money row.** `invoice/page.tsx:135` computes
  `serviceIrr = gross − commission − vat` = `208750` for booking 8, but the real nurse payout is `212500` —
  wrong by exactly the VAT, because VAT is additive and not part of gross. It also labels `invoice.grossIrr`
  as the invoice total while the served `totalIrr` (`41250`, the commission invoice) is never shown. Violates
  client rule 18 ("the client displays money; it never computes it").
- **The wallet «پرداخت‌ها» tab is permanently empty** for a card-paying customer — `bookings/payment_history`
  is a live `404` on every visit (REQ-047).
- **`InvoiceDto` serves no payment method, transaction reference or seller fiscal identity** (REQ-049), so
  those rows never render on the real path.
- **The escrow ledger has no read surface.** `ledger_entries` is exposed only through
  `GetNursePayableBalance`; a customer, an admin and a tester have no way to see the balanced capture group
  the flow's core invariant depends on.
- **`GET invoices/{bookingId}` is `[Authorize]` only** (`InvoicesController.cs:17`); tenancy is enforced inside
  `GetInvoiceQuery`. Not re-probed cross-tenant this session — UNVERIFIED that a foreign customer gets `404`.
