285 lines
13 KiB
PHP
285 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Payment\Service;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Clinic\Repository\ClinicRepository;
|
|
use App\Doctor\Repository\DoctorRepository;
|
|
use App\Payment\Entity\Payment;
|
|
use App\Payment\Entity\PaymentLog;
|
|
use App\Payment\Gateway\GatewayFactory;
|
|
use App\Payment\Gateway\PaymentInitResult;
|
|
use App\Payment\Repository\PaymentLogRepository;
|
|
use App\Payment\Repository\PaymentRepository;
|
|
use App\Representation\Service\JalaliDateService;
|
|
use App\Settlement\Service\CommissionService;
|
|
use App\Sms\Entity\SmsLog;
|
|
use App\Sms\Service\SmsService;
|
|
use App\Sms\Service\SmsTextResolver;
|
|
use App\Sms\Service\SmsWalletService;
|
|
use App\Subscription\Service\SubscriptionService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* تمام منطق پرداخت (ارتباط با بانک + verify + post-action) اینجاست تا کنترلر فقط
|
|
* Orchestration کند. verify امن داخل transaction با قفل بدبینانه انجام میشود.
|
|
*/
|
|
final class PaymentManager
|
|
{
|
|
public function __construct(
|
|
private readonly GatewayFactory $gateways,
|
|
private readonly PaymentRepository $paymentRepo,
|
|
private readonly PaymentLogRepository $paymentLogRepo,
|
|
private readonly CircuitBreakerService $circuitBreaker,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly SubscriptionService $subscriptionService,
|
|
private readonly SmsWalletService $smsWalletService,
|
|
private readonly SmsService $smsService,
|
|
private readonly SmsTextResolver $smsText,
|
|
private readonly DoctorRepository $doctorRepo,
|
|
private readonly ClinicRepository $clinicRepo,
|
|
private readonly CommissionService $commissionService,
|
|
private readonly JalaliDateService $jalali,
|
|
private readonly string $appBaseUrl,
|
|
) {}
|
|
|
|
/**
|
|
* درگاه را برای یک پرداخت 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);
|
|
$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;
|
|
}
|
|
}
|