feat(payment): unify payment flow with new pure redirect entry and update related endpoints
This commit is contained in:
@@ -23,6 +23,7 @@ class SiteConfigController extends BaseController
|
||||
'tax_enabled',
|
||||
'tax_percent',
|
||||
'sms_panel_fee_rials',
|
||||
'sms_price_rials',
|
||||
'appointment_fee_rials',
|
||||
'site_name',
|
||||
'support_phone',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ class Payment
|
||||
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
|
||||
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
|
||||
public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; }
|
||||
public function setFrontendAddress(?string $a): self { $this->frontendAddress = $a ?: null; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
|
||||
@@ -27,7 +27,23 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (str_starts_with($path, '/api') && !str_starts_with($path, '/api/doc')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
// صفحات مرورگرمحورِ پرداخت (order/pay/callback) HTML برمیگردانند و به
|
||||
// inline style + فونت + فرمِ انتقال به شاپرک نیاز دارند؛ بقیهٔ API (JSON)
|
||||
// همان سیاست سختگیرانه را میگیرد.
|
||||
$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/');
|
||||
|
||||
$response->headers->set(
|
||||
'Content-Security-Policy',
|
||||
$isPaymentPage
|
||||
? "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||||
. "font-src https://cdn.jsdelivr.net data:; img-src data:; "
|
||||
. "form-action https://*.shaparak.ir; base-uri 'none'"
|
||||
: "default-src 'none'"
|
||||
);
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin') || str_starts_with($path, '/api')) {
|
||||
|
||||
@@ -4,12 +4,9 @@ namespace App\Sms\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Gateway\MellatGateway;
|
||||
use App\Payment\Gateway\MockGateway;
|
||||
use App\Payment\Gateway\SepGateway;
|
||||
use App\Payment\Gateway\GatewayFactory;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -37,15 +34,19 @@ class SmsWalletController extends BaseController
|
||||
private readonly SmsWalletTransactionRepository $txRepo,
|
||||
private readonly SmsSettingsRepository $settingsRepo,
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly MellatGateway $mellat,
|
||||
private readonly SepGateway $sep,
|
||||
private readonly MockGateway $mock,
|
||||
private readonly GatewayFactory $gateways,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
/** قیمت هر پیامک از تنظیمات سایت؛ با fallback به مقدار پیشفرض. */
|
||||
private function smsPriceRials(): int
|
||||
{
|
||||
return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/balance', methods: ['GET'])]
|
||||
public function balance(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -55,7 +56,7 @@ class SmsWalletController extends BaseController
|
||||
}
|
||||
|
||||
$balanceRials = $this->walletService->getBalance($entityType, $entityId);
|
||||
$smsPriceRials = self::SMS_PRICE_RIALS;
|
||||
$smsPriceRials = $this->smsPriceRials();
|
||||
$estimatedSms = (int) floor($balanceRials / $smsPriceRials);
|
||||
|
||||
return $this->success([
|
||||
@@ -81,17 +82,8 @@ class SmsWalletController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
|
||||
}
|
||||
|
||||
if ($this->configRepo->get('payment_test_mode') === '1') {
|
||||
$gateway = $this->mock;
|
||||
} else {
|
||||
$gateway = match ($gatewayName) {
|
||||
'mellat' => $this->mellat,
|
||||
'sep' => $this->sep,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($gateway === null) {
|
||||
// فقط اعتبارسنجی درگاه؛ ارتباط با بانک در flow واحد (GET /payment/pay) انجام میشود.
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
|
||||
}
|
||||
|
||||
@@ -100,19 +92,9 @@ class SmsWalletController extends BaseController
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage ?? 'درگاه در دسترس نیست', 503);
|
||||
}
|
||||
|
||||
$payment->setGatewayToken($result->token);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'redirect_url' => $result->redirectUrl,
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user