Files
clinicpro/docs/api/payment.md
T

36 KiB
Raw Blame History

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 از طریق query order_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:
    • initiatePOST .../ipg2/rest/bpPayRequest با JSON + هدر Authorization: Basic base64(userName:userPassword)؛ پاسخ رشتهٔ 0,RefId.
    • فرم پرداخت → https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat (POST RefId). توجه: 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 است (terminalId 134759344 / user user134759344)؛ در حالت 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 at meta.totalRecords. Ordered by created_at DESC.

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-minute expires_at is cleared, so a paid booking is never auto-expired. قطعی‌شدن تصمیم پزشک/منشی است: POST /api/v1/appointment/{uuid}/confirm (appointment.md). پیامک تأیید پرداخت همان لحظه برای بیمار ارسال می‌شود. تاریخِ نوبت در متن پیامک به‌صورت شمسی (JalaliDateService::formatDateTime، مثل ۱۴۰۵/۰۴/۰۲ ۰۹:۰۰) درج می‌شود. If the booking already lapsed to expired before 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 می‌شود و 302 return?status=expired (پیام «مهلت پرداخت به پایان رسید»).
  • نوبت قابل‌پرداخت نیست (نه pending/confirmed) → 302 return?status=invalid.
  • درگاه نامعتبر/غیرفعال → 302 return?status=gateway.
  • موفق → ساخت/ادامهٔ Payment pending (بدون pending تکراری via findPendingByAppointment) و 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 (verify ok 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's amount_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_id identifies exactly one settled transaction. If the callback's reference already belongs to another payment, it is rejected (payment → failed). Enforced by a unique index on payments.reference_id with an application-level pre-check.
  • User canceled (e.g. Mellat ResCode=17, SEP State=CanceledByUser, mock cancel=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} — حذف شد

پرداخت اشتراک 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

canceled is 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 booking expired and its pending payment canceled.

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).