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
+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',