<?php declare(strict_types=1);
namespace App\Subscriber;
use App\Entity\CreditHistory;
use App\Entity\PaymentVxsOrder;
use App\Entity\User;
use App\Event\CreditsEvent;
use Doctrine\DBAL\Connection;
use Doctrine\Persistence\ManagerRegistry;
use LogicException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ManageCreditsSubscriber implements EventSubscriberInterface
{
private ManagerRegistry $doctrine;
private LoggerInterface $logger;
public function __construct(ManagerRegistry $doctrine, LoggerInterface $paymentLogger)
{
$this->doctrine = $doctrine;
$this->logger = $paymentLogger;
}
public function onCredits(CreditsEvent $event): void
{
/** @var Connection $connection */
$connection = $this->doctrine->getConnection();
if (!$connection->isTransactionActive()) {
throw new RuntimeException('You must start DB transaction in order to use this event');
}
if (!($event->getUser() instanceof User)) {
throw new RuntimeException('The event must have a user.');
}
$eventLog = 'CreditsEvent: user: ' . $event->getUser()->getId();
$eventLog .= ' action: ' . $event->getAction();
$this->logger->info($eventLog . ' start');
$creditsRelated = $event->getRelatedEntity();
if ($creditsRelated instanceof PaymentVxsOrder) {
$eventLog .= ' ' . CreditsEvent::PROVIDER_VXS;
}
if (null !== $event->getBillAt()) {
$eventLog .= ' bill at: ' . $event->getBillAt()->format('Y-m-d H:i:s');
}
$credits = $event->getCredits();
if (in_array($event->getAction(), [CreditsEvent::ACTION_CHARGEBACK, CreditsEvent::ACTION_REFUND], true)) {
$credits *= -1;
$eventLog .= ' deduct';
}
$this->manageCredits($event, $credits);
$this->logger->info($eventLog . ' end');
$this->doctrine->getManager()->flush();
}
private function manageCredits(CreditsEvent $event, int $creditsChange): void
{
if ($creditsChange === 0) {
return;
}
$user = $event->getUser();
if (null === $user || !($user instanceof User)) {
throw new RuntimeException('User must be present in the credits event.');
}
$userCredits = $user->getCredits();
$this->doctrine->getRepository(User::class)->addCredits($user, $creditsChange);
if ($user->getCredits() >= 0) {
return;
}
if (!in_array($event->getAction(), [CreditsEvent::ACTION_CHARGEBACK, CreditsEvent::ACTION_REFUND], true)) {
$this->logger->error('User ' . $user->getId() . ' performed a transaction with type "' . $event->getAction() . '" that lead to negative balance.');
throw new LogicException('User will have negative credits after the operation "' . $event->getAction() . '" which is not VXS chargeback/refund.');
}
$this->logger->warning(
sprintf(
'User with id %s had low credits problem. His credits were %s adding %s',
$user->getId(),
$userCredits,
$creditsChange
),
['Payment']
);
$refundedCredits = $this->refundPurchases($user, $user->getCredits());
if (0 === $refundedCredits) {
return;
}
$this->doctrine->getRepository(User::class)->addCredits($user, $refundedCredits);
}
private function refundPurchases(User $user, int $missingCredits): int
{
$refundedCredits = 0;
/** @var CreditHistory[] $creditCharges */
$creditCharges = [];
$page = 0;
while ($missingCredits < 0) {
if (0 === count($creditCharges)) {
/** @var CreditHistory[] $creditCharges */
$creditCharges = $this->doctrine->getRepository(CreditHistory::class)
// select charges of this user
->filter([
'user' => $user->getId(),
'operation' => [
CreditHistory::OPERATIONS[CreditsEvent::ACTION_DEAL_BID],
CreditHistory::OPERATIONS[CreditsEvent::ACTION_PROJECT_BID],
],
'refunded' => 0,
'credits' => '!=0',
])
->addOrderBy('e.lastModified', 'DESC')
->addOrderBy('e.id', 'DESC')
->setFirstResult($page++)
->setMaxResults(40)->getQuery()->getResult();
}
$creditCharge = array_shift($creditCharges);
if (null === $creditCharge) {
// There are no credit charges to refund.
// Then there is the only way: remove all missing from an account.
return -1 * $missingCredits;
}
$proposalBid = $creditCharge->getProjectBid() ?? $creditCharge->getDealBid();
if (null === $proposalBid || $proposalBid->isCanceled() || $proposalBid->isAccessed()) {
continue;
}
// charge is the negative number
$missingCredits -= $creditCharge->getCredits();
$refundedCredits += -1 * $creditCharge->getCredits();
$creditCharge->setRefunded(true);
$proposalBid->setRefunded(true);
}
return $refundedCredits;
}
/**
* @return array<string, array<int|string, array<int|string, int|string>|int|string>|string>
*/
public static function getSubscribedEvents(): array
{
return [
CreditsEvent::class => 'onCredits',
];
}
}