domainResolver->resolve($payment->getFrontendAddress())->representationId(); } /** * درگاه را برای یک پرداخت pending init می‌کند (ارتباط با بانک). * موفق → PaymentInitResult؛ ناموفق → false (پرداخت failed و ذخیره‌شده). */ public function startGatewayHandoff(Payment $payment): PaymentInitResult|false { $gatewayName = $payment->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); // شمارهٔ کارت ماسک‌شدهٔ پرداخت‌کننده (ملت: CardHolderPan) برای نمایش در پنل. $cardPan = $callbackData['CardHolderPan'] ?? $callbackData['SecurePan'] ?? null; if ($cardPan) { $payment->setMetadata(($payment->getMetadata() ?? []) + ['card_pan' => (string) $cardPan]); } $this->em->persist($payment); $this->runPostAction($payment); $this->log($payment, PaymentLog::ACTION_VERIFY, 'success', $result->referenceId, $clientIp, $this->sanitize($callbackData)); return $payment; }); } /** * استرداد وجه (کل یا جزئی) یک پرداخت موفق. داخل transaction + قفل ردیف. * $amountRials = null → کل مبلغ. استرداد کامل → وضعیت refunded. */ public function refundPayment(Payment $payment, ?int $amountRials, string $clientIp): PaymentRefundResult { return $this->em->wrapInTransaction(function () use ($payment, $amountRials, $clientIp): PaymentRefundResult { $locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId()); if ($locked === null) { return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد'); } if ($locked->getStatus() !== Payment::STATUS_SUCCESS) { return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل استرداد است'); } $total = $locked->getAmountRials(); $meta = $locked->getMetadata() ?? []; $refunded = array_sum(array_column($meta['refunds'] ?? [], 'amount')); $amount = $amountRials ?? ($total - $refunded); // null = باقی‌ماندهٔ قابل استرداد if ($amount <= 0 || $refunded + $amount > $total) { return new PaymentRefundResult(false, errorMessage: 'مبلغ استرداد نامعتبر است'); } $gateway = $this->gateways->resolve($locked->getGateway()); $result = $gateway?->refund((string) $locked->getId(), (string) $locked->getReferenceId(), $amount) ?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است'); if ($result->success) { $meta['refunds'][] = ['amount' => $amount, 'ref' => $result->refundRefId, 'at' => time()]; $locked->setMetadata($meta); // فقط استرداد کامل، post-action (نوبت/اشتراک/کیف‌پول) را معکوس می‌کند. if ($refunded + $amount >= $total) { $locked->setStatus(Payment::STATUS_REFUNDED); $this->runReversePostAction($locked); } $this->em->persist($locked); } $this->log($locked, PaymentLog::ACTION_REFUND, $result->success ? 'success' : 'failed', $result->refundRefId ?: null, $clientIp, ['amount' => $amount, 'error' => $result->errorMessage]); return $result; }); } /** برگشت وجه یک پرداخت settle‌نشده. در موفقیت وضعیت refunded. داخل transaction + قفل. */ public function reversePayment(Payment $payment, string $clientIp): PaymentRefundResult { return $this->em->wrapInTransaction(function () use ($payment, $clientIp): PaymentRefundResult { $locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId()); if ($locked === null) { return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد'); } if ($locked->getStatus() !== Payment::STATUS_SUCCESS) { return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل برگشت است'); } $gateway = $this->gateways->resolve($locked->getGateway()); $result = $gateway?->reverse((string) $locked->getId(), (string) $locked->getReferenceId()) ?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است'); if ($result->success) { $locked->setStatus(Payment::STATUS_REFUNDED); $this->runReversePostAction($locked); $this->em->persist($locked); } $this->log($locked, PaymentLog::ACTION_REVERSE, $result->success ? 'success' : 'failed', null, $clientIp, ['error' => $result->errorMessage]); return $result; }); } 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 runReversePostAction(Payment $payment): void { match ($payment->getType()) { Payment::TYPE_SUBSCRIPTION => $this->subscriptionService->deleteByPayment($payment), Payment::TYPE_SMS_WALLET => $this->reverseSmsWalletCharge($payment), Payment::TYPE_APPOINTMENT => $this->reverseAppointment($payment), default => null, }; } private function reverseAppointment(Payment $payment): void { $appointment = $payment->getAppointment(); if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CANCELLED_BY_USER)) { return; } // لغو نوبت → اسلات به‌طور خودکار آزاد می‌شود (availability از نوبت‌های active محاسبه می‌شود). $appointment->transitionTo(Appointment::STATUS_CANCELLED_BY_USER); $this->em->persist($appointment); } private function reverseSmsWalletCharge(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->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک'); } 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(), $this->bookingRepresentationIdFor($payment), $doctor->getId(), ); $mobile = $appointment->getPatientMobile(); if ($mobile) { $this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [ 'doctor' => $doctor->getName(), 'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false), 'time' => date('H:i', $appointment->getSlotStart()), ]); } // اعلان به شمارهٔ «اعلان نوبت» دکتر — فقط برای نوبت‌های پرداخت‌شدهٔ سایت (همین مسیر). $notify = $doctor->getNotificationMobile(); if ($notify) { $this->smsService->dispatchTemplate(SmsLog::TAG_DOCTOR_APPOINTMENT, $notify, [ 'patient' => $appointment->getPatientName() ?? '—', 'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false), 'time' => date('H:i', $appointment->getSlotStart()), ]); } } private function handleSubscriptionActivation(Payment $payment): void { $periodUuid = ($payment->getMetadata() ?? [])['period_uuid'] ?? null; if ($periodUuid === null) { return; } $user = $payment->getUser(); $doctor = $this->doctorRepo->findByUser($user); $bookingRepId = $this->bookingRepresentationIdFor($payment); if ($doctor !== null) { $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid); $this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $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(), $bookingRepId, 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; } }