feat(payment): unify payment callback endpoint for all gateways and types

This commit is contained in:
hamed
2026-08-09 16:02:48 +03:30
parent a6a965a2aa
commit 2471c90cbb
10 changed files with 205 additions and 71 deletions
+2 -3
View File
@@ -42,7 +42,7 @@ security:
security: false
payment_callback:
pattern: ^/api/v1/(payment/(callback|pay|order)/|subscription-payment/callback/)
pattern: ^/api/v1/payment/(callback|pay/|order/)
stateless: true
security: false
@@ -85,10 +85,9 @@ security:
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
- { path: ^/session/token, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/payment/callback/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/payment/callback, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/payment/pay/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/payment/order/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/subscription-payment/callback/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/categorys/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS }
- path: '^/api/v1/doctor/[^/]+$'
+27 -14
View File
@@ -58,7 +58,7 @@
**کمیسیون دامنه‌محور (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`).
**یکدستیِ 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` تغییر نمی‌کنند.
@@ -268,23 +268,37 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
---
## POST `/api/v1/payment/callback/{gateway}`
## GET `/api/v1/payment/callback/{gateway}`
## POST `/api/v1/payment/callback`
## GET `/api/v1/payment/callback`
Payment gateway callback. Called by the bank after user completes (or cancels) payment. هر درگاه callback مخصوص خودش را دارد؛ URL آن هنگام `initiate` از `APP_BASE_URL` ساخته می‌شود:
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}?order_id={orderId}
{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 معنی دارد.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `gateway` | string | `mellat` or `sep` |
### 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:**
@@ -350,7 +364,7 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
}
```
> مثل appointment: کلاینت مرورگر را به `pay_url` هدایت می‌کند؛ init درگاه در `GET /api/v1/payment/pay/{orderId}` انجام می‌شود (نه در این POST). Callback این نوع به `/api/v1/subscription-payment/callback/` می‌رود.
> مثل appointment: کلاینت مرورگر را به `pay_url` هدایت می‌کند؛ init درگاه در `GET /api/v1/payment/pay/{orderId}` انجام می‌شود (نه در این POST). Callback این نوع هم به همان `/api/v1/payment/callback` می‌رود.
### Errors
| Code | HTTP | Description |
@@ -371,11 +385,10 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
---
## POST/GET `/api/v1/subscription-payment/callback/{gateway}`
## ~~POST/GET `/api/v1/subscription-payment/callback/{gateway}`~~ — حذف شد
Callback for subscription payments. Same behavior as appointment callback but credits wallet instead.
**Permission:** `PUBLIC`
پرداخت اشتراک callback اختصاصی ندارد. از [`/api/v1/payment/callback`](#post-apiv1paymentcallback)
استفاده کنید؛ نوع پرداخت از خودِ رکورد `Payment` خوانده می‌شود.
---
+4 -2
View File
@@ -168,9 +168,11 @@
---
## GET /api/v1/subscription-payment/callback/{gateway}
## POST|GET /api/v1/payment/callback
callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSubscription` به صورت خودکار ایجاد می‌شود (بر اساس `period_uuid` ذخیره‌شده در metadata پرداخت).
callback مشترک همهٔ درگاه‌ها و همهٔ نوع‌های پرداخت — پس از پرداخت موفق، `ClinicSubscription` به صورت خودکار ایجاد می‌شود (بر اساس `period_uuid` ذخیره‌شده در metadata پرداخت).
مسیر اختصاصی قبلی `/api/v1/subscription-payment/callback/{gateway}` حذف شده و `404` می‌دهد. قرارداد کامل: [payment.md](payment.md#post-apiv1paymentcallback).
---
+29 -42
View File
@@ -296,13 +296,20 @@ class PaymentController extends BaseController
// ── Payment Callback (public — no JWT) ───────────────────────────────────
#[OA\Post(
path: '/api/v1/payment/callback/{gateway}',
summary: 'Payment gateway callback (public, IP-restricted)',
path: '/api/v1/payment/callback',
summary: 'Payment gateway callback — single endpoint for every gateway and payment type (public)',
parameters: [
new OA\Parameter(
name: 'gateway',
in: 'path',
name: 'order_id',
in: 'query',
required: true,
schema: new OA\Schema(type: 'string', example: 'ORD-1712345678-ab12')
),
new OA\Parameter(
name: 'gateway',
in: 'query',
required: false,
description: 'Falls back to the gateway stored on the payment when omitted',
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
),
],
@@ -322,10 +329,23 @@ class PaymentController extends BaseController
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
#[Route(PaymentManager::CALLBACK_PATH, methods: ['POST', 'GET'])]
public function callback(Request $request): \Symfony\Component\HttpFoundation\Response
{
$clientIp = $request->getClientIp() ?? '';
$clientIp = $request->getClientIp() ?? '';
$callbackData = array_merge($request->query->all(), $request->request->all());
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
// تک مسیر برای همهٔ درگاه‌ها: نام درگاه از query می‌آید و در نبودش از خودِ
// رکورد پرداخت خوانده می‌شود، تا آدرسِ ثبت‌شده نزد بانک هیچ‌وقت عوض نشود.
$gateway = (string) ($callbackData['gateway'] ?? '');
if ($gateway === '') {
$gateway = $this->paymentRepo->findByOrderId((string) $orderId)?->getGateway() ?? '';
}
if ($gateway === '') {
return $this->renderPaymentResult('notfound');
}
// درگاه‌های ملت و سپ نتیجه را با ریدایرکتِ مرورگرِ کاربر (POST/GET) برمی‌گردانند،
// نه server-to-server؛ پس IP دریافتی، IPِ کاربر است و allowlist شاپرک اعمال نمی‌شود
// (در غیر این صورت هر callback واقعی — از جمله «لغو» — رد می‌شد). امنیت از طریق چک
@@ -336,9 +356,6 @@ class PaymentController extends BaseController
return $this->renderPaymentResult('forbidden');
}
$callbackData = array_merge($request->query->all(), $request->request->all());
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
if ($payment === null) {
@@ -433,38 +450,8 @@ class PaymentController extends BaseController
]);
}
#[OA\Post(
path: '/api/v1/subscription-payment/callback/{gateway}',
summary: 'Subscription payment gateway callback (public, IP-restricted)',
parameters: [
new OA\Parameter(
name: 'gateway',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
),
],
responses: [
new OA\Response(
response: 200,
description: 'Callback processed — either a redirect or JSON result',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean'),
new OA\Property(property: 'payment', type: 'object'),
]
)
),
new OA\Response(response: 302, description: 'Redirect to frontend with payment result'),
new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{
return $this->callback($gateway, $request);
}
// پرداخت اشتراک callback اختصاصی ندارد؛ همان `callback()` مشترک همهٔ نوع‌ها را
// پردازش می‌کند و نوع را از رکورد پرداخت می‌خواند.
// ── Status ────────────────────────────────────────────────────────────────
+10 -4
View File
@@ -249,12 +249,18 @@ final class PaymentManager
});
}
/**
* تک آدرس بازگشت برای همهٔ درگاه‌ها و همهٔ نوع‌های پرداخت؛ درگاه و سفارش
* به‌صورت query param می‌روند تا مسیر ثابت و قابل ثبت در پنل بانک بماند.
*/
public const CALLBACK_PATH = '/api/v1/payment/callback';
public function callbackUrl(Payment $payment): string
{
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
? '/api/v1/subscription-payment/callback/'
: '/api/v1/payment/callback/';
return $this->appBaseUrl . $prefix . $payment->getGateway() . '?order_id=' . $payment->getOrderId();
return $this->appBaseUrl . self::CALLBACK_PATH . '?' . http_build_query([
'gateway' => $payment->getGateway(),
'order_id' => $payment->getOrderId(),
]);
}
// ── Post-actions ──────────────────────────────────────────────────────────
@@ -33,8 +33,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
$isPaymentPage =
str_starts_with($path, '/api/v1/payment/order/')
|| str_starts_with($path, '/api/v1/payment/pay/')
|| str_starts_with($path, '/api/v1/payment/callback/')
|| str_starts_with($path, '/api/v1/subscription-payment/callback/');
|| $path === '/api/v1/payment/callback';
$response->headers->set(
'Content-Security-Policy',
@@ -56,7 +56,8 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
private function fireCallback(Payment $payment): void
{
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
// بدون `gateway` فرستاده می‌شود تا fallbackِ خواندن درگاه از رکورد پرداخت هم پوشش بخورد.
$this->client->request('POST', '/api/v1/payment/callback?' . http_build_query([
'order_id' => $payment->getOrderId(),
'mock' => '1',
'ResCode' => '0',
+4 -2
View File
@@ -37,7 +37,8 @@ class PaymentCallbackAmountTest extends ApiTestCase
private function fireCallback(Payment $payment, int $reportedAmount): void
{
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
$this->client->request('POST', '/api/v1/payment/callback?' . http_build_query([
'gateway' => 'mock',
'order_id' => $payment->getOrderId(),
'mock' => '1',
'ResCode' => '0',
@@ -91,7 +92,8 @@ class PaymentCallbackAmountTest extends ApiTestCase
private function fireCallbackWithRef(Payment $payment, string $refId, int $reportedAmount): void
{
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
$this->client->request('POST', '/api/v1/payment/callback?' . http_build_query([
'gateway' => 'mock',
'order_id' => $payment->getOrderId(),
'mock' => '1',
'ResCode' => '0',
@@ -0,0 +1,125 @@
<?php
namespace App\Tests\Payment;
use App\Config\Entity\SiteConfig;
use App\Payment\Entity\Payment;
use App\Payment\Service\PaymentManager;
use App\Tests\ApiTestCase;
/**
* Every gateway and every payment type shares one callback path. The gateway
* and the order travel as query params so the URL registered with the bank
* never has to change.
*/
class UnifiedPaymentCallbackTest extends ApiTestCase
{
private function enableTestMode(): void
{
$cfg = $this->em->getRepository(SiteConfig::class)->findOneBy(['configKey' => 'payment_test_mode']);
if ($cfg === null) {
$cfg = new SiteConfig('payment_test_mode', '1');
$this->em->persist($cfg);
} else {
$cfg->setValue('1');
}
$this->em->flush();
}
private function makePayment(string $type, string $gateway = 'mock'): Payment
{
$payment = $this->stampTenant(new Payment($this->createUser(), 50000, $gateway, $type));
$this->em->persist($payment);
$this->em->flush();
return $payment;
}
private function reload(Payment $payment): Payment
{
$this->em->clear();
return $this->em->getRepository(Payment::class)->find($payment->getId());
}
/** @param array<string, string> $extra */
private function fireCallback(Payment $payment, array $extra = []): void
{
$this->client->request('POST', PaymentManager::CALLBACK_PATH . '?' . http_build_query(array_merge([
'order_id' => $payment->getOrderId(),
'mock' => '1',
'ResCode' => '0',
'mock_amount' => '50000',
], $extra)));
}
/** @return array<string, array{string}> */
public static function paymentTypeProvider(): array
{
return [
'appointment' => [Payment::TYPE_APPOINTMENT],
'subscription' => [Payment::TYPE_SUBSCRIPTION],
'sms wallet' => [Payment::TYPE_SMS_WALLET],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('paymentTypeProvider')]
public function testEveryPaymentTypeUsesTheSameCallbackPath(string $type): void
{
$this->enableTestMode();
$payment = $this->makePayment($type);
$url = static::getContainer()->get(PaymentManager::class)->callbackUrl($payment);
$this->assertStringContainsString(PaymentManager::CALLBACK_PATH . '?', $url);
$this->assertStringNotContainsString('/subscription-payment/', $url);
$this->assertStringContainsString('gateway=mock', $url);
$this->assertStringContainsString('order_id=' . $payment->getOrderId(), $url);
}
public function testCallbackAcceptsGatewayAsQueryParam(): void
{
$this->enableTestMode();
$payment = $this->makePayment(Payment::TYPE_SMS_WALLET);
$this->fireCallback($payment, ['gateway' => 'mock']);
$this->assertSame(Payment::STATUS_SUCCESS, $this->reload($payment)->getStatus());
}
public function testCallbackFallsBackToTheGatewayStoredOnThePayment(): void
{
$this->enableTestMode();
$payment = $this->makePayment(Payment::TYPE_SMS_WALLET);
$this->fireCallback($payment);
$this->assertSame(Payment::STATUS_SUCCESS, $this->reload($payment)->getStatus());
}
public function testUnknownOrderWithoutGatewayIsNotFound(): void
{
$this->enableTestMode();
$this->client->request('POST', PaymentManager::CALLBACK_PATH . '?' . http_build_query([
'order_id' => 'ORD-DOES-NOT-EXIST',
'mock' => '1',
'ResCode' => '0',
]));
$this->assertSame(200, $this->client->getResponse()->getStatusCode());
$this->assertStringContainsString('یافت نشد', (string) $this->client->getResponse()->getContent());
}
public function testRemovedLegacyCallbackRoutesReturn404(): void
{
$this->enableTestMode();
$payment = $this->makePayment(Payment::TYPE_SUBSCRIPTION);
$query = '?' . http_build_query(['order_id' => $payment->getOrderId()]);
foreach (['/api/v1/payment/callback/mock', '/api/v1/subscription-payment/callback/mock'] as $legacy) {
$this->client->request('POST', $legacy . $query);
$this->assertSame(404, $this->client->getResponse()->getStatusCode(), $legacy);
}
}
}
+1 -1
View File
@@ -53,6 +53,7 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'practice_domain_list' => 'حوزه‌های فعالیت — دادهٔ مرجع',
'app_subscription_subscription_plans' => 'پلن‌های اشتراک — کاتالوگ عمومی',
'app_payment_payment_config' => 'نام درگاه‌ها و کارمزد — بدون مقدار محرمانه',
'app_payment_payment_callback' => 'callback بانک — عمدا PUBLIC؛ بدون order_id معتبر فقط صفحهٔ «یافت نشد» می‌دهد',
'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت',
'resource_strategies' => 'فهرست ثابتِ استراتژی‌های تخصیص منبع',
'app_clinicservice_clinicservice_listservicecategories' => 'دسته‌های ثابت خدمت (سرپایی/بستری)',
@@ -116,7 +117,6 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'app_auth_auth_resetpassword' => 'بازیابی رمز',
'app_auth_preregistration_submit' => 'پیش‌ثبت‌نام عمومی',
'app_payment_payment_callback' => 'کال‌بک درگاه — بدون توکن فراخوانی می‌شود',
'app_payment_payment_subscriptioncallback' => 'کال‌بک درگاه اشتراک',
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',