Files
clinicpro/docs/api/payment.md
T

22 KiB
Raw Blame History

Payment API

Prefix: /api/v1/payment, /api/v1/subscription-payment
Supported Gateways: mellat (Mellat Bank SOAP) | sep (SEP REST)


معماری (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 شاپرک مگر تست)
   │  gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
   │  post-action: confirm نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + کمیسیون + پیامک
   │  (۴) 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 بی‌اثر).

یکدستیِ 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 تغییر نمی‌کنند.


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 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 is transitioned pending → confirmed (its 15-minute expires_at is cleared) and a confirmation SMS is dispatched to the patient's mobile. تاریخِ نوبت در متن پیامک به‌صورت شمسی (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 باشد، پس از 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_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 نبود/نامجاز).
  • نوبت قابل‌پرداخت نیست (نه 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" }.
  • اگر وضعیت پرداخت 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 — 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 (verify ok 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'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 → confirmed, its 15-minute expires_at cleared, 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-...",
    "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_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

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