Files
clinicpro/.claude/prompt/payment-single-flow-consolidation.md
T

160 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# یکسان‌سازی کامل پرداخت به یک 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/<APPT_UUID>?gateway=mellat&return=http://yazd-nobat.localhost:3000/payment/result"
```
- بعد از تغییر API → `docs/api/payment.md` و `docs/api/sms.md` در همین session.