Files
clinicpro/.claude/prompt/payment-flow-architecture.md
T
hamed c247ac2c80 feat(payment): implement PaymentManager for handling payment logic and callbacks
- Refactor PaymentController to delegate payment processing to PaymentManager.
- Add findByOrderIdForUpdate method in PaymentRepository for pessimistic locking.
- Create PaymentLog entity and repository for auditing payment actions.
- Implement startGatewayHandoff and processCallback methods in PaymentManager.
- Introduce transaction handling and logging for payment verification.
- Update payment flow to ensure idempotency and prevent race conditions.
- Enhance security by logging sensitive actions without exposing credentials.
- Update database schema with migration for payment_logs table.
- Document changes in payment flow architecture.
2026-07-02 15:36:08 +03:30

180 lines
13 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.
# بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend)
## پروژه
`clinicpro` (Backend). **cross-repo** — پرامپت همتا: `nobat724_front/.claude/prompt/payment-flow-frontend.md` (کلاینت این قرارداد را مصرف می‌کند؛ Backend اول اجرا شود).
## زمینه
بخش بزرگی از معماری هدف **قبلاً پیاده شده** و نباید دوباره ساخته شود:
- جریان backend-driven: `POST /api/v1/payment/appointment` (اعتبارسنجی سفارش + ساخت `Payment` در وضعیت pending، بدون تماس با بانک) → `pay_url``GET /api/v1/payment/pay/{orderId}` (تماس با بانک + انتقال 302 یا فرم auto-submit POST) → `callback` (verify) → `RedirectResponse` به `frontend_address` (همان دامنهٔ مبدأ) با `?payment_uuid=..&status=..`.
- `GatewayFactory` (`src/Payment/Gateway/GatewayFactory.php`) الگوی Factory/Strategy را دارد؛ `resolve()`, `isEnabled()`, `isTestMode()`, `activeGateways()`. کنترلر دیگر منطق انتخاب درگاه ندارد.
- امنیت موجود: Open-Redirect guard (`payment_allowed_frontend_hosts`)، IP-restrict شاپرک در callback، جلوگیری از replay با یکتایی `reference_id`، بررسی مبلغ (`amountRials !== result->amountRials`)، CircuitBreaker.
این پرامپت فقط **شکاف‌های باقی‌ماندهٔ معماری/امنیت** را می‌بندد؛ رفتار جریان بیرونی نباید بشکند.
## مشکل / هدف
طبق spec معمار، این موارد هنوز رعایت نشده‌اند:
1. **کنترلر هنوز چاق است** — منطق تماس با بانک (در `pay`) و کل verify + post-actions (در `callback`: confirm نوبت، فعال‌سازی اشتراک، شارژ کیف‌پول، کمیسیون، پیامک) داخل `PaymentController` است. طبق spec: `Controller → PaymentManager → GatewayFactory → PaymentGatewayInterface`. باید یک سرویس `PaymentManager` این منطق را بگیرد و کنترلر فقط Orchestration کند.
2. **نبود قفل/تراکنش در verify** — هنگام verify هم‌زمانِ دو callback (race)، فقط یکتاییِ `reference_id` جلوگیری می‌کند؛ باید در یک DB transaction با قفل بدبینانه روی ردیف `Payment` انجام شود.
3. **لاگ کامل تراکنش وجود ندارد** — spec «ثبت کامل Logها + Gateway Response + Request Time + Authority» می‌خواهد. الان فقط `gatewayToken` و `reference_id` ذخیره می‌شود.
4. **`getStatus` پاسخ double-nested دارد** (`success(['data'=>...])`).
5. **subscription-payment و sms-wallet هنوز init را داخل POST انجام می‌دهند** (فقط appointment به الگوی pay-endpoint منتقل شده) — ناسازگاری معماری و همان باگ درگاه POSTیِ ملت.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Payment/Controller/PaymentController.php` | کنترلر فعلی؛ باید به Orchestration خالص کاهش یابد |
| `src/Payment/Service/PaymentManager.php` | **جدید** — منطق start/verify/post-actions |
| `src/Payment/Gateway/GatewayFactory.php` | موجود (Factory) — بدون تغییر بزرگ |
| `src/Payment/Gateway/PaymentGatewayInterface.php` | قرارداد درگاه |
| `src/Payment/Entity/Payment.php` | افزودن فیلدهای authority/response/requested_at یا metadata |
| `src/Payment/Entity/PaymentLog.php` | **جدید (اختیاری ولی توصیه‌شده)** — audit trail |
| `src/Payment/Repository/PaymentRepository.php` | افزودن `findByOrderIdForUpdate()` (قفل) |
| `docs/api/payment.md` | به‌روزرسانی |
## وضعیت فعلی
کنترلر `pay()` مستقیماً با درگاه و repo کار می‌کند:
```php
#[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])]
public function pay(string $orderId): Response
{
$payment = $this->paymentRepo->findByOrderId($orderId);
// ... resolve gateway, circuitBreaker, $gateway->initiate(...), setGatewayToken, redirect/autoSubmitForm
}
```
`callback()` کل verify + post-action را دارد:
```php
$result = $gw?->verify($callbackData);
// ست وضعیت، بررسی مبلغ، بررسی replay (reference_id)، سپس:
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) { $this->handleSubscriptionActivation($payment); }
elseif (... SMS_WALLET) { $this->handleSmsWalletCharge($payment); }
elseif (... APPOINTMENT) { $this->handleAppointmentConfirmation($payment); }
return $this->redirectToFrontend($payment, true);
```
`getStatus()` double-nested:
```php
return $this->success(['data' => $payment->toArray()]); // ❌ data.data.data
```
## وظایف
### ۱. ساخت سرویس `PaymentManager` و لاغر کردن کنترلر
`src/Payment/Service/PaymentManager.php` بساز که این متدها را داشته باشد و از `GatewayFactory`, `PaymentRepository`, `CircuitBreakerService`, `EntityManagerInterface`, `LoggerInterface` و سرویس‌های post-action (SubscriptionService, SmsWalletService, CommissionService, SmsService, AppointmentRepository, ...) از طریق **DI** استفاده کند:
```php
final class PaymentManager
{
public function __construct(
private GatewayFactory $gateways,
private PaymentRepository $paymentRepo,
private CircuitBreakerService $circuitBreaker,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private string $appBaseUrl,
// + سرویس‌های post-action
) {}
/** init درگاه برای پرداخت pending و بازگرداندن نتیجه انتقال (redirectMethod/url/params). */
public function startGatewayHandoff(Payment $payment): PaymentInitResult|false { ... }
/** verify امنِ callback داخل transaction + قفل ردیف؛ اجرای post-action؛ بازگرداندن success bool. */
public function processCallback(string $orderId, array $callbackData, string $clientIp): Payment|null { ... }
}
```
- منطق فعلیِ `pay()` (resolve + circuitBreaker + initiate + setGatewayToken) → `startGatewayHandoff()`.
- منطق فعلیِ `callback()` (verify + amount + replay + status + post-actions) → `processCallback()`.
- متدهای `handleAppointmentConfirmation`, `handleSubscriptionActivation`, `handleSmsWalletCharge` از کنترلر به `PaymentManager` منتقل شوند.
- کنترلر فقط: خواندن request، فراخوانی manager، ساخت `RedirectResponse`/`autoSubmitForm`/`error`. متد `autoSubmitForm` و `redirectToFrontend` و `isAllowedFrontend`/`allowedHosts` می‌توانند در کنترلر بمانند (لایهٔ HTTP) یا به یک `PaymentRedirectResponder` منتقل شوند — یکی را انتخاب و مستند کن.
### ۲. transaction + قفل بدبینانه در verify (جلوگیری از race / double-verify)
در `processCallback`، پرداخت را با قفل بخوان و کل verify+status+post-action را در یک تراکنش انجام بده:
```php
return $this->em->wrapInTransaction(function () use ($orderId, $callbackData, $clientIp) {
$payment = $this->paymentRepo->findByOrderIdForUpdate($orderId); // SELECT ... FOR UPDATE
if ($payment === null) return null;
if ($payment->getStatus() !== Payment::STATUS_PENDING) return $payment; // قبلاً پردازش شده → idempotent
// verify، amount check، replay، setStatus، post-action
return $payment;
});
```
`PaymentRepository::findByOrderIdForUpdate()`:
```php
public function findByOrderIdForUpdate(string $orderId): ?Payment
{
return $this->createQueryBuilder('p')
->where('p.orderId = :o')->setParameter('o', $orderId)
->getQuery()
->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
}
```
> نکته: قفل فقط داخل تراکنش معتبر است. گارد `status !== pending → return` باعث idempotent شدن verify تکراری می‌شود.
### ۳. لاگ کامل تراکنش (`PaymentLog`)
Entity جدید `src/Payment/Entity/PaymentLog.php` با فیلدها: `id`, `paymentId` (FK)، `action` (`initiate`/`verify`/`callback``gateway`، `authority`/`token`، `requestPayload` (json, بدون افشای اعتبارنامه)، `responsePayload` (json)، `clientIp`، `createdAt` (Unix ts). در `PaymentManager` روی init و verify یک رکورد لاگ ثبت شود.
- **migration:** بعد از ساخت Entity، `doctrine:migrations:diff` + `migrate`.
- **عدم افشای اطلاعات حساس:** اعتبارنامهٔ درگاه (username/password/terminal) هرگز در لاگ ذخیره نشود.
### ۴. ذخیرهٔ authority/response/request-time روی Payment
اگر `PaymentLog` را پیاده کردی، این‌ها آنجا ثبت می‌شوند و کافی است. در غیر این‌صورت در `Payment::$metadata` کلیدهای `authority`, `gateway_response`, `requested_at` را ذخیره کن. یکی را انتخاب کن (ترجیحاً `PaymentLog`).
### ۵. رفع double-nesting در `getStatus`
```php
return $this->success($payment->toArray()); // به‌جای ['data'=>...]
```
مصرف‌کننده‌ها را چک کن: پنل ادمین از `GET /api/v1/admin/payments/{uuid}` استفاده می‌کند (تخت، مستقل). `nobat724_front` `app/payment/[uuid]/page.js` از `getPayment` استفاده می‌کند — اگر به double-nest وابسته است، همان‌جا هم اصلاح کن (در پرامپت frontend ذکر شده).
### ۶. یکسان‌سازی subscription و sms-wallet با الگوی pay-endpoint
`POST /api/v1/subscription-payment` و مسیر sms-wallet را مثل appointment بازطراحی کن: POST فقط `Payment` pending بسازد و `pay_url` برگرداند؛ init واقعی در `GET /payment/pay/{orderId}` (که عمومی است و بر اساس `payment->getType()` کار می‌کند). این هم درگاه POSTیِ ملت را برای این جریان‌ها درست می‌کند و هم معماری را یکدست.
- **توجه:** مصرف‌کنندهٔ subscription، پنل ادمین `clinicpro` است — بعد از تغییر قرارداد، `assets/admin/` جایی که subscription-payment را صدا می‌زند به `pay_url` سوییچ کن (مثل frontend).
### ۷. مستندسازی
`docs/api/payment.md` را با معماری نهایی (سرویس‌ها، PaymentManager، PaymentLog، جریان یکدست همهٔ typeها) به‌روز کن.
## نکات مهم
- **جریان بیرونی نباید بشکند:** endpointها و قرارداد (`pay_url`, `frontend_address?status=`) ثابت بمانند؛ فقط لایه‌بندی داخلی عوض می‌شود. بعد از هر مرحله با تست‌های واقعی (زیر) صحت را بررسی کن.
- **step-1 معماری (کلیک → بک‌اند):** به‌دلیل اینکه JWT سایت در کوکیِ همان دامنه است و redirect full-page کوکی cross-domain نمی‌برد، ساخت `Payment` نیازمند **XHR authenticated** است؛ سپس مرورگر full-page به `pay_url` می‌رود. این استانداردِ امنِ چند-دامنه است. اگر «بدون هیچ XHR» الزامی است، به‌جای آن یک **توکن یک‌بارمصرفِ امضاشده** در URL لازم است — در این صورت آن را پیاده کن؛ در غیر این‌صورت الگوی XHR+redirect را حفظ و مستند کن.
- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها `success/error`. تاریخ‌ها Unix timestamp.
- Entity جدید (`PaymentLog`) → **migration الزامی**.
- بعد از تغییر: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec php vendor/bin/phpstan analyse`، و تست دستی زیر.
- بعد از تغییر API → `docs/api/payment.md` در همین session.
## تست دستی (ddev، در حالت `payment_test_mode=1`)
```bash
# pay endpoint یک پرداخت pending باید 302 به callback بدهد (Mock)
curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" "https://clinic-pro.ddev.site/api/v1/payment/pay/ORD-XXXX"
# callback موفق باید 302 به frontend_address?status=success بدهد
# verify تکراری (دوبار زدن callback) نباید وضعیت را دوباره پردازش کند (idempotent)
```
## خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود)
۱ Flow ۲ کلاس‌ها ۳ سرویس‌ها ۴ کنترلرها ۵ مسئولیت هر کلاس ۶ نقاط ضعف ۷ بهبود ۸ امنیت ۹ Performance ۱۰ افزودن درگاه جدید (Open/Closed via GatewayFactory).