14 KiB
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/payments/escrow-ledger.md · product/business/13-tax-invoicing-and-legal.md Integration: docs/integration/domains/payment.md · docs/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 — 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; the request DTO in 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 |
| 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, platform-summary GT-2 |
| Payment window | 30 min, server-frozen onto BookingRequests.PaymentDeadlineAt |
key booking_payment_deadline_minutes; business/05 |
| 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 |
| 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) |
| The platform holds no cash | «escrow» is a ledger state over funds custodied at the licensed provider (GT-1) | platform-summary |
| 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 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 ispayment_deadline_expired, 1 isexpired_no_response, 3 isrejected_by_nurse. The seed contains noaccepted_awaiting_paymentrequest — you must make one (step 4) to reach a payable C6. The live DB is not pristine: this session'sbooking_requests/listfor…010also 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):
- Open
/fa/bookings/checkout?request_id=13. Expect: the already-paid convergence card («این رزرو پرداخت شده است»), not a pay button — the summary returnsrequestStatus: "converted". - Open
/fa/bookings/8/invoice. Expect:INV-0000000008, VAT row labelled۱۰٪, مودیان chip «در انتظار» (moadianStatus: "pending"— the mockIMoadianClientreturnsregisteredonly whenSeams:Moadian:ForceRegisteredis on, and it is off by default), no PDF button (pdfUrl: null). - Open
/fa/wallet→ «پرداختها». Expect: the empty state, not an error —bookings/payment_history404s and the BNPL half is mock-empty (useWalletHistoryRows.ts).
Create something payable (the workaround):
- As
09120000010: search → C4 → submit a booking request. As09120000001(زهرا عزیزی): accept it from the nurse inbox. Expect: statusaccepted_awaiting_paymentand a 30-minute countdown. (If a leftover payable request is still inside its window — id21at this stamp — reuse it and skip to step 5.) - Return to
/fa/bookings/checkout?request_id=<new>. Expect: the money breakdown, the live countdown, and an enabled «پرداخت». - 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:200with the booking detail, request →converted, and two notifications of different types (booking_confirmedto the customer,booking_confirmed_nurseto the nurse). But: this path posts no ledger group, nopayment_transactionsrow, no invoice and no coordination ticket (ConvertRequestToBookingCommand.Handler.cs— it stops atCommitAsync+ notify), soGET /invoices/{newBookingId}will404. 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→200withbookingId: 8·invoices/8→200;vatIrr "3750",totalIrr "41250",moadianStatus "pending",pdfUrl null.
Known gaps
- Tapping «پرداخت» dead-ends the browser.
MockPaymentProvider.cs:20returnshttps://mock-psp.local/pay/{ref}— a non-existent host — andcheckout/page.tsx:137-140doeswindow.location.assignon any absolute URL. The customer lands on a DNS error and never returns. Probed:POST bookings/21/paymentsreally does return that URL with a200. The local card-gateway harness page (checkout/gateway/page.tsx) was deleted in64f6aa4, so nothing catches the hop. - Nothing fires the PSP webhook locally, so no new card payment reaches
ConfirmPaymentAndPostLedgerin dev: thepayment_transactionsrow stayspending, no ledger group is posted, no invoice is issued.bookings/convertsubstitutes 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(clienttypes.ts:46-47) declaresnurseAvatarUrlandnurseVerified; the server DTO constructs neither (GetCheckoutSummaryQuery.Handler.cs:48-67, absent from the probed payload), sonurseVerifiedisundefined→<TrustBadge state="unverified">atcheckout/page.tsx:326. REQ-046. sessionCountis served but nullable, and the client types it as non-null. The server does emit the field ("sessionCount": nullon requests 13 and 21 —ctx.SessionCountisint?, defaulted to1only for the internalgrossmaths); the client declaressessionCount: number(types.ts:52) and feeds it straight into the ICU labelrow_service_cost_with_countatcheckout/page.tsx:265. UNVERIFIED how next-intl renders a nullcount— not reproduced in a browser this session.- The confirmation screen cannot deep-link to the booking.
booking_requests/get/{id}does servebookingId(probed8), butclientApi.ts:58types the response asOmit<…,'bookingId'>and:73hardcodesbookingId: 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-37sayscheckout_summary"404s until the backend delivers it" (it returns200);constants.ts:5-16still says "Mock is primary this phase" and "the contract serves no checkout summary" whileUSE_PAYMENT_MOCK = false; andconstants.ts:53hardcodesMOCK_PLATFORM_FEE_RATE = 0.12against the server's seeded0.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:135computesserviceIrr = gross − commission − vat=208750for booking 8, but the real nurse payout is212500— wrong by exactly the VAT, because VAT is additive and not part of gross. It also labelsinvoice.grossIrras the invoice total while the servedtotalIrr(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_historyis a live404on every visit (REQ-047). InvoiceDtoserves 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_entriesis exposed only throughGetNursePayableBalance; 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 insideGetInvoiceQuery. Not re-probed cross-tenant this session — UNVERIFIED that a foreign customer gets404.