From 61200adc74f0ae7dd9d1af437031e79d075fa181 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Mon, 23 Mar 2026 00:12:30 +0100 Subject: [PATCH] Add stock management, order notifications, webhooks, expiration cron, and billet type validation - Decrement billet quantity after purchase in BilletOrderService::generateOrderTickets - Block purchase when stock is exhausted (quantity <= 0) in OrderController::buildOrderItems - Add organizer email notification on new order (order_notification_orga template) - Add organizer email notification on cancel/refund (order_cancelled_orga template) - Add ExpirePendingOrdersCommand (app:orders:expire-pending) cron every 5min via Ansible - Cancels pending orders older than 30 minutes, restores stock, invalidates tickets - Includes BilletBuyerRepository::findExpiredPending query method - 3 unit tests covering: no expired orders, stock restoration, unlimited billets - Add payment_intent.payment_failed webhook: cancels order, logs audit, emails buyer - Add charge.refunded webhook: sets order to refunded, invalidates tickets, notifies orga and buyer - Validate billet type (billet/reservation_brocante/vote) against organizer offer - getAllowedBilletTypes: gratuit=billet only, basic/sur-mesure=all types - Server-side validation in hydrateBilletFromRequest, UI filtering in templates - Update TASK_CHECKUP.md: all Billetterie & Commandes items now complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASK_CHECKUP.md | 16 +-- ansible/deploy.yml | 7 + config/reference.php | 2 +- package.json | 2 + src/Command/ExpirePendingOrdersCommand.php | 71 ++++++++++ src/Controller/AccountController.php | 32 ++++- src/Controller/OrderController.php | 10 +- src/Controller/StripeWebhookController.php | 85 +++++++++++- src/Repository/BilletBuyerRepository.php | 14 ++ src/Service/BilletOrderService.php | 48 ++++++- templates/account/add_billet.html.twig | 4 +- templates/account/edit_billet.html.twig | 4 +- .../email/order_cancelled_orga.html.twig | 53 ++++++++ .../email/order_notification_orga.html.twig | 55 ++++++++ templates/email/order_refunded.html.twig | 24 ++++ templates/email/payment_failed.html.twig | 28 ++++ .../ExpirePendingOrdersCommandTest.php | 123 ++++++++++++++++++ 17 files changed, 558 insertions(+), 20 deletions(-) create mode 100644 src/Command/ExpirePendingOrdersCommand.php create mode 100644 templates/email/order_cancelled_orga.html.twig create mode 100644 templates/email/order_notification_orga.html.twig create mode 100644 templates/email/order_refunded.html.twig create mode 100644 templates/email/payment_failed.html.twig create mode 100644 tests/Command/ExpirePendingOrdersCommandTest.php diff --git a/TASK_CHECKUP.md b/TASK_CHECKUP.md index 1fb6fa6..2514606 100644 --- a/TASK_CHECKUP.md +++ b/TASK_CHECKUP.md @@ -3,14 +3,14 @@ ## A faire ### Billetterie & Commandes -- [ ] Décrémenter la quantité disponible du billet après achat (stock management) -- [ ] Empêcher l'achat si stock épuisé (vérification côté serveur) -- [ ] Ajouter un email de notification à l'orga quand une commande est passée -- [ ] Ajouter un email de notification à l'orga quand une commande est annulée/remboursée -- [ ] Gérer l'expiration des commandes pending (cron pour annuler après X minutes) -- [ ] Ajouter le webhook `payment_intent.payment_failed` pour gérer les échecs -- [ ] Ajouter le webhook `charge.refunded` pour mettre à jour le statut automatiquement -- [ ] Vérifier le type de billet (billet/reservation_brocante/vote) selon l'offre orga à la création +- [x] Décrémenter la quantité disponible du billet après achat (stock management) +- [x] Empêcher l'achat si stock épuisé (vérification côté serveur) +- [x] Ajouter un email de notification à l'orga quand une commande est passée +- [x] Ajouter un email de notification à l'orga quand une commande est annulée/remboursée +- [x] Gérer l'expiration des commandes pending (cron pour annuler après X minutes) +- [x] Ajouter le webhook `payment_intent.payment_failed` pour gérer les échecs +- [x] Ajouter le webhook `charge.refunded` pour mettre à jour le statut automatiquement +- [x] Vérifier le type de billet (billet/reservation_brocante/vote) selon l'offre orga à la création ### Invitations Organisateur - [x] Bloquer l'envoi d'invitations (billets) si Stripe n'est pas validé — non nécessaire car invitations = gratuit (pas de paiement Stripe) diff --git a/ansible/deploy.yml b/ansible/deploy.yml index 3dd5b51..75190b3 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -189,6 +189,13 @@ job: "/var/backups/e-ticket/backup.sh >> /var/log/e-ticket-backup.log 2>&1" user: bot + - name: Configure expire pending orders cron (every 5 minutes) + cron: + name: "e-ticket expire pending orders" + minute: "*/5" + job: "docker compose -f /var/www/e-ticket/docker-compose-prod.yml exec -T php php bin/console app:orders:expire-pending --env=prod >> /var/log/e-ticket-expire-orders.log 2>&1" + user: bot + - name: Configure messenger monitor cron (every hour) cron: name: "e-ticket messenger monitor" diff --git a/config/reference.php b/config/reference.php index 4795ff8..5aa5c1e 100644 --- a/config/reference.php +++ b/config/reference.php @@ -622,7 +622,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * rate_limiter?: bool|array{ // Rate limiter configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * limiters?: arraybuyerRepo->findExpiredPending($threshold); + + $count = \count($orders); + + if (0 === $count) { + $io->success('No expired pending orders.'); + + return Command::SUCCESS; + } + + foreach ($orders as $order) { + $order->setStatus('cancelled'); + + $tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); + foreach ($tickets as $ticket) { + $ticket->setState(BilletOrder::STATE_INVALID); + } + + foreach ($order->getItems() as $item) { + $billet = $item->getBillet(); + if ($billet && null !== $billet->getQuantity()) { + $billet->setQuantity($billet->getQuantity() + $item->getQuantity()); + } + } + + $this->audit->log('order_expired', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'event' => $order->getEvent()->getTitle(), + ]); + } + + $this->em->flush(); + + $io->success($count.' pending order(s) expired.'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index c306f1b..6abb574 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -653,6 +653,7 @@ class AccountController extends AbstractController return $this->render('account/add_billet.html.twig', [ 'event' => $event, 'category' => $category, + 'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()), 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, @@ -691,6 +692,7 @@ class AccountController extends AbstractController return $this->render('account/edit_billet.html.twig', [ 'event' => $event, 'billet' => $billet, + 'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()), 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, @@ -849,7 +851,7 @@ class AccountController extends AbstractController } #[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])] - public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit): Response + public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); @@ -878,6 +880,8 @@ class AccountController extends AbstractController 'event' => $event->getTitle(), ]); + $billetOrderService->notifyOrganizerCancelled($order, 'annulee'); + $this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); @@ -887,7 +891,7 @@ class AccountController extends AbstractController * @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])] - public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit): Response + public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); @@ -927,6 +931,8 @@ class AccountController extends AbstractController 'totalHT' => $order->getTotalHTDecimal(), ]); + $billetOrderService->notifyOrganizerCancelled($order, 'remboursee'); + $this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); @@ -1238,6 +1244,17 @@ class AccountController extends AbstractController } } + /** + * @return string[] + */ + public static function getAllowedBilletTypes(?string $offer): array + { + return match ($offer) { + 'basic', 'sur-mesure' => ['billet', 'reservation_brocante', 'vote'], + default => ['billet'], + }; + } + private function hydrateBilletFromRequest(Billet $billet, Request $request): void { $billet->setName(trim($request->request->getString('name'))); @@ -1247,7 +1264,16 @@ class AccountController extends AbstractController $billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet')); $billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit')); $billet->setNotBuyable($request->request->getBoolean('not_buyable')); - $billet->setType($request->request->getString('type', 'billet')); + + $type = $request->request->getString('type', 'billet'); + /** @var User $user */ + $user = $this->getUser(); + $allowedTypes = self::getAllowedBilletTypes($user->getOffer()); + if (!\in_array($type, $allowedTypes, true)) { + $type = 'billet'; + } + $billet->setType($type); + $billet->setDescription(trim($request->request->getString('description')) ?: null); $pictureFile = $request->files->get('picture'); diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index b14460e..1d641a2 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -210,6 +210,7 @@ class OrderController extends AbstractController 'totalHT' => $order->getTotalHTDecimal(), ]); $billetOrderService->generateAndSendTickets($order); + $billetOrderService->notifyOrganizer($order); } $failed = 'failed' === $redirectStatus; @@ -307,8 +308,13 @@ class OrderController extends AbstractController continue; } - if (!$billet->isUnlimited() && $qty > $billet->getQuantity()) { - $qty = $billet->getQuantity(); + if (!$billet->isUnlimited()) { + if ($billet->getQuantity() <= 0) { + continue; + } + if ($qty > $billet->getQuantity()) { + $qty = $billet->getQuantity(); + } } $orderItem = new BilletBuyerItem(); diff --git a/src/Controller/StripeWebhookController.php b/src/Controller/StripeWebhookController.php index 4f9e0a9..3985b02 100644 --- a/src/Controller/StripeWebhookController.php +++ b/src/Controller/StripeWebhookController.php @@ -3,11 +3,13 @@ namespace App\Controller; use App\Entity\BilletBuyer; +use App\Entity\BilletOrder; use App\Entity\Payout; use App\Entity\User; use App\Service\BilletOrderService; use App\Service\MailerService; use App\Service\PayoutPdfService; +use App\Service\AuditService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route; class StripeWebhookController extends AbstractController { #[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])] - public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService, BilletOrderService $billetOrderService): Response + public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService, BilletOrderService $billetOrderService, AuditService $audit): Response { $payload = $request->getContent(); $signature = $request->headers->get('Stripe-Signature', ''); @@ -34,6 +36,8 @@ class StripeWebhookController extends AbstractController match ($type) { 'payout.created', 'payout.updated', 'payout.paid', 'payout.failed', 'payout.canceled' => $this->handlePayout($event, $em, $mailerService, $pdfService), 'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event, $em, $billetOrderService), + 'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event, $em, $mailerService, $audit), + 'charge.refunded' => $this->handleChargeRefunded($event, $em, $mailerService, $audit, $billetOrderService), default => null, }; @@ -137,6 +141,85 @@ class StripeWebhookController extends AbstractController $billetOrderService->generateOrderTickets($order); $billetOrderService->generateAndSendTickets($order); + $billetOrderService->notifyOrganizer($order); + } + + private function handlePaymentIntentFailed(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, AuditService $audit): void + { + $paymentIntent = $event->data->object; + $orderId = $paymentIntent->metadata->order_id ?? null; + + if (!$orderId) { + return; + } + + $order = $em->getRepository(BilletBuyer::class)->find((int) $orderId); + if (!$order || BilletBuyer::STATUS_PENDING !== $order->getStatus()) { + return; + } + + $errorMessage = $paymentIntent->last_payment_error->message ?? 'Paiement refuse'; + + $order->setStatus(BilletBuyer::STATUS_CANCELLED); + $em->flush(); + + $audit->log('payment_failed', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'error' => $errorMessage, + ]); + + if ($order->getEmail()) { + $mailerService->sendEmail( + $order->getEmail(), + 'Echec de paiement - '.$order->getEvent()->getTitle(), + $this->renderView('email/payment_failed.html.twig', [ + 'order' => $order, + 'errorMessage' => $errorMessage, + ]), + ); + } + } + + private function handleChargeRefunded(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, AuditService $audit, BilletOrderService $billetOrderService): void + { + $charge = $event->data->object; + $paymentIntentId = $charge->payment_intent ?? null; + + if (!$paymentIntentId) { + return; + } + + $order = $em->getRepository(BilletBuyer::class)->findOneBy(['stripeSessionId' => $paymentIntentId]); + if (!$order || BilletBuyer::STATUS_REFUNDED === $order->getStatus()) { + return; + } + + $order->setStatus(BilletBuyer::STATUS_REFUNDED); + + $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); + foreach ($tickets as $ticket) { + $ticket->setState(BilletOrder::STATE_INVALID); + } + + $em->flush(); + + $audit->log('order_refunded_webhook', 'BilletBuyer', $order->getId(), [ + 'orderNumber' => $order->getOrderNumber(), + 'event' => $order->getEvent()->getTitle(), + 'totalHT' => $order->getTotalHTDecimal(), + ]); + + $billetOrderService->notifyOrganizerCancelled($order, 'remboursee'); + + if ($order->getEmail()) { + $mailerService->sendEmail( + $order->getEmail(), + 'Remboursement - '.$order->getEvent()->getTitle(), + $this->renderView('email/order_refunded.html.twig', [ + 'order' => $order, + ]), + ); + } } private function handlePayout(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): void diff --git a/src/Repository/BilletBuyerRepository.php b/src/Repository/BilletBuyerRepository.php index 0104507..1032b4c 100644 --- a/src/Repository/BilletBuyerRepository.php +++ b/src/Repository/BilletBuyerRepository.php @@ -15,4 +15,18 @@ class BilletBuyerRepository extends ServiceEntityRepository { parent::__construct($registry, BilletBuyer::class); } + + /** + * @return BilletBuyer[] + */ + public function findExpiredPending(\DateTimeImmutable $threshold): array + { + return $this->createQueryBuilder('o') + ->where('o.status = :status') + ->andWhere('o.createdAt < :threshold') + ->setParameter('status', BilletBuyer::STATUS_PENDING) + ->setParameter('threshold', $threshold) + ->getQuery() + ->getResult(); + } } diff --git a/src/Service/BilletOrderService.php b/src/Service/BilletOrderService.php index 73e7eef..4cdfbd0 100644 --- a/src/Service/BilletOrderService.php +++ b/src/Service/BilletOrderService.php @@ -35,7 +35,16 @@ class BilletOrderService { foreach ($order->getItems() as $item) { $billet = $item->getBillet(); - if (!$billet || 'billet' !== $billet->getType() || !$billet->isGeneratedBillet()) { + if (!$billet) { + continue; + } + + if (null !== $billet->getQuantity()) { + $newQty = $billet->getQuantity() - $item->getQuantity(); + $billet->setQuantity(max(0, $newQty)); + } + + if ('billet' !== $billet->getType() || !$billet->isGeneratedBillet()) { continue; } @@ -58,6 +67,25 @@ class BilletOrderService $this->orderIndex->indexOrder($order); } + public function notifyOrganizerCancelled(BilletBuyer $order, string $action = 'annulee'): void + { + $organizer = $order->getEvent()->getAccount(); + if (!$organizer || !$organizer->getEmail()) { + return; + } + + $html = $this->twig->render('email/order_cancelled_orga.html.twig', [ + 'order' => $order, + 'action' => $action, + ]); + + $this->mailer->sendEmail( + $organizer->getEmail(), + 'Commande '.$action.' - '.$order->getEvent()->getTitle(), + $html, + ); + } + public function generatePdf(BilletOrder $ticket): string { $order = $ticket->getBilletBuyer(); @@ -130,6 +158,24 @@ class BilletOrderService return $dompdf->output(); } + public function notifyOrganizer(BilletBuyer $order): void + { + $organizer = $order->getEvent()->getAccount(); + if (!$organizer || !$organizer->getEmail()) { + return; + } + + $html = $this->twig->render('email/order_notification_orga.html.twig', [ + 'order' => $order, + ]); + + $this->mailer->sendEmail( + $organizer->getEmail(), + 'Nouvelle commande - '.$order->getEvent()->getTitle(), + $html, + ); + } + public function generateAndSendTickets(BilletBuyer $order): void { $tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); diff --git a/templates/account/add_billet.html.twig b/templates/account/add_billet.html.twig index a6bc466..08145c8 100644 --- a/templates/account/add_billet.html.twig +++ b/templates/account/add_billet.html.twig @@ -22,8 +22,8 @@ diff --git a/templates/account/edit_billet.html.twig b/templates/account/edit_billet.html.twig index ee9d789..90a1db2 100644 --- a/templates/account/edit_billet.html.twig +++ b/templates/account/edit_billet.html.twig @@ -22,8 +22,8 @@ diff --git a/templates/email/order_cancelled_orga.html.twig b/templates/email/order_cancelled_orga.html.twig new file mode 100644 index 0000000..cb9005e --- /dev/null +++ b/templates/email/order_cancelled_orga.html.twig @@ -0,0 +1,53 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Commande {{ action }} - {{ order.event.title }}{% endblock %} + +{% block content %} +

Commande {{ action }}

+

Bonjour,

+

La commande {{ order.orderNumber }} pour l'evenement {{ order.event.title }} a ete {{ action }}.

+ + + + + + + + + + + + + + + + + + + + + + +
Commande{{ order.orderNumber }}
Statut{{ action|upper }}
Acheteur{{ order.firstName }} {{ order.lastName }}
Email{{ order.email }}
Montant HT{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
+ + + + + + + + + + + {% for item in order.items %} + + + + + + {% endfor %} + +
BilletQtTotal HT
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
+ +

Les billets associes a cette commande ont ete invalides. Vous pouvez consulter le detail depuis votre espace organisateur.

+{% endblock %} diff --git a/templates/email/order_notification_orga.html.twig b/templates/email/order_notification_orga.html.twig new file mode 100644 index 0000000..8c3d5a8 --- /dev/null +++ b/templates/email/order_notification_orga.html.twig @@ -0,0 +1,55 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Nouvelle commande - {{ order.event.title }}{% endblock %} + +{% block content %} +

Nouvelle commande !

+

Bonjour,

+

Une nouvelle commande a ete passee pour votre evenement {{ order.event.title }}.

+ + + + + + + + + + + + + + + + + + +
Commande{{ order.orderNumber }}
Acheteur{{ order.firstName }} {{ order.lastName }}
Email{{ order.email }}
Date{{ order.paidAt|date('d/m/Y H:i') }}
+ + + + + + + + + + + {% for item in order.items %} + + + + + + {% endfor %} + + + + + + + +
BilletQtTotal HT
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
Total HT{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
+ +

Vous pouvez consulter le detail de cette commande depuis votre espace organisateur, onglet Statistiques.

+{% endblock %} diff --git a/templates/email/order_refunded.html.twig b/templates/email/order_refunded.html.twig new file mode 100644 index 0000000..b54f34b --- /dev/null +++ b/templates/email/order_refunded.html.twig @@ -0,0 +1,24 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Remboursement - {{ order.event.title }}{% endblock %} + +{% block content %} +

Votre commande a ete remboursee

+

Bonjour {{ order.firstName }},

+

Votre commande {{ order.orderNumber }} pour l'evenement {{ order.event.title }} a ete remboursee.

+ + + + + + + + + + +
Commande{{ order.orderNumber }}
Montant rembourse{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
+ +

Le remboursement sera visible sur votre releve bancaire sous 5 a 10 jours ouvrables. Les billets associes a cette commande ont ete invalides.

+ +

Si vous avez des questions, contactez l'organisateur de l'evenement.

+{% endblock %} diff --git a/templates/email/payment_failed.html.twig b/templates/email/payment_failed.html.twig new file mode 100644 index 0000000..422e4d5 --- /dev/null +++ b/templates/email/payment_failed.html.twig @@ -0,0 +1,28 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Echec de paiement - {{ order.event.title }}{% endblock %} + +{% block content %} +

Echec de paiement

+

Bonjour {{ order.firstName }},

+

Votre paiement pour la commande {{ order.orderNumber }} (evenement {{ order.event.title }}) n'a pas pu aboutir.

+ + + + + + + + + + + + + + +
Commande{{ order.orderNumber }}
Montant{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
Motif{{ errorMessage }}
+ +

Votre commande a ete annulee. Vous pouvez retenter votre achat depuis la page de l'evenement.

+ +

Si vous pensez qu'il s'agit d'une erreur, contactez votre banque ou reessayez avec un autre moyen de paiement.

+{% endblock %} diff --git a/tests/Command/ExpirePendingOrdersCommandTest.php b/tests/Command/ExpirePendingOrdersCommandTest.php new file mode 100644 index 0000000..022ce20 --- /dev/null +++ b/tests/Command/ExpirePendingOrdersCommandTest.php @@ -0,0 +1,123 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->buyerRepo = $this->createMock(BilletBuyerRepository::class); + $this->audit = $this->createMock(AuditService::class); + + $command = new ExpirePendingOrdersCommand($this->em, $this->buyerRepo, $this->audit); + $app = new Application(); + $app->addCommand($command); + $this->tester = new CommandTester($app->find('app:orders:expire-pending')); + } + + public function testNoExpiredOrders(): void + { + $this->buyerRepo->method('findExpiredPending')->willReturn([]); + $this->em->expects($this->never())->method('flush'); + + $this->tester->execute([]); + $this->assertStringContainsString('No expired pending orders', $this->tester->getDisplay()); + } + + public function testExpiresOldPendingOrders(): void + { + $event = $this->createMock(Event::class); + $event->method('getTitle')->willReturn('Test Event'); + + $billet = $this->createMock(Billet::class); + $billet->method('getQuantity')->willReturn(5); + + $item = $this->createMock(BilletBuyerItem::class); + $item->method('getBillet')->willReturn($billet); + $item->method('getQuantity')->willReturn(2); + + $order = $this->createMock(BilletBuyer::class); + $order->method('getId')->willReturn(1); + $order->method('getOrderNumber')->willReturn('2026-03-23-1'); + $order->method('getEvent')->willReturn($event); + $order->method('getItems')->willReturn(new ArrayCollection([$item])); + + $order->expects($this->once())->method('setStatus')->with('cancelled'); + $billet->expects($this->once())->method('setQuantity')->with(7); + + $this->buyerRepo->method('findExpiredPending')->willReturn([$order]); + + $ticket = $this->createMock(BilletOrder::class); + $ticket->expects($this->once())->method('setState')->with(BilletOrder::STATE_INVALID); + + $ticketRepo = $this->createMock(EntityRepository::class); + $ticketRepo->method('findBy')->willReturn([$ticket]); + + $this->em->method('getRepository') + ->with(BilletOrder::class) + ->willReturn($ticketRepo); + + $this->em->expects($this->once())->method('flush'); + + $this->audit->expects($this->once()) + ->method('log') + ->with('order_expired', 'BilletBuyer', 1, $this->anything()); + + $this->tester->execute([]); + $this->assertStringContainsString('1 pending order(s) expired', $this->tester->getDisplay()); + } + + public function testExpiresOrderWithUnlimitedBillet(): void + { + $event = $this->createMock(Event::class); + $event->method('getTitle')->willReturn('Test'); + + $billet = $this->createMock(Billet::class); + $billet->method('getQuantity')->willReturn(null); + + $item = $this->createMock(BilletBuyerItem::class); + $item->method('getBillet')->willReturn($billet); + $item->method('getQuantity')->willReturn(1); + + $order = $this->createMock(BilletBuyer::class); + $order->method('getId')->willReturn(2); + $order->method('getOrderNumber')->willReturn('2026-03-23-2'); + $order->method('getEvent')->willReturn($event); + $order->method('getItems')->willReturn(new ArrayCollection([$item])); + + $billet->expects($this->never())->method('setQuantity'); + + $this->buyerRepo->method('findExpiredPending')->willReturn([$order]); + + $ticketRepo = $this->createMock(EntityRepository::class); + $ticketRepo->method('findBy')->willReturn([]); + + $this->em->method('getRepository') + ->with(BilletOrder::class) + ->willReturn($ticketRepo); + + $this->tester->execute([]); + $this->assertStringContainsString('1 pending order(s) expired', $this->tester->getDisplay()); + } +}