diff --git a/.claude/prompt/payment-single-flow-consolidation.md b/.claude/prompt/payment-single-flow-consolidation.md new file mode 100644 index 00000000..7f14cb81 --- /dev/null +++ b/.claude/prompt/payment-single-flow-consolidation.md @@ -0,0 +1,159 @@ +# یکسان‌سازی کامل پرداخت به یک Flow واحد + entry ریدایرکت خالص (Backend + Admin) + +## پروژه + +`clinicpro` (Backend + Admin SPA). **cross-repo** — پرامپت همتا: `nobat724_front/.claude/prompt/payment-single-flow-frontend.md` (سایت عمومی entry پرداخت را مصرف می‌کند؛ Backend اول اجرا شود). + +## زمینه + +بعد از بازطراحی‌های قبلی، بخش زیادی از «flow واحد» موجود است و **نباید دوباره ساخته شود**: + +- `PaymentManager` (`src/Payment/Service/PaymentManager.php`): تنها جایی که با درگاه صحبت می‌کند و verify (transaction+قفل+idempotent+post-action+log) را انجام می‌دهد. +- `GatewayFactory`: Factory/Strategy انتخاب درگاه. +- `GET /api/v1/payment/pay/{orderId}` (عمومی): ارتباط با بانک + انتقال 302/فرم POST. +- `callback/{gateway}` → `PaymentManager::processCallback` → 302 به `frontend_address` (دامنهٔ مبدأ با `status`). +- appointment و subscription: POST فقط `Payment` می‌سازد و `pay_url` می‌دهد؛ سپس همان pay-endpoint. + +اما دو انحراف از «تنها یک روش پرداخت» باقی مانده که این پرامپت آن‌ها را رفع می‌کند: + +1. **`SmsWalletController::charge` یک Flow پرداخت جداگانه است** — خودش درگاه را resolve می‌کند (`mellat/sep/mock` با `match`)، خودش `new Payment` + `$gateway->initiate(...)` را صدا می‌زند و `redirect_url` بانک را برمی‌گرداند. این نقض «یک flow واحد» است و باید حذف و به flow واحد منتقل شود. +2. **entry پرداخت هنوز نیازمند یک XHR است** (POST برای ساخت `Payment` سپس ریدایرکت به `pay_url`). طبق نیاز جدید باید یک entry ریدایرکتِ خالص هم وجود داشته باشد: مرورگر مستقیماً به `GET /api/v1/payment/order/{appointmentUuid}` برود و Backend همهٔ کار (اعتبارسنجی + ساخت Payment + ارتباط با بانک + ریدایرکت به شاپرک) را انجام دهد. + +## مشکل / هدف + +- تنها **یک** مسیر ساخت/شروع پرداخت در کل backend وجود داشته باشد: ساخت `Payment` (هر `type`) → `PaymentManager::startGatewayHandoff` → بانک → `callback` → `PaymentManager::processCallback` → ریدایرکت به دامنهٔ مبدأ. +- هیچ کنترلری غیر از `PaymentManager` نباید `->initiate(` یا resolve مستقیم درگاه داشته باشد. +- افزودن entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}` (بدون XHR) برای مسیر نوبت. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Sms/Controller/SmsWalletController.php` | **حذف flow جدا**: متد `charge` باید فقط Payment بسازد و `pay_url` بدهد | +| `src/Payment/Controller/PaymentController.php` | افزودن `GET /payment/order/{appointmentUuid}`؛ نگه‌داشتن pay/callback | +| `src/Payment/Service/PaymentManager.php` | موجود — منبع واحد ارتباط با درگاه (بدون تغییر بزرگ) | +| `src/Payment/Gateway/GatewayFactory.php` | موجود — resolve/activeGateways | +| `src/Payment/Repository/PaymentRepository.php` | موجود `findPendingByAppointment` برای جلوگیری از pending تکراری | +| `config/packages/security.yaml` | `^/api/v1/payment/order/` عمومی شود | +| `assets/admin/pages/SmsWalletPage.tsx`, `SubscriptionPage.tsx` | مصرف `pay_url` (قبلاً به‌روز شده؛ فقط تأیید) | +| `docs/api/payment.md`, `docs/api/sms.md` | به‌روزرسانی | + +## وضعیت فعلی (flow جدا در SmsWallet — باید حذف شود) + +```php +// src/Sms/Controller/SmsWalletController.php (charge) +if ($this->configRepo->get('payment_test_mode') === '1') { + $gateway = $this->mock; +} else { + $gateway = match ($gatewayName) { 'mellat' => $this->mellat, 'sep' => $this->sep, default => null }; +} +if ($gateway === null) { return $this->error(...); } +$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress); +$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]); +$this->paymentRepo->save($payment); +$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); +$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); // ❌ ارتباط مستقیم با درگاه +// ... setGatewayToken ... return ['redirect_url' => $result->redirectUrl]; // ❌ redirect_url بانک +``` + +## وظایف + +### ۱. حذف Flow جداگانهٔ SmsWallet و انتقال به flow واحد + +`SmsWalletController::charge` را طوری بازنویسی کن که **هیچ ارتباطی با درگاه نداشته باشد**؛ فقط `Payment` بسازد و `pay_url` بدهد (دقیقاً مثل appointment/subscription): + +```php +$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress); +$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]); +$this->paymentRepo->save($payment); + +return $this->success([ + 'payment_uuid' => $payment->getUuid(), + 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), + 'order_id' => $payment->getOrderId(), +]); +``` + +- فقط اعتبارسنجی درگاه با `GatewayFactory::resolve($gatewayName) === null` (بدون init). `GatewayFactory` را به کنترلر تزریق کن. +- Dependencyهای درگاه از `SmsWalletController` حذف شوند: `MellatGateway`, `SepGateway`, `MockGateway` و منطق `match`/`configRepo` مربوط به درگاه. (init توسط `PaymentManager` در `pay` endpoint انجام می‌شود؛ post-action `TYPE_SMS_WALLET` از قبل در `PaymentManager::handleSmsWalletCharge` هست.) +- Callback این نوع از قبل به `/api/v1/payment/callback/{gateway}` → `PaymentManager` می‌رود (چون `pay` endpoint با `PaymentManager::callbackUrl` بر اساس type می‌سازد؛ برای غیر-subscription پیشوند `/payment/callback/` است). تأیید کن. + +### ۲. entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}` + +در `PaymentController` یک route جدید (عمومی، بدون JWT) اضافه کن که کل مراحل ۴ تا ۶ نیاز را انجام دهد و **نیازی به XHR نداشته باشد**: + +```php +#[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])] +public function startOrderPayment(string $appointmentUuid, Request $request): Response +{ + $gatewayName = trim((string) $request->query->get('gateway', '')); + $return = trim((string) $request->query->get('return', '')); + + $appointment = $this->appointmentRepo->findByUuid($appointmentUuid); + if ($appointment === null) { + return $this->redirectToReturn($return, 'notfound'); + } + // اعتبارسنجی سفارش: قابل‌پرداخت بودن (pending/confirmed) و منقضی نبودن + if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) { + return $this->redirectToReturn($return, 'invalid'); + } + if (!empty($return) && !$this->isAllowedFrontend($return)) { + return $this->redirectToReturn('', 'invalid'); // آدرس بازگشت مجاز نیست + } + if ($this->gateways->resolve($gatewayName) === null) { + return $this->redirectToReturn($return, 'gateway'); + } + + // جلوگیری از pending تکراری: اگر پرداخت pending برای این نوبت هست، همان را ادامه بده. + $payment = $this->paymentRepo->findPendingByAppointment($appointment) + ?? $this->createAppointmentPayment($appointment, $gatewayName, $return); + + // ارتباط با بانک + انتقال به شاپرک (همان مسیر واحد). + $result = $this->paymentManager->startGatewayHandoff($payment); + if ($result === false) { + return $this->redirectToFrontend($payment, false); + } + return $result->redirectMethod === 'POST' + ? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams) + : new RedirectResponse($result->redirectUrl); +} +``` + +نکته‌ها: +- `createAppointmentPayment()` همان منطق ساخت `Payment` در `initiateAppointment` را کپسوله کند (fee از `appointment_fee_rials`، `frontend_address = $return`). +- اگر یک pending موجود بود ولی درگاه/گیت‌وی متفاوت انتخاب شده، تصمیم بگیر: یا درگاهِ pending را به‌روزرسانی کن یا همان را ادامه بده (ساده‌ترین: همان pending را ادامه بده). +- `redirectToReturn(string $return, string $status)`: اگر `$return` مجاز بود `302` به `"$return?status=$status"`، وگرنه یک JSON خطای کوتاه. +- **مالکیت/امنیت (مهم):** این endpoint عمومی است و به‌صورت full-page redirect از دامنهٔ دیگری فراخوانی می‌شود؛ JWT در دسترس نیست. بنابراین `appointmentUuid` نقش capability را دارد (غیرقابل‌حدس/UUID). پرداخت فقط به نفع صاحب نوبت است، پس شروع پرداخت توسط دارندهٔ لینک ریسک مالی ندارد. اگر مالکیت سخت‌گیرانه لازم است، یک پارامتر امضاشدهٔ `sig` (HMAC از uuid + secret) اضافه کن و در این endpoint verify کن؛ در غیر این‌صورت همین کافی است. تصمیم را در docs ذکر کن. + +### ۳. عمومی‌کردن route جدید در security + +در `config/packages/security.yaml`: +- الگوی firewall `payment_callback` را گسترش بده تا `^/api/v1/payment/(callback|pay|order)/` را پوشش دهد. +- یک `access_control` برای `^/api/v1/payment/order/` با `PUBLIC_ACCESS`. + +### ۴. تضمین «تنها یک flow» + +- بعد از تغییرات، مطمئن شو تنها فایلی که `->initiate(` یا resolve مستقیم درگاه دارد `PaymentManager` است: + ```bash + grep -rn "->initiate(\|new Payment(" src --include=*.php + ``` + انتظار: `new Payment(` فقط در نقاط ساختِ Payment (کنترلرها/SmsWallet برای ساخت، بدون init)؛ `->initiate(` فقط در `PaymentManager` و کلاس‌های Gateway. +- `payment_url`/`redirect_url` بانکی نباید از هیچ endpointی به کلاینت برگردد؛ فقط `pay_url` (یا ریدایرکت مستقیم در `order`). + +### ۵. مستندسازی + +- `docs/api/payment.md`: افزودن `GET /api/v1/payment/order/{appointmentUuid}` (پارامترهای `gateway`, `return`, رفتار، امنیت، نمودار به‌روزشده). +- `docs/api/sms.md`: به‌روزرسانی `POST /api/v1/sms/wallet/charge` — حالا `pay_url` برمی‌گرداند و init در pay-endpoint واحد است. + +## نکات مهم + +- **جریان بیرونی نباید بشکند:** `pay`/`callback`/`frontend_address?status=` ثابت بمانند. `order` یک entry جدید است، نه جایگزین pay. +- **همهٔ typeها یک مسیر:** appointment (هم XHR→pay_url و هم entry جدید order)، subscription و sms_wallet (XHR→pay_url) همگی به `pay`+`callback`+`PaymentManager` می‌رسند. تفاوت فقط در ساختِ اولیهٔ Payment است. +- **علت باقی‌ماندن یک XHR برای subscription/sms:** این‌ها order uuidِ ازپیش‌موجود ندارند و از پنل ادمین (JWT در localStorage) شروع می‌شوند؛ ساخت Payment نیازمند auth است. entry ریدایرکتِ خالص فقط برای نوبت (که uuid عمومی دارد) ممکن است. این موضوع در docs شفاف شود. +- controllerها از `BaseController`؛ تاریخ‌ها Unix timestamp. +- بعد از تغییر: `ddev exec php -l`, `ddev exec php bin/console cache:clear`, `ddev exec php vendor/bin/phpstan analyse src/Payment src/Sms`, و تست دستی `order` در `payment_test_mode=1`: + ```bash + curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" \ + "https://clinic-pro.ddev.site/api/v1/payment/order/?gateway=mellat&return=http://yazd-nobat.localhost:3000/payment/result" + ``` +- بعد از تغییر API → `docs/api/payment.md` و `docs/api/sms.md` در همین session. diff --git a/.claude/prompt/sms-price-and-wallet-settings.md b/.claude/prompt/sms-price-and-wallet-settings.md new file mode 100644 index 00000000..1f116cf5 --- /dev/null +++ b/.claude/prompt/sms-price-and-wallet-settings.md @@ -0,0 +1,139 @@ +# قیمت هر پیامک در تنظیمات + رفع تنظیمات ارسال پیامک کیف‌پول + +## پروژه + +`clinicpro` (Backend + Admin SPA). + +## زمینه + +دو مشکل در بخش پیامک پنل ادمین: + +1. **`/admin/settings` → بخش «پیامک»**: امکان تعیین «هزینه هر پیامک» وجود ندارد (قبلاً بود). قیمت هر پیامک اکنون در بک‌اند **هاردکد** است: `SmsWalletController::SMS_PRICE_RIALS = 500`. باید به یک تنظیمِ قابل‌ویرایش در همین صفحه تبدیل شود. +2. **`/admin/sms-wallet` → «تنظیمات ارسال پیامک»**: درست کار نمی‌کند — بعد از ذخیره، وضعیت واقعی سرور (به‌ویژه وضعیت تأیید متن پیامک بعد از ویزیت) در UI منعکس نمی‌شود، چون state محلی بعد از ذخیره/رفچ ریست نمی‌شود. + +## مشکل / هدف + +- افزودن کلید تنظیم `sms_price_rials` (قیمت هر پیامک) که در `/admin/settings` بخش پیامک قابل ویرایش باشد و بک‌اند به‌جای مقدار هاردکد از آن استفاده کند. +- رفع باگِ state کهنه در فرم تنظیمات ارسالِ `/admin/sms-wallet`. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Config/Controller/SiteConfigController.php` | `ALLOWED_KEYS` تنظیمات سایت (باید `sms_price_rials` اضافه شود) | +| `src/Sms/Controller/SmsWalletController.php` | استفاده از قیمت پیامک (هاردکد) + `resolveEntity` + endpoint تنظیمات | +| `assets/admin/pages/SettingsPage.tsx` | فرم تنظیمات؛ بخش `sms` | +| `assets/admin/pages/SmsWalletPage.tsx` | فرم «تنظیمات ارسال پیامک» (state محلی) | +| `docs/api/sms.md` | مستندات (به‌روزرسانی) | + +## وضعیت فعلی + +قیمت هاردکد و balance: + +```php +// src/Sms/Controller/SmsWalletController.php +private const SMS_PRICE_RIALS = 500; +// ... +$smsPriceRials = self::SMS_PRICE_RIALS; +$estimatedSms = (int) floor($balanceRials / $smsPriceRials); +return $this->success([ + 'balance_rials' => $balanceRials, + 'sms_price_rials' => $smsPriceRials, + 'estimated_sms_count' => $estimatedSms, +]); +``` + +کلیدهای مجاز تنظیمات (بدون `sms_price_rials`): + +```php +// src/Config/Controller/SiteConfigController.php +private const ALLOWED_KEYS = [ + // ... + 'sms_panel_fee_rials', + 'appointment_fee_rials', + // ... +]; +``` + +بخش پیامکِ `SettingsPage.tsx` فقط وضعیت کلید API را نشان می‌دهد (فیلد قیمت ندارد): + +```tsx +{current.id === 'sms' && ( + // فقط sms_api_key_configured نمایش داده می‌شود +)} +``` + +فرم تنظیماتِ `SmsWalletPage.tsx` — state محلی بعد از ذخیره ریست نمی‌شود: + +```tsx +const [localSettings, setLocalSettings] = useState(null); +const currentSettings = localSettings ?? settings; + +const saveMutation = useMutation({ + mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); }, + // ❌ localSettings ریست نمی‌شود → currentSettings همان نسخهٔ ویرایش‌شده می‌ماند، + // داده‌ی تازهٔ سرور (مثل post_visit_text_status = 'pending') نمایش داده نمی‌شود +}); +``` + +## وظایف + +### ۱. افزودن کلید `sms_price_rials` به تنظیمات سایت (Backend) + +در `SiteConfigController::ALLOWED_KEYS` کلید `'sms_price_rials'` را اضافه کن (کنار `sms_panel_fee_rials`). با این کار GET/PATCH `/api/v1/admin/settings` این کلید را برمی‌گرداند/ذخیره می‌کند. + +### ۲. استفادهٔ بک‌اند از قیمتِ قابل‌تنظیم به‌جای هاردکد + +در `SmsWalletController`: +- `SiteConfigRepository` را به constructor تزریق کن (اگر نیست). +- یک helper خصوصی بساز: `private function smsPriceRials(): int { return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS)); }` و `SMS_PRICE_RIALS = 500` را به‌عنوان **fallback** نگه‌دار. +- در `balance()` به‌جای `self::SMS_PRICE_RIALS` از `$this->smsPriceRials()` استفاده کن. +- **جستجو کن** آیا جای دیگری قیمت هر پیامک برای کسر از کیف‌پول هنگام ارسال استفاده می‌شود (مثلاً `SmsWalletService` یا مسیر ارسال پیامک). اگر بله، همان‌جا هم از `sms_price_rials` (config) استفاده شود تا کسر و «تعداد تخمینی» هم‌خوان باشند. اگر جایی مقدار ثابت دیگری هست، آن را هم به config متصل کن. + +### ۳. فیلد «هزینه هر پیامک» در `SettingsPage.tsx` (Admin) + +- به `FormValues` schema و `defaultValues` کلید `sms_price_rials` را اضافه کن (مثل `sms_panel_fee_rials`؛ نوع string، پیش‌فرض مثلاً `'500'`). +- در بخش `current.id === 'sms'` یک `Field` با `input type="number"` برای `sms_price_rials` اضافه کن (الگوی دقیقاً مشابه فیلد `sms_panel_fee_rials` در بخش financial): + ```tsx + + + + ``` +- مطمئن شو مقدار اولیه از `data` (پاسخ GET `/api/v1/admin/settings`) خوانده و در PATCH ارسال می‌شود (چون کل `FormValues` ارسال می‌شود، با افزودن به schema/defaults خودکار انجام می‌شود). + +### ۴. رفع state کهنهٔ فرم تنظیمات در `SmsWalletPage.tsx` + +بعد از ذخیرهٔ موفق، `localSettings` را ریست کن تا `currentSettings` به دادهٔ تازهٔ سرور برگردد (وضعیت تأیید متن، مقادیر نرمال‌شده): + +```tsx +const saveMutation = useMutation({ + mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body), + onSuccess: () => { + setLocalSettings(null); // ← افزوده شود + qc.invalidateQueries({ queryKey: ['sms-settings'] }); + toast.success('تنظیمات ذخیره شد'); + }, + onError: (e: any) => toast.error(e.message), +}); +``` + +- همچنین اگر لازم است که با هر بار تازه‌شدن `settingsData` هم state محلی صفر شود (برای جلوگیری از ماندگاری ویرایش‌های ذخیره‌نشده پس از رفچ)، یک `useEffect(() => { setLocalSettings(null); }, [settingsData])` اضافه کن. +- در بدنهٔ PATCH فقط فیلدهای موردنیاز فرستاده شوند (`reminder_enabled`, `reminder_hours_before`, `post_visit_enabled`, `post_visit_text`)؛ backend بقیه را نادیده می‌گیرد ولی برای تمیزی می‌توان همین‌ها را صریح فرستاد. + +### ۵. دسترسی صفحهٔ کیف‌پول برای کاربرانِ بدون entity (بررسی و تصمیم) + +`SmsWalletController::resolveEntity` فقط برای `ROLE_DOCTOR` و `ROLE_CLINIC` entity برمی‌گرداند؛ برای بقیه (از جمله ادمینِ بدون پروفایل، secretary، clinic_doctor) `['unknown', null]` → همهٔ endpointها `403 پروفایل یافت نشد` می‌دهند و صفحه «کار نمی‌کند». + +- اگر صفحهٔ `/admin/sms-wallet` باید برای نقش‌های دیگر (مثل `clinic_doctor`) هم کار کند، `resolveEntity` را گسترش بده تا آن نقش‌ها را هم به doctor/clinic نگاشت کند. +- اگر کیف‌پول فقط برای doctor/clinic معنا دارد، در `SmsWalletPage.tsx` هنگام خطای 403 یک empty-state مناسب («کیف پول پیامک فقط برای پزشک/کلینیک فعال است») نمایش بده تا صفحه سفید/خراب نشود. +- تصمیم را بر اساس نقش‌های واقعی پروژه بگیر و در گزارش ذکر کن. + +## نکات مهم + +- کلید تنظیم `sms_price_rials` باید همان واحد ریال باشد؛ balance→count و کسرِ هنگام ارسال باید از **یک** مقدار بخوانند تا ناسازگاری نشود. +- `SMS_PRICE_RIALS = 500` به‌عنوان fallback بماند (نصب‌های بدون مقدار config نشکنند). +- الگوهای موجود پنل: `Field` + `register` (React Hook Form)، پاسخ‌ها `data?.data`، تاریخ‌ها Unix. +- بعد از تغییر Backend/endpoint: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec php vendor/bin/phpstan analyse src/Sms src/Config`؛ و بعد از تغییر TS: `ddev exec npx tsc --noEmit --project tsconfig.json`. +- طبق قانون پروژه، `docs/api/sms.md` (و در صورت لزوم `docs/api/admin.md` برای کلید تنظیم جدید) در همین session به‌روز شود: افزودن `sms_price_rials` به لیست تنظیمات و توضیح استفادهٔ آن. +- Entity تغییر نمی‌کند → migration لازم نیست (فقط یک ردیف در جدول تنظیمات سایت که با set ساخته می‌شود). diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx index 059320f6..38c3f04d 100644 --- a/assets/admin/pages/SettingsPage.tsx +++ b/assets/admin/pages/SettingsPage.tsx @@ -32,6 +32,7 @@ const schema = z.object({ tax_enabled: z.string(), tax_percent: z.string(), sms_panel_fee_rials: z.string(), + sms_price_rials: z.string(), appointment_fee_rials: z.string(), // payment gateways payment_test_mode: z.string(), @@ -60,6 +61,7 @@ const toForm = (s: Partial): FormValues => ({ tax_enabled: s.tax_enabled ?? '0', tax_percent: s.tax_percent ?? '10', sms_panel_fee_rials: s.sms_panel_fee_rials ?? '1500000', + sms_price_rials: s.sms_price_rials ?? '500', appointment_fee_rials: s.appointment_fee_rials ?? '150000', payment_test_mode: s.payment_test_mode ?? '0', mellat_enabled: s.mellat_enabled ?? '1', @@ -452,6 +454,9 @@ export default function SettingsPage() { : <>تنظیم‌نشده — مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید} + + + )} diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx index 2d2798b8..c3329852 100644 --- a/assets/admin/pages/SmsWalletPage.tsx +++ b/assets/admin/pages/SmsWalletPage.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 { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -68,7 +68,10 @@ function SmsWalletPageInner() { const chargeMutation = useMutation({ mutationFn: ({ amount_rials }: ChargeForm) => - api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }), + api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { + gateway, amount_rials, + frontend_address: `${window.location.origin}${window.location.pathname}`, + }), onSuccess: (res: any) => { const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; if (url) window.location.href = url; @@ -80,9 +83,16 @@ function SmsWalletPageInner() { const [localSettings, setLocalSettings] = useState(null); const currentSettings = localSettings ?? settings; + // با هر بار تازه‌شدن داده‌ی سرور، ویرایش محلی صفر شود تا وضعیت واقعی (مثل وضعیت تأیید متن) نمایش داده شود. + useEffect(() => { setLocalSettings(null); }, [settingsData]); + const saveMutation = useMutation({ mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); }, + onSuccess: () => { + setLocalSettings(null); + qc.invalidateQueries({ queryKey: ['sms-settings'] }); + toast.success('تنظیمات ذخیره شد'); + }, onError: (e: any) => toast.error(e.message), }); @@ -233,11 +243,14 @@ function SmsWalletPageInner() { )} -
setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })} - /> +
{/* ردیف: پیامک بعد از ویزیت */} @@ -253,11 +266,14 @@ function SmsWalletPageInner() {
متن تشکر پس از پایان مراجعه برای بیمار ارسال می‌شود
-
setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })} - /> +
{/* محتوای باز‌شونده */} diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index 450563d9..c315a706 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -83,7 +83,10 @@ export default function SubscriptionPage() { const purchaseMutation = useMutation({ mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) => - api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials }), + api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { + period_uuid, gateway, amount_rials, + frontend_address: `${window.location.origin}/admin/subscription`, + }), onSuccess: (res: any) => { const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; if (url) window.location.href = url; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b172c9db..98a3fc74 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -38,7 +38,7 @@ security: security: false payment_callback: - pattern: ^/api/v1/(payment/(callback|pay)/|subscription-payment/callback/) + pattern: ^/api/v1/(payment/(callback|pay|order)/|subscription-payment/callback/) stateless: true security: false @@ -75,6 +75,7 @@ security: - { 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/payment/order/, 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/admin.md b/docs/api/admin.md index 4a4202de..3255294e 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -1033,6 +1033,7 @@ Reject a pending request. **Permission:** `ROLE_ADMIN` | `tax_enabled` | `0` | فعال‌سازی مالیات بر ارزش افزوده | | `tax_percent` | `10` | درصد مالیات | | `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر می‌شود) | +| `sms_price_rials` | `500` | هزینه هر پیامک ارسالی به ریال؛ مبنای محاسبهٔ تعداد پیامک از موجودی کیف‌پول (`GET /api/v1/sms/wallet/balance`). قابل ویرایش در `/admin/settings` → بخش پیامک | | `appointment_fee_rials` | `150000` | مبلغ هر نوبت به ریال؛ مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند. backend از همین کلید می‌خواند و در `GET /api/v1/payment/config` expose می‌شود | | `log_retention_days` | `90` | مدت نگهداری لاگ‌ها (روز)؛ کاماند روزانه `app:prune-logs` لاگ‌های قدیمی‌تر را حذف می‌کند. `0` = نگهداری نامحدود | diff --git a/docs/api/payment.md b/docs/api/payment.md index 2f4bd86c..0fe850e2 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -185,6 +185,31 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew --- +## GET `/api/v1/payment/order/{appointmentUuid}` + +**entry ریدایرکتِ خالص (بدون XHR)** برای شروع پرداخت نوبت. مرورگر مستقیماً به این آدرس هدایت می‌شود؛ Backend همهٔ کار را انجام می‌دهد: اعتبارسنجی سفارش → ساخت `Payment` → ارتباط با بانک → ریدایرکت به شاپرک. + +**Permission:** `PUBLIC` (بدون JWT). + +### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `gateway` | string | ✅ | نام درگاه فعال (`mellat`/`sep`) — از `GET /payment/config` | +| `return` | string | ❌ | آدرس بازگشت (دامنهٔ مبدأ)؛ باید در `payment_allowed_frontend_hosts` مجاز باشد | + +### رفتار +- نوبت یافت نشد → `302` به `return?status=notfound` (یا `422` اگر return نبود/نامجاز). +- نوبت قابل‌پرداخت نیست (نه `pending`/`confirmed`) → `302` `return?status=invalid`. +- درگاه نامعتبر/غیرفعال → `302` `return?status=gateway`. +- موفق → ساخت/ادامهٔ `Payment` pending (بدون pending تکراری via `findPendingByAppointment`) و `302` به بانک (یا فرم auto-submit POST برای ملت). + +### امنیت مالکیت +چون entry عمومی و full-page cross-domain است، JWT در دسترس نیست؛ `appointmentUuid` (UUID غیرقابل‌حدس) نقش capability را دارد و پرداخت فقط به نفع صاحب نوبت است. برای مالکیت سخت‌گیرانه می‌توان پارامتر امضاشدهٔ `sig` (HMAC) افزود. + +> **صفحهٔ نتیجه:** این endpointهای مرورگرمحور (`order`/`pay`/`callback`) هرگز JSON به کاربر نمی‌دهند. اگر `return` معتبر باشد → `302` به همان دامنه با `?status=`؛ در غیر این‌صورت (نبود/نامعتبر بودن `return`، یافت‌نشدن سفارش، نبود آدرس بازگشت) یک صفحهٔ **Twig** (`templates/payment/result.html.twig`, RTL، `noindex`) با پیام وضعیت رندر می‌شود. + +--- + ## GET `/api/v1/payment/pay/{orderId}` انتقال مرورگر به درگاه پرداخت برای یک پرداختِ `pending`. **عمومی (بدون JWT)** — مرورگر مستقیماً به این آدرس هدایت می‌شود. صلاحیت اوردر قبلاً در `POST /api/v1/payment/appointment` (نیازمند JWT) بررسی و پرداخت ساخته شده است. **ارتباط با بانک (init درگاه) در همین endpoint انجام می‌شود.** diff --git a/docs/api/sms.md b/docs/api/sms.md index f422f2bf..2e2f2853 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -9,8 +9,8 @@ - **کلید API کاوه‌نگار فقط از متغیر محیطی `KAVENEGAR_API_KEY` خوانده می‌شود** — نه از دیتابیس و نه از پنل. در پنل ادمین فقط وضعیت read-only «تنظیم‌شده/نشده» نمایش داده می‌شود. - شماره فرستنده تنظیم نمی‌شود؛ کاوه‌نگار از خط پیش‌فرض حساب استفاده می‌کند. - endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمی‌گرداند. -- `PATCH /api/v1/admin/settings` دیگر کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender`، `sms_price_rials` را نمی‌پذیرد (از `ALLOWED_KEYS` حذف شده‌اند). -- قیمت هر پیامک ثابت است: `SmsWalletController::SMS_PRICE_RIALS = 500` ریال. +- `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمی‌پذیرد (از `ALLOWED_KEYS` حذف شده‌اند). +- **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده می‌شود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیش‌فرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال به‌عنوان fallback استفاده می‌شود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمی‌گرداند. --- @@ -348,13 +348,13 @@ Updated template with `status: "rejected"`. "success": true, "data": { "payment_uuid": "...", - "redirect_url": "https://gateway...", + "pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-...", "order_id": "ORD-..." } } ``` -پس از پرداخت موفق، موجودی کیف خودکار شارژ می‌شود. +> این endpoint فقط `Payment` (type=`sms_wallet`) می‌سازد و `pay_url` می‌دهد؛ **ارتباط با بانک اینجا انجام نمی‌شود** و از flow واحد پرداخت (`GET /payment/pay/{orderId}` → callback → `PaymentManager`) عبور می‌کند. کلاینت باید مرورگر را به `pay_url` هدایت کند. پس از پرداخت موفق، `PaymentManager` موجودی کیف را خودکار شارژ می‌کند. ### GET /api/v1/sms/wallet/logs diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 32cf6bfa..e6e4bfed 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -144,12 +144,6 @@ parameters: count: 1 path: src/Sms/Command/SeedSmsMessageTemplatesCommand.php - - - message: '#^Property App\\Payment\\Gateway\\PaymentInitResult\:\:\$errorMessage \(string\) on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.property - count: 1 - path: src/Sms/Controller/SmsWalletController.php - - message: '#^Property App\\Subscription\\Controller\\SubscriptionController\:\:\$subscriptionRepo is never read, only written\.$#' identifier: property.onlyWritten diff --git a/src/Config/Controller/SiteConfigController.php b/src/Config/Controller/SiteConfigController.php index 558cf6ce..b45c922d 100644 --- a/src/Config/Controller/SiteConfigController.php +++ b/src/Config/Controller/SiteConfigController.php @@ -23,6 +23,7 @@ class SiteConfigController extends BaseController 'tax_enabled', 'tax_percent', 'sms_panel_fee_rials', + 'sms_price_rials', 'appointment_fee_rials', 'site_name', 'support_phone', diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 96f6b2a9..56e31dc8 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -126,6 +126,83 @@ class PaymentController extends BaseController ]); } + // ── Pure-redirect entry (no XHR) — browser navigates here to start payment ── + + #[OA\Get( + path: '/api/v1/payment/order/{appointmentUuid}', + summary: 'Start an appointment payment via a pure browser redirect (no XHR)', + parameters: [ + new OA\Parameter(name: 'appointmentUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'gateway', in: 'query', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'return', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'uri')), + ], + responses: [ + new OA\Response(response: 302, description: 'Redirect to the bank, or back to return URL with status on validation failure'), + ] + )] + #[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])] + public function startOrderPayment(string $appointmentUuid, Request $request): \Symfony\Component\HttpFoundation\Response + { + $gatewayName = trim((string) $request->query->get('gateway', '')); + $return = trim((string) $request->query->get('return', '')); + + if ($return !== '' && !$this->isAllowedFrontend($return)) { + return $this->renderPaymentResult('invalid_return'); + } + + $appointment = $this->appointmentRepo->findByUuid($appointmentUuid); + if ($appointment === null) { + return $this->redirectToReturn($return, 'notfound'); + } + + // اعتبارسنجی سفارش: فقط نوبت قابل‌پرداخت. + if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) { + return $this->redirectToReturn($return, 'invalid'); + } + + if ($this->gateways->resolve($gatewayName) === null) { + return $this->redirectToReturn($return, 'gateway'); + } + + // جلوگیری از pending تکراری: پرداخت pending موجود را ادامه بده وگرنه بساز. + $payment = $this->paymentRepo->findPendingByAppointment($appointment) + ?? $this->createAppointmentPayment($appointment, $gatewayName, $return); + + // آدرس بازگشت را روی پرداختِ بازاستفاده‌شده هم به‌روزرسانی کن تا بازگشت درست باشد. + if ($return !== '' && $payment->getFrontendAddress() !== $return) { + $payment->setFrontendAddress($return); + $this->paymentRepo->save($payment); + } + + // ارتباط با بانک + انتقال به شاپرک — همان مسیر واحد. + $result = $this->paymentManager->startGatewayHandoff($payment); + if ($result === false) { + return $this->redirectToFrontend($payment, false); + } + + return $result->redirectMethod === 'POST' + ? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams) + : new RedirectResponse($result->redirectUrl); + } + + private function createAppointmentPayment(Appointment $appointment, string $gatewayName, string $return): Payment + { + $feeRials = (int) $this->configRepo->get('appointment_fee_rials'); + $payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return); + $payment->setAppointment($appointment); + $this->paymentRepo->save($payment); + return $payment; + } + + private function redirectToReturn(string $return, string $status): \Symfony\Component\HttpFoundation\Response + { + if ($return !== '' && $this->isAllowedFrontend($return)) { + $sep = str_contains($return, '?') ? '&' : '?'; + return new RedirectResponse($return . $sep . 'status=' . $status); + } + return $this->renderPaymentResult($status); + } + // ── Gateway hand-off (public — browser redirect to the bank) ─────────────── #[OA\Get( @@ -144,7 +221,7 @@ class PaymentController extends BaseController { $payment = $this->paymentRepo->findByOrderId($orderId); if ($payment === null) { - return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); + return $this->renderPaymentResult('notfound'); } // فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay). @@ -166,24 +243,13 @@ class PaymentController extends BaseController return new RedirectResponse($result->redirectUrl); } - /** یک صفحهٔ HTML با فرمی که به‌صورت خودکار (POST) به درگاه ارسال می‌شود. */ + /** صفحهٔ انتقال به درگاه (Twig) با فرمِ auto-submit به‌صورت 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']); + return $this->render('payment/redirect.html.twig', [ + 'action' => $action, + 'params' => $params, + ]); } // ── Payment Callback (public — no JWT) ─────────────────────────────────── @@ -229,7 +295,7 @@ HTML; // verify امن (transaction + قفل + idempotent + post-action + log) در سرویس. $payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId); if ($payment === null) { - return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); + return $this->renderPaymentResult('notfound'); } return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); @@ -470,18 +536,45 @@ HTML; return false; } + /** ثانیهٔ تأخیر پیش از ریدایرکت خودکار به فرانت‌اند در صفحهٔ نتیجه. */ + private const RESULT_REDIRECT_DELAY = 5; + + /** صفحهٔ نتیجهٔ پرداخت (Twig). اگر $redirectTo داده شود، بعد از چند ثانیه به آن می‌رود. */ + private function renderPaymentResult(string $status, ?Payment $payment = null, string $redirectTo = ''): \Symfony\Component\HttpFoundation\Response + { + $labels = [ + 'success' => ['پرداخت موفق', 'پرداخت شما با موفقیت انجام شد.'], + 'failed' => ['پرداخت ناموفق', 'پرداخت انجام نشد. در صورت کسر وجه، مبلغ طی ۷۲ ساعت بازمی‌گردد.'], + 'canceled' => ['پرداخت لغو شد', 'پرداخت توسط شما لغو شد.'], + 'pending' => ['در انتظار پرداخت', 'این پرداخت هنوز نهایی نشده است.'], + 'notfound' => ['سفارش یافت نشد', 'سفارش موردنظر یافت نشد.'], + 'invalid' => ['قابل پرداخت نیست', 'این سفارش در وضعیت قابل پرداخت نیست.'], + 'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'], + 'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'], + ]; + [$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.']; + + return $this->render('payment/result.html.twig', [ + 'status' => $status, + 'title' => $title, + 'message' => $message, + 'payment' => $payment?->toArray(), + 'redirect_to' => $redirectTo, + 'delay' => self::RESULT_REDIRECT_DELAY, + ]); + } + private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response { $base = $payment->getFrontendAddress(); if (empty($base)) { - return new JsonResponse([ - 'success' => $success, - 'payment' => $payment->toArray(), - ]); + // آدرس بازگشتی نداریم → فقط صفحهٔ نتیجه (بدون ریدایرکت خودکار). + return $this->renderPaymentResult($payment->getStatus(), $payment); } - $sep = str_contains($base, '?') ? '&' : '?'; - $url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus(); - return new RedirectResponse($url); + // صفحهٔ نتیجه را نشان بده و بعد از چند ثانیه به همان فرانت‌اندِ مبدأ برگرد. + $sep = str_contains($base, '?') ? '&' : '?'; + $url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus(); + return $this->renderPaymentResult($payment->getStatus(), $payment, $url); } } diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 33ae0ba3..35def484 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -110,6 +110,7 @@ class Payment public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; } public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; } public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; } + public function setFrontendAddress(?string $a): self { $this->frontendAddress = $a ?: null; $this->touch(); return $this; } private function touch(): void { $this->updatedAt = time(); } diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php index 64e33393..13d31159 100644 --- a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -27,7 +27,23 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface $path = $event->getRequest()->getPathInfo(); if (str_starts_with($path, '/api') && !str_starts_with($path, '/api/doc')) { - $response->headers->set('Content-Security-Policy', "default-src 'none'"); + // صفحات مرورگرمحورِ پرداخت (order/pay/callback) HTML برمی‌گردانند و به + // inline style + فونت + فرمِ انتقال به شاپرک نیاز دارند؛ بقیهٔ API (JSON) + // همان سیاست سخت‌گیرانه را می‌گیرد. + $isPaymentPage = + str_starts_with($path, '/api/v1/payment/order/') + || str_starts_with($path, '/api/v1/payment/pay/') + || str_starts_with($path, '/api/v1/payment/callback/') + || str_starts_with($path, '/api/v1/subscription-payment/callback/'); + + $response->headers->set( + 'Content-Security-Policy', + $isPaymentPage + ? "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " + . "font-src https://cdn.jsdelivr.net data:; img-src data:; " + . "form-action https://*.shaparak.ir; base-uri 'none'" + : "default-src 'none'" + ); } if (str_starts_with($path, '/admin') || str_starts_with($path, '/api')) { diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php index b17efb0a..aef5af64 100644 --- a/src/Sms/Controller/SmsWalletController.php +++ b/src/Sms/Controller/SmsWalletController.php @@ -4,12 +4,9 @@ namespace App\Sms\Controller; use App\Auth\Entity\User; use App\Clinic\Repository\ClinicRepository; -use App\Config\Repository\SiteConfigRepository; use App\Doctor\Repository\DoctorRepository; use App\Payment\Entity\Payment; -use App\Payment\Gateway\MellatGateway; -use App\Payment\Gateway\MockGateway; -use App\Payment\Gateway\SepGateway; +use App\Payment\Gateway\GatewayFactory; use App\Payment\Repository\PaymentRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -37,15 +34,19 @@ class SmsWalletController extends BaseController private readonly SmsWalletTransactionRepository $txRepo, private readonly SmsSettingsRepository $settingsRepo, private readonly PaymentRepository $paymentRepo, - private readonly SiteConfigRepository $configRepo, - private readonly MellatGateway $mellat, - private readonly SepGateway $sep, - private readonly MockGateway $mock, + private readonly GatewayFactory $gateways, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, + private readonly \App\Config\Repository\SiteConfigRepository $configRepo, private readonly string $appBaseUrl, ) {} + /** قیمت هر پیامک از تنظیمات سایت؛ با fallback به مقدار پیش‌فرض. */ + private function smsPriceRials(): int + { + return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS)); + } + #[Route('/api/v1/sms/wallet/balance', methods: ['GET'])] public function balance(#[CurrentUser] User $user): JsonResponse { @@ -55,7 +56,7 @@ class SmsWalletController extends BaseController } $balanceRials = $this->walletService->getBalance($entityType, $entityId); - $smsPriceRials = self::SMS_PRICE_RIALS; + $smsPriceRials = $this->smsPriceRials(); $estimatedSms = (int) floor($balanceRials / $smsPriceRials); return $this->success([ @@ -81,17 +82,8 @@ class SmsWalletController extends BaseController return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422); } - if ($this->configRepo->get('payment_test_mode') === '1') { - $gateway = $this->mock; - } else { - $gateway = match ($gatewayName) { - 'mellat' => $this->mellat, - 'sep' => $this->sep, - default => null, - }; - } - - if ($gateway === null) { + // فقط اعتبارسنجی درگاه؛ ارتباط با بانک در flow واحد (GET /payment/pay) انجام می‌شود. + if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422); } @@ -100,19 +92,9 @@ class SmsWalletController extends BaseController $payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]); $this->paymentRepo->save($payment); - $callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); - $result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); - - if (!$result->success) { - return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage ?? 'درگاه در دسترس نیست', 503); - } - - $payment->setGatewayToken($result->token); - $this->paymentRepo->save($payment); - return $this->success([ 'payment_uuid' => $payment->getUuid(), - 'redirect_url' => $result->redirectUrl, + 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), 'order_id' => $payment->getOrderId(), ]); } diff --git a/templates/payment/redirect.html.twig b/templates/payment/redirect.html.twig new file mode 100644 index 00000000..408b37a0 --- /dev/null +++ b/templates/payment/redirect.html.twig @@ -0,0 +1,56 @@ + + + + + + + در حال انتقال به درگاه پرداخت… + + + + +
+
+

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

+

لطفاً چند لحظه صبر کنید…
در صورت انتقال‌نشدن خودکار، روی دکمهٔ زیر بزنید.

+
+ {% for name, value in params %} + + {% endfor %} + +
+
پرداخت امن از طریق درگاه بانکی
+
+ + diff --git a/templates/payment/result.html.twig b/templates/payment/result.html.twig new file mode 100644 index 00000000..3cc6ea0f --- /dev/null +++ b/templates/payment/result.html.twig @@ -0,0 +1,146 @@ + + + + + + + {% if redirect_to %}{% endif %} + نتیجه پرداخت + + + + + {% set kind = status == 'success' ? 'ok' : (status == 'pending' ? 'warn' : 'err') %} +
+
+ +
+
+ +
+ +

{{ title }}

+

{{ message }}

+ + {% if payment %} +
+ مبلغ + {{ payment.amount_rials|number_format(0, '.', ',') }} ریال +
+
+
شماره سفارش{{ payment.order_id }}
+ {% if payment.reference_id %}
شماره مرجع{{ payment.reference_id }}
{% endif %} +
درگاه{{ payment.gateway == 'sep' ? 'سپ (سامان کیش)' : (payment.gateway == 'mellat' ? 'بانک ملت' : payment.gateway) }}
+
+ {% endif %} + + {% if redirect_to %} + بازگشت به سایت +

در حال انتقال خودکار طی {{ delay }} ثانیه…

+ {% else %} +

می‌توانید این صفحه را ببندید و به برنامه بازگردید.

+ {% endif %} + +
پرداخت امن از طریق درگاه بانکی
+
+ + {% if redirect_to %} + + {% endif %} + +