5.7 KiB
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 commitd3ec723and 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; reversals are 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).
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:
- Payment is initiated against the accepted request, not a booking. The
bookingsrow does not exist yet.POST bookings/{bookingRequestId}/paymentstakes a request id despite thebookings/prefix — the route is misleading and the parameter name is the truth. - There is no client verify endpoint. The server re-verifies with the acquirer inside the webhook handler. A client-reported "success" is never trusted.
- The client learns the outcome by polling.
getPaymentOutcomemaps the request status (converted→ succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining half. - One
Idempotency-Keyper attempt, reused across retries of that attempt; a new attempt takes a new key. A409on 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) 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
CheckoutSummaryDtoserves the money breakdown so the client never derives it (REQ-016, delivered):serviceCostIrr,commissionIrr,vatIrr,vatRate,totalIrrand the three-amount splitgrossPriceIrr/balinyaarCommissionIrr/nursePayoutAmount. All digit strings.- VAT is on Balinyaar's commission only — the platform's taxable supply — never on the nurse payout.
InitiatePaymentResultis{ transactionId, redirectUrl, gatewayReferenceCode }. The client hands off toredirectUrland keepstransactionIdto poll.InvoiceDtocarriestotalIrr(REQ-033, partial) = platform commission + BNPL commission + VAT, andmoadianStatus/moadianReferenceNumberfor the سامانه مودیان e-invoicing submission.issuingEntityTypedistinguishes a platform-issued invoice from a partner-center one — see partner-center.md.- The acquirer is a seam.
Seams:Payments:Provider=mock(default) /zarinpal/sadad/vandar/jibit;IPaymentProvider,ISettlementSplitProviderandIWebhookVerifierswap together. Webhook signature secrets are per-provider (Seams:Payments:WebhookSigningSecrets), read from theX-Signatureheader 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 |