# 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 |
