feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
<?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\Payment\Gateway\MellatGateway;
|
||||
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 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;
|
||||
|
||||
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 CircuitBreakerService $circuitBreaker,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
|
||||
// ── Appointment Payment ───────────────────────────────────────────────────
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, 150000, $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) ───────────────────────────────────
|
||||
|
||||
#[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->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) {
|
||||
$payment->setStatus(Payment::STATUS_FAILED);
|
||||
$this->paymentRepo->save($payment);
|
||||
$this->circuitBreaker->recordFailure($gateway);
|
||||
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
|
||||
$this->circuitBreaker->recordSuccess($gateway);
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$payment->setReferenceId($result->referenceId);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->redirectToFrontend($payment, true);
|
||||
}
|
||||
|
||||
// ── Subscription Payment ──────────────────────────────────────────────────
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[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
|
||||
{
|
||||
return match ($name) {
|
||||
'mellat' => $this->mellat,
|
||||
'sep' => $this->sep,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function isAllowedFrontend(string $url): bool
|
||||
{
|
||||
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user