From 1e342a695f28481255ab057d7c596b03a99f691a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 2 Jul 2026 12:19:43 +0330 Subject: [PATCH] feat(payment): enhance payment flow with new pay endpoint and POST redirect method --- config/packages/security.yaml | 3 +- docs/api/payment.md | 31 ++++++- src/Payment/Controller/PaymentController.php | 98 +++++++++++++++++--- src/Payment/Gateway/MellatGateway.php | 10 +- src/Payment/Gateway/PaymentInitResult.php | 6 ++ 5 files changed, 129 insertions(+), 19 deletions(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 2d9bac6f..b172c9db 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -38,7 +38,7 @@ security: security: false payment_callback: - pattern: ^/api/v1/(payment|subscription-payment)/callback/ + pattern: ^/api/v1/(payment/(callback|pay)/|subscription-payment/callback/) stateless: true security: false @@ -74,6 +74,7 @@ security: - { 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/pay/, 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 } diff --git a/docs/api/payment.md b/docs/api/payment.md index 15d81d09..1d537007 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -107,12 +107,14 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew "success": true, "data": { "payment_uuid": "pay-uuid-...", - "redirect_url": "https://bpm.shaparak.ir/pgwchannel/...", - "order_id": "CLINICPRO-1717000000-ABC123" + "pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX", + "order_id": "ORD-XXXX" } } ``` +> **جریان انتقال به درگاه:** این endpoint فقط **صلاحیت اوردر** را بررسی و یک پرداختِ `pending` می‌سازد؛ **هیچ ارتباطی با بانک برقرار نمی‌کند**. کلاینت باید مرورگر را به `pay_url` هدایت کند. سپس `GET /api/v1/payment/pay/{orderId}` (سمت بک‌اند) درگاه را init می‌کند (ارتباط با بانک) و مرورگر را به درگاه می‌فرستد (302 برای درگاه‌های GET مثل سپ، یا فرم auto-submit با متد POST برای درگاه ملت). به این ترتیب کلاینت هرگز مستقیم به بانک ریکوست نمی‌زند. +> > **مبلغ نوبت:** مبلغِ پرداخت از کلید `appointment_fee_rials` تنظیمات سایت خوانده می‌شود (نه از client و نه hardcode). برای تغییر، در `/admin/settings` ویرایش کنید. > > **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. تاریخِ نوبت در متن پیامک به‌صورت **شمسی** (`JalaliDateService::formatDateTime`، مثل `۱۴۰۵/۰۴/۰۲ ۰۹:۰۰`) درج می‌شود. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band. @@ -126,7 +128,30 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew | `ERR_FORBIDDEN_001` | 403 | Not the patient | | `ERR_PAYMENT_003` | 422 | Appointment not in payable state | | `ERR_PAYMENT_002` | 422 | Invalid amount | -| `ERR_PAYMENT_001` | 503 | Payment gateway unavailable | +| `ERR_VALIDATION_001` | 422 | درگاه پرداخت نامعتبر یا غیرفعال / آدرس بازگشت مجاز نیست | + +--- + +## GET `/api/v1/payment/pay/{orderId}` + +انتقال مرورگر به درگاه پرداخت برای یک پرداختِ `pending`. **عمومی (بدون JWT)** — مرورگر مستقیماً به این آدرس هدایت می‌شود. صلاحیت اوردر قبلاً در `POST /api/v1/payment/appointment` (نیازمند JWT) بررسی و پرداخت ساخته شده است. **ارتباط با بانک (init درگاه) در همین endpoint انجام می‌شود.** + +### Path Parameters +| Param | Type | Description | +|-------|------|-------------| +| `orderId` | string | `order_id` پرداخت (از پاسخ initiate) | + +### رفتار +- اگر پرداخت یافت نشد → `404 { success:false, message:"payment not found" }`. +- اگر وضعیت پرداخت `pending` نباشد → `302` به `frontend_address` با نتیجه (جلوگیری از پرداخت تکراری). +- درگاه resolve می‌شود (در حالت تست → mock)؛ اگر نامعتبر بود یا circuit breaker باز بود → پرداخت `failed` و `302` به `frontend_address`. +- `gateway->initiate(...)` صدا زده می‌شود (ارتباط با بانک). در صورت شکست → `failed` و `302` به `frontend_address`. +- در صورت موفقیت: توکن ذخیره و انتقال به درگاه: + - متد `GET` (سپ/mock) → `302` به URL درگاه. + - متد `POST` (ملت) → صفحهٔ HTML با فرم **auto-submit** (POST) به درگاه، شامل فیلدهای لازم (مثل `RefId`). + +### Permission +`PUBLIC` (در `security.yaml` تحت firewall `payment_callback` و access_control عمومی). --- diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 7110ed4d..a78911b9 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -178,37 +178,107 @@ class PaymentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); } - $gateway = $this->resolveGateway($gatewayName); - if ($gateway === null) { + // اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام + // نمی‌شود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام می‌گیرد. + if ($this->resolveGateway($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } - if ($this->circuitBreaker->isOpen($gatewayName)) { - return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503); - } - $feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress); $payment->setAppointment($appointment); $this->paymentRepo->save($payment); + // مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک + // + انتقال به درگاه انجام می‌شود. کلاینت هرگز مستقیم به بانک ریکوست نمی‌زند. + return $this->success([ + 'payment_uuid' => $payment->getUuid(), + 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), + 'order_id' => $payment->getOrderId(), + ]); + } + + // ── Gateway hand-off (public — browser redirect to the bank) ─────────────── + + #[OA\Get( + path: '/api/v1/payment/pay/{orderId}', + summary: 'Redirect the browser to the payment gateway for a pending payment', + parameters: [ + new OA\Parameter(name: 'orderId', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 302, description: 'Redirect (GET gateway) or auto-submitting POST form (POST gateway)'), + new OA\Response(response: 404, description: 'Payment not found'), + ] + )] + #[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])] + public function pay(string $orderId): \Symfony\Component\HttpFoundation\Response + { + $payment = $this->paymentRepo->findByOrderId($orderId); + if ($payment === null) { + return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); + } + + // فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay). + if ($payment->getStatus() !== Payment::STATUS_PENDING) { + return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); + } + + $gatewayName = $payment->getGateway(); + $gateway = $this->resolveGateway($gatewayName); + $testMode = $this->configRepo->get('payment_test_mode') === '1'; + + if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) { + $payment->setStatus(Payment::STATUS_FAILED); + $this->paymentRepo->save($payment); + return $this->redirectToFrontend($payment, false); + } + + // ارتباط با بانک (init) از سمت بک‌اند انجام می‌شود. $callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); $result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl); if (!$result->success) { - $this->circuitBreaker->recordFailure($gatewayName); - return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503); + if (!$testMode) { + $this->circuitBreaker->recordFailure($gatewayName); + } + $payment->setStatus(Payment::STATUS_FAILED); + $this->paymentRepo->save($payment); + return $this->redirectToFrontend($payment, false); } - $this->circuitBreaker->recordSuccess($gatewayName); + if (!$testMode) { + $this->circuitBreaker->recordSuccess($gatewayName); + } $payment->setGatewayToken($result->token); $this->paymentRepo->save($payment); - return $this->success([ - 'payment_uuid' => $payment->getUuid(), - 'redirect_url' => $result->redirectUrl, - 'order_id' => $payment->getOrderId(), - ]); + // انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت). + if ($result->redirectMethod === 'POST') { + return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams); + } + + return new RedirectResponse($result->redirectUrl); + } + + /** یک صفحهٔ HTML با فرمی که به‌صورت خودکار (POST) به درگاه ارسال می‌شود. */ + private function autoSubmitForm(string $action, array $params): \Symfony\Component\HttpFoundation\Response + { + $fields = ''; + foreach ($params as $name => $value) { + $fields .= sprintf( + '', + htmlspecialchars((string) $name, ENT_QUOTES), + htmlspecialchars((string) $value, ENT_QUOTES) + ); + } + $safeAction = htmlspecialchars($action, ENT_QUOTES); + $html = <<در حال انتقال به درگاه پرداخت… +

در حال انتقال به درگاه پرداخت…

+
{$fields}
+HTML; + return new \Symfony\Component\HttpFoundation\Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']); } // ── Payment Callback (public — no JWT) ─────────────────────────────────── diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index eb0dce38..2ce4a392 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -53,7 +53,15 @@ class MellatGateway implements PaymentGatewayInterface $refId = $this->parseRefId($response->getContent()); $redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId; - return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId); + // درگاه ملت باید با POST فرم (فیلد RefId) باز شود؛ redirectUrl (شامل RefId) + // برای سازگاری با مصرف‌کننده‌های قدیمی نگه داشته می‌شود. + return new PaymentInitResult( + true, + redirectUrl: $redirectUrl, + token: $refId, + redirectMethod: 'POST', + redirectParams: ['RefId' => $refId], + ); } catch (\Throwable $e) { $this->logger->error(sprintf('Payment initiate failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]); return new PaymentInitResult(false, errorMessage: $e->getMessage()); diff --git a/src/Payment/Gateway/PaymentInitResult.php b/src/Payment/Gateway/PaymentInitResult.php index a25df6e0..f2089928 100644 --- a/src/Payment/Gateway/PaymentInitResult.php +++ b/src/Payment/Gateway/PaymentInitResult.php @@ -4,10 +4,16 @@ namespace App\Payment\Gateway; final class PaymentInitResult { + /** + * @param 'GET'|'POST' $redirectMethod نحوهٔ انتقال به درگاه: GET (302) یا POST (فرم auto-submit) + * @param array $redirectParams فیلدهای فرم برای حالت POST + */ public function __construct( public readonly bool $success, public readonly string $redirectUrl = '', public readonly string $token = '', public readonly string $errorMessage = '', + public readonly string $redirectMethod = 'GET', + public readonly array $redirectParams = [], ) {} }