12 KiB
12 KiB
یکسانسازی کامل پرداخت به یک 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.
اما دو انحراف از «تنها یک روش پرداخت» باقی مانده که این پرامپت آنها را رفع میکند:
SmsWalletController::chargeیک Flow پرداخت جداگانه است — خودش درگاه را resolve میکند (mellat/sep/mockباmatch)، خودشnew Payment+$gateway->initiate(...)را صدا میزند وredirect_urlبانک را برمیگرداند. این نقض «یک flow واحد» است و باید حذف و به flow واحد منتقل شود.- 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 — باید حذف شود)
// 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درpayendpoint انجام میشود؛ post-actionTYPE_SMS_WALLETاز قبل درPaymentManager::handleSmsWalletChargeهست.) - Callback این نوع از قبل به
/api/v1/payment/callback/{gateway}→PaymentManagerمیرود (چونpayendpoint با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=*.phpnew 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.