- 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.
180 lines
13 KiB
Markdown
180 lines
13 KiB
Markdown
# بازطراحی معماری پرداخت — سرویسمحور، امن، توسعهپذیر (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).
|