diff --git a/.claude/prompt/production-fixes-cors-payment-gateways.md b/.claude/prompt/production-fixes-cors-payment-gateways.md new file mode 100644 index 00000000..82b3807e --- /dev/null +++ b/.claude/prompt/production-fixes-cors-payment-gateways.md @@ -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 = { 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ی تغییر بده که همهٔ دامنه‌های عمومی `-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 .`. diff --git a/.env.example b/.env.example index a9b24d2b..7e690a9b 100644 --- a/.env.example +++ b/.env.example @@ -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). همهٔ دامنه‌های عمومی چند-شهری «-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 ### diff --git a/assets/admin/hooks/usePaymentConfig.ts b/assets/admin/hooks/usePaymentConfig.ts index 86fb8ad1..b39c718f 100644 --- a/assets/admin/hooks/usePaymentConfig.ts +++ b/assets/admin/hooks/usePaymentConfig.ts @@ -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>({ + const { data } = useQuery>({ 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 ?? [], + }; } diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index a2c553a9..f8a9ee74 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -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 = { 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(''); const { data: plansData, isLoading: plansLoading } = useQuery>({ 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() { ) : ( -
-
- انتخاب درگاه پرداخت + gateways.length === 0 ? ( +
+ در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید.
-
- {(['mellat', 'sep'] as const).map((gw) => ( - - ))} + ) : ( +
+
+ انتخاب درگاه پرداخت +
+
+ {gateways.map((gw) => ( + + ))} +
-
+ ) )} {/* دکمه‌ها */} @@ -352,7 +365,7 @@ export default function SubscriptionPage() {