Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.
payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.
Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:
- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
SmsWalletController and already carries its environment in the metadata;
without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
so it cannot drive the subscription backfill. The environment is derived
the way handleSubscriptionActivation derives it — and that method now
reads the pair off the payment instead of re-deriving it, so a payment and
the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
none of the four creation sites set it; the wallet is a person's, with a
running balance per user. It and Settlement, which withdraws from that same
wallet, are global with a recorded reason instead.
bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.
Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
33 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} عبور میکنند؛ PaymentManager::callbackUrl() پیشوند callback را بر اساس 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": "بانک ملت" }
]
}
}
| 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-...",
"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/{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. نکتهٔ مهم: درگاههای ملت و سپ نتیجه را با ریدایرکتِ مرورگرِ کاربر (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 معنی دارد.
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 → پنجرهٔ انقضا پاک میشود ولی وضعیت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",
"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-...",
"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/subscription-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}
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).