create mvp path
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user