# payment — card checkout, the PSP webhook, invoices > Client seam `client/src/services/payment/` · `USE_PAYMENT_MOCK = false` (**real**) · 3 server ops > Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29). The card money path. Three endpoints on the wire; the domain reads three more that belong to neighbours. Installment checkout is [bnpl.md](bnpl.md); reversals are [refunds.md](refunds.md). ## Endpoints | Method | Path | Auth | Verdict | | --- | --- | --- | --- | | POST | `/api/v1/bookings/{bookingRequestId}/payments` | `[Authorize]` · `sensitive` 20/min | wired · **`Idempotency-Key`** | | GET | `/api/v1/invoices/{bookingId}` | `[Authorize]` | wired | | POST | `/api/v1/webhooks/payments/{provider}` | **anonymous** · `webhook` 120/min | server-only — the PSP calls it | Also read by this domain, documented with their owners: `GET booking_requests/checkout_summary/{id}` and `GET booking_requests/get/{id}` ([booking-requests.md](booking-requests.md)). ### Phantom — 1 | Client call | REQ | Live? | | --- | --- | --- | | `GET /api/v1/bookings/payment_history` | REQ-047 | **Yes — this domain's mock is off.** The wallet «پرداخت‌ها» tab calls it, gets a 404, and renders its empty state. Guarded, but a real 404 on every visit | ## The flow, and why it is shaped this way ``` accepted request ──initiate──▸ PSP hosted page ──customer pays──▸ PSP webhook (money-free) (redirectUrl) │ ▼ server re-verifies, then creates + confirms the booking ``` Four rules that follow, and they are the whole design: 1. **Payment is initiated against the accepted *request*, not a booking.** The `bookings` row does not exist yet. `POST bookings/{bookingRequestId}/payments` takes a **request** id despite the `bookings/` prefix — the route is misleading and the parameter name is the truth. 2. **There is no client verify endpoint.** The server re-verifies with the acquirer *inside* the webhook handler. A client-reported "success" is never trusted. 3. **The client learns the outcome by polling.** `getPaymentOutcome` maps the request status (`converted` → succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining half. 4. **One `Idempotency-Key` per attempt**, reused across retries of that attempt; a new attempt takes a new key. A `409` on initiate means "already in progress / already captured" — a benign convergence, and the client must not surface it as an error. **Webhook idempotency does not use the header.** The handler upserts the provider event first, keyed on `external_event_id`, and no-ops on a duplicate; `bookings.booking_request_id` is `UNIQUE` so a replay cannot create a second booking; a unique-violation on confirm is treated as idempotent success. The DB constraint is the backstop, not the handler's `if`. `POST bookings/convert` ([bookings.md](bookings.md)) is the **Development-only** capture simulator that stands in for the webhook locally. It is fail-closed outside Development/Testing. ## Shape rules the JSON does not express - **`CheckoutSummaryDto` serves the money breakdown so the client never derives it** (REQ-016, delivered): `serviceCostIrr`, `commissionIrr`, `vatIrr`, `vatRate`, `totalIrr` **and** the three-amount split `grossPriceIrr` / `balinyaarCommissionIrr` / `nursePayoutAmount`. All digit strings. - **VAT is on Balinyaar's commission only** — the platform's taxable supply — never on the nurse payout. - **`InitiatePaymentResult` is `{ transactionId, redirectUrl, gatewayReferenceCode }`.** The client hands off to `redirectUrl` and keeps `transactionId` to poll. - **`InvoiceDto` carries `totalIrr`** (REQ-033, partial) = platform commission + BNPL commission + VAT, and `moadianStatus`/`moadianReferenceNumber` for the سامانه مودیان e-invoicing submission. `issuingEntityType` distinguishes a platform-issued invoice from a partner-center one — see [partner-center.md](partner-center.md). - **The acquirer is a seam.** `Seams:Payments:Provider` = `mock` (default) / `zarinpal` / `sadad` / `vandar` / `jibit`; `IPaymentProvider`, `ISettlementSplitProvider` and `IWebhookVerifier` swap together. Webhook signature secrets are per-provider (`Seams:Payments:WebhookSigningSecrets`), read from the `X-Signature` header by default. A provider with no signature falls back to the mandatory server-side re-verify. ## Enums | Vocabulary | Values | | --- | --- | | `PaymentTransactionStatus` | `pending` `succeeded` `failed` | | `MoadianStatus` | `pending` `submitted` `registered` `failed` | | `GatewayReturnOutcome` *(client-side, from the return URL)* | `success` `failure` | Verified against `Entities/Payments/PaymentTransactionStatus.cs` and `Entities/Invoices/MoadianStatus.cs`. `GatewayReturnOutcome` is a client reading of the acquirer's redirect and is **advisory only** — the authoritative outcome is the polled request status. ## Open REQs | REQ | Status | Effect | | --- | --- | --- | | REQ-017 | delivered (partial in practice) | `bookingId` is on the converted request; the first-class transaction-status read is not, so the client polls the request status instead | | REQ-046 | open | No nurse identity on the checkout summary and no client-readable payment reference. The real receipt hides the identity avatar/badge and the tracking line | | REQ-047 | open | No customer payment-transactions list. **Live 404** on `bookings/payment_history`; the wallet tab renders empty | | REQ-049 | open | `InvoiceDto` has no payment method, transaction reference, or seller fiscal identity. The invoice renders the money breakdown and مودیان status unconditionally |