feat(payment): unify payment flow with new pure redirect entry and update related endpoints

This commit is contained in:
hamed
2026-07-02 17:08:50 +03:30
parent c247ac2c80
commit 7f5c65129c
17 changed files with 720 additions and 82 deletions
+118 -25
View File
@@ -126,6 +126,83 @@ class PaymentController extends BaseController
]);
}
// ── Pure-redirect entry (no XHR) — browser navigates here to start payment ──
#[OA\Get(
path: '/api/v1/payment/order/{appointmentUuid}',
summary: 'Start an appointment payment via a pure browser redirect (no XHR)',
parameters: [
new OA\Parameter(name: 'appointmentUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'gateway', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'return', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'uri')),
],
responses: [
new OA\Response(response: 302, description: 'Redirect to the bank, or back to return URL with status on validation failure'),
]
)]
#[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])]
public function startOrderPayment(string $appointmentUuid, Request $request): \Symfony\Component\HttpFoundation\Response
{
$gatewayName = trim((string) $request->query->get('gateway', ''));
$return = trim((string) $request->query->get('return', ''));
if ($return !== '' && !$this->isAllowedFrontend($return)) {
return $this->renderPaymentResult('invalid_return');
}
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
if ($appointment === null) {
return $this->redirectToReturn($return, 'notfound');
}
// اعتبارسنجی سفارش: فقط نوبت قابل‌پرداخت.
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
return $this->redirectToReturn($return, 'invalid');
}
if ($this->gateways->resolve($gatewayName) === null) {
return $this->redirectToReturn($return, 'gateway');
}
// جلوگیری از pending تکراری: پرداخت pending موجود را ادامه بده وگرنه بساز.
$payment = $this->paymentRepo->findPendingByAppointment($appointment)
?? $this->createAppointmentPayment($appointment, $gatewayName, $return);
// آدرس بازگشت را روی پرداختِ بازاستفاده‌شده هم به‌روزرسانی کن تا بازگشت درست باشد.
if ($return !== '' && $payment->getFrontendAddress() !== $return) {
$payment->setFrontendAddress($return);
$this->paymentRepo->save($payment);
}
// ارتباط با بانک + انتقال به شاپرک — همان مسیر واحد.
$result = $this->paymentManager->startGatewayHandoff($payment);
if ($result === false) {
return $this->redirectToFrontend($payment, false);
}
return $result->redirectMethod === 'POST'
? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams)
: new RedirectResponse($result->redirectUrl);
}
private function createAppointmentPayment(Appointment $appointment, string $gatewayName, string $return): Payment
{
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return);
$payment->setAppointment($appointment);
$this->paymentRepo->save($payment);
return $payment;
}
private function redirectToReturn(string $return, string $status): \Symfony\Component\HttpFoundation\Response
{
if ($return !== '' && $this->isAllowedFrontend($return)) {
$sep = str_contains($return, '?') ? '&' : '?';
return new RedirectResponse($return . $sep . 'status=' . $status);
}
return $this->renderPaymentResult($status);
}
// ── Gateway hand-off (public — browser redirect to the bank) ───────────────
#[OA\Get(
@@ -144,7 +221,7 @@ class PaymentController extends BaseController
{
$payment = $this->paymentRepo->findByOrderId($orderId);
if ($payment === null) {
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
return $this->renderPaymentResult('notfound');
}
// فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay).
@@ -166,24 +243,13 @@ class PaymentController extends BaseController
return new RedirectResponse($result->redirectUrl);
}
/** یک صفحهٔ HTML با فرمی که به‌صورت خودکار (POST) به درگاه ارسال می‌شود. */
/** صفحهٔ انتقال به درگاه (Twig) با فرمِ auto-submit به‌صورت POST. */
private function autoSubmitForm(string $action, array $params): \Symfony\Component\HttpFoundation\Response
{
$fields = '';
foreach ($params as $name => $value) {
$fields .= sprintf(
'<input type="hidden" name="%s" value="%s">',
htmlspecialchars((string) $name, ENT_QUOTES),
htmlspecialchars((string) $value, ENT_QUOTES)
);
}
$safeAction = htmlspecialchars($action, ENT_QUOTES);
$html = <<<HTML
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><title>در حال انتقال به درگاه پرداخت…</title></head>
<body onload="document.forms[0].submit()"><p style="font-family:Tahoma,sans-serif;text-align:center;margin-top:40px">در حال انتقال به درگاه پرداخت…</p>
<form method="POST" action="{$safeAction}">{$fields}<noscript><button type="submit">ادامه</button></noscript></form></body></html>
HTML;
return new \Symfony\Component\HttpFoundation\Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
return $this->render('payment/redirect.html.twig', [
'action' => $action,
'params' => $params,
]);
}
// ── Payment Callback (public — no JWT) ───────────────────────────────────
@@ -229,7 +295,7 @@ HTML;
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
if ($payment === null) {
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
return $this->renderPaymentResult('notfound');
}
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
@@ -470,18 +536,45 @@ HTML;
return false;
}
/** ثانیهٔ تأخیر پیش از ریدایرکت خودکار به فرانت‌اند در صفحهٔ نتیجه. */
private const RESULT_REDIRECT_DELAY = 5;
/** صفحهٔ نتیجهٔ پرداخت (Twig). اگر $redirectTo داده شود، بعد از چند ثانیه به آن می‌رود. */
private function renderPaymentResult(string $status, ?Payment $payment = null, string $redirectTo = ''): \Symfony\Component\HttpFoundation\Response
{
$labels = [
'success' => ['پرداخت موفق', 'پرداخت شما با موفقیت انجام شد.'],
'failed' => ['پرداخت ناموفق', 'پرداخت انجام نشد. در صورت کسر وجه، مبلغ طی ۷۲ ساعت بازمی‌گردد.'],
'canceled' => ['پرداخت لغو شد', 'پرداخت توسط شما لغو شد.'],
'pending' => ['در انتظار پرداخت', 'این پرداخت هنوز نهایی نشده است.'],
'notfound' => ['سفارش یافت نشد', 'سفارش موردنظر یافت نشد.'],
'invalid' => ['قابل پرداخت نیست', 'این سفارش در وضعیت قابل پرداخت نیست.'],
'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'],
'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'],
];
[$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];
return $this->render('payment/result.html.twig', [
'status' => $status,
'title' => $title,
'message' => $message,
'payment' => $payment?->toArray(),
'redirect_to' => $redirectTo,
'delay' => self::RESULT_REDIRECT_DELAY,
]);
}
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
{
$base = $payment->getFrontendAddress();
if (empty($base)) {
return new JsonResponse([
'success' => $success,
'payment' => $payment->toArray(),
]);
// آدرس بازگشتی نداریم → فقط صفحهٔ نتیجه (بدون ریدایرکت خودکار).
return $this->renderPaymentResult($payment->getStatus(), $payment);
}
$sep = str_contains($base, '?') ? '&' : '?';
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
return new RedirectResponse($url);
// صفحهٔ نتیجه را نشان بده و بعد از چند ثانیه به همان فرانت‌اندِ مبدأ برگرد.
$sep = str_contains($base, '?') ? '&' : '?';
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
return $this->renderPaymentResult($payment->getStatus(), $payment, $url);
}
}