Files
baya-monorepo/archive/docs/flows/checkout-and-payment.md
T
2026-08-02 20:01:31 +03:30

168 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.