Unify and harden the payment flow (same API for the main site and all consumer sites; per-client difference is only frontend_address). - Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new PaymentVerifyResult::canceled flag; callback sets canceled vs failed and skips the circuit-breaker on cancel. - Expiry job now cancels the pending payment when a booking lapses (AppointmentExpiryService + PaymentRepository::findPendingByAppointment). - frontend_address allowlist is read from the payment_allowed_frontend_hosts site setting (manageable via PATCH /api/v1/admin/settings), falling back to the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code change. - .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost / *.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS. - Update docs/api/payment.md and docs/api/admin.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 KiB
Payment API
Prefix:
/api/v1/payment,/api/v1/subscription-payment
Supported Gateways:mellat(Mellat Bank SOAP) |sep(SEP REST)
GET /api/v1/payment/config
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
Permission: IS_AUTHENTICATED_FULLY
Response 200
{
"success": true,
"data": {
"test_mode": true
}
}
| Field | Type | Description |
|---|---|---|
test_mode |
boolean | true = درگاه آزمایشی فعال است — backend از MockGateway استفاده میکند و پول واقعی کسر نمیشود |
نکته: این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمیکند. تنها یک boolean برای مصرف frontend است.
GET /api/v1/my/payments
List the authenticated user's own payments (derived from the token — there is no userId in the URL). Used by the public dashboard's transactions tab.
Permission: IS_AUTHENTICATED_FULLY
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
page |
integer | 1 | Page number |
limit |
integer | 20 | Items per page (max 100) |
status |
string | — | Optional filter: pending / success / failed / canceled / refunded |
Response 200 (paginated)
{
"success": true,
"data": [
{
"uuid": "pay-uuid-...",
"order_id": "ORD-84FB82E12A4A45CE",
"amount_rials": 590000,
"status": "success",
"gateway": "mellat",
"type": "appointment",
"reference_id": "...",
"appointment_uuid": "appt-uuid-...",
"created_at": 1781521834
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
Items at
data(flat array), total atmeta.totalRecords. Ordered bycreated_atDESC.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
POST /api/v1/payment/appointment
Initiate payment for an appointment. Returns a redirect URL to the payment gateway.
Permission: AUTH — must be the appointment owner (patient)
Request Body (application/json)
{
"appointment_uuid": "appt-uuid-...",
"gateway": "mellat",
"frontend_address": "https://myapp.com/payment/result"
}
| Field | Type | Required | Description |
|---|---|---|---|
appointment_uuid |
string (UUID) | ✅ | Appointment to pay for |
gateway |
string | ✅ | "mellat" or "sep" |
frontend_address |
string | ❌ | Redirect URL after payment (overrides default) |
Response 200
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
"order_id": "CLINICPRO-1717000000-ABC123"
}
}
On successful callback for an appointment payment, the booking is transitioned
pending → confirmed(its 15-minuteexpires_atis cleared) and a confirmation SMS is dispatched to the patient's mobile. If the booking already lapsed toexpiredbefore payment confirmed, it is not re-confirmed (the transition is rejected) — handle refund out of band.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not the patient |
ERR_PAYMENT_003 |
422 | Appointment not in payable state |
ERR_PAYMENT_002 |
422 | Invalid amount |
ERR_PAYMENT_001 |
503 | Payment gateway unavailable |
POST /api/v1/payment/callback/{gateway}
GET /api/v1/payment/callback/{gateway}
Payment gateway callback. Called by the bank after user completes (or cancels) payment.
Permission: PUBLIC — called by the gateway, not the user
Path Parameters
| Param | Type | Description |
|---|---|---|
gateway |
string | mellat or sep |
Request (varies by gateway)
Mellat POST fields:
ResCode=0&SaleOrderId=...&SaleReferenceId=...&RefId=...
SEP POST fields:
Status=2&RRN=...&RefNum=...&TerminalId=...&TraceNo=...
Response
After verifying the gateway result, the backend redirects the user back to the origin site (frontend_address) with the outcome appended as query params:
{frontend_address}?payment_uuid={uuid}&status={status}
- Success (
verifyok): payment →success, then the type-specific action runs (appointment →confirmed, subscription → activated, sms_wallet → credited). - User canceled (e.g. Mellat
ResCode=17, SEPState=CanceledByUser, mockcancel=1): payment →canceled. The gateway circuit-breaker is not marked as failed (it's a user choice, not a gateway fault). - Failed (any other unsuccessful verify): payment →
failed, circuit-breaker records a failure.
If frontend_address is empty, a JSON body { success, payment } is returned instead of a redirect.
Notes
- On appointment success: status →
confirmed, its 15-minuteexpires_atcleared, confirmation SMS dispatched. - Payment record stores:
order_id,amount_rials,status,gateway,reference_id,frontend_address,callback_ip. - Same flow for all clients (the main site and every consumer site) — the only per-client difference is
frontend_address, which is validated against an allowlist (see below) to prevent open redirects.
POST /api/v1/subscription-payment
Initiate a subscription / wallet top-up payment (not tied to a specific appointment).
Permission: AUTH
Request Body
{
"gateway": "mellat",
"amount_rials": 1000000,
"frontend_address": "https://myapp.com/wallet/result"
}
| Field | Type | Required | Description |
|---|---|---|---|
gateway |
string | ✅ | "mellat" or "sep" |
amount_rials |
integer | ✅ | Amount in Rials (min: 10,000) |
frontend_address |
string | ❌ | Redirect URL after payment |
Response 200
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
"order_id": "CLINICPRO-SUB-1717000000-XYZ"
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_PAYMENT_002 |
422 | Invalid amount |
ERR_PAYMENT_001 |
503 | Gateway unavailable |
POST/GET /api/v1/subscription-payment/callback/{gateway}
Callback for subscription payments. Same behavior as appointment callback but credits wallet instead.
Permission: PUBLIC
GET /api/v1/payment/{uuid}
Get payment status and details.
Permission: AUTH — must be the payment owner or ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Payment UUID |
Response 200
{
"success": true,
"data": {
"uuid": "pay-uuid-...",
"order_id": "CLINICPRO-1717000000-ABC123",
"amount_rials": 500000,
"status": "paid",
"gateway": "mellat",
"ref_id": "123456789",
"appointment_uuid": "appt-uuid-...",
"created_at": 1717000000,
"paid_at": 1717000120
}
}
Payment Status Values:
| Value | Description |
|---|---|
pending |
Transaction created, awaiting payment |
success |
Successfully paid and verified |
failed |
Gateway returned a failure |
canceled |
User canceled at the gateway, or the payment window lapsed (booking expired) |
refunded |
Refunded |
canceledis set in two cases: (1) the gateway callback reports a user cancellation, and (2) the appointment's 15-minute payment window lapses — the scheduled expiry job marks the bookingexpiredand its pending paymentcanceled.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not the owner |
ERR_NOT_FOUND_001 |
404 | Payment not found |
Sandbox (test) mode & origin allowlist
Test mode: when payment_test_mode (in admin settings) is 1, every initiation resolves to the internal MockGateway — no bank call is made, the transaction is simulated and verified inside the system, and the user is redirected back to frontend_address exactly like a real payment. GET /api/v1/payment/config exposes this as test_mode.
Origin allowlist: frontend_address (the origin site the user returns to) must match an allowed host, to prevent open redirects. The allowlist is read from the payment_allowed_frontend_hosts site setting (comma-separated hosts), falling back to the ALLOWED_FRONTEND_HOSTS env var when the setting is empty. Manage it via PATCH /api/v1/admin/settings — so a new consumer site can be allowed without a code or .env change. A non-allowed host yields 422 ERR_VALIDATION_001 (field: frontend_address).