feat(payment): unify payment callback endpoint for all gateways and types
This commit is contained in:
@@ -42,7 +42,7 @@ security:
|
|||||||
security: false
|
security: false
|
||||||
|
|
||||||
payment_callback:
|
payment_callback:
|
||||||
pattern: ^/api/v1/(payment/(callback|pay|order)/|subscription-payment/callback/)
|
pattern: ^/api/v1/payment/(callback|pay/|order/)
|
||||||
stateless: true
|
stateless: true
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
@@ -85,10 +85,9 @@ security:
|
|||||||
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/session/token, 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/pay/, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/v1/payment/order/, 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/categorys/, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS }
|
- { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS }
|
||||||
- path: '^/api/v1/doctor/[^/]+$'
|
- path: '^/api/v1/doctor/[^/]+$'
|
||||||
|
|||||||
+27
-14
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
**کمیسیون دامنهمحور (post-action):** نمایندهی مبدأ از `payment.frontend_address` با `DomainContextResolver` تعیین میشود؛ کمیسیون (نوبت و اشتراک) فقط وقتی ثبت میشود که این نماینده فعال باشد **و** پزشک/کلینیک موضوع خرید `representation_id` همان نماینده را داشته باشد — جزئیات در `docs/api/representation.md` §قانون کمیسیون دامنهمحور.
|
**کمیسیون دامنهمحور (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` تغییر نمیکنند.
|
**افزودن درگاه جدید (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}`
|
## POST `/api/v1/payment/callback`
|
||||||
## GET `/api/v1/payment/callback/{gateway}`
|
## 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` (دامنهٔ ثبتشده نزد ملت/شاپرک) باشد.
|
**نکته 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 معنی دارد.
|
**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
|
### Query Parameters
|
||||||
| Param | Type | Description |
|
| Param | Type | Required | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `gateway` | string | `mellat` or `sep` |
|
| `order_id` | string | yes | شناسهٔ سفارش (`ORD-…`)؛ در نبودش `ResNum` خوانده میشود |
|
||||||
|
| `gateway` | string | no | `mellat` \| `sep` \| `mock`. در نبودش از فیلد `gateway` همان رکورد پرداخت خوانده میشود |
|
||||||
|
|
||||||
### Request (varies by gateway)
|
### Request (varies by gateway)
|
||||||
**Mellat POST fields:**
|
**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
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| 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.
|
پرداخت اشتراک callback اختصاصی ندارد. از [`/api/v1/payment/callback`](#post-apiv1paymentcallback)
|
||||||
|
استفاده کنید؛ نوع پرداخت از خودِ رکورد `Payment` خوانده میشود.
|
||||||
**Permission:** `PUBLIC`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -296,13 +296,20 @@ class PaymentController extends BaseController
|
|||||||
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
||||||
|
|
||||||
#[OA\Post(
|
#[OA\Post(
|
||||||
path: '/api/v1/payment/callback/{gateway}',
|
path: '/api/v1/payment/callback',
|
||||||
summary: 'Payment gateway callback (public, IP-restricted)',
|
summary: 'Payment gateway callback — single endpoint for every gateway and payment type (public)',
|
||||||
parameters: [
|
parameters: [
|
||||||
new OA\Parameter(
|
new OA\Parameter(
|
||||||
name: 'gateway',
|
name: 'order_id',
|
||||||
in: 'path',
|
in: 'query',
|
||||||
required: true,
|
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'])
|
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'),
|
new OA\Response(response: 404, description: 'Payment not found'),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
#[Route(PaymentManager::CALLBACK_PATH, methods: ['POST', 'GET'])]
|
||||||
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
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) برمیگردانند،
|
// درگاههای ملت و سپ نتیجه را با ریدایرکتِ مرورگرِ کاربر (POST/GET) برمیگردانند،
|
||||||
// نه server-to-server؛ پس IP دریافتی، IPِ کاربر است و allowlist شاپرک اعمال نمیشود
|
// نه server-to-server؛ پس IP دریافتی، IPِ کاربر است و allowlist شاپرک اعمال نمیشود
|
||||||
// (در غیر این صورت هر callback واقعی — از جمله «لغو» — رد میشد). امنیت از طریق چک
|
// (در غیر این صورت هر callback واقعی — از جمله «لغو» — رد میشد). امنیت از طریق چک
|
||||||
@@ -336,9 +356,6 @@ class PaymentController extends BaseController
|
|||||||
return $this->renderPaymentResult('forbidden');
|
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) در سرویس.
|
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
|
||||||
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
|
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
|
||||||
if ($payment === null) {
|
if ($payment === null) {
|
||||||
@@ -433,38 +450,8 @@ class PaymentController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Post(
|
// پرداخت اشتراک callback اختصاصی ندارد؛ همان `callback()` مشترک همهٔ نوعها را
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ────────────────────────────────────────────────────────────────
|
// ── Status ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -249,12 +249,18 @@ final class PaymentManager
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تک آدرس بازگشت برای همهٔ درگاهها و همهٔ نوعهای پرداخت؛ درگاه و سفارش
|
||||||
|
* بهصورت query param میروند تا مسیر ثابت و قابل ثبت در پنل بانک بماند.
|
||||||
|
*/
|
||||||
|
public const CALLBACK_PATH = '/api/v1/payment/callback';
|
||||||
|
|
||||||
public function callbackUrl(Payment $payment): string
|
public function callbackUrl(Payment $payment): string
|
||||||
{
|
{
|
||||||
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
|
return $this->appBaseUrl . self::CALLBACK_PATH . '?' . http_build_query([
|
||||||
? '/api/v1/subscription-payment/callback/'
|
'gateway' => $payment->getGateway(),
|
||||||
: '/api/v1/payment/callback/';
|
'order_id' => $payment->getOrderId(),
|
||||||
return $this->appBaseUrl . $prefix . $payment->getGateway() . '?order_id=' . $payment->getOrderId();
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Post-actions ──────────────────────────────────────────────────────────
|
// ── Post-actions ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
|
|||||||
$isPaymentPage =
|
$isPaymentPage =
|
||||||
str_starts_with($path, '/api/v1/payment/order/')
|
str_starts_with($path, '/api/v1/payment/order/')
|
||||||
|| str_starts_with($path, '/api/v1/payment/pay/')
|
|| str_starts_with($path, '/api/v1/payment/pay/')
|
||||||
|| str_starts_with($path, '/api/v1/payment/callback/')
|
|| $path === '/api/v1/payment/callback';
|
||||||
|| str_starts_with($path, '/api/v1/subscription-payment/callback/');
|
|
||||||
|
|
||||||
$response->headers->set(
|
$response->headers->set(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
|||||||
|
|
||||||
private function fireCallback(Payment $payment): void
|
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(),
|
'order_id' => $payment->getOrderId(),
|
||||||
'mock' => '1',
|
'mock' => '1',
|
||||||
'ResCode' => '0',
|
'ResCode' => '0',
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class PaymentCallbackAmountTest extends ApiTestCase
|
|||||||
|
|
||||||
private function fireCallback(Payment $payment, int $reportedAmount): void
|
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(),
|
'order_id' => $payment->getOrderId(),
|
||||||
'mock' => '1',
|
'mock' => '1',
|
||||||
'ResCode' => '0',
|
'ResCode' => '0',
|
||||||
@@ -91,7 +92,8 @@ class PaymentCallbackAmountTest extends ApiTestCase
|
|||||||
|
|
||||||
private function fireCallbackWithRef(Payment $payment, string $refId, int $reportedAmount): void
|
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(),
|
'order_id' => $payment->getOrderId(),
|
||||||
'mock' => '1',
|
'mock' => '1',
|
||||||
'ResCode' => '0',
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ class ApiLeastPrivilegeTest extends ApiTestCase
|
|||||||
'practice_domain_list' => 'حوزههای فعالیت — دادهٔ مرجع',
|
'practice_domain_list' => 'حوزههای فعالیت — دادهٔ مرجع',
|
||||||
'app_subscription_subscription_plans' => 'پلنهای اشتراک — کاتالوگ عمومی',
|
'app_subscription_subscription_plans' => 'پلنهای اشتراک — کاتالوگ عمومی',
|
||||||
'app_payment_payment_config' => 'نام درگاهها و کارمزد — بدون مقدار محرمانه',
|
'app_payment_payment_config' => 'نام درگاهها و کارمزد — بدون مقدار محرمانه',
|
||||||
|
'app_payment_payment_callback' => 'callback بانک — عمدا PUBLIC؛ بدون order_id معتبر فقط صفحهٔ «یافت نشد» میدهد',
|
||||||
'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت',
|
'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت',
|
||||||
'resource_strategies' => 'فهرست ثابتِ استراتژیهای تخصیص منبع',
|
'resource_strategies' => 'فهرست ثابتِ استراتژیهای تخصیص منبع',
|
||||||
'app_clinicservice_clinicservice_listservicecategories' => 'دستههای ثابت خدمت (سرپایی/بستری)',
|
'app_clinicservice_clinicservice_listservicecategories' => 'دستههای ثابت خدمت (سرپایی/بستری)',
|
||||||
@@ -116,7 +117,6 @@ class ApiLeastPrivilegeTest extends ApiTestCase
|
|||||||
'app_auth_auth_resetpassword' => 'بازیابی رمز',
|
'app_auth_auth_resetpassword' => 'بازیابی رمز',
|
||||||
'app_auth_preregistration_submit' => 'پیشثبتنام عمومی',
|
'app_auth_preregistration_submit' => 'پیشثبتنام عمومی',
|
||||||
'app_payment_payment_callback' => 'کالبک درگاه — بدون توکن فراخوانی میشود',
|
'app_payment_payment_callback' => 'کالبک درگاه — بدون توکن فراخوانی میشود',
|
||||||
'app_payment_payment_subscriptioncallback' => 'کالبک درگاه اشتراک',
|
|
||||||
|
|
||||||
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
|
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
|
||||||
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
|
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
|
||||||
|
|||||||
Reference in New Issue
Block a user