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

13 KiB
Raw Blame History

بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend)

پروژه

clinicpro (Backend). cross-repo — پرامپت همتا: nobat724_front/.claude/prompt/payment-flow-frontend.md (کلاینت این قرارداد را مصرف می‌کند؛ Backend اول اجرا شود).

زمینه

بخش بزرگی از معماری هدف قبلاً پیاده شده و نباید دوباره ساخته شود:

  • جریان backend-driven: POST /api/v1/payment/appointment (اعتبارسنجی سفارش + ساخت Payment در وضعیت pending، بدون تماس با بانک) → pay_urlGET /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 کار می‌کند:

#[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 را دارد:

$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:

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 استفاده کند:

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 را در یک تراکنش انجام بده:

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():

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/callbackgateway، 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

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)

# 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).