diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 45c2a4e5..eda3ad8c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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/[^/]+$' diff --git a/docs/api/payment.md b/docs/api/payment.md index 18c7ffbc..668bff1e 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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` خوانده می‌شود. --- diff --git a/docs/api/subscription.md b/docs/api/subscription.md index b395e030..a1a7f2f7 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -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). --- diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 3836573a..452f7302 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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 ──────────────────────────────────────────────────────────────── diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 35ba6770..7df49068 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -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 ────────────────────────────────────────────────────────── diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php index 0782a43e..fa9ad592 100644 --- a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -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', diff --git a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php index 37328739..e78f8ee5 100644 --- a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php +++ b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php @@ -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', diff --git a/tests/Payment/PaymentCallbackAmountTest.php b/tests/Payment/PaymentCallbackAmountTest.php index 1295ffe3..b6c2ed23 100644 --- a/tests/Payment/PaymentCallbackAmountTest.php +++ b/tests/Payment/PaymentCallbackAmountTest.php @@ -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', diff --git a/tests/Payment/UnifiedPaymentCallbackTest.php b/tests/Payment/UnifiedPaymentCallbackTest.php new file mode 100644 index 00000000..33e8d706 --- /dev/null +++ b/tests/Payment/UnifiedPaymentCallbackTest.php @@ -0,0 +1,125 @@ +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 $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 */ + 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); + } + } +} diff --git a/tests/Shared/ApiLeastPrivilegeTest.php b/tests/Shared/ApiLeastPrivilegeTest.php index d4280152..0e8ce826 100644 --- a/tests/Shared/ApiLeastPrivilegeTest.php +++ b/tests/Shared/ApiLeastPrivilegeTest.php @@ -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' => 'تغییر رمزِ خودِ کاربر',