# 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=`. **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": }` 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-"`.** 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` → `` 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`.