13 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": false,
"appointment_fee_rials": 150000,
"gateways": [
{ "name": "mellat", "label": "بانک ملت" }
]
}
}
| Field | Type | Description |
|---|---|---|
test_mode |
boolean | true = درگاه آزمایشی فعال است — backend از MockGateway استفاده میکند و پول واقعی کسر نمیشود |
appointment_fee_rials |
integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید appointment_fee_rials). frontend برای نمایش «مبلغ قابل پرداخت» از این میخواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده میشود (نه از client) |
gateways |
array | فقط درگاههای فعال (اعتبارنامهشان در تنظیمات سایت یا env ست شده). هر عضو: { name, label }. frontend فقط همینها را برای انتخاب نمایش میدهد. در test_mode تنها [{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }] برمیگردد. اگر هیچ درگاهی فعال نباشد آرایه خالی است و frontend باید پرداخت را غیرفعال کند. |
فعالبودن هر درگاه با PaymentGatewayInterface::isConfigured() و کلید فعالسازی در تنظیمات سایت تعیین میشود: mellat نیازمند mellat_terminal_id + mellat_username + mellat_password؛ sep نیازمند sep_terminal_id. علاوه بر این، اگر ادمین درگاه را در تنظیمات غیرفعال کند (mellat_enabled / sep_enabled = "0")، آن درگاه از این لیست حذف میشود و در initiate نیز رد میشود (خطای ۴۲۲: «درگاه پرداخت نامعتبر یا غیرفعال است»). کلید تنظیمنشده = فعال (پیشفرض).
نکته: این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمیکند — فقط نام/برچسب درگاههای فعال.
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"
}
}
مبلغ نوبت: مبلغِ پرداخت از کلید
appointment_fee_rialsتنظیمات سایت خوانده میشود (نه از client و نه hardcode). برای تغییر، در/admin/settingsویرایش کنید.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. تاریخِ نوبت در متن پیامک بهصورت شمسی (JalaliDateService::formatDateTime، مثل۱۴۰۵/۰۴/۰۲ ۰۹:۰۰) درج میشود. If the booking already lapsed toexpiredbefore payment confirmed, it is not re-confirmed (the transition is rejected) — handle refund out of band.پورسانت نماینده: اگر پزشک نوبت
representation_idداشته باشد وappointment_commission_enabled=1باشد، پس از confirm شدنCommissionServiceهزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیفپولش واریز میکند (ردیفFinancialBreakdownثبت میشود). برای پرداخت اشتراک هم اگرupgrade_commission_enabled=1و پزشک/کلینیکrepresentation_idداشته باشد همین منطق با درصدupgrade_commission_percentاعمال میشود. کلیدهای تنظیمات و ترتیب محاسبه درdocs/api/admin.md.
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. هر درگاه callback مخصوص خودش را دارد؛ URL آن هنگام initiate از APP_BASE_URL ساخته میشود:
{APP_BASE_URL}/api/v1/payment/callback/{gateway}?order_id={orderId}
نکته IPG ملت: طبق راهنمای درگاه ملت، callBackUrl باید روی دامنهٔ ثبتشدهٔ پذیرنده باشد و IP مجاز نیست (در غیر این صورت کد پاسخ 62 — «مسیر back call در دامنهٔ ثبتشده نیست»). بنابراین APP_BASE_URL در پروداکشن باید دقیقاً https://clinic-pro.ir (دامنهٔ ثبتشده نزد ملت/شاپرک) باشد.
Permission: PUBLIC — called by the gateway, not the user (محدود به IPهای شبکهٔ شاپرک isAllowedCallbackIp؛ در test_mode بدون محدودیت IP)
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 and amount matches): payment →success, then the type-specific action runs (appointment →confirmed, subscription → activated, sms_wallet → credited). - Amount mismatch: when the gateway reports the settled amount (SEP
AffectiveAmount) and it does not equal the order'samount_rials, the callback is treated as failed — payment →failed, the type-specific action does not run. Guards against underpayment and replaying another (cheaper) order's reference. Gateways that don't report a settled amount (Mellat binds it server-side to the original request) skip this check. - Replayed reference: a gateway
reference_ididentifies exactly one settled transaction. If the callback's reference already belongs to another payment, it is rejected (payment →failed). Enforced by a unique index onpayments.reference_idwith an application-level pre-check. - 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).