36 KiB
Payment API
Prefix:
/api/v1/payment,/api/v1/subscription-payment
Supported Gateways:mellat(Mellat Bank SOAP) |sep(SEP REST)
دسترسی منشی:
GET /api/v1/my/paymentsبرایROLE_SECRETARYبه مجوزpayments.viewنیاز دارد (SecretaryAccessChecker)؛ نبودِ مجوز →403. جزئیات: secretary.md.
محیط پرداخت: هر پرداخت جفت
(entity_type, entity_id)دارد و به محیط گیرنده تعلق میگیرد، نه به پرداختکننده — نوبت → محیط همان نوبت، اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیط همان کیف پول. بیمار محیطی انتخاب نکرده، پسTenantFilterبرایش خاموش است و پرداخت خودش را میبیند. جزئیات: architecture/tenancy.md.
معماری (Flow & مسئولیتها)
اصل: Frontend هرگز مستقیم با بانک صحبت نمیکند و منطق پرداخت را نگه نمیدارد. Backend تنها مرجع معتبر (Source of Truth) است.
[Frontend] کلیک پرداخت
│ (۱) XHR authenticated: POST /api/v1/payment/appointment {appointment_uuid, gateway, frontend_address}
▼
[PaymentController::initiateAppointment] ← فقط Orchestration + Validation
│ اعتبارسنجی: وجود نوبت، مالکیت کاربر، وضعیت قابلپرداخت، آدرس بازگشت مجاز، فعال بودن درگاه (GatewayFactory)
│ ساخت Payment(pending) + ذخیره؛ برمیگرداند pay_url (هیچ ارتباطی با بانک اینجا نیست)
▼
[Frontend] مرورگر full-page redirect → pay_url
│ (۲) GET /api/v1/payment/pay/{orderId} (عمومی)
▼
[PaymentController::pay]
│ GatewayFactory::resolve(نام درگاه) → درگاه (تست→Mock)
│ CircuitBreaker چک؛ gateway->initiate(amount, orderId, callbackUrl) ← ارتباط با بانک
│ ذخیرهٔ token؛ انتقال به بانک: 302 (GET) یا فرم auto-submit POST (ملت)
▼
[درگاه بانک / شاپرک] پرداخت کاربر
│ (۳) بازگشت به callbackUrl بکاند
▼
[PaymentController::callback] (عمومی؛ ملت/سپ ریدایرکت مرورگر → بدون IP-check، امنیت با tamper+verify)
│ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
│ post-action: برداشتن انقضای نوبت / فعالسازی اشتراک / شارژ کیفپول + پیامک
│ (۴) RedirectResponse → frontend_address?payment_uuid=..&status=.. (همان دامنهٔ مبدأ)
▼
[Frontend] /payment/result → نمایش وضعیت
کلاسها و مسئولیتها:
| کلاس | مسئولیت (SRP) |
|---|---|
PaymentController |
فقط Orchestration: دریافت request، اعتبارسنجی مالکیت/سفارش، فراخوانی PaymentManager، ساخت پاسخ/redirect HTTP (autoSubmitForm, redirectToFrontend, IP-check, Open-Redirect guard). بدون منطق درگاه/verify. |
PaymentManager (src/Payment/Service/) |
منطق پرداخت: startGatewayHandoff() (init درگاه + CircuitBreaker + ذخیرهٔ token) و processCallback() (verify داخل transaction + قفل بدبینانه، بررسی مبلغ، ضد-replay، idempotent، post-action، لاگ). |
GatewayFactory (src/Payment/Gateway/) |
Factory + Strategy: انتخاب درگاه بر اساس نام + حالت تست + فعال بودن؛ فهرست درگاههای فعال. |
PaymentGatewayInterface |
قرارداد درگاه: initiate(), verify(), isConfigured(), getName(). |
MellatGateway / SepGateway / MockGateway |
پیادهسازی هر درگاه (SOAP/REST/mock). ملت با POST به بانک، سپ با GET. |
PaymentInitResult / PaymentVerifyResult |
DTO نتیجهٔ init/verify (شامل redirectMethod/redirectParams). |
CircuitBreakerService |
جلوگیری از فشار روی درگاهِ خراب. |
Payment (Entity) |
وضعیت پرداخت، orderId یکتا، referenceId یکتا (backstop برای replay)، frontendAddress (دامنهٔ مبدأ). |
PaymentLog (Entity) + PaymentLogRepository |
audit trail: هر گام (initiate/verify) با نتیجه، authority، IP، payload کالبک (بدون اعتبارنامه). |
امنیت verify: processCallback داخل EntityManager::wrapInTransaction با findByOrderIdForUpdate (SELECT … FOR UPDATE) اجرا میشود؛ گاردِ «فقط pending» آن را idempotent میکند (verify تکراری/race بیاثر).
کمیسیون دامنهمحور (post-action): نمایندهی مبدأ از payment.frontend_address با DomainContextResolver تعیین میشود؛ کمیسیون (نوبت و اشتراک) فقط وقتی ثبت میشود که این نماینده فعال باشد و پزشک/کلینیک موضوع خرید representation_id همان نماینده را داشته باشد — جزئیات در docs/api/representation.md §قانون کمیسیون دامنهمحور.
یکدستیِ typeها: هر سه نوع (appointment/subscription/sms_wallet) از همان GET /payment/pay/{orderId} عبور میکنند و روی همان یک POST|GET /api/v1/payment/callback برمیگردند؛ PaymentManager::callbackUrl() دیگر بر اساس type شاخه نمیزند. POST این endpointها فقط Payment pending میسازد و pay_url برمیگرداند (نه redirect_url).
افزودن درگاه جدید (Open/Closed): یک کلاس جدید implements PaymentGatewayInterface بساز، در GatewayFactory::$gateways + LABELS ثبت کن. PaymentController/PaymentManager تغییر نمیکنند.
درگاه ملت (BPM) — نکات پیادهسازی طبق مستند رسمی ۱.۳۸:
- prod = SOAP با
SoapClientبومی روی WSDL عملیاتیhttps://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl(متدها:bpPayRequest،bpVerifyRequest+bpSettleRequestجدا،bpRefundRequest،bpReversalRequest). WSDL با کلیدmellat_wsdl_urlقابل override است (مثلاً WSDL تستِ pgw.dev). نیازمند اکستنشنsoapروی سرور. sandbox = REST (banktest.ir) بدون تغییر. - چکلیست عملیاتی prod: (۱) IP سرور نزد «بهپرداخت ملت» whitelist شود (وگرنه کد
421). (۲)APP_BASE_URL= دامنهٔ ثبتشدهٔ پذیرنده (نه IP؛ وگرنه کد62). (۳) اعتبارنامهٔ واقعی در env (MELLAT_*) یا/admin/settings. (۴)mellat_sandbox=0وpayment_test_mode=0. (۵) پورتهای ۴۴۳/۸۰ خروجی باز. - کدهای پاسخ با
mellatMessage()به پیام فارسی نگاشت میشوند. orderIdملت از نوع long (عددی) است؛ به همین دلیلPaymentManagerهنگام init،payment.idعددی را بهعنوان orderId درگاه میفرستد (نه رشتهٔORD-…). جستجوی پرداخت در callback از طریق queryorder_id(رشتهٔORD-…) انجام میشود.- verify+settle با یک فراخوانی
bpVerifySettleRequestانجام میشود و بهsaleOrderId(= همانpayment.idمرحلهٔ Pay) وsaleReferenceId(که بانک در callback POST میفرستد) نیاز دارد — نهRefId. کدهای0/43/45(موفق/قبلاً verify/قبلاً settle) موفق تلقی میشوند.reference_idذخیرهشده =SaleReferenceId. - چک ضد-دستکاری (اجباری مستند): در callback،
RefIdبازگشتی باید باgateway_tokenذخیرهشده وSaleOrderIdباpayment.idبرابر باشد؛ در غیر اینصورت تراکنشfailedمیشود (این چک برای درگاههایی که این فیلدها را برنمیگردانند، مثل سپ، رد میشود). - دامنهٔ callback/Referer: ملت
RefererوcallBackUrlرا با دامنهٔ ثبتشدهٔ پذیرنده مقایسه میکند؛ در صورت عدم تطابق خطای62. مطمئن شوید دامنهٔ بکاند = دامنهٔ ثبتشده نزد ملت.
استرداد / برگشت وجه ملت: درگاه دو متد عودت دارد که از پنل ادمین (POST /api/v1/admin/payments/{uuid}/refund و /reverse) در دسترساند:
- Refund (
bpRefundRequest): برای تراکنش settleشده؛ کل یا جزئی (چندباره تا سقف مبلغ خرید). خروجی0,RefId؛ کد0فقط پذیرش اولیه است. استردادها درpayment.metadata.refunds[]نگه داشته میشوند؛ استرداد کامل → وضعیتrefunded. - Reversal (
bpReversalRequest): فقط برای تراکنش settleنشده (قبل از واریز)؛ کد0/48موفق. چون جریان ما بلافاصله verify+settle میکند، مسیر اصلی Refund است. - در
MellatGateway: sandbox=REST (/ipg2/rest/bpRefundRequest,/bpReversalRequestبا Basic Auth)، prod=SOAP.orderIdهر درخواست یکتای عددی است.SepGateway/MockGatewayهم متدها را دارند (سپ = عدم پشتیبانی، mock = موفق). - معکوسسازی post-action: هنگام استرداد کامل (یا برگشت وجه)، اثر پرداخت هم برگردانده میشود (
runReversePostAction): نوبت →cancelled_by_user(اسلات آزاد میشود)؛ اشتراک → حذفClinicSubscriptionساختهشده از آن پرداخت؛ کیفپول پیامک →deductمبلغ شارژ. استرداد جزئی post-action را برنمیگرداند.
حالت Sandbox ملت (موقت — banktest.ir): برای تست بدون درگاه واقعی، فلگ تنظیماتی mellat_sandbox وجود دارد (روی branch feature/mellat-sandbox؛ موقت).
- روشنکردن:
mellat_sandbox=1وpayment_test_mode=0(sandbox اتصال واقعی است، نهMockGateway؛ اگرpayment_test_mode=1هم باشد sandbox اولویت دارد). خاموش (نبود/0) = رفتار prod. - پروتکل sandbox = REST (نه SOAP): SOAP آزمایشیِ banktest روی
pgwchannelخطای502میدهد؛ فقط REST (.../ipg2/rest/…) سالم است. پس در حالت sandbox،MellatGateway:initiate→POST .../ipg2/rest/bpPayRequestبا JSON + هدرAuthorization: Basic base64(userName:userPassword)؛ پاسخ رشتهٔ0,RefId.- فرم پرداخت →
https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat(POSTRefId). توجه: API رویipg2/restاست ولی صفحهٔ فرم فقط رویpgwchannel/startpay.mellatسالم است (ipg2/startpay.mellatروی banktest خطای404میدهد). verify→ sandbox متد ترکیبیbpVerifySettleRequestرا پشتیبانی نمیکند (کد44)؛ پس دو فراخوانی جدا:POST .../ipg2/rest/bpVerifyRequest(قبول0/43) سپسPOST .../ipg2/rest/bpSettleRequest(قبول0/45). در prod همانbpVerifySettleRequestترکیبی (SOAP) استفاده میشود.- callback در sandbox از IP خارج از رنج شاپرک میآید؛ کنترلر برای
gateway=mellat+mellat_sandboxچک IP را رد میکند (مثلpayment_test_mode).
- credentials sandbox در خودِ
MellatGatewayبهصورت const است (terminalId134759344/ useruser134759344)؛ در حالت sandbox عمداً ازsite_configخوانده نمیشود تا credentials واقعیِ prod نشتی نکند. کل رفتار prod (SOAP رویbpm.shaparak.ir) دستنخورده باقی میماند. - CSP صفحات پرداخت
form-actionرا علاوه بر*.shaparak.irبهsandbox.banktest.irهم میدهد. - موقتی: بعد از اتمام تست، این branch/فلگ باید حذف شود.
- محدودیت sandbox: banktest عملیات استرداد/برگشت وجه را شبیهسازی نمیکند؛
bpRefundRequestهمیشه کد34(خطای سیستمی) میدهد. کدهای پاسخ ملت باmellatMessage()به پیام فارسی نگاشت میشوند. استرداد واقعی فقط در prod (SOAP) کار میکند.
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": "بانک ملت" }
],
"tax_percent": 10
}
}
| 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 باید پرداخت را غیرفعال کند. |
tax_percent |
number | نرخ مالیات بر ارزش افزوده برای اشتراک و شارژ کیف پول پیامک. صفر یعنی مالیات خاموش است (tax_enabled=0). frontend با این عدد جمع کل را پیش از ارسال درخواست نشان میدهد؛ مبلغ نهایی همیشه در backend دوباره حساب میشود. نوبت این نرخ را به این شکل بهکار نمیبرد — آنجا مبلغ شامل مالیات است. |
فعالبودن هر درگاه با 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
فیلتر محیط اینجا اعمال نمیشود. رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی مالکش از راه
user_idتعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک، منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت. دورزدن فیلتر فقط از راهApp\Shared\Tenant\TenantFilterScopeانجام میشود و مجوز دستنخورده باقی میماند.
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-...",
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX",
"order_id": "ORD-XXXX"
}
}
جریان انتقال به درگاه: این endpoint فقط صلاحیت اوردر را بررسی و یک پرداختِ
pendingمیسازد؛ هیچ ارتباطی با بانک برقرار نمیکند. کلاینت باید مرورگر را بهpay_urlهدایت کند. سپسGET /api/v1/payment/pay/{orderId}(سمت بکاند) درگاه را init میکند (ارتباط با بانک) و مرورگر را به درگاه میفرستد (302 برای درگاههای GET مثل سپ، یا فرم auto-submit با متد POST برای درگاه ملت). به این ترتیب کلاینت هرگز مستقیم به بانک ریکوست نمیزند.مبلغ نوبت: مبلغِ پرداخت از کلید
appointment_fee_rialsتنظیمات سایت خوانده میشود (نه از client و نه hardcode). برای تغییر، در/admin/settingsویرایش کنید.On successful callback for an appointment payment, the booking stays
pending(«ثبت شده») and only its 15-minuteexpires_atis cleared, so a paid booking is never auto-expired. قطعیشدن تصمیم پزشک/منشی است:POST /api/v1/appointment/{uuid}/confirm(appointment.md). پیامک تأیید پرداخت همان لحظه برای بیمار ارسال میشود. تاریخِ نوبت در متن پیامک بهصورت شمسی (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باشد،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_VALIDATION_001 |
422 | درگاه پرداخت نامعتبر یا غیرفعال / آدرس بازگشت مجاز نیست |
GET /api/v1/payment/order/{appointmentUuid}
entry ریدایرکتِ خالص (بدون XHR) برای شروع پرداخت نوبت. مرورگر مستقیماً به این آدرس هدایت میشود؛ Backend همهٔ کار را انجام میدهد: اعتبارسنجی سفارش → ساخت Payment → ارتباط با بانک → ریدایرکت به شاپرک.
Permission: PUBLIC (بدون JWT).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
gateway |
string | ✅ | نام درگاه فعال (mellat/sep) — از GET /payment/config |
return |
string | ❌ | آدرس بازگشت (دامنهٔ مبدأ)؛ باید در payment_allowed_frontend_hosts مجاز باشد |
رفتار
- نوبت یافت نشد →
302بهreturn?status=notfound(یا422اگر return نبود/نامجاز). - نوبت منقضی (
status=expiredیا گذشتنِ پنجرهٔ ۱۵ دقیقهایexpires_at/زمان اسلات) → نوبتexpiredو پرداخت pending آنcanceledمیشود و302return?status=expired(پیام «مهلت پرداخت به پایان رسید»). - نوبت قابلپرداخت نیست (نه
pending/confirmed) →302return?status=invalid. - درگاه نامعتبر/غیرفعال →
302return?status=gateway. - موفق → ساخت/ادامهٔ
Paymentpending (بدون pending تکراری viafindPendingByAppointment) و302به بانک (یا فرم auto-submit POST برای ملت).
امنیت مالکیت
چون entry عمومی و full-page cross-domain است، JWT در دسترس نیست؛ appointmentUuid (UUID غیرقابلحدس) نقش capability را دارد و پرداخت فقط به نفع صاحب نوبت است. برای مالکیت سختگیرانه میتوان پارامتر امضاشدهٔ sig (HMAC) افزود.
صفحهٔ نتیجه: این endpointهای مرورگرمحور (
order/pay/callback) هرگز JSON به کاربر نمیدهند. اگرreturnمعتبر باشد →302به همان دامنه با?status=؛ در غیر اینصورت (نبود/نامعتبر بودنreturn، یافتنشدن سفارش، نبود آدرس بازگشت) یک صفحهٔ Twig (templates/payment/result.html.twig, RTL،noindex) با پیام وضعیت رندر میشود.
GET /api/v1/payment/pay/{orderId}
انتقال مرورگر به درگاه پرداخت برای یک پرداختِ pending. عمومی (بدون JWT) — مرورگر مستقیماً به این آدرس هدایت میشود. صلاحیت اوردر قبلاً در POST /api/v1/payment/appointment (نیازمند JWT) بررسی و پرداخت ساخته شده است. ارتباط با بانک (init درگاه) در همین endpoint انجام میشود.
Path Parameters
| Param | Type | Description |
|---|---|---|
orderId |
string | order_id پرداخت (از پاسخ initiate) |
رفتار
- اگر پرداخت یافت نشد →
404 { success:false, message:"payment not found" }. - پرداختِ نوبت با نوبتِ منقضی (نوع
appointmentو نوبتexpiredیا گذشتنِ پنجرهٔ ۱۵ دقیقهای): نوبتexpiredو پرداخت pending آنcanceledمیشود و صفحهٔ نتیجهٔexpired(«مهلت پرداخت به پایان رسید») رندر میشود — به بانک نمیرود. - اگر وضعیت پرداخت
pendingنباشد →302بهfrontend_addressبا نتیجه (جلوگیری از پرداخت تکراری). - درگاه resolve میشود (در حالت تست → mock)؛ اگر نامعتبر بود یا circuit breaker باز بود → پرداخت
failedو302بهfrontend_address. gateway->initiate(...)صدا زده میشود (ارتباط با بانک). در صورت شکست →failedو302بهfrontend_address.- در صورت موفقیت: توکن ذخیره و انتقال به درگاه:
- متد
GET(سپ/mock) →302به URL درگاه. - متد
POST(ملت) → صفحهٔ HTML با فرم auto-submit (POST) به درگاه، شامل فیلدهای لازم (مثلRefId).
- متد
Permission
PUBLIC (در security.yaml تحت firewall payment_callback و access_control عمومی).
POST /api/v1/payment/callback
GET /api/v1/payment/callback
Payment gateway callback. Called by the bank after user completes (or cancels) payment.
یک آدرس برای همه. همهٔ درگاهها (mellat، sep، mock) و همهٔ نوعهای پرداخت
(appointment، subscription، sms_wallet) روی همین یک مسیر برمیگردند؛ درگاه و سفارش
بهصورت query param میروند. URL هنگام initiate در PaymentManager::callbackUrl() از
APP_BASE_URL و ثابت PaymentManager::CALLBACK_PATH ساخته میشود:
{APP_BASE_URL}/api/v1/payment/callback?gateway={gateway}&order_id={orderId}
دلیل: مسیر ثابت میماند، پس آدرسِ ثبتشده در پنل پذیرندگی بانک با اضافهشدن درگاه یا نوع پرداخت جدید عوض نمیشود.
Breaking change. دو مسیر قدیمی حذف شدهاند و
404میدهند:POST|GET /api/v1/payment/callback/{gateway}وPOST|GET /api/v1/subscription-payment/callback/{gateway}. آدرس ثبتشده در پنل ملت و سپ باید به مسیر جدید بهروز شود.
نکته IPG ملت: طبق راهنمای درگاه ملت، callBackUrl باید روی دامنهٔ ثبتشدهٔ پذیرنده باشد و IP مجاز نیست (در غیر این صورت کد پاسخ 62 — «مسیر back call در دامنهٔ ثبتشده نیست»). بنابراین APP_BASE_URL در پروداکشن باید دقیقاً https://clinic-pro.ir (دامنهٔ ثبتشده نزد ملت/شاپرک) باشد.
Permission: PUBLIC. نکتهٔ مهم: درگاههای ملت و سپ نتیجه را با ریدایرکتِ مرورگرِ کاربر (POST/GET) برمیگردانند، نه server-to-server؛ پس IP دریافتی، IPِ کاربر است و allowlist شاپرک اعمال نمیشود (برای gateway ∈ {mellat, sep} و نیز test_mode). در غیر این صورت هر callback واقعی — از جمله «لغو» توسط کاربر — با «دسترسی غیرمجاز» رد میشد. امنیت از طریق چک ضد-دستکاری (RefId==gateway_token، SaleOrderId==payment.id) و verify سمت بانک در PaymentManager تأمین میشود. isAllowedCallbackIp فقط برای درگاههای آیندهٔ server-to-server معنی دارد.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
order_id |
string | yes | شناسهٔ سفارش (ORD-…)؛ در نبودش ResNum خوانده میشود |
gateway |
string | no | mellat | sep | mock. در نبودش از فیلد gateway همان رکورد پرداخت خوانده میشود |
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 → پنجرهٔ انقضا پاک میشود ولی وضعیتpendingمیماند، 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 میماند
pending، فقطexpires_atپاک میشود و پیامک پرداخت ارسال میگردد؛ قطعیکردن باPOST /api/v1/appointment/{uuid}/confirmاست. - 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",
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://myapp.com/wallet/result"
}
| Field | Type | Required | Description |
|---|---|---|---|
gateway |
string | ✅ | "mellat" or "sep" |
period_uuid |
string | ✅ | دورهٔ اشتراک؛ مبلغ از آن محاسبه میشود |
frontend_address |
string | ❌ | Redirect URL after payment |
amount_rialsدیگر خوانده نمیشود. مبلغ =price_rials + tax_rialsهمان دوره، محاسبهشده درSubscriptionTaxCalculator. قیمت دوره خالص است و مالیات رویش اضافه میشود — جزئیات در subscription.md. پاسخ هم هر چهار عدد را برمیگرداند:price_rials،tax_percent،tax_rials،payable_rials.
Response 200
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX",
"order_id": "ORD-XXXX"
}
}
مثل appointment: کلاینت مرورگر را به
pay_urlهدایت میکند؛ init درگاه درGET /api/v1/payment/pay/{orderId}انجام میشود (نه در این POST). Callback این نوع هم به همان/api/v1/payment/callbackمیرود.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_PAYMENT_002 |
422 | Invalid amount |
ERR_PAYMENT_004 |
422 | خریدار صاحب هیچ محیطی نیست (نه پزشک، نه کلینیک) |
ERR_PAYMENT_001 |
503 | Gateway unavailable |
{
"success": false,
"errors": [{ "code": "ERR_PAYMENT_004", "message": "محیط این پرداخت مشخص نیست" }]
}
اشتراک روی محیطی مینشیند که خریدار صاحبش است (اول مطب شخصی، بعد کلینیک) — نه روی محیط فعالش. همان جفت روی خودِ اشتراک هم ثبت میشود، پس پرداخت و اشتراک هرگز روی دو محیط متفاوت نمیافتند.
POST/GET /api/v1/subscription-payment/callback/{gateway} — حذف شد
/api/v1/subscription-payment/callback/{gateway}پرداخت اشتراک callback اختصاصی ندارد. از /api/v1/payment/callback
استفاده کنید؛ نوع پرداخت از خودِ رکورد Payment خوانده میشود.
GET /api/v1/payment/{uuid}
Get payment status and details.
Permission: AUTH — must be the payment owner or ROLE_ADMIN
فیلتر محیط اینجا اعمال نمیشود. رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی مالکش از راه
user_idتعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک، منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت. دورزدن فیلتر فقط از راهApp\Shared\Tenant\TenantFilterScopeانجام میشود و مجوز دستنخورده باقی میماند.
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).