feat(payment): enhance payment flow with new pay endpoint and POST redirect method
This commit is contained in:
@@ -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 }
|
||||
|
||||
+28
-3
@@ -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 عمومی).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(
|
||||
'<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']);
|
||||
}
|
||||
|
||||
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -4,10 +4,16 @@ namespace App\Payment\Gateway;
|
||||
|
||||
final class PaymentInitResult
|
||||
{
|
||||
/**
|
||||
* @param 'GET'|'POST' $redirectMethod نحوهٔ انتقال به درگاه: GET (302) یا POST (فرم auto-submit)
|
||||
* @param array<string, string> $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 = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user