Files
clinicpro/src/Payment/Controller/PaymentController.php
T
hamed c247ac2c80 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.
2026-07-02 15:36:08 +03:30

488 lines
22 KiB
PHP

<?php
namespace App\Payment\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Payment\Entity\Payment;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\PaymentManager;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
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 GatewayFactory $gateways,
private readonly PaymentManager $paymentManager,
private readonly SiteConfigRepository $configRepo,
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: 'pay_url', type: 'string', format: 'uri', description: 'مرورگر به این آدرس بک‌اند هدایت شود؛ بک‌اند به درگاه منتقل می‌کند'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[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');
}
// اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام
// نمی‌شود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام می‌گیرد.
if ($this->gateways->resolve($gatewayName) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
}
$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);
// مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک
// + انتقال به درگاه انجام می‌شود. کلاینت هرگز مستقیم به بانک ریکوست نمی‌زند.
return $this->success([
'payment_uuid' => $payment->getUuid(),
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
'order_id' => $payment->getOrderId(),
]);
}
// ── Gateway hand-off (public — browser redirect to the bank) ───────────────
#[OA\Get(
path: '/api/v1/payment/pay/{orderId}',
summary: 'Redirect the browser to the payment gateway for a pending payment',
parameters: [
new OA\Parameter(name: 'orderId', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 302, description: 'Redirect (GET gateway) or auto-submitting POST form (POST gateway)'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])]
public function pay(string $orderId): \Symfony\Component\HttpFoundation\Response
{
$payment = $this->paymentRepo->findByOrderId($orderId);
if ($payment === null) {
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
}
// فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay).
if ($payment->getStatus() !== Payment::STATUS_PENDING) {
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
// ارتباط با بانک + init در سرویس انجام می‌شود؛ کنترلر فقط انتقال HTTP را می‌سازد.
$result = $this->paymentManager->startGatewayHandoff($payment);
if ($result === false) {
return $this->redirectToFrontend($payment, false);
}
// انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت).
if ($result->redirectMethod === 'POST') {
return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams);
}
return new RedirectResponse($result->redirectUrl);
}
/** یک صفحهٔ HTML با فرمی که به‌صورت خودکار (POST) به درگاه ارسال می‌شود. */
private function autoSubmitForm(string $action, array $params): \Symfony\Component\HttpFoundation\Response
{
$fields = '';
foreach ($params as $name => $value) {
$fields .= sprintf(
'<input type="hidden" name="%s" value="%s">',
htmlspecialchars((string) $name, ENT_QUOTES),
htmlspecialchars((string) $value, ENT_QUOTES)
);
}
$safeAction = htmlspecialchars($action, ENT_QUOTES);
$html = <<<HTML
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><title>در حال انتقال به درگاه پرداخت…</title></head>
<body onload="document.forms[0].submit()"><p style="font-family:Tahoma,sans-serif;text-align:center;margin-top:40px">در حال انتقال به درگاه پرداخت…</p>
<form method="POST" action="{$safeAction}">{$fields}<noscript><button type="submit">ادامه</button></noscript></form></body></html>
HTML;
return new \Symfony\Component\HttpFoundation\Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
}
// ── 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() ?? '';
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'] ?? '';
// 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);
}
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
// ── 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: 'pay_url', type: 'string', format: 'uri'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[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');
}
if ($this->gateways->resolve($gatewayName) === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
}
$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);
// مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام می‌شود.
return $this->success([
'payment_uuid' => $payment->getUuid(),
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
'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: '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'
),
]
)
),
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->gateways->isTestMode(),
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
'gateways' => $this->gateways->activeGateways(),
]);
}
#[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($payment->toArray());
}
// ── Private helpers ───────────────────────────────────────────────────────
/** @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 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);
}
}