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

12 KiB
Raw Blame History

یکسان‌سازی کامل پرداخت به یک 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 → بانک → callbackPaymentManager::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 — باید حذف شود)

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

$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 نداشته باشد:

#[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 است:
    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:
    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.