96 lines
5.7 KiB
Markdown
96 lines
5.7 KiB
Markdown
# 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 |
|