- Refactor PaymentController to delegate payment processing to PaymentManager. - Add findByOrderIdForUpdate method in PaymentRepository for pessimistic locking. - Create PaymentLog entity and repository for auditing payment actions. - Implement startGatewayHandoff and processCallback methods in PaymentManager. - Introduce transaction handling and logging for payment verification. - Update payment flow to ensure idempotency and prevent race conditions. - Enhance security by logging sensitive actions without exposing credentials. - Update database schema with migration for payment_logs table. - Document changes in payment flow architecture.
20 KiB
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 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 is transitioned
pending → confirmed(its 15-minuteexpires_atis cleared) and a confirmation SMS is dispatched to the patient's mobile. تاریخِ نوبت در متن پیامک بهصورت شمسی (JalaliDateService::formatDateTime، مثل۱۴۰۵/۰۴/۰۲ ۰۹:۰۰) درج میشود. If the booking already lapsed toexpiredbefore payment confirmed, it is not re-confirmed (the transition is rejected) — handle refund out of band.پورسانت نماینده: اگر پزشک نوبت
representation_idداشته باشد وappointment_commission_enabled=1باشد، پس از confirm شدنCommissionServiceهزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیفپولش واریز میکند (ردیفFinancialBreakdownثبت میشود). برای پرداخت اشتراک هم اگرupgrade_commission_enabled=1و پزشک/کلینیکrepresentation_idداشته باشد همین منطق با درصدupgrade_commission_percentاعمال میشود. کلیدهای تنظیمات و ترتیب محاسبه درdocs/api/admin.md.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not the patient |
ERR_PAYMENT_003 |
422 | Appointment not in payable state |
ERR_PAYMENT_002 |
422 | Invalid amount |
ERR_VALIDATION_001 |
422 | درگاه پرداخت نامعتبر یا غیرفعال / آدرس بازگشت مجاز نیست |
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 (
verifyok and amount matches): payment →success, then the type-specific action runs (appointment →confirmed, subscription → activated, sms_wallet → credited). - Amount mismatch: when the gateway reports the settled amount (SEP
AffectiveAmount) and it does not equal the order'samount_rials, the callback is treated as failed — payment →failed, the type-specific action does not run. Guards against underpayment and replaying another (cheaper) order's reference. Gateways that don't report a settled amount (Mellat binds it server-side to the original request) skip this check. - Replayed reference: a gateway
reference_ididentifies exactly one settled transaction. If the callback's reference already belongs to another payment, it is rejected (payment →failed). Enforced by a unique index onpayments.reference_idwith an application-level pre-check. - User canceled (e.g. Mellat
ResCode=17, SEPState=CanceledByUser, mockcancel=1): payment →canceled. The gateway circuit-breaker is not marked as failed (it's a user choice, not a gateway fault). - Failed (any other unsuccessful verify): payment →
failed, circuit-breaker records a failure.
If frontend_address is empty, a JSON body { success, payment } is returned instead of a redirect.
Notes
- On appointment success: status →
confirmed, its 15-minuteexpires_atcleared, confirmation SMS dispatched. - Payment record stores:
order_id,amount_rials,status,gateway,reference_id,frontend_address,callback_ip. - Same flow for all clients (the main site and every consumer site) — the only per-client difference is
frontend_address, which is validated against an allowlist (see below) to prevent open redirects.
POST /api/v1/subscription-payment
Initiate a subscription / wallet top-up payment (not tied to a specific appointment).
Permission: AUTH
Request Body
{
"gateway": "mellat",
"amount_rials": 1000000,
"frontend_address": "https://myapp.com/wallet/result"
}
| Field | Type | Required | Description |
|---|---|---|---|
gateway |
string | ✅ | "mellat" or "sep" |
amount_rials |
integer | ✅ | Amount in Rials (min: 10,000) |
frontend_address |
string | ❌ | Redirect URL after payment |
Response 200
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"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 |
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).