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:
hamed
2026-07-02 15:36:08 +03:30
parent ca71c49451
commit c247ac2c80
12 changed files with 761 additions and 378 deletions
+31 -374
View File
@@ -5,20 +5,13 @@ namespace App\Payment\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\MockGateway;
use App\Payment\Gateway\SepGateway;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
use App\Payment\Service\PaymentManager;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsWalletService;
use App\Subscription\Service\SubscriptionService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -39,19 +32,9 @@ class PaymentController extends BaseController
public function __construct(
private readonly PaymentRepository $paymentRepo,
private readonly AppointmentRepository $appointmentRepo,
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly MockGateway $mock,
private readonly CircuitBreakerService $circuitBreaker,
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly SmsService $smsService,
private readonly \App\Sms\Service\SmsTextResolver $smsText,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly GatewayFactory $gateways,
private readonly PaymentManager $paymentManager,
private readonly SiteConfigRepository $configRepo,
private readonly \App\Settlement\Service\CommissionService $commissionService,
private readonly \App\Representation\Service\JalaliDateService $jalali,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -84,7 +67,7 @@ class PaymentController extends BaseController
property: 'data',
properties: [
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
new OA\Property(property: 'pay_url', type: 'string', format: 'uri', description: 'مرورگر به این آدرس بک‌اند هدایت شود؛ بک‌اند به درگاه منتقل می‌کند'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
@@ -92,63 +75,8 @@ class PaymentController extends BaseController
]
)
),
new OA\Response(
response: 401,
description: 'Unauthorized',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 503,
description: 'Gateway unavailable',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
@@ -180,7 +108,7 @@ class PaymentController extends BaseController
// اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام
// نمی‌شود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام می‌گیرد.
if ($this->resolveGateway($gatewayName) === null) {
if ($this->gateways->resolve($gatewayName) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
}
@@ -224,35 +152,12 @@ class PaymentController extends BaseController
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
$gatewayName = $payment->getGateway();
$gateway = $this->resolveGateway($gatewayName);
$testMode = $this->configRepo->get('payment_test_mode') === '1';
if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) {
$payment->setStatus(Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
// ارتباط با بانک + init در سرویس انجام می‌شود؛ کنترلر فقط انتقال HTTP را می‌سازد.
$result = $this->paymentManager->startGatewayHandoff($payment);
if ($result === false) {
return $this->redirectToFrontend($payment, false);
}
// ارتباط با بانک (init) از سمت بک‌اند انجام می‌شود.
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
if (!$result->success) {
if (!$testMode) {
$this->circuitBreaker->recordFailure($gatewayName);
}
$payment->setStatus(Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
return $this->redirectToFrontend($payment, false);
}
if (!$testMode) {
$this->circuitBreaker->recordSuccess($gatewayName);
}
$payment->setGatewayToken($result->token);
$this->paymentRepo->save($payment);
// انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت).
if ($result->redirectMethod === 'POST') {
return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams);
@@ -313,75 +218,21 @@ HTML;
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{
$clientIp = $request->getClientIp() ?? '';
$isTestMode = $this->configRepo->get('payment_test_mode') === '1';
if (!$isTestMode && !$this->isAllowedCallbackIp($clientIp)) {
$clientIp = $request->getClientIp() ?? '';
if (!$this->gateways->isTestMode() && !$this->isAllowedCallbackIp($clientIp)) {
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
}
$callbackData = array_merge($request->query->all(), $request->request->all());
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
$payment = $this->paymentRepo->findByOrderId($orderId);
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
if ($payment === null) {
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
}
$payment->setCallbackIp($clientIp);
$gw = $this->resolveGateway($gateway);
$result = $gw?->verify($callbackData) ?? null;
if ($result === null || !$result->success) {
$canceled = $result !== null && $result->canceled;
$payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
if (!$canceled) {
$this->circuitBreaker->recordFailure($gateway);
}
return $this->redirectToFrontend($payment, false);
}
$this->circuitBreaker->recordSuccess($gateway);
// Gateway-confirmed amount must match the amount we charged. Gateways that
// report the settled amount (SEP: AffectiveAmount) let us catch an
// underpayment / RefNum-replay; gateways that don't report it bind the
// amount server-side to the original request, so amountRials is 0 here.
if ($result->amountRials > 0 && $result->amountRials !== $payment->getAmountRials()) {
$payment->setStatus(Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
return $this->redirectToFrontend($payment, false);
}
// A gateway reference identifies exactly one settled transaction. If it
// already belongs to another payment, this is a replay — reject it. The
// unique DB index on reference_id is the hard backstop behind this check.
if ($result->referenceId !== '') {
$owner = $this->paymentRepo->findByReferenceId($result->referenceId);
if ($owner !== null && $owner->getId() !== $payment->getId()) {
$payment->setStatus(Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
return $this->redirectToFrontend($payment, false);
}
}
$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
$this->paymentRepo->save($payment);
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
$this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
$this->handleSmsWalletCharge($payment);
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
$this->handleAppointmentConfirmation($payment);
}
return $this->redirectToFrontend($payment, true);
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
// ── Subscription Payment ──────────────────────────────────────────────────
@@ -412,7 +263,7 @@ HTML;
property: 'data',
properties: [
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
new OA\Property(property: 'pay_url', type: 'string', format: 'uri'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
@@ -420,63 +271,8 @@ HTML;
]
)
),
new OA\Response(
response: 401,
description: 'Unauthorized',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 503,
description: 'Gateway unavailable',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
@@ -496,15 +292,10 @@ HTML;
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
}
$gateway = $this->resolveGateway($gatewayName);
if ($gateway === null) {
if ($this->gateways->resolve($gatewayName) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
}
if ($this->circuitBreaker->isOpen($gatewayName)) {
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
}
$periodUuid = trim($data['period_uuid'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
if ($periodUuid !== '') {
@@ -512,21 +303,10 @@ HTML;
}
$this->paymentRepo->save($payment);
$callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
if (!$result->success) {
$this->circuitBreaker->recordFailure($gatewayName);
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503);
}
$this->circuitBreaker->recordSuccess($gatewayName);
$payment->setGatewayToken($result->token);
$this->paymentRepo->save($payment);
// مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام می‌شود.
return $this->success([
'payment_uuid' => $payment->getUuid(),
'redirect_url' => $result->redirectUrl,
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
'order_id' => $payment->getOrderId(),
]);
}
@@ -588,18 +368,12 @@ HTML;
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'amount_rials', type: 'integer'),
new OA\Property(property: 'gateway', type: 'string'),
new OA\Property(property: 'reference_id', type: 'string', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
],
type: 'object'
),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'amount_rials', type: 'integer'),
new OA\Property(property: 'gateway', type: 'string'),
new OA\Property(property: 'reference_id', type: 'string', nullable: true),
new OA\Property(property: 'created_at', type: 'integer'),
],
type: 'object'
),
@@ -615,36 +389,13 @@ HTML;
#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
$testMode = $this->configRepo->get('payment_test_mode') === '1';
return $this->success([
'test_mode' => $testMode,
'test_mode' => $this->gateways->isTestMode(),
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
'gateways' => $this->activeGateways($testMode),
'gateways' => $this->gateways->activeGateways(),
]);
}
/**
* درگاه‌های قابل‌انتخاب: در حالت تست فقط درگاه آزمایشی، در غیر این صورت هر درگاهی که اعتبارنامه‌اش ست شده.
* @return array<int, array{name: string, label: string}>
*/
private function activeGateways(bool $testMode): array
{
if ($testMode) {
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
}
$labels = ['mellat' => 'بانک ملت', 'sep' => 'سپ (سامان کیش)'];
$gateways = [];
foreach ([$this->mellat, $this->sep] as $gateway) {
$name = $gateway->getName();
if ($gateway->isConfigured() && $this->isGatewayEnabled($name)) {
$gateways[] = ['name' => $name, 'label' => $labels[$name] ?? $name];
}
}
return $gateways;
}
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/my/payments', methods: ['GET'])]
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -675,35 +426,11 @@ HTML;
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $payment->toArray()]);
return $this->success($payment->toArray());
}
// ── Private helpers ───────────────────────────────────────────────────────
private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null
{
if ($this->configRepo->get('payment_test_mode') === '1') {
return $this->mock;
}
if (!$this->isGatewayEnabled($name)) {
return null;
}
return match ($name) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
}
/** آیا این درگاه در تنظیمات فعال است؟ کلید تنظیم‌نشده = فعال (سازگاری با نصب‌های قبلی). */
private function isGatewayEnabled(string $name): bool
{
$v = $this->configRepo->get($name . '_enabled');
return $v === null || $v === '1';
}
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
private function allowedHosts(): array
{
@@ -743,76 +470,6 @@ HTML;
return false;
}
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);
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->save($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(\App\Sms\Entity\SmsLog::TAG_PAYMENT, [
'doctor' => $appointment->getDoctor()->getName(),
'date' => $when,
]);
$this->smsService->dispatchAsync(
$mobile,
$message,
tag: \App\Sms\Entity\SmsLog::TAG_PAYMENT,
);
}
}
private function handleSubscriptionActivation(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$periodUuid = $meta['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 redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
{
$base = $payment->getFrontendAddress();
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Payment\Entity;
use App\Payment\Repository\PaymentLogRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* ردپای حسابرسی (audit trail) هر گام از چرخهٔ پرداخت.
* اعتبارنامهٔ درگاه هرگز اینجا ذخیره نمی‌شود.
*/
#[ORM\Entity(repositoryClass: PaymentLogRepository::class)]
#[ORM\Table(name: 'payment_logs')]
#[ORM\Index(columns: ['payment_id'], name: 'idx_payment_logs_payment')]
class PaymentLog
{
public const ACTION_INITIATE = 'initiate';
public const ACTION_VERIFY = 'verify';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'payment_id', type: 'integer')]
private int $paymentId;
#[ORM\Column(type: 'string', length: 20)]
private string $action;
#[ORM\Column(type: 'string', length: 20)]
private string $gateway;
/** نتیجهٔ گام: success | failed | canceled | pending */
#[ORM\Column(type: 'string', length: 20)]
private string $result;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $authority = null;
#[ORM\Column(name: 'client_ip', type: 'string', length: 45, nullable: true)]
private ?string $clientIp = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $payload = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(
int $paymentId,
string $action,
string $gateway,
string $result,
?string $authority = null,
?string $clientIp = null,
?array $payload = null,
) {
$this->paymentId = $paymentId;
$this->action = $action;
$this->gateway = $gateway;
$this->result = $result;
$this->authority = $authority;
$this->clientIp = $clientIp;
$this->payload = $payload;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getPaymentId(): int { return $this->paymentId; }
public function getAction(): string { return $this->action; }
public function getResult(): string { return $this->result; }
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
/**
* انتخاب/ساخت درگاه پرداخت (Factory + Strategy).
*
* تمام منطقِ «کدام درگاه، فعال است یا نه، حالت تست» اینجا متمرکز است تا کنترلر
* فقط Orchestration کند. افزودن درگاه جدید = ثبت آن به‌عنوان سرویس و اضافه‌کردن
* یک case در map + یک برچسب، بدون تغییر کنترلر.
*/
class GatewayFactory
{
/** @var array<string, PaymentGatewayInterface> */
private array $gateways;
/** @var array<string, string> برچسب فارسی هر درگاه */
private const LABELS = [
'mellat' => 'بانک ملت',
'sep' => 'سپ (سامان کیش)',
];
public function __construct(
MellatGateway $mellat,
SepGateway $sep,
private readonly MockGateway $mock,
private readonly SiteConfigRepository $configRepo,
) {
$this->gateways = [
$mellat->getName() => $mellat,
$sep->getName() => $sep,
];
}
public function isTestMode(): bool
{
return $this->configRepo->get('payment_test_mode') === '1';
}
/**
* درگاهِ قابل‌استفاده برای این نام؛ در حالت تست همیشه Mock، در غیر این‌صورت
* درگاه واقعی در صورت فعال بودن. null یعنی نامعتبر/غیرفعال.
*/
public function resolve(string $name): ?PaymentGatewayInterface
{
if ($this->isTestMode()) {
return $this->mock;
}
if (!$this->isEnabled($name)) {
return null;
}
return $this->gateways[$name] ?? null;
}
/** آیا درگاه در تنظیمات فعال است؟ کلید تنظیم‌نشده = فعال (سازگاری با نصب‌های قبلی). */
public function isEnabled(string $name): bool
{
$v = $this->configRepo->get($name . '_enabled');
return $v === null || $v === '1';
}
/**
* درگاه‌های قابل‌انتخاب برای نمایش به کاربر: در حالت تست فقط درگاه آزمایشی،
* در غیر این‌صورت هر درگاهی که اعتبارنامه‌اش ست شده و فعال است.
*
* @return array<int, array{name: string, label: string}>
*/
public function activeGateways(): array
{
if ($this->isTestMode()) {
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
}
$out = [];
foreach ($this->gateways as $name => $gateway) {
if ($gateway->isConfigured() && $this->isEnabled($name)) {
$out[] = ['name' => $name, 'label' => self::LABELS[$name] ?? $name];
}
}
return $out;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Payment\Repository;
use App\Payment\Entity\PaymentLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PaymentLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PaymentLog::class);
}
public function save(PaymentLog $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -59,6 +59,19 @@ class PaymentRepository extends ServiceEntityRepository
return $this->findOneBy(['referenceId' => $referenceId]);
}
/**
* قفل بدبینانه روی ردیف پرداخت (باید داخل یک transaction فعال صدا زده شود).
* برای جلوگیری از verify هم‌زمانِ دو callback (race / double-verify).
*/
public function findByOrderIdForUpdate(string $orderId): ?Payment
{
return $this->createQueryBuilder('p')
->where('p.orderId = :o')->setParameter('o', $orderId)
->getQuery()
->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
}
public function findPendingByAppointment(Appointment $appointment): ?Payment
{
return $this->findOneBy([
+268
View File
@@ -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;
}
}