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:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
@@ -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);
}
}