[]]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['appointment_uuid', 'gateway'], properties: [ new OA\Property(property: 'appointment_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']), new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true), ] ) ), responses: [ new OA\Response( response: 200, description: 'Payment initiated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'pay_url', type: 'string', format: 'uri', description: 'مرورگر به این آدرس بک‌اند هدایت شود؛ بک‌اند به درگاه منتقل می‌کند'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' ), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/payment/appointment', methods: ['POST'])] public function initiateAppointment(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $appointmentUuid = trim($data['appointment_uuid'] ?? ''); $gatewayName = trim($data['gateway'] ?? 'mellat'); $frontendAddress = trim($data['frontend_address'] ?? ''); $appointment = $this->appointmentRepo->findByUuid($appointmentUuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); } if ($appointment->getUser()->getId() !== $user->getId()) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) { return $this->error(ErrorCodes::ERR_PAYMENT_003, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_003), 422); } // Validate Open Redirect if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); } // اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام // نمی‌شود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام می‌گیرد. if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } $feeRials = (int) $this->configRepo->get('appointment_fee_rials'); $payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress); $payment->setAppointment($appointment); $this->paymentRepo->save($payment); // مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک // + انتقال به درگاه انجام می‌شود. کلاینت هرگز مستقیم به بانک ریکوست نمی‌زند. return $this->success([ 'payment_uuid' => $payment->getUuid(), 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), 'order_id' => $payment->getOrderId(), ]); } // ── Gateway hand-off (public — browser redirect to the bank) ─────────────── #[OA\Get( path: '/api/v1/payment/pay/{orderId}', summary: 'Redirect the browser to the payment gateway for a pending payment', parameters: [ new OA\Parameter(name: 'orderId', in: 'path', required: true, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response(response: 302, description: 'Redirect (GET gateway) or auto-submitting POST form (POST gateway)'), new OA\Response(response: 404, description: 'Payment not found'), ] )] #[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])] public function pay(string $orderId): \Symfony\Component\HttpFoundation\Response { $payment = $this->paymentRepo->findByOrderId($orderId); if ($payment === null) { return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); } // فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay). if ($payment->getStatus() !== Payment::STATUS_PENDING) { return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); } // ارتباط با بانک + init در سرویس انجام می‌شود؛ کنترلر فقط انتقال HTTP را می‌سازد. $result = $this->paymentManager->startGatewayHandoff($payment); if ($result === false) { return $this->redirectToFrontend($payment, false); } // انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت). if ($result->redirectMethod === 'POST') { return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams); } return new RedirectResponse($result->redirectUrl); } /** یک صفحهٔ HTML با فرمی که به‌صورت خودکار (POST) به درگاه ارسال می‌شود. */ private function autoSubmitForm(string $action, array $params): \Symfony\Component\HttpFoundation\Response { $fields = ''; foreach ($params as $name => $value) { $fields .= sprintf( '', htmlspecialchars((string) $name, ENT_QUOTES), htmlspecialchars((string) $value, ENT_QUOTES) ); } $safeAction = htmlspecialchars($action, ENT_QUOTES); $html = <<در حال انتقال به درگاه پرداخت…

در حال انتقال به درگاه پرداخت…

{$fields}
HTML; return new \Symfony\Component\HttpFoundation\Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']); } // ── Payment Callback (public — no JWT) ─────────────────────────────────── #[OA\Post( path: '/api/v1/payment/callback/{gateway}', summary: 'Payment gateway callback (public, IP-restricted)', parameters: [ new OA\Parameter( name: 'gateway', in: 'path', required: true, schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep']) ), ], responses: [ new OA\Response( response: 200, description: 'Callback processed — either a redirect or JSON result', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean'), new OA\Property(property: 'payment', type: 'object'), ] ) ), new OA\Response(response: 302, description: 'Redirect to frontend with payment result'), new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'), new OA\Response(response: 404, description: 'Payment not found'), ] )] #[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])] public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response { $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'] ?? ''; // 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); } return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); } // ── Subscription Payment ────────────────────────────────────────────────── #[OA\Post( path: '/api/v1/subscription-payment', summary: 'Initiate a subscription payment', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['gateway', 'amount_rials'], properties: [ new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']), new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true), new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1), ] ) ), responses: [ new OA\Response( response: 200, description: 'Subscription payment initiated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'pay_url', type: 'string', format: 'uri'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' ), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/subscription-payment', methods: ['POST'])] public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $gatewayName = trim($data['gateway'] ?? 'mellat'); $frontendAddress = trim($data['frontend_address'] ?? ''); $amountRials = (int) ($data['amount_rials'] ?? 0); if ($amountRials <= 0) { return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422); } if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); } if ($this->gateways->resolve($gatewayName) === null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway'); } $periodUuid = trim($data['period_uuid'] ?? ''); $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); if ($periodUuid !== '') { $payment->setMetadata(['period_uuid' => $periodUuid]); } $this->paymentRepo->save($payment); // مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام می‌شود. return $this->success([ 'payment_uuid' => $payment->getUuid(), 'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(), 'order_id' => $payment->getOrderId(), ]); } #[OA\Post( path: '/api/v1/subscription-payment/callback/{gateway}', summary: 'Subscription payment gateway callback (public, IP-restricted)', parameters: [ new OA\Parameter( name: 'gateway', in: 'path', required: true, schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep']) ), ], responses: [ new OA\Response( response: 200, description: 'Callback processed — either a redirect or JSON result', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean'), new OA\Property(property: 'payment', type: 'object'), ] ) ), new OA\Response(response: 302, description: 'Redirect to frontend with payment result'), new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'), new OA\Response(response: 404, description: 'Payment not found'), ] )] #[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])] public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response { return $this->callback($gateway, $request); } // ── Status ──────────────────────────────────────────────────────────────── #[OA\Get( path: '/api/v1/payment/{uuid}', summary: 'Get payment status by UUID', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Payment details', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), 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: 'integer'), ], type: 'object' ), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Payment not found'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/payment/config', methods: ['GET'])] public function config(): JsonResponse { return $this->success([ 'test_mode' => $this->gateways->isTestMode(), 'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0), 'gateways' => $this->gateways->activeGateways(), ]); } #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/my/payments', methods: ['GET'])] public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse { $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 20))); $status = $request->query->get('status'); $items = array_map( fn(Payment $p) => $p->toArray(), $this->paymentRepo->findByUser($user, $status, $page, $limit) ); $total = $this->paymentRepo->countByUser($user, $status); return $this->paginated($items, $total, $page, $limit); } #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/payment/{uuid}', methods: ['GET'])] public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse { $payment = $this->paymentRepo->findByUuid($uuid); if ($payment === null) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404); } if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } return $this->success($payment->toArray()); } // ── Private helpers ─────────────────────────────────────────────────────── /** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */ private function allowedHosts(): array { $fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? ''); $raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts; return array_filter(array_map('trim', explode(',', $raw))); } private function isAllowedFrontend(string $url): bool { $hosts = $this->allowedHosts(); if (empty($hosts)) { return false; } $host = parse_url($url, PHP_URL_HOST); return in_array($host, $hosts, true); } private function isAllowedCallbackIp(string $ip): bool { if (empty($ip)) { return false; } foreach (self::ALLOWED_CALLBACK_IPS as $cidr) { [$subnet, $maskBits] = explode('/', $cidr); $maskBits = (int) $maskBits; $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); if ($ipLong === false || $subnetLong === false) { continue; } $mask = -1 << (32 - $maskBits); if (($ipLong & $mask) === ($subnetLong & $mask)) { return true; } } return false; } private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response { $base = $payment->getFrontendAddress(); if (empty($base)) { return new JsonResponse([ 'success' => $success, 'payment' => $payment->toArray(), ]); } $sep = str_contains($base, '?') ? '&' : '?'; $url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus(); return new RedirectResponse($url); } }