feat(payment): implement PaymentManager for handling payment logic and callbacks
- Refactor PaymentController to delegate payment processing to PaymentManager. - Add findByOrderIdForUpdate method in PaymentRepository for pessimistic locking. - Create PaymentLog entity and repository for auditing payment actions. - Implement startGatewayHandoff and processCallback methods in PaymentManager. - Introduce transaction handling and logging for payment verification. - Update payment flow to ensure idempotency and prevent race conditions. - Enhance security by logging sensitive actions without exposing credentials. - Update database schema with migration for payment_logs table. - Document changes in payment flow architecture.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
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;
|
||||
}
|
||||
|
||||
$callbackUrl = $this->callbackUrl($payment);
|
||||
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $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);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user