reference_id (the gateway's settled-transaction ref) was not unique, so the same successful callback — or a RefNum replayed onto another order — could credit twice. Add a unique index (NULL until success, so pending/failed rows don't collide) and an application-level pre-check in the callback that fails the payment if the reference already belongs to another order. The unique index is the hard backstop behind the check. Regression: PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
726 lines
31 KiB
PHP
726 lines
31 KiB
PHP
<?php
|
|
|
|
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\Repository\PaymentRepository;
|
|
use App\Payment\Service\CircuitBreakerService;
|
|
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;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
|
|
#[OA\Tag(name: 'Payments')]
|
|
class PaymentController extends BaseController
|
|
{
|
|
// Shaparak payment network callback IP ranges
|
|
private const ALLOWED_CALLBACK_IPS = [
|
|
'91.92.0.0/16',
|
|
'195.146.32.0/22',
|
|
];
|
|
|
|
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 SiteConfigRepository $configRepo,
|
|
private readonly \App\Settlement\Service\CommissionService $commissionService,
|
|
private readonly \App\Representation\Service\JalaliDateService $jalali,
|
|
private readonly string $appBaseUrl,
|
|
private readonly string $allowedFrontendHosts = '',
|
|
) {}
|
|
|
|
// ── Appointment Payment ───────────────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/payment/appointment',
|
|
summary: 'Initiate an appointment payment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['appointment_uuid', 'gateway'],
|
|
properties: [
|
|
new OA\Property(property: 'appointment_uuid', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']),
|
|
new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Payment initiated',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
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: 'order_id', type: 'string'),
|
|
],
|
|
type: 'object'
|
|
),
|
|
]
|
|
)
|
|
),
|
|
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'),
|
|
]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/payment/appointment', methods: ['POST'])]
|
|
public function initiateAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$appointmentUuid = trim($data['appointment_uuid'] ?? '');
|
|
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
|
$frontendAddress = trim($data['frontend_address'] ?? '');
|
|
|
|
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
|
|
if ($appointment === null) {
|
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404);
|
|
}
|
|
|
|
if ($appointment->getUser()->getId() !== $user->getId()) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
|
|
return $this->error(ErrorCodes::ERR_PAYMENT_003, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_003), 422);
|
|
}
|
|
|
|
// Validate Open Redirect
|
|
if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
|
}
|
|
|
|
$gateway = $this->resolveGateway($gatewayName);
|
|
if ($gateway === 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);
|
|
}
|
|
|
|
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
|
|
$payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
|
|
$payment->setAppointment($appointment);
|
|
$this->paymentRepo->save($payment);
|
|
|
|
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
|
$result = $gateway->initiate($payment->getAmountRials(), $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);
|
|
|
|
return $this->success([
|
|
'payment_uuid' => $payment->getUuid(),
|
|
'redirect_url' => $result->redirectUrl,
|
|
'order_id' => $payment->getOrderId(),
|
|
]);
|
|
}
|
|
|
|
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/payment/callback/{gateway}',
|
|
summary: 'Payment gateway callback (public, IP-restricted)',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'gateway',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Callback processed — either a redirect or JSON result',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean'),
|
|
new OA\Property(property: 'payment', type: 'object'),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 302, description: 'Redirect to frontend with payment result'),
|
|
new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'),
|
|
new OA\Response(response: 404, description: 'Payment not found'),
|
|
]
|
|
)]
|
|
#[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)) {
|
|
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);
|
|
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);
|
|
}
|
|
|
|
// ── Subscription Payment ──────────────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/subscription-payment',
|
|
summary: 'Initiate a subscription payment',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['gateway', 'amount_rials'],
|
|
properties: [
|
|
new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']),
|
|
new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true),
|
|
new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Subscription payment initiated',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
new OA\Property(
|
|
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: 'order_id', type: 'string'),
|
|
],
|
|
type: 'object'
|
|
),
|
|
]
|
|
)
|
|
),
|
|
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'),
|
|
]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/subscription-payment', methods: ['POST'])]
|
|
public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$data = json_decode($request->getContent(), true) ?? [];
|
|
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
|
$frontendAddress = trim($data['frontend_address'] ?? '');
|
|
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
|
|
|
if ($amountRials <= 0) {
|
|
return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422);
|
|
}
|
|
|
|
if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) {
|
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
|
}
|
|
|
|
$gateway = $this->resolveGateway($gatewayName);
|
|
if ($gateway === 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 !== '') {
|
|
$payment->setMetadata(['period_uuid' => $periodUuid]);
|
|
}
|
|
$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);
|
|
|
|
return $this->success([
|
|
'payment_uuid' => $payment->getUuid(),
|
|
'redirect_url' => $result->redirectUrl,
|
|
'order_id' => $payment->getOrderId(),
|
|
]);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/subscription-payment/callback/{gateway}',
|
|
summary: 'Subscription payment gateway callback (public, IP-restricted)',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'gateway',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Callback processed — either a redirect or JSON result',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean'),
|
|
new OA\Property(property: 'payment', type: 'object'),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 302, description: 'Redirect to frontend with payment result'),
|
|
new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'),
|
|
new OA\Response(response: 404, description: 'Payment not found'),
|
|
]
|
|
)]
|
|
#[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
|
public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
|
{
|
|
return $this->callback($gateway, $request);
|
|
}
|
|
|
|
// ── Status ────────────────────────────────────────────────────────────────
|
|
|
|
#[OA\Get(
|
|
path: '/api/v1/payment/{uuid}',
|
|
summary: 'Get payment status by UUID',
|
|
security: [['bearerAuth' => []]],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'uuid',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string', format: 'uuid')
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Payment details',
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'success', type: 'boolean', example: true),
|
|
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'
|
|
),
|
|
],
|
|
type: 'object'
|
|
),
|
|
]
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
|
new OA\Response(response: 403, description: 'Forbidden'),
|
|
new OA\Response(response: 404, description: 'Payment not found'),
|
|
]
|
|
)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
|
public function config(): JsonResponse
|
|
{
|
|
return $this->success([
|
|
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
|
|
'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
|
|
]);
|
|
}
|
|
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/my/payments', methods: ['GET'])]
|
|
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$page = max(1, (int) $request->query->get('page', 1));
|
|
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
|
$status = $request->query->get('status');
|
|
|
|
$items = array_map(
|
|
fn(Payment $p) => $p->toArray(),
|
|
$this->paymentRepo->findByUser($user, $status, $page, $limit)
|
|
);
|
|
$total = $this->paymentRepo->countByUser($user, $status);
|
|
|
|
return $this->paginated($items, $total, $page, $limit);
|
|
}
|
|
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
|
|
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
|
|
{
|
|
$payment = $this->paymentRepo->findByUuid($uuid);
|
|
if ($payment === null) {
|
|
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
|
|
}
|
|
|
|
if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
|
}
|
|
|
|
return $this->success(['data' => $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;
|
|
}
|
|
|
|
return match ($name) {
|
|
'mellat' => $this->mellat,
|
|
'sep' => $this->sep,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
|
|
private function allowedHosts(): array
|
|
{
|
|
$fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? '');
|
|
$raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts;
|
|
return array_filter(array_map('trim', explode(',', $raw)));
|
|
}
|
|
|
|
private function isAllowedFrontend(string $url): bool
|
|
{
|
|
$hosts = $this->allowedHosts();
|
|
if (empty($hosts)) {
|
|
return false;
|
|
}
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
return in_array($host, $hosts, true);
|
|
}
|
|
|
|
private function isAllowedCallbackIp(string $ip): bool
|
|
{
|
|
if (empty($ip)) {
|
|
return false;
|
|
}
|
|
foreach (self::ALLOWED_CALLBACK_IPS as $cidr) {
|
|
[$subnet, $maskBits] = explode('/', $cidr);
|
|
$maskBits = (int) $maskBits;
|
|
$ipLong = ip2long($ip);
|
|
$subnetLong = ip2long($subnet);
|
|
if ($ipLong === false || $subnetLong === false) {
|
|
continue;
|
|
}
|
|
$mask = -1 << (32 - $maskBits);
|
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
|
return true;
|
|
}
|
|
}
|
|
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();
|
|
if (empty($base)) {
|
|
return new JsonResponse([
|
|
'success' => $success,
|
|
'payment' => $payment->toArray(),
|
|
]);
|
|
}
|
|
|
|
$sep = str_contains($base, '?') ? '&' : '?';
|
|
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
|
|
return new RedirectResponse($url);
|
|
}
|
|
}
|