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);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Payment\Entity;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'payments')]
#[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')]
#[ORM\Index(columns: ['user_id'], name: 'idx_payments_user')]
class Payment
{
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_REFUNDED = 'refunded';
public const TYPE_APPOINTMENT = 'appointment';
public const TYPE_SUBSCRIPTION = 'subscription';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'order_id', type: 'string', length: 64, unique: true)]
private string $orderId;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
#[ORM\Column(name: 'amount_rials', type: 'integer')]
private int $amountRials;
#[ORM\Column(type: 'string', length: 30)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(type: 'string', length: 20)]
private string $gateway;
#[ORM\Column(type: 'string', length: 30)]
private string $type;
#[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true)]
private ?string $gatewayToken = null;
#[ORM\Column(name: 'reference_id', type: 'string', length: 255, nullable: true)]
private ?string $referenceId = null;
#[ORM\Column(name: 'frontend_address', type: 'string', length: 500, nullable: true)]
private ?string $frontendAddress = null;
#[ORM\Column(name: 'callback_ip', type: 'string', length: 45, nullable: true)]
private ?string $callbackIp = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(User $user, int $amountRials, string $gateway, string $type, string $frontendAddress = '')
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->orderId = 'ORD-' . strtoupper(substr(str_replace('-', '', Uuid::v4()->toRfc4122()), 0, 16));
$this->user = $user;
$this->amountRials = $amountRials;
$this->gateway = $gateway;
$this->type = $type;
$this->frontendAddress = $frontendAddress ?: null;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getOrderId(): string { return $this->orderId; }
public function getUser(): User { return $this->user; }
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getAmountRials(): int { return $this->amountRials; }
public function getStatus(): string { return $this->status; }
public function getGateway(): string { return $this->gateway; }
public function getType(): string { return $this->type; }
public function getGatewayToken(): ?string { return $this->gatewayToken; }
public function getReferenceId(): ?string { return $this->referenceId; }
public function getFrontendAddress(): ?string { return $this->frontendAddress; }
public function getCallbackIp(): ?string { return $this->callbackIp; }
public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; }
public function setGatewayToken(?string $t): self { $this->gatewayToken = $t; $this->touch(); return $this; }
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'order_id' => $this->orderId,
'amount_rials' => $this->amountRials,
'status' => $this->status,
'gateway' => $this->gateway,
'type' => $this->type,
'reference_id' => $this->referenceId,
'appointment_uuid' => $this->appointment?->getUuid(),
'created_at' => $this->createdAt,
];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Payment\Gateway;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MellatGateway implements PaymentGatewayInterface
{
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $terminalId,
private readonly string $username,
private readonly string $password,
) {}
public function getName(): string { return 'mellat'; }
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
try {
$response = $this->httpClient->request('POST',
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
'timeout' => 10,
]
);
$resCode = $this->parseResCode($response->getContent());
if ($resCode !== '0') {
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
}
$refId = $this->parseRefId($response->getContent());
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId);
} catch (\Throwable $e) {
return new PaymentInitResult(false, errorMessage: $e->getMessage());
}
}
public function verify(array $callbackData): PaymentVerifyResult
{
$refId = $callbackData['RefId'] ?? '';
$resCode = $callbackData['ResCode'] ?? '';
if ($resCode !== '0') {
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
}
try {
$response = $this->httpClient->request('POST',
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [
'body' => $this->buildVerifyPayload($refId),
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
'timeout' => 10,
]
);
$verifyCode = $this->parseResCode($response->getContent());
if ($verifyCode !== '0') {
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode");
}
return new PaymentVerifyResult(true, referenceId: $refId);
} catch (\Throwable $e) {
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
}
}
private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string
{
return <<<XML
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
<soapenv:Body>
<int:bpPayRequest>
<terminalId>{$this->terminalId}</terminalId>
<userName>{$this->username}</userName>
<userPassword>{$this->password}</userPassword>
<orderId>{$orderId}</orderId>
<amount>{$amount}</amount>
<localDate>{$this->date()}</localDate>
<localTime>{$this->time()}</localTime>
<additionalData></additionalData>
<callBackUrl>{$callbackUrl}</callBackUrl>
<payerId>0</payerId>
</int:bpPayRequest>
</soapenv:Body>
</soapenv:Envelope>
XML;
}
private function buildVerifyPayload(string $refId): string
{
return <<<XML
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
<soapenv:Body>
<int:bpVerifyRequest>
<terminalId>{$this->terminalId}</terminalId>
<userName>{$this->username}</userName>
<userPassword>{$this->password}</userPassword>
<orderId>{$refId}</orderId>
<saleOrderId>{$refId}</saleOrderId>
<saleReferenceId>{$refId}</saleReferenceId>
</int:bpVerifyRequest>
</soapenv:Body>
</soapenv:Envelope>
XML;
}
private function parseResCode(string $xml): string
{
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
$parts = explode(',', $m[1] ?? '');
return trim($parts[0] ?? '-1');
}
private function parseRefId(string $xml): string
{
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
$parts = explode(',', $m[1] ?? '');
return trim($parts[1] ?? '');
}
private function date(): string { return date('Ymd'); }
private function time(): string { return date('His'); }
}
@@ -0,0 +1,18 @@
<?php
namespace App\Payment\Gateway;
interface PaymentGatewayInterface
{
public function getName(): string;
/**
* Initiates payment, returns redirect URL or token.
*/
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult;
/**
* Verifies callback and confirms payment.
*/
public function verify(array $callbackData): PaymentVerifyResult;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Payment\Gateway;
final class PaymentInitResult
{
public function __construct(
public readonly bool $success,
public readonly string $redirectUrl = '',
public readonly string $token = '',
public readonly string $errorMessage = '',
) {}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Payment\Gateway;
final class PaymentVerifyResult
{
public function __construct(
public readonly bool $success,
public readonly string $referenceId = '',
public readonly string $errorMessage = '',
public readonly int $amountRials = 0,
) {}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Payment\Gateway;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SepGateway implements PaymentGatewayInterface
{
private const TOKEN_URL = 'https://sep.shaparak.ir/onlinepg/onlinepg';
private const PAYMENT_URL = 'https://sep.shaparak.ir/OnlinePG/OnlinePG';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $terminalId,
) {}
public function getName(): string { return 'sep'; }
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
try {
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
'json' => [
'action' => 'token',
'TerminalId' => $this->terminalId,
'Amount' => $amountRials,
'ResNum' => $orderId,
'RedirectUrl' => $callbackUrl,
],
'timeout' => 10,
]);
$data = $response->toArray();
if (($data['status'] ?? -1) !== 1) {
return new PaymentInitResult(false, errorMessage: $data['errorDesc'] ?? 'SEP error');
}
$token = $data['token'];
$redirectUrl = self::PAYMENT_URL . '?Token=' . $token . '&GetMethod=true';
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $token);
} catch (\Throwable $e) {
return new PaymentInitResult(false, errorMessage: $e->getMessage());
}
}
public function verify(array $callbackData): PaymentVerifyResult
{
$state = $callbackData['State'] ?? '';
if (strtolower($state) !== 'ok') {
return new PaymentVerifyResult(false, errorMessage: "Payment state: $state");
}
$refNum = $callbackData['RefNum'] ?? '';
try {
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
'json' => [
'action' => 'verify',
'TerminalId' => $this->terminalId,
'RefNum' => $refNum,
],
'timeout' => 10,
]);
$data = $response->toArray();
if (($data['TransactionDetail']['AffectiveAmount'] ?? 0) <= 0) {
return new PaymentVerifyResult(false, errorMessage: 'SEP verify failed');
}
return new PaymentVerifyResult(
true,
referenceId: $refNum,
amountRials: (int) $data['TransactionDetail']['AffectiveAmount']
);
} catch (\Throwable $e) {
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Payment\Repository;
use App\Payment\Entity\Payment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Payment::class);
}
public function findByUuid(string $uuid): ?Payment
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByOrderId(string $orderId): ?Payment
{
return $this->findOneBy(['orderId' => $orderId]);
}
public function save(Payment $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Payment\Service;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class CircuitBreakerService
{
private const FAIL_THRESHOLD = 3;
private const OPEN_TTL = 300; // 5 minutes
public function __construct(private readonly CacheInterface $cache) {}
public function isOpen(string $gateway): bool
{
$item = $this->cache->getItem('cb_open_' . $gateway);
return $item->isHit();
}
public function recordFailure(string $gateway): void
{
$countKey = 'cb_fail_' . $gateway;
$item = $this->cache->getItem($countKey);
$count = ($item->isHit() ? (int) $item->get() : 0) + 1;
$item->set($count)->expiresAfter(self::OPEN_TTL);
$this->cache->save($item);
if ($count >= self::FAIL_THRESHOLD) {
$openItem = $this->cache->getItem('cb_open_' . $gateway);
$openItem->set(true)->expiresAfter(self::OPEN_TTL);
$this->cache->save($openItem);
}
}
public function recordSuccess(string $gateway): void
{
$this->cache->deleteItem('cb_fail_' . $gateway);
$this->cache->deleteItem('cb_open_' . $gateway);
}
}