From c247ac2c80f94946148767fb0b9ccb33a0827717 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 2 Jul 2026 15:36:08 +0330 Subject: [PATCH] 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. --- .claude/prompt/payment-flow-architecture.md | 179 ++++++++ assets/admin/pages/SmsWalletPage.tsx | 2 +- assets/admin/pages/SubscriptionPage.tsx | 2 +- config/services.yaml | 4 + docs/api/payment.md | 59 ++- migrations/Version20260702115209.php | 29 ++ src/Payment/Controller/PaymentController.php | 405 ++---------------- src/Payment/Entity/PaymentLog.php | 73 ++++ src/Payment/Gateway/GatewayFactory.php | 84 ++++ .../Repository/PaymentLogRepository.php | 21 + src/Payment/Repository/PaymentRepository.php | 13 + src/Payment/Service/PaymentManager.php | 268 ++++++++++++ 12 files changed, 761 insertions(+), 378 deletions(-) create mode 100644 .claude/prompt/payment-flow-architecture.md create mode 100644 migrations/Version20260702115209.php create mode 100644 src/Payment/Entity/PaymentLog.php create mode 100644 src/Payment/Gateway/GatewayFactory.php create mode 100644 src/Payment/Repository/PaymentLogRepository.php create mode 100644 src/Payment/Service/PaymentManager.php diff --git a/.claude/prompt/payment-flow-architecture.md b/.claude/prompt/payment-flow-architecture.md new file mode 100644 index 00000000..8045c697 --- /dev/null +++ b/.claude/prompt/payment-flow-architecture.md @@ -0,0 +1,179 @@ +# بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (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). diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx index 39bc380d..2d2798b8 100644 --- a/assets/admin/pages/SmsWalletPage.tsx +++ b/assets/admin/pages/SmsWalletPage.tsx @@ -70,7 +70,7 @@ function SmsWalletPageInner() { mutationFn: ({ amount_rials }: ChargeForm) => api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }), onSuccess: (res: any) => { - const url = res?.data?.redirect_url ?? res?.data?.payment_url; + const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; if (url) window.location.href = url; else toast.error('خطا در دریافت لینک پرداخت'); }, diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index f8a9ee74..450563d9 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -85,7 +85,7 @@ export default function SubscriptionPage() { mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) => api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials }), onSuccess: (res: any) => { - const url = res?.data?.redirect_url ?? res?.data?.payment_url; + const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; if (url) window.location.href = url; else toast.error('خطا در دریافت لینک پرداخت'); }, diff --git a/config/services.yaml b/config/services.yaml index ba4f3831..9deaca1a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -88,6 +88,10 @@ services: $appBaseUrl: '%env(APP_BASE_URL)%' $allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%' + App\Payment\Service\PaymentManager: + arguments: + $appBaseUrl: '%env(APP_BASE_URL)%' + App\Sms\Provider\KavehNegarProvider: arguments: $apiKey: '%env(default::KAVENEGAR_API_KEY)%' diff --git a/docs/api/payment.md b/docs/api/payment.md index 1d537007..2f4bd86c 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -5,6 +5,59 @@ --- +## معماری (Flow & مسئولیت‌ها) + +**اصل:** Frontend هرگز مستقیم با بانک صحبت نمی‌کند و منطق پرداخت را نگه نمی‌دارد. Backend تنها مرجع معتبر (Source of Truth) است. + +``` +[Frontend] کلیک پرداخت + │ (۱) XHR authenticated: POST /api/v1/payment/appointment {appointment_uuid, gateway, frontend_address} + ▼ +[PaymentController::initiateAppointment] ← فقط Orchestration + Validation + │ اعتبارسنجی: وجود نوبت، مالکیت کاربر، وضعیت قابل‌پرداخت، آدرس بازگشت مجاز، فعال بودن درگاه (GatewayFactory) + │ ساخت Payment(pending) + ذخیره؛ برمی‌گرداند pay_url (هیچ ارتباطی با بانک اینجا نیست) + ▼ +[Frontend] مرورگر full-page redirect → pay_url + │ (۲) GET /api/v1/payment/pay/{orderId} (عمومی) + ▼ +[PaymentController::pay] + │ GatewayFactory::resolve(نام درگاه) → درگاه (تست→Mock) + │ CircuitBreaker چک؛ gateway->initiate(amount, orderId, callbackUrl) ← ارتباط با بانک + │ ذخیرهٔ token؛ انتقال به بانک: 302 (GET) یا فرم auto-submit POST (ملت) + ▼ +[درگاه بانک / شاپرک] پرداخت کاربر + │ (۳) بازگشت به callbackUrl بک‌اند + ▼ +[PaymentController::callback] (عمومی، محدود به IP شاپرک مگر تست) + │ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت + │ post-action: confirm نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + کمیسیون + پیامک + │ (۴) RedirectResponse → frontend_address?payment_uuid=..&status=.. (همان دامنهٔ مبدأ) + ▼ +[Frontend] /payment/result → نمایش وضعیت +``` + +**کلاس‌ها و مسئولیت‌ها:** + +| کلاس | مسئولیت (SRP) | +|------|----------------| +| `PaymentController` | فقط Orchestration: دریافت request، اعتبارسنجی مالکیت/سفارش، فراخوانی `PaymentManager`، ساخت پاسخ/redirect HTTP (`autoSubmitForm`, `redirectToFrontend`, IP-check, Open-Redirect guard). بدون منطق درگاه/verify. | +| `PaymentManager` (`src/Payment/Service/`) | **منطق پرداخت**: `startGatewayHandoff()` (init درگاه + CircuitBreaker + ذخیرهٔ token) و `processCallback()` (verify داخل **transaction + قفل بدبینانه**، بررسی مبلغ، ضد-replay، idempotent، post-action، لاگ). | +| `GatewayFactory` (`src/Payment/Gateway/`) | **Factory + Strategy**: انتخاب درگاه بر اساس نام + حالت تست + فعال بودن؛ فهرست درگاه‌های فعال. | +| `PaymentGatewayInterface` | قرارداد درگاه: `initiate()`, `verify()`, `isConfigured()`, `getName()`. | +| `MellatGateway` / `SepGateway` / `MockGateway` | پیاده‌سازی هر درگاه (SOAP/REST/mock). ملت با POST به بانک، سپ با GET. | +| `PaymentInitResult` / `PaymentVerifyResult` | DTO نتیجهٔ init/verify (شامل `redirectMethod`/`redirectParams`). | +| `CircuitBreakerService` | جلوگیری از فشار روی درگاهِ خراب. | +| `Payment` (Entity) | وضعیت پرداخت، `orderId` یکتا، `referenceId` یکتا (backstop برای replay)، `frontendAddress` (دامنهٔ مبدأ). | +| `PaymentLog` (Entity) + `PaymentLogRepository` | **audit trail**: هر گام (`initiate`/`verify`) با نتیجه، authority، IP، payload کال‌بک (بدون اعتبارنامه). | + +**امنیت verify:** `processCallback` داخل `EntityManager::wrapInTransaction` با `findByOrderIdForUpdate` (SELECT … FOR UPDATE) اجرا می‌شود؛ گاردِ «فقط `pending`» آن را **idempotent** می‌کند (verify تکراری/race بی‌اثر). + +**یکدستیِ typeها:** هر سه نوع (`appointment`/`subscription`/`sms_wallet`) از همان `GET /payment/pay/{orderId}` عبور می‌کنند؛ `PaymentManager::callbackUrl()` پیشوند callback را بر اساس `type` انتخاب می‌کند. POST این endpointها فقط `Payment` pending می‌سازد و `pay_url` برمی‌گرداند (نه `redirect_url`). + +**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمی‌کنند. + +--- + ## GET `/api/v1/payment/config` دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend. @@ -231,12 +284,14 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm "success": true, "data": { "payment_uuid": "pay-uuid-...", - "redirect_url": "https://bpm.shaparak.ir/pgwchannel/...", - "order_id": "CLINICPRO-SUB-1717000000-XYZ" + "pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX", + "order_id": "ORD-XXXX" } } ``` +> مثل appointment: کلاینت مرورگر را به `pay_url` هدایت می‌کند؛ init درگاه در `GET /api/v1/payment/pay/{orderId}` انجام می‌شود (نه در این POST). Callback این نوع به `/api/v1/subscription-payment/callback/` می‌رود. + ### Errors | Code | HTTP | Description | |------|------|-------------| diff --git a/migrations/Version20260702115209.php b/migrations/Version20260702115209.php new file mode 100644 index 00000000..2af932fa --- /dev/null +++ b/migrations/Version20260702115209.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE payment_logs (id INT AUTO_INCREMENT NOT NULL, payment_id INT NOT NULL, action VARCHAR(20) NOT NULL, gateway VARCHAR(20) NOT NULL, result VARCHAR(20) NOT NULL, authority VARCHAR(255) DEFAULT NULL, client_ip VARCHAR(45) DEFAULT NULL, payload JSON DEFAULT NULL, created_at INT NOT NULL, INDEX idx_payment_logs_payment (payment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE payment_logs'); + } +} diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index a78911b9..96f6b2a9 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -5,20 +5,13 @@ namespace App\Payment\Controller; use App\Appointment\Entity\Appointment; use App\Appointment\Repository\AppointmentRepository; use App\Auth\Entity\User; -use App\Clinic\Repository\ClinicRepository; -use App\Doctor\Repository\DoctorRepository; use App\Payment\Entity\Payment; use App\Config\Repository\SiteConfigRepository; -use App\Payment\Gateway\MellatGateway; -use App\Payment\Gateway\MockGateway; -use App\Payment\Gateway\SepGateway; +use App\Payment\Gateway\GatewayFactory; use App\Payment\Repository\PaymentRepository; -use App\Payment\Service\CircuitBreakerService; +use App\Payment\Service\PaymentManager; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; -use App\Sms\Service\SmsService; -use App\Sms\Service\SmsWalletService; -use App\Subscription\Service\SubscriptionService; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -39,19 +32,9 @@ class PaymentController extends BaseController public function __construct( private readonly PaymentRepository $paymentRepo, private readonly AppointmentRepository $appointmentRepo, - private readonly MellatGateway $mellat, - private readonly SepGateway $sep, - private readonly MockGateway $mock, - private readonly CircuitBreakerService $circuitBreaker, - private readonly SubscriptionService $subscriptionService, - private readonly SmsWalletService $smsWalletService, - private readonly SmsService $smsService, - private readonly \App\Sms\Service\SmsTextResolver $smsText, - private readonly DoctorRepository $doctorRepo, - private readonly ClinicRepository $clinicRepo, + private readonly GatewayFactory $gateways, + private readonly PaymentManager $paymentManager, private readonly SiteConfigRepository $configRepo, - private readonly \App\Settlement\Service\CommissionService $commissionService, - private readonly \App\Representation\Service\JalaliDateService $jalali, private readonly string $appBaseUrl, private readonly string $allowedFrontendHosts = '', ) {} @@ -84,7 +67,7 @@ class PaymentController extends BaseController property: 'data', properties: [ new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), - new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'), + new OA\Property(property: 'pay_url', type: 'string', format: 'uri', description: 'مرورگر به این آدرس بک‌اند هدایت شود؛ بک‌اند به درگاه منتقل می‌کند'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' @@ -92,63 +75,8 @@ class PaymentController extends BaseController ] ) ), - new OA\Response( - response: 401, - description: 'Unauthorized', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), - new OA\Response( - response: 422, - description: 'Validation error', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), - new OA\Response( - response: 503, - description: 'Gateway unavailable', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] @@ -180,7 +108,7 @@ class PaymentController extends BaseController // اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام // نمی‌شود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام می‌گیرد. - if ($this->resolveGateway($gatewayName) === null) { + if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } @@ -224,35 +152,12 @@ class PaymentController extends BaseController return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); } - $gatewayName = $payment->getGateway(); - $gateway = $this->resolveGateway($gatewayName); - $testMode = $this->configRepo->get('payment_test_mode') === '1'; - - if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) { - $payment->setStatus(Payment::STATUS_FAILED); - $this->paymentRepo->save($payment); + // ارتباط با بانک + init در سرویس انجام می‌شود؛ کنترلر فقط انتقال HTTP را می‌سازد. + $result = $this->paymentManager->startGatewayHandoff($payment); + if ($result === false) { return $this->redirectToFrontend($payment, false); } - // ارتباط با بانک (init) از سمت بک‌اند انجام می‌شود. - $callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); - $result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl); - - if (!$result->success) { - if (!$testMode) { - $this->circuitBreaker->recordFailure($gatewayName); - } - $payment->setStatus(Payment::STATUS_FAILED); - $this->paymentRepo->save($payment); - return $this->redirectToFrontend($payment, false); - } - - if (!$testMode) { - $this->circuitBreaker->recordSuccess($gatewayName); - } - $payment->setGatewayToken($result->token); - $this->paymentRepo->save($payment); - // انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت). if ($result->redirectMethod === 'POST') { return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams); @@ -313,75 +218,21 @@ HTML; #[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])] public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response { - $clientIp = $request->getClientIp() ?? ''; - $isTestMode = $this->configRepo->get('payment_test_mode') === '1'; - if (!$isTestMode && !$this->isAllowedCallbackIp($clientIp)) { + $clientIp = $request->getClientIp() ?? ''; + if (!$this->gateways->isTestMode() && !$this->isAllowedCallbackIp($clientIp)) { return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403); } $callbackData = array_merge($request->query->all(), $request->request->all()); $orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? ''; - $payment = $this->paymentRepo->findByOrderId($orderId); + // verify امن (transaction + قفل + idempotent + post-action + log) در سرویس. + $payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId); if ($payment === null) { return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); } - $payment->setCallbackIp($clientIp); - - $gw = $this->resolveGateway($gateway); - $result = $gw?->verify($callbackData) ?? null; - - if ($result === null || !$result->success) { - $canceled = $result !== null && $result->canceled; - $payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED); - $this->paymentRepo->save($payment); - if (!$canceled) { - $this->circuitBreaker->recordFailure($gateway); - } - - return $this->redirectToFrontend($payment, false); - } - - $this->circuitBreaker->recordSuccess($gateway); - - // Gateway-confirmed amount must match the amount we charged. Gateways that - // report the settled amount (SEP: AffectiveAmount) let us catch an - // underpayment / RefNum-replay; gateways that don't report it bind the - // amount server-side to the original request, so amountRials is 0 here. - if ($result->amountRials > 0 && $result->amountRials !== $payment->getAmountRials()) { - $payment->setStatus(Payment::STATUS_FAILED); - $this->paymentRepo->save($payment); - - return $this->redirectToFrontend($payment, false); - } - - // A gateway reference identifies exactly one settled transaction. If it - // already belongs to another payment, this is a replay — reject it. The - // unique DB index on reference_id is the hard backstop behind this check. - if ($result->referenceId !== '') { - $owner = $this->paymentRepo->findByReferenceId($result->referenceId); - if ($owner !== null && $owner->getId() !== $payment->getId()) { - $payment->setStatus(Payment::STATUS_FAILED); - $this->paymentRepo->save($payment); - - return $this->redirectToFrontend($payment, false); - } - } - - $payment->setStatus(Payment::STATUS_SUCCESS); - $payment->setReferenceId($result->referenceId); - $this->paymentRepo->save($payment); - - if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) { - $this->handleSubscriptionActivation($payment); - } elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) { - $this->handleSmsWalletCharge($payment); - } elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) { - $this->handleAppointmentConfirmation($payment); - } - - return $this->redirectToFrontend($payment, true); + return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); } // ── Subscription Payment ────────────────────────────────────────────────── @@ -412,7 +263,7 @@ HTML; property: 'data', properties: [ new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), - new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'), + new OA\Property(property: 'pay_url', type: 'string', format: 'uri'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' @@ -420,63 +271,8 @@ HTML; ] ) ), - new OA\Response( - response: 401, - description: 'Unauthorized', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), - new OA\Response( - response: 422, - description: 'Validation error', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), - new OA\Response( - response: 503, - description: 'Gateway unavailable', - content: new OA\JsonContent( - properties: [ - new OA\Property(property: 'success', type: 'boolean', example: false), - new OA\Property( - property: 'errors', - type: 'array', - items: new OA\Items( - properties: [ - new OA\Property(property: 'code', type: 'string'), - new OA\Property(property: 'message', type: 'string'), - ] - ) - ), - ] - ) - ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] @@ -496,15 +292,10 @@ HTML; return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); } - $gateway = $this->resolveGateway($gatewayName); - if ($gateway === null) { + if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } - if ($this->circuitBreaker->isOpen($gatewayName)) { - return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503); - } - $periodUuid = trim($data['period_uuid'] ?? ''); $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); if ($periodUuid !== '') { @@ -512,21 +303,10 @@ HTML; } $this->paymentRepo->save($payment); - $callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); - $result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); - - if (!$result->success) { - $this->circuitBreaker->recordFailure($gatewayName); - return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503); - } - - $this->circuitBreaker->recordSuccess($gatewayName); - $payment->setGatewayToken($result->token); - $this->paymentRepo->save($payment); - + // مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام می‌شود. return $this->success([ 'payment_uuid' => $payment->getUuid(), - 'redirect_url' => $result->redirectUrl, + 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), 'order_id' => $payment->getOrderId(), ]); } @@ -588,18 +368,12 @@ HTML; new OA\Property( property: 'data', properties: [ - new OA\Property( - property: 'data', - properties: [ - new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), - new OA\Property(property: 'status', type: 'string'), - new OA\Property(property: 'amount_rials', type: 'integer'), - new OA\Property(property: 'gateway', type: 'string'), - new OA\Property(property: 'reference_id', type: 'string', nullable: true), - new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), - ], - type: 'object' - ), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'amount_rials', type: 'integer'), + new OA\Property(property: 'gateway', type: 'string'), + new OA\Property(property: 'reference_id', type: 'string', nullable: true), + new OA\Property(property: 'created_at', type: 'integer'), ], type: 'object' ), @@ -615,36 +389,13 @@ HTML; #[Route('/api/v1/payment/config', methods: ['GET'])] public function config(): JsonResponse { - $testMode = $this->configRepo->get('payment_test_mode') === '1'; - return $this->success([ - 'test_mode' => $testMode, + 'test_mode' => $this->gateways->isTestMode(), 'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0), - 'gateways' => $this->activeGateways($testMode), + 'gateways' => $this->gateways->activeGateways(), ]); } - /** - * درگاه‌های قابل‌انتخاب: در حالت تست فقط درگاه آزمایشی، در غیر این صورت هر درگاهی که اعتبارنامه‌اش ست شده. - * @return array - */ - private function activeGateways(bool $testMode): array - { - if ($testMode) { - return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']]; - } - - $labels = ['mellat' => 'بانک ملت', 'sep' => 'سپ (سامان کیش)']; - $gateways = []; - foreach ([$this->mellat, $this->sep] as $gateway) { - $name = $gateway->getName(); - if ($gateway->isConfigured() && $this->isGatewayEnabled($name)) { - $gateways[] = ['name' => $name, 'label' => $labels[$name] ?? $name]; - } - } - return $gateways; - } - #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/my/payments', methods: ['GET'])] public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse @@ -675,35 +426,11 @@ HTML; return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } - return $this->success(['data' => $payment->toArray()]); + return $this->success($payment->toArray()); } // ── Private helpers ─────────────────────────────────────────────────────── - private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null - { - if ($this->configRepo->get('payment_test_mode') === '1') { - return $this->mock; - } - - if (!$this->isGatewayEnabled($name)) { - return null; - } - - return match ($name) { - 'mellat' => $this->mellat, - 'sep' => $this->sep, - default => null, - }; - } - - /** آیا این درگاه در تنظیمات فعال است؟ کلید تنظیم‌نشده = فعال (سازگاری با نصب‌های قبلی). */ - private function isGatewayEnabled(string $name): bool - { - $v = $this->configRepo->get($name . '_enabled'); - return $v === null || $v === '1'; - } - /** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */ private function allowedHosts(): array { @@ -743,76 +470,6 @@ HTML; return false; } - private function handleSmsWalletCharge(Payment $payment): void - { - $meta = $payment->getMetadata() ?? []; - $entityType = $meta['entity_type'] ?? null; - $entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null; - - if ($entityType === null || $entityId === null) { - return; - } - - $wallet = $this->smsWalletService->getOrCreate($entityType, $entityId); - $this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment); - } - - private function handleAppointmentConfirmation(Payment $payment): void - { - $appointment = $payment->getAppointment(); - if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) { - return; - } - - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); - $this->appointmentRepo->save($appointment); - - $doctor = $appointment->getDoctor(); - $this->commissionService->processAppointment( - $payment, - $doctor->getRepresentationId(), - $appointment->getBookingRepresentationId(), - $doctor->getId(), - ); - - $mobile = $appointment->getPatientMobile(); - if ($mobile) { - $when = $this->jalali->formatDateTime($appointment->getSlotStart()); - $message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_PAYMENT, [ - 'doctor' => $appointment->getDoctor()->getName(), - 'date' => $when, - ]); - $this->smsService->dispatchAsync( - $mobile, - $message, - tag: \App\Sms\Entity\SmsLog::TAG_PAYMENT, - ); - } - } - - private function handleSubscriptionActivation(Payment $payment): void - { - $meta = $payment->getMetadata() ?? []; - $periodUuid = $meta['period_uuid'] ?? null; - if ($periodUuid === null) { - return; - } - - $user = $payment->getUser(); - $doctor = $this->doctorRepo->findByUser($user); - if ($doctor !== null) { - $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid); - $this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null); - return; - } - - $clinic = $this->clinicRepo->findByUser($user); - if ($clinic !== null) { - $this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid); - $this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId()); - } - } - private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response { $base = $payment->getFrontendAddress(); diff --git a/src/Payment/Entity/PaymentLog.php b/src/Payment/Entity/PaymentLog.php new file mode 100644 index 00000000..da1ad0a2 --- /dev/null +++ b/src/Payment/Entity/PaymentLog.php @@ -0,0 +1,73 @@ +paymentId = $paymentId; + $this->action = $action; + $this->gateway = $gateway; + $this->result = $result; + $this->authority = $authority; + $this->clientIp = $clientIp; + $this->payload = $payload; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getPaymentId(): int { return $this->paymentId; } + public function getAction(): string { return $this->action; } + public function getResult(): string { return $this->result; } +} diff --git a/src/Payment/Gateway/GatewayFactory.php b/src/Payment/Gateway/GatewayFactory.php new file mode 100644 index 00000000..f87a7120 --- /dev/null +++ b/src/Payment/Gateway/GatewayFactory.php @@ -0,0 +1,84 @@ + */ + private array $gateways; + + /** @var array برچسب فارسی هر درگاه */ + private const LABELS = [ + 'mellat' => 'بانک ملت', + 'sep' => 'سپ (سامان کیش)', + ]; + + public function __construct( + MellatGateway $mellat, + SepGateway $sep, + private readonly MockGateway $mock, + private readonly SiteConfigRepository $configRepo, + ) { + $this->gateways = [ + $mellat->getName() => $mellat, + $sep->getName() => $sep, + ]; + } + + public function isTestMode(): bool + { + return $this->configRepo->get('payment_test_mode') === '1'; + } + + /** + * درگاهِ قابل‌استفاده برای این نام؛ در حالت تست همیشه Mock، در غیر این‌صورت + * درگاه واقعی در صورت فعال بودن. null یعنی نامعتبر/غیرفعال. + */ + public function resolve(string $name): ?PaymentGatewayInterface + { + if ($this->isTestMode()) { + return $this->mock; + } + if (!$this->isEnabled($name)) { + return null; + } + return $this->gateways[$name] ?? null; + } + + /** آیا درگاه در تنظیمات فعال است؟ کلید تنظیم‌نشده = فعال (سازگاری با نصب‌های قبلی). */ + public function isEnabled(string $name): bool + { + $v = $this->configRepo->get($name . '_enabled'); + return $v === null || $v === '1'; + } + + /** + * درگاه‌های قابل‌انتخاب برای نمایش به کاربر: در حالت تست فقط درگاه آزمایشی، + * در غیر این‌صورت هر درگاهی که اعتبارنامه‌اش ست شده و فعال است. + * + * @return array + */ + public function activeGateways(): array + { + if ($this->isTestMode()) { + return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']]; + } + + $out = []; + foreach ($this->gateways as $name => $gateway) { + if ($gateway->isConfigured() && $this->isEnabled($name)) { + $out[] = ['name' => $name, 'label' => self::LABELS[$name] ?? $name]; + } + } + return $out; + } +} diff --git a/src/Payment/Repository/PaymentLogRepository.php b/src/Payment/Repository/PaymentLogRepository.php new file mode 100644 index 00000000..fd8d03e4 --- /dev/null +++ b/src/Payment/Repository/PaymentLogRepository.php @@ -0,0 +1,21 @@ +getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Payment/Repository/PaymentRepository.php b/src/Payment/Repository/PaymentRepository.php index 8fc6121b..e526dd7b 100644 --- a/src/Payment/Repository/PaymentRepository.php +++ b/src/Payment/Repository/PaymentRepository.php @@ -59,6 +59,19 @@ class PaymentRepository extends ServiceEntityRepository return $this->findOneBy(['referenceId' => $referenceId]); } + /** + * قفل بدبینانه روی ردیف پرداخت (باید داخل یک transaction فعال صدا زده شود). + * برای جلوگیری از verify هم‌زمانِ دو callback (race / double-verify). + */ + 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(); + } + public function findPendingByAppointment(Appointment $appointment): ?Payment { return $this->findOneBy([ diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php new file mode 100644 index 00000000..95040036 --- /dev/null +++ b/src/Payment/Service/PaymentManager.php @@ -0,0 +1,268 @@ +getGateway(); + $gateway = $this->gateways->resolve($gatewayName); + $testMode = $this->gateways->isTestMode(); + + if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) { + $this->failPayment($payment, PaymentLog::ACTION_INITIATE, ['reason' => 'gateway_unavailable']); + return false; + } + + $callbackUrl = $this->callbackUrl($payment); + $result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl); + + if (!$result->success) { + if (!$testMode) { + $this->circuitBreaker->recordFailure($gatewayName); + } + $this->failPayment($payment, PaymentLog::ACTION_INITIATE, ['error' => $result->errorMessage]); + return false; + } + + if (!$testMode) { + $this->circuitBreaker->recordSuccess($gatewayName); + } + $payment->setGatewayToken($result->token); + $this->paymentRepo->save($payment); + $this->log($payment, PaymentLog::ACTION_INITIATE, 'success', $result->token, null, ['token' => $result->token]); + + return $result; + } + + /** + * verify امنِ callback داخل transaction + قفل ردیف. idempotent: اگر پرداخت + * قبلاً نهایی شده باشد بدون پردازش دوباره همان را برمی‌گرداند. + * null یعنی پرداخت یافت نشد. + */ + public function processCallback(string $gatewayName, array $callbackData, string $clientIp, string $orderId): ?Payment + { + return $this->em->wrapInTransaction(function () use ($gatewayName, $callbackData, $clientIp, $orderId): ?Payment { + $payment = $this->paymentRepo->findByOrderIdForUpdate($orderId); + if ($payment === null) { + return null; + } + + // جلوگیری از verify تکراری / race: فقط پرداخت pending پردازش می‌شود. + if ($payment->getStatus() !== Payment::STATUS_PENDING) { + return $payment; + } + + $payment->setCallbackIp($clientIp); + + $gateway = $this->gateways->resolve($gatewayName); + $result = $gateway?->verify($callbackData); + + if ($result === null || !$result->success) { + $canceled = $result !== null && $result->canceled; + $payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED); + $this->em->persist($payment); + if (!$canceled) { + $this->circuitBreaker->recordFailure($gatewayName); + } + $this->log($payment, PaymentLog::ACTION_VERIFY, $payment->getStatus(), null, $clientIp, $this->sanitize($callbackData)); + return $payment; + } + + $this->circuitBreaker->recordSuccess($gatewayName); + + // مبلغ تأییدشدهٔ درگاه باید با مبلغِ ثبت‌شده برابر باشد (ضد underpayment/دستکاری). + if ($result->amountRials > 0 && $result->amountRials !== $payment->getAmountRials()) { + $payment->setStatus(Payment::STATUS_FAILED); + $this->em->persist($payment); + $this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', $result->referenceId, $clientIp, ['reason' => 'amount_mismatch']); + return $payment; + } + + // مرجع درگاه یکتاست؛ اگر متعلق به پرداخت دیگری باشد replay است. + if ($result->referenceId !== '') { + $owner = $this->paymentRepo->findByReferenceId($result->referenceId); + if ($owner !== null && $owner->getId() !== $payment->getId()) { + $payment->setStatus(Payment::STATUS_FAILED); + $this->em->persist($payment); + $this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', $result->referenceId, $clientIp, ['reason' => 'replay']); + return $payment; + } + } + + $payment->setStatus(Payment::STATUS_SUCCESS); + $payment->setReferenceId($result->referenceId); + $this->em->persist($payment); + + $this->runPostAction($payment); + + $this->log($payment, PaymentLog::ACTION_VERIFY, 'success', $result->referenceId, $clientIp, $this->sanitize($callbackData)); + return $payment; + }); + } + + public function callbackUrl(Payment $payment): string + { + $prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION + ? '/api/v1/subscription-payment/callback/' + : '/api/v1/payment/callback/'; + return $this->appBaseUrl . $prefix . $payment->getGateway() . '?order_id=' . $payment->getOrderId(); + } + + // ── Post-actions ────────────────────────────────────────────────────────── + + private function runPostAction(Payment $payment): void + { + match ($payment->getType()) { + Payment::TYPE_SUBSCRIPTION => $this->handleSubscriptionActivation($payment), + Payment::TYPE_SMS_WALLET => $this->handleSmsWalletCharge($payment), + Payment::TYPE_APPOINTMENT => $this->handleAppointmentConfirmation($payment), + default => null, + }; + } + + private function handleAppointmentConfirmation(Payment $payment): void + { + $appointment = $payment->getAppointment(); + if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) { + return; + } + + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + + $doctor = $appointment->getDoctor(); + $this->commissionService->processAppointment( + $payment, + $doctor->getRepresentationId(), + $appointment->getBookingRepresentationId(), + $doctor->getId(), + ); + + $mobile = $appointment->getPatientMobile(); + if ($mobile) { + $when = $this->jalali->formatDateTime($appointment->getSlotStart()); + $message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, [ + 'doctor' => $doctor->getName(), + 'date' => $when, + ]); + $this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT); + } + } + + private function handleSubscriptionActivation(Payment $payment): void + { + $periodUuid = ($payment->getMetadata() ?? [])['period_uuid'] ?? null; + if ($periodUuid === null) { + return; + } + + $user = $payment->getUser(); + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor !== null) { + $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid); + $this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null); + return; + } + + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic !== null) { + $this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid); + $this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId()); + } + } + + private function handleSmsWalletCharge(Payment $payment): void + { + $meta = $payment->getMetadata() ?? []; + $entityType = $meta['entity_type'] ?? null; + $entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null; + if ($entityType === null || $entityId === null) { + return; + } + + $wallet = $this->smsWalletService->getOrCreate($entityType, $entityId); + $this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function failPayment(Payment $payment, string $action, array $payload): void + { + $payment->setStatus(Payment::STATUS_FAILED); + $this->paymentRepo->save($payment); + $this->log($payment, $action, 'failed', null, null, $payload); + } + + private function log(Payment $payment, string $action, string $result, ?string $authority, ?string $clientIp, ?array $payload): void + { + try { + $this->paymentLogRepo->save(new PaymentLog( + (int) $payment->getId(), + $action, + $payment->getGateway(), + $result, + $authority, + $clientIp, + $payload, + )); + } catch (\Throwable $e) { + // لاگ نباید جریان پرداخت را بشکند. + $this->logger->error('PaymentLog write failed: ' . $e->getMessage(), ['orderId' => $payment->getOrderId()]); + } + } + + /** حذف کلیدهای حساس احتمالی از payload کال‌بک قبل از ذخیره. */ + private function sanitize(array $data): array + { + unset($data['password'], $data['userPassword'], $data['userName']); + return $data; + } +}