# یکسان‌سازی کامل پرداخت به یک 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.