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; } // درگاه ملت orderId عددی (long) می‌خواهد؛ id عددیِ Payment را می‌فرستیم. // جستجوی پرداخت در callback از طریق query `order_id` (رشتهٔ ORD-…) انجام می‌شود. $callbackUrl = $this->callbackUrl($payment); $result = $gateway->initiate($payment->getAmountRials(), (string) $payment->getId(), $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); // چک امنیتی اجباری مستند: مقادیر بازگشتی باید با مقادیر مرحلهٔ Pay همین // پرداخت بخوانند (ضد parameter tampering). فقط وقتی درگاه این فیلدها را // برمی‌گرداند اعمال می‌شود (ملت: RefId + SaleOrderId؛ سپ آن‌ها را ندارد). $token = $payment->getGatewayToken(); $refIdMismatch = $token !== null && isset($callbackData['RefId']) && !hash_equals($token, (string) $callbackData['RefId']); $orderMismatch = isset($callbackData['SaleOrderId']) && (string) $callbackData['SaleOrderId'] !== (string) $payment->getId(); if ($refIdMismatch || $orderMismatch) { $payment->setStatus(Payment::STATUS_FAILED); $this->em->persist($payment); $this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', null, $clientIp, ['reason' => 'tampering']); return $payment; } $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; } }