feat(payment): enhance payment configuration to include active gateways and update CORS settings

This commit is contained in:
hamed
2026-07-01 12:45:18 +03:30
parent e1eae1099c
commit dfc86391c4
10 changed files with 342 additions and 41 deletions
@@ -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
View File
@@ -22,7 +22,8 @@ JWT_PASSPHRASE=CHANGE_ME_STRONG_PASSPHRASE
###< lexik/jwt-authentication-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 ###
###> symfony/messenger ###
@@ -56,7 +57,9 @@ UPLOAD_DIR=var/uploads
###< File Upload ###
###> Payment ###
ALLOWED_FRONTEND_HOSTS=your-domain.com
APP_BASE_URL=https://your-domain.com
# میزبان‌های مجاز برای redirect بازگشت پرداخت (کاما-جدا): پنل + دامنه‌های عمومی نوبت‌دهی.
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 اختیاری است.
###< Payment ###
+16 -2
View File
@@ -2,11 +2,25 @@ import { useQuery } from '@tanstack/react-query';
import { api } 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() {
const { data } = useQuery<ApiResponse<{ test_mode: boolean }>>({
const { data } = useQuery<ApiResponse<PaymentConfig>>({
queryKey: ['payment-config'],
queryFn: () => api.get('/api/v1/payment/config'),
staleTime: 5 * 60 * 1000,
});
return { isTestMode: data?.data?.test_mode ?? false };
return {
isTestMode: data?.data?.test_mode ?? false,
gateways: data?.data?.gateways ?? [],
};
}
+42 -29
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
CheckIcon, XMarkIcon, SparklesIcon, RocketLaunchIcon,
@@ -42,14 +42,12 @@ const PLAN_META: Record<string, {
},
};
const GATEWAY_LABELS: Record<string, string> = { mellat: 'بانک ملت', sep: 'سپ (صادرات)' };
// ── Main Page ─────────────────────────────────────────────────────────────
export default function SubscriptionPage() {
const qc = useQueryClient();
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[]>>({
queryKey: ['subscription-plans'],
@@ -61,7 +59,13 @@ export default function SubscriptionPage() {
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 myRaw = myData?.data;
@@ -320,31 +324,40 @@ export default function SubscriptionPage() {
</div>
</div>
) : (
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
انتخاب درگاه پرداخت
gateways.length === 0 ? (
<div style={{
background: 'var(--danger-bg)', border: '1px solid var(--danger)',
borderRadius: 10, padding: '12px 16px', fontSize: 13, color: 'var(--danger)',
}}>
در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید.
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{(['mellat', 'sep'] as const).map((gw) => (
<button
key={gw}
onClick={() => setSelectedGateway(gw)}
style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: `2px solid ${selectedGateway === gw ? 'var(--primary)' : 'var(--border)'}`,
background: selectedGateway === gw ? 'var(--primary-soft)' : 'var(--surface)',
color: selectedGateway === gw ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: selectedGateway === gw ? 700 : 500,
fontSize: 13, transition: '.14s', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
{GATEWAY_LABELS[gw]}
</button>
))}
) : (
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
انتخاب درگاه پرداخت
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{gateways.map((gw) => (
<button
key={gw.name}
onClick={() => setSelectedGateway(gw.name)}
style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
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)',
fontWeight: selectedGateway === gw.name ? 700 : 500,
fontSize: 13, transition: '.14s', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
{gw.label}
</button>
))}
</div>
</div>
</div>
)
)}
{/* دکمه‌ها */}
@@ -352,7 +365,7 @@ export default function SubscriptionPage() {
<button
className="btn primary"
style={{ flex: 1, height: 44 }}
disabled={purchaseMutation.isPending}
disabled={purchaseMutation.isPending || (!isTestMode && (gateways.length === 0 || selectedGateway === ''))}
onClick={() =>
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway, amount_rials: purchaseTarget.period.price_rials })
}
+17 -5
View File
@@ -16,8 +16,11 @@
{
"success": true,
"data": {
"test_mode": true,
"appointment_fee_rials": 150000
"test_mode": false,
"appointment_fee_rials": 150000,
"gateways": [
{ "name": "mellat", "label": "بانک ملت" }
]
}
}
```
@@ -26,8 +29,11 @@
|-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `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}`
## 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
| Param | Type | Description |
+26 -2
View File
@@ -545,12 +545,36 @@ class PaymentController extends BaseController
#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
$testMode = $this->configRepo->get('payment_test_mode') === '1';
return $this->success([
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
'test_mode' => $testMode,
'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')]
#[Route('/api/v1/my/payments', methods: ['GET'])]
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
+7
View File
@@ -21,6 +21,13 @@ class MellatGateway implements PaymentGatewayInterface
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
{
return $this->configRepo->get($key) ?: $envFallback;
+2
View File
@@ -6,6 +6,8 @@ class MockGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'mock'; }
public function isConfigured(): bool { return true; }
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
$redirectUrl = $callbackUrl . '&mock=1&ResCode=0&RefId=MOCK-' . $orderId;
@@ -6,6 +6,11 @@ interface PaymentGatewayInterface
{
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.
*/
+5
View File
@@ -20,6 +20,11 @@ class SepGateway implements PaymentGatewayInterface
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
{
return $this->configRepo->get($key) ?: $envFallback;