Files
clinicpro/src/Payment/Service/PaymentManager.php
T

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;
}
}