feat(payment): enhance payment configuration to include active gateways and update CORS settings
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
# رفع باگهای پروداکشن: CORS + payment/config 500 + درگاههای فعال و callback ملت
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (Backend Symfony + پنل ادمین React). دو مورد cross-repo با سایت عمومی `nobat724_front` دارد (دامنههای عمومی و CORS) که در همینجا فقط سمت backend اصلاح میشود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
در پروداکشن (backend روی `https://clinic-pro.ir`، سایت عمومی روی دامنههای چند-شهری مثل `https://yasuj-nobat.ir`) سه باگ گزارش شده:
|
||||||
|
|
||||||
|
1. **CORS**: از `https://yasuj-nobat.ir` درخواست به `https://clinic-pro.ir/api/v1/user/send-code` با خطای preflight رد میشود: `No 'Access-Control-Allow-Origin' header is present`.
|
||||||
|
2. **payment/config 500**: در `https://clinic-pro.ir/admin/subscription` فراخوانی `GET /api/v1/payment/config` مکرراً `500` میدهد.
|
||||||
|
3. **درگاهها**: الان فقط درگاه **ملت** فعال است ولی UI هر دو درگاه (ملت + سپ) را نشان میدهد؛ باید فقط درگاههای فعال قابلانتخاب باشند. همچنین هر درگاه باید callback مخصوص خودش داشته باشد و مطابق راهنمای IPG ملت (`callBackUrl` باید روی دامنهٔ ثبتشده باشد).
|
||||||
|
|
||||||
|
> نکته تشخیصی: `GET /api/v1/payment/config` روی محیط لوکال ddev با توکن ادمین **200** برمیگرداند (`{"success":true,"data":{"test_mode":false,"appointment_fee_rials":15000}}`). پس 500 مختص محیط/دادهٔ پروداکشن است و نباید حدس زد — **ابتدا استثنای واقعی از لاگ پروداکشن استخراج شود**، سپس endpoint مقاومسازی شود.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
| ------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||||
|
| `config/packages/nelmio_cors.yaml` | تنظیم CORS، مبتنی بر env `CORS_ALLOW_ORIGIN` (regex) |
|
||||||
|
| `.env` / `.env.example` (+ env پروداکشن) | مقدار `CORS_ALLOW_ORIGIN` و `ALLOWED_FRONTEND_HOSTS` و `APP_BASE_URL` |
|
||||||
|
| `src/Payment/Controller/PaymentController.php` | `config()` (خط ۵۴۶)، `initiateAppointment()`، `callback()`، `resolveGateway()` |
|
||||||
|
| `src/Payment/Gateway/MellatGateway.php` | درگاه ملت؛ اعتبارنامه از `SiteConfig` با fallback به env |
|
||||||
|
| `src/Payment/Gateway/SepGateway.php` | درگاه سپ |
|
||||||
|
| `src/Config/Repository/SiteConfigRepository.php` | کلیدهای `mellat_*`, `sep_terminal_id`, `payment_test_mode`, `appointment_fee_rials` |
|
||||||
|
| `assets/admin/pages/SubscriptionPage.tsx` | انتخاب درگاه (خط ۳۲۸ آرایهٔ hardcode `['mellat','sep']`) |
|
||||||
|
| `assets/admin/pages/AdminSubscriptionPage.tsx` | مشابه، در صورت داشتن انتخاب درگاه |
|
||||||
|
| `docs/api/payment.md` | مستند endpointها |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### CORS — `config/packages/nelmio_cors.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"]
|
||||||
|
allow_methods: ["GET", "OPTIONS", "POST", "PATCH", "DELETE"]
|
||||||
|
allow_headers:
|
||||||
|
[
|
||||||
|
"Content-Type",
|
||||||
|
"Authorization",
|
||||||
|
"X-CSRF-Token",
|
||||||
|
"Content-Disposition",
|
||||||
|
]
|
||||||
|
paths:
|
||||||
|
"^/api/": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }
|
||||||
|
"^/oauth/": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }
|
||||||
|
"^/health": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` فعلی (لوکال) — فقط دامنههای ddev/localhost را مجاز میکند:
|
||||||
|
|
||||||
|
```
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://([a-z0-9-]+\.)*(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost,yazd-nobat.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` هنوز placeholder دارد: `CORS_ALLOW_ORIGIN='^https://your-domain\.com$'`.
|
||||||
|
|
||||||
|
### payment/config — `PaymentController::config()` (خط ۵۴۶)
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
||||||
|
public function config(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success([
|
||||||
|
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
|
||||||
|
'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### resolveGateway + callback (کد واقعی)
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function resolveGateway(string $name): ?PaymentGatewayInterface
|
||||||
|
{
|
||||||
|
if ($this->configRepo->get('payment_test_mode') === '1') return $this->mock;
|
||||||
|
return match ($name) {
|
||||||
|
'mellat' => $this->mellat,
|
||||||
|
'sep' => $this->sep,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// در initiateAppointment: callback هر درگاه از APP_BASE_URL ساخته میشود
|
||||||
|
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||||
|
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
|
||||||
|
```
|
||||||
|
|
||||||
|
روتهای callback موجود (per-gateway، عمومی و IP-restricted):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST|GET /api/v1/payment/callback/{gateway}
|
||||||
|
POST|GET /api/v1/subscription-payment/callback/{gateway}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MellatGateway — تشخیص فعالبودن
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function cfg(string $key, string $envFallback): string
|
||||||
|
{
|
||||||
|
return $this->configRepo->get($key) ?: $envFallback;
|
||||||
|
}
|
||||||
|
// اعتبارنامهها: mellat_terminal_id / mellat_username / mellat_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### فرانت — `SubscriptionPage.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const GATEWAY_LABELS: Record<string, string> = { mellat: 'بانک ملت', sep: 'سپ (سامان کیش)' };
|
||||||
|
const [selectedGateway, setSelectedGateway] = useState<'mellat' | 'sep'>('mellat');
|
||||||
|
// ...
|
||||||
|
{(['mellat', 'sep'] as const).map((gw) => ( /* دکمهٔ انتخاب هر دو درگاه، همیشه */ ))}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. رفع CORS پروداکشن
|
||||||
|
|
||||||
|
**ریشه:** regex `CORS_ALLOW_ORIGIN` در env پروداکشن دامنههای عمومی چند-شهری (`*-nobat.ir`) و خودِ `clinic-pro.ir` را پوشش نمیدهد؛ در نتیجه preflight `OPTIONS /api/v1/user/send-code` هدر `Access-Control-Allow-Origin` نمیگیرد و مرورگر بلاک میکند.
|
||||||
|
|
||||||
|
- در **env پروداکشن** (و `.env`/`.env.example` بهعنوان مرجع) مقدار `CORS_ALLOW_ORIGIN` را به regexی تغییر بده که همهٔ دامنههای عمومی `<city>-nobat.ir` (با/بدون subdomain و `www`) و پنل `clinic-pro.ir` را مجاز کند. نمونهٔ پیشنهادی:
|
||||||
|
|
||||||
|
```
|
||||||
|
CORS_ALLOW_ORIGIN='^https://([a-z0-9-]+\.)*([a-z0-9-]+-nobat\.ir|clinic-pro\.ir)$'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ALLOWED_FRONTEND_HOSTS` (لیست میزبانهای مجاز برای redirect پرداخت، در `PaymentController::allowedHosts()`) نیز باید شامل دامنههای عمومی پروداکشن باشد، مثلاً:
|
||||||
|
|
||||||
|
```
|
||||||
|
ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.env.example` را از placeholder `your-domain.com` به همین الگو بهروزرسانی کن تا برای دیپلویهای بعدی درست باشد.
|
||||||
|
- بعد از تغییر env روی سرور: `php bin/console cache:clear` (regex در کش کانتینر خوانده میشود).
|
||||||
|
- **تأیید:** با `curl -i -X OPTIONS 'https://clinic-pro.ir/api/v1/user/send-code' -H 'Origin: https://yasuj-nobat.ir' -H 'Access-Control-Request-Method: POST'` باید هدر `Access-Control-Allow-Origin: https://yasuj-nobat.ir` برگردد.
|
||||||
|
|
||||||
|
### ۲. رفع payment/config 500 + فقط درگاههای فعال
|
||||||
|
|
||||||
|
**۲.۱ استخراج علت واقعی 500 (بدون حدس):** روی پروداکشن لاگ استثنای `/api/v1/payment/config` را بگیر (`var/log/prod.log` یا لاگ کانتینر/`docker logs`). چون لوکال 200 میدهد، علت محتمل یکی از اینهاست — تأیید کن، حدس نزن:
|
||||||
|
|
||||||
|
- مهاجرتهای اجرانشده در پروداکشن (جدول `site_config` یا ستونها) → `php bin/console doctrine:migrations:migrate` روی prod.
|
||||||
|
- خطای اتصال/کوئری `SiteConfigRepository` هنگام خواندن کلید.
|
||||||
|
- ناسازگاری کد دیپلویشده با DEFAULTS جدید در `SiteConfigRepository`.
|
||||||
|
|
||||||
|
**۲.۲ مقاومسازی و افزودن درگاههای فعال به پاسخ:** `config()` را طوری تغییر بده که علاوه بر `test_mode` و `appointment_fee_rials`، لیست **درگاههای فعال** را برگرداند تا فرانت فقط همانها را نشان دهد. یک درگاه «فعال» است اگر اعتبارنامههایش (در `SiteConfig` یا env) ست شده باشند:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
||||||
|
public function config(): JsonResponse
|
||||||
|
{
|
||||||
|
$testMode = $this->configRepo->get('payment_test_mode') === '1';
|
||||||
|
|
||||||
|
$cfg = fn(string $key): string => (string) ($this->configRepo->get($key) ?? '');
|
||||||
|
|
||||||
|
$gateways = [];
|
||||||
|
if ($testMode) {
|
||||||
|
$gateways[] = ['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)'];
|
||||||
|
} else {
|
||||||
|
$mellatActive = $cfg('mellat_terminal_id') !== '' && $cfg('mellat_username') !== '' && $cfg('mellat_password') !== '';
|
||||||
|
$sepActive = $cfg('sep_terminal_id') !== '';
|
||||||
|
if ($mellatActive) $gateways[] = ['name' => 'mellat', 'label' => 'بانک ملت'];
|
||||||
|
if ($sepActive) $gateways[] = ['name' => 'sep', 'label' => 'سپ (سامان کیش)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'test_mode' => $testMode,
|
||||||
|
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
|
||||||
|
'gateways' => $gateways,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر env پروداکشن هم fallback اعتبارنامهٔ ملت را دارد، توجه کن که `MellatGateway::cfg()` اول `SiteConfig` بعد env را میخواند؛ برای همراستایی، تشخیص «فعال» را هم به همین ترتیب انجام بده (اول کلید DB، اگر خالی بود env مربوطه). در صورت نیاز یک helper خصوصی مشابه `cfg($key, $envFallback)` در کنترلر اضافه کن.
|
||||||
|
|
||||||
|
**۲.۳ فرانت — فقط درگاههای فعال:** در `SubscriptionPage.tsx` (و `AdminSubscriptionPage.tsx` اگر انتخاب درگاه دارد):
|
||||||
|
|
||||||
|
- لیست درگاهها را از `GET /api/v1/payment/config` → `data.gateways` بخوان، نه آرایهٔ hardcode `['mellat','sep']`.
|
||||||
|
- `selectedGateway` را به اولین درگاه فعال مقداردهی اولیه کن؛ اگر فقط یک درگاه فعال بود، همان بهصورت پیشفرض انتخاب و بقیه نمایش داده نشوند.
|
||||||
|
- اگر `gateways` خالی بود (هیچ درگاه فعالی نیست) پیام مناسب نشان بده و دکمهٔ پرداخت را غیرفعال کن.
|
||||||
|
|
||||||
|
### ۳. Callback هر درگاه مطابق راهنمای IPG ملت
|
||||||
|
|
||||||
|
callback بهازای هر درگاه **از قبل وجود دارد** (`/api/v1/payment/callback/{gateway}` و نسخهٔ subscription، ساختهشده از `APP_BASE_URL`). طبق راهنمای ملت (نگارش ۱.۳۸) نکات الزامی که باید تضمین شوند:
|
||||||
|
|
||||||
|
- **`callBackUrl` باید روی دامنهٔ ثبتشدهٔ پذیرنده باشد، نه IP** (در غیر این صورت کد پاسخ `62`: «مسیر back call در دامنهٔ ثبتشده نیست»). یعنی در پروداکشن `APP_BASE_URL` **باید دقیقاً** `https://clinic-pro.ir` (دامنهٔ ثبتشده نزد ملت/شاپرک) باشد. env پروداکشن را بررسی و اصلاح کن.
|
||||||
|
- **هدر `Referer` هنگام Redirect** به `startpay.mellat` باید دامنهٔ ثبتشده باشد؛ چون این POST سمت مرورگر انجام میشود، مطمئن شو صفحهای که کاربر را redirect میکند روی `clinic-pro.ir` سرو میشود.
|
||||||
|
- **نکتهٔ امنیتی callback (ص ۳۴ راهنما):** پس از دریافت Call Back باید `RefId` و `SaleOrderId` دریافتی دقیقاً همان مقادیر تراکنش اولیه باشند و در صورت عدم تطابق، تراکنش نامعتبر و از `bpVerify` خودداری شود. در `PaymentController::callback()` بررسی کن که `order_id` به Payment درست bind میشود و مبلغ/RefNum تکراری (replay) رد میشود (منطق underpayment/replay موجود است — تأیید و در صورت نقص تکمیل کن).
|
||||||
|
- **مستندسازی:** در `docs/api/payment.md` جدول callback هر درگاه را دقیق کن:
|
||||||
|
- ملت: `POST/GET {APP_BASE_URL}/api/v1/payment/callback/mellat?order_id=...`
|
||||||
|
- سپ: `POST/GET {APP_BASE_URL}/api/v1/payment/callback/sep?order_id=...`
|
||||||
|
- نسخهٔ اشتراک: `.../api/v1/subscription-payment/callback/{gateway}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **همه پاسخها از `BaseController`** (`$this->success()` / `$this->error()`)؛ ساختار پاسخ `payment/config` نباید بشکند (فرانت `data?.data` میخواند).
|
||||||
|
- **تغییر قرارداد payment/config** فیلد جدید `gateways` اضافه میکند — علاوه بر `SubscriptionPage`، هر مصرفکنندهٔ دیگر (`nobat724_front` اگر این endpoint را صدا میزند) باید سازگار بماند؛ فیلدهای قبلی حذف نشوند (backward-compatible).
|
||||||
|
- **بدون Entity جدید** → migration لازم نیست؛ فقط اگر پروداکشن مهاجرت اجرانشده دارد، همان اجرا شود.
|
||||||
|
- **CORS**: `origin_regex: true` است، پس مقدار env یک regex است نه لیست دامنه؛ regex را تست کن که هم `https://yasuj-nobat.ir` و هم `https://www.yasuj-nobat.ir` و هم `https://clinic-pro.ir` را match کند و دامنهٔ ناخواسته را match نکند.
|
||||||
|
- **تستها:**
|
||||||
|
- `ddev exec php -l` روی فایلهای PHP تغییریافته.
|
||||||
|
- `ddev exec php bin/phpunit tests/Payment` (تستهای callback موجود نشکند).
|
||||||
|
- `ddev exec php bin/console cache:clear` بعد از تغییر nelmio/env.
|
||||||
|
- `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` برای فرانت.
|
||||||
|
- تست زندهٔ `GET /api/v1/payment/config` با توکن ادمین → باید `gateways` را برگرداند.
|
||||||
|
- **مستندات:** بعد از تغییر `PaymentController`، `docs/api/payment.md` را در همین session بهروز کن (قانون standing پروژه).
|
||||||
|
- بعد از پایان: `graphify update .`.
|
||||||
+6
-3
@@ -22,7 +22,8 @@ JWT_PASSPHRASE=CHANGE_ME_STRONG_PASSPHRASE
|
|||||||
###< lexik/jwt-authentication-bundle ###
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
CORS_ALLOW_ORIGIN='^https://your-domain\.com$'
|
# regex است (origin_regex: true). همهٔ دامنههای عمومی چند-شهری «<city>-nobat.ir» و پنل «clinic-pro.ir» را پوشش میدهد.
|
||||||
|
CORS_ALLOW_ORIGIN='^https://([a-z0-9-]+\.)*([a-z0-9-]+-nobat\.ir|clinic-pro\.ir)$'
|
||||||
###< nelmio/cors-bundle ###
|
###< nelmio/cors-bundle ###
|
||||||
|
|
||||||
###> symfony/messenger ###
|
###> symfony/messenger ###
|
||||||
@@ -56,7 +57,9 @@ UPLOAD_DIR=var/uploads
|
|||||||
###< File Upload ###
|
###< File Upload ###
|
||||||
|
|
||||||
###> Payment ###
|
###> Payment ###
|
||||||
ALLOWED_FRONTEND_HOSTS=your-domain.com
|
# میزبانهای مجاز برای redirect بازگشت پرداخت (کاما-جدا): پنل + دامنههای عمومی نوبتدهی.
|
||||||
APP_BASE_URL=https://your-domain.com
|
ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir
|
||||||
|
# باید دقیقاً دامنهٔ ثبتشده نزد ملت/شاپرک باشد (callBackUrl از این ساخته میشود؛ IP مجاز نیست — کد پاسخ 62).
|
||||||
|
APP_BASE_URL=https://clinic-pro.ir
|
||||||
# کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده میشوند؛ env فقط fallback اختیاری است.
|
# کلیدهای درگاه (mellat/sep) از پنل «تنظیمات سایت» (DB) خوانده میشوند؛ env فقط fallback اختیاری است.
|
||||||
###< Payment ###
|
###< Payment ###
|
||||||
|
|||||||
@@ -2,11 +2,25 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
|
||||||
|
export interface PaymentGatewayInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentConfig {
|
||||||
|
test_mode: boolean;
|
||||||
|
appointment_fee_rials: number;
|
||||||
|
gateways: PaymentGatewayInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export function usePaymentConfig() {
|
export function usePaymentConfig() {
|
||||||
const { data } = useQuery<ApiResponse<{ test_mode: boolean }>>({
|
const { data } = useQuery<ApiResponse<PaymentConfig>>({
|
||||||
queryKey: ['payment-config'],
|
queryKey: ['payment-config'],
|
||||||
queryFn: () => api.get('/api/v1/payment/config'),
|
queryFn: () => api.get('/api/v1/payment/config'),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
return { isTestMode: data?.data?.test_mode ?? false };
|
return {
|
||||||
|
isTestMode: data?.data?.test_mode ?? false,
|
||||||
|
gateways: data?.data?.gateways ?? [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
CheckIcon, XMarkIcon, SparklesIcon, RocketLaunchIcon,
|
CheckIcon, XMarkIcon, SparklesIcon, RocketLaunchIcon,
|
||||||
@@ -42,14 +42,12 @@ const PLAN_META: Record<string, {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GATEWAY_LABELS: Record<string, string> = { mellat: 'بانک ملت', sep: 'سپ (صادرات)' };
|
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SubscriptionPage() {
|
export default function SubscriptionPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [purchaseTarget, setPurchaseTarget] = useState<{ period: SubscriptionPeriod; planName: string } | null>(null);
|
const [purchaseTarget, setPurchaseTarget] = useState<{ period: SubscriptionPeriod; planName: string } | null>(null);
|
||||||
const [selectedGateway, setSelectedGateway] = useState<'mellat' | 'sep'>('mellat');
|
const [selectedGateway, setSelectedGateway] = useState<string>('');
|
||||||
|
|
||||||
const { data: plansData, isLoading: plansLoading } = useQuery<ApiResponse<SubscriptionPlan[]>>({
|
const { data: plansData, isLoading: plansLoading } = useQuery<ApiResponse<SubscriptionPlan[]>>({
|
||||||
queryKey: ['subscription-plans'],
|
queryKey: ['subscription-plans'],
|
||||||
@@ -61,7 +59,13 @@ export default function SubscriptionPage() {
|
|||||||
queryFn: () => api.get('/api/v1/subscription/my'),
|
queryFn: () => api.get('/api/v1/subscription/my'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isTestMode } = usePaymentConfig();
|
const { isTestMode, gateways } = usePaymentConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gateways.length > 0 && !gateways.some((g) => g.name === selectedGateway)) {
|
||||||
|
setSelectedGateway(gateways[0].name);
|
||||||
|
}
|
||||||
|
}, [gateways, selectedGateway]);
|
||||||
|
|
||||||
const plans = plansData?.data ?? [];
|
const plans = plansData?.data ?? [];
|
||||||
const myRaw = myData?.data;
|
const myRaw = myData?.data;
|
||||||
@@ -320,31 +324,40 @@ export default function SubscriptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
gateways.length === 0 ? (
|
||||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
|
<div style={{
|
||||||
انتخاب درگاه پرداخت
|
background: 'var(--danger-bg)', border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 10, padding: '12px 16px', fontSize: 13, color: 'var(--danger)',
|
||||||
|
}}>
|
||||||
|
در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید.
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
) : (
|
||||||
{(['mellat', 'sep'] as const).map((gw) => (
|
<div>
|
||||||
<button
|
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
|
||||||
key={gw}
|
انتخاب درگاه پرداخت
|
||||||
onClick={() => setSelectedGateway(gw)}
|
</div>
|
||||||
style={{
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
{gateways.map((gw) => (
|
||||||
border: `2px solid ${selectedGateway === gw ? 'var(--primary)' : 'var(--border)'}`,
|
<button
|
||||||
background: selectedGateway === gw ? 'var(--primary-soft)' : 'var(--surface)',
|
key={gw.name}
|
||||||
color: selectedGateway === gw ? 'var(--primary-700)' : 'var(--text-2)',
|
onClick={() => setSelectedGateway(gw.name)}
|
||||||
fontWeight: selectedGateway === gw ? 700 : 500,
|
style={{
|
||||||
fontSize: 13, transition: '.14s', fontFamily: 'inherit',
|
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
border: `2px solid ${selectedGateway === gw.name ? 'var(--primary)' : 'var(--border)'}`,
|
||||||
}}
|
background: selectedGateway === gw.name ? 'var(--primary-soft)' : 'var(--surface)',
|
||||||
>
|
color: selectedGateway === gw.name ? 'var(--primary-700)' : 'var(--text-2)',
|
||||||
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
|
fontWeight: selectedGateway === gw.name ? 700 : 500,
|
||||||
{GATEWAY_LABELS[gw]}
|
fontSize: 13, transition: '.14s', fontFamily: 'inherit',
|
||||||
</button>
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
))}
|
}}
|
||||||
|
>
|
||||||
|
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
|
||||||
|
{gw.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* دکمهها */}
|
{/* دکمهها */}
|
||||||
@@ -352,7 +365,7 @@ export default function SubscriptionPage() {
|
|||||||
<button
|
<button
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
style={{ flex: 1, height: 44 }}
|
style={{ flex: 1, height: 44 }}
|
||||||
disabled={purchaseMutation.isPending}
|
disabled={purchaseMutation.isPending || (!isTestMode && (gateways.length === 0 || selectedGateway === ''))}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway, amount_rials: purchaseTarget.period.price_rials })
|
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway, amount_rials: purchaseTarget.period.price_rials })
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-5
@@ -16,8 +16,11 @@
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"test_mode": true,
|
"test_mode": false,
|
||||||
"appointment_fee_rials": 150000
|
"appointment_fee_rials": 150000,
|
||||||
|
"gateways": [
|
||||||
|
{ "name": "mellat", "label": "بانک ملت" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -26,8 +29,11 @@
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده میکند و پول واقعی کسر نمیشود |
|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده میکند و پول واقعی کسر نمیشود |
|
||||||
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این میخواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده میشود (نه از client) |
|
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این میخواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده میشود (نه از client) |
|
||||||
|
| `gateways` | array | فقط درگاههای **فعال** (اعتبارنامهشان در تنظیمات سایت یا env ست شده). هر عضو: `{ name, label }`. frontend فقط همینها را برای انتخاب نمایش میدهد. در `test_mode` تنها `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمیگردد. اگر هیچ درگاهی فعال نباشد آرایه خالی است و frontend باید پرداخت را غیرفعال کند. |
|
||||||
|
|
||||||
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمیکند. تنها یک boolean برای مصرف frontend است.
|
فعالبودن هر درگاه با `PaymentGatewayInterface::isConfigured()` تعیین میشود: `mellat` نیازمند `mellat_terminal_id` + `mellat_username` + `mellat_password`؛ `sep` نیازمند `sep_terminal_id`.
|
||||||
|
|
||||||
|
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمیکند — فقط نام/برچسب درگاههای فعال.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,9 +133,15 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
|
|||||||
## POST `/api/v1/payment/callback/{gateway}`
|
## POST `/api/v1/payment/callback/{gateway}`
|
||||||
## GET `/api/v1/payment/callback/{gateway}`
|
## GET `/api/v1/payment/callback/{gateway}`
|
||||||
|
|
||||||
Payment gateway callback. Called by the bank after user completes (or cancels) payment.
|
Payment gateway callback. Called by the bank after user completes (or cancels) payment. هر درگاه callback مخصوص خودش را دارد؛ URL آن هنگام `initiate` از `APP_BASE_URL` ساخته میشود:
|
||||||
|
|
||||||
**Permission:** `PUBLIC` — called by the gateway, not the user
|
```
|
||||||
|
{APP_BASE_URL}/api/v1/payment/callback/{gateway}?order_id={orderId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**نکته IPG ملت:** طبق راهنمای درگاه ملت، `callBackUrl` باید روی **دامنهٔ ثبتشدهٔ پذیرنده** باشد و **IP مجاز نیست** (در غیر این صورت کد پاسخ `62` — «مسیر back call در دامنهٔ ثبتشده نیست»). بنابراین `APP_BASE_URL` در پروداکشن باید دقیقاً `https://clinic-pro.ir` (دامنهٔ ثبتشده نزد ملت/شاپرک) باشد.
|
||||||
|
|
||||||
|
**Permission:** `PUBLIC` — called by the gateway, not the user (محدود به IPهای شبکهٔ شاپرک `isAllowedCallbackIp`؛ در `test_mode` بدون محدودیت IP)
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|
|||||||
@@ -545,12 +545,36 @@ class PaymentController extends BaseController
|
|||||||
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
||||||
public function config(): JsonResponse
|
public function config(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$testMode = $this->configRepo->get('payment_test_mode') === '1';
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
|
'test_mode' => $testMode,
|
||||||
'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
|
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
|
||||||
|
'gateways' => $this->activeGateways($testMode),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* درگاههای قابلانتخاب: در حالت تست فقط درگاه آزمایشی، در غیر این صورت هر درگاهی که اعتبارنامهاش ست شده.
|
||||||
|
* @return array<int, array{name: string, label: string}>
|
||||||
|
*/
|
||||||
|
private function activeGateways(bool $testMode): array
|
||||||
|
{
|
||||||
|
if ($testMode) {
|
||||||
|
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = ['mellat' => 'بانک ملت', 'sep' => 'سپ (سامان کیش)'];
|
||||||
|
$gateways = [];
|
||||||
|
foreach ([$this->mellat, $this->sep] as $gateway) {
|
||||||
|
if ($gateway->isConfigured()) {
|
||||||
|
$name = $gateway->getName();
|
||||||
|
$gateways[] = ['name' => $name, 'label' => $labels[$name] ?? $name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $gateways;
|
||||||
|
}
|
||||||
|
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
#[Route('/api/v1/my/payments', methods: ['GET'])]
|
#[Route('/api/v1/my/payments', methods: ['GET'])]
|
||||||
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ class MellatGateway implements PaymentGatewayInterface
|
|||||||
|
|
||||||
public function getName(): string { return 'mellat'; }
|
public function getName(): string { return 'mellat'; }
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->cfg('mellat_terminal_id', $this->terminalId) !== ''
|
||||||
|
&& $this->cfg('mellat_username', $this->username) !== ''
|
||||||
|
&& $this->cfg('mellat_password', $this->password) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
private function cfg(string $key, string $envFallback): string
|
private function cfg(string $key, string $envFallback): string
|
||||||
{
|
{
|
||||||
return $this->configRepo->get($key) ?: $envFallback;
|
return $this->configRepo->get($key) ?: $envFallback;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class MockGateway implements PaymentGatewayInterface
|
|||||||
{
|
{
|
||||||
public function getName(): string { return 'mock'; }
|
public function getName(): string { return 'mock'; }
|
||||||
|
|
||||||
|
public function isConfigured(): bool { return true; }
|
||||||
|
|
||||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||||
{
|
{
|
||||||
$redirectUrl = $callbackUrl . '&mock=1&ResCode=0&RefId=MOCK-' . $orderId;
|
$redirectUrl = $callbackUrl . '&mock=1&ResCode=0&RefId=MOCK-' . $orderId;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ interface PaymentGatewayInterface
|
|||||||
{
|
{
|
||||||
public function getName(): string;
|
public function getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this gateway has valid credentials configured and can be offered to users.
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiates payment, returns redirect URL or token.
|
* Initiates payment, returns redirect URL or token.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class SepGateway implements PaymentGatewayInterface
|
|||||||
|
|
||||||
public function getName(): string { return 'sep'; }
|
public function getName(): string { return 'sep'; }
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->cfg('sep_terminal_id', $this->terminalId) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
private function cfg(string $key, string $envFallback): string
|
private function cfg(string $key, string $envFallback): string
|
||||||
{
|
{
|
||||||
return $this->configRepo->get($key) ?: $envFallback;
|
return $this->configRepo->get($key) ?: $envFallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user