Files
baya-monorepo/docs/flows/checkout-and-payment.md
T
2026-08-02 17:18:36 +03:30

14 KiB
Raw Blame History

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 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):

  1. 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.)
  2. Return to /fa/bookings/checkout?request_id=<new>. Expect: the money breakdown, the live countdown, and an enabled «پرداخت».
  3. 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/13200; vatIrr "3409", grossPriceIrr "250000", balinyaarCommissionIrr "37500".
  • checkout_summary/21 (a genuinely payable request) → 200, requestStatus "accepted_awaiting_payment".
  • POST bookings/21/payments + Idempotency-Key200, 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-Key409 "This booking has already been paid."
  • bookings/payment_history404 · booking_requests/get/13200 with bookingId: 8 · invoices/8200; 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.