[]]], 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: 'redirect_url', type: 'string', format: 'uri'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' ), ] ) ), 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'), ] ) ), ] ) ), ] )] #[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'); } $gateway = $this->resolveGateway($gatewayName); if ($gateway === 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); } $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); $callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); $result = $gateway->initiate($payment->getAmountRials(), $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); return $this->success([ 'payment_uuid' => $payment->getUuid(), 'redirect_url' => $result->redirectUrl, 'order_id' => $payment->getOrderId(), ]); } // ── 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() ?? ''; $isTestMode = $this->configRepo->get('payment_test_mode') === '1'; if (!$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); 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); } // ── 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: 'redirect_url', type: 'string', format: 'uri'), new OA\Property(property: 'order_id', type: 'string'), ], type: 'object' ), ] ) ), 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'), ] ) ), ] ) ), ] )] #[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'); } $gateway = $this->resolveGateway($gatewayName); if ($gateway === 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 !== '') { $payment->setMetadata(['period_uuid' => $periodUuid]); } $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); return $this->success([ 'payment_uuid' => $payment->getUuid(), 'redirect_url' => $result->redirectUrl, '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: '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' ), ], 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->configRepo->get('payment_test_mode') === '1', 'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'), ]); } #[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(['data' => $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; } return match ($name) { 'mellat' => $this->mellat, 'sep' => $this->sep, default => null, }; } /** @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 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(); 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); } }