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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 00:12:30 +01:00
parent f03b33ac5a
commit 61200adc74
17 changed files with 558 additions and 20 deletions

View File

@@ -3,14 +3,14 @@
## A faire ## A faire
### Billetterie & Commandes ### Billetterie & Commandes
- [ ] Décrémenter la quantité disponible du billet après achat (stock management) - [x] 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) - [x] 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 - [x] 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 - [x] 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) - [x] 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 - [x] Ajouter le webhook `payment_intent.payment_failed` pour gérer les échecs
- [ ] Ajouter le webhook `charge.refunded` pour mettre à jour le statut automatiquement - [x] 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] Vérifier le type de billet (billet/reservation_brocante/vote) selon l'offre orga à la création
### Invitations Organisateur ### 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) - [x] Bloquer l'envoi d'invitations (billets) si Stripe n'est pas validé — non nécessaire car invitations = gratuit (pas de paiement Stripe)

View File

@@ -189,6 +189,13 @@
job: "/var/backups/e-ticket/backup.sh >> /var/log/e-ticket-backup.log 2>&1" job: "/var/backups/e-ticket/backup.sh >> /var/log/e-ticket-backup.log 2>&1"
user: bot 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) - name: Configure messenger monitor cron (every hour)
cron: cron:
name: "e-ticket messenger monitor" name: "e-ticket messenger monitor"

View File

@@ -622,7 +622,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* rate_limiter?: bool|array{ // Rate limiter configuration * rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: [] * limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"

View File

@@ -13,6 +13,8 @@
}, },
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "^20.8.4", "@happy-dom/global-registrator": "^20.8.4",
"@hotwired/stimulus": "^3.0.0",
"@spomky-labs/pwa-bundle": "file:vendor/spomky-labs/pwa-bundle/assets",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"eslint": "9", "eslint": "9",

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Command;
use App\Entity\BilletOrder;
use App\Repository\BilletBuyerRepository;
use App\Service\AuditService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:orders:expire-pending',
description: 'Cancel pending orders older than 30 minutes',
)]
class ExpirePendingOrdersCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private BilletBuyerRepository $buyerRepo,
private AuditService $audit,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$threshold = new \DateTimeImmutable('-30 minutes');
$orders = $this->buyerRepo->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;
}
}

View File

@@ -653,6 +653,7 @@ class AccountController extends AbstractController
return $this->render('account/add_billet.html.twig', [ return $this->render('account/add_billet.html.twig', [
'event' => $event, 'event' => $event,
'category' => $category, 'category' => $category,
'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()),
'breadcrumbs' => [ 'breadcrumbs' => [
self::BREADCRUMB_HOME, self::BREADCRUMB_HOME,
self::BREADCRUMB_ACCOUNT, self::BREADCRUMB_ACCOUNT,
@@ -691,6 +692,7 @@ class AccountController extends AbstractController
return $this->render('account/edit_billet.html.twig', [ return $this->render('account/edit_billet.html.twig', [
'event' => $event, 'event' => $event,
'billet' => $billet, 'billet' => $billet,
'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()),
'breadcrumbs' => [ 'breadcrumbs' => [
self::BREADCRUMB_HOME, self::BREADCRUMB_HOME,
self::BREADCRUMB_ACCOUNT, 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'])] #[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'); $this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -878,6 +880,8 @@ class AccountController extends AbstractController
'event' => $event->getTitle(), 'event' => $event->getTitle(),
]); ]);
$billetOrderService->notifyOrganizerCancelled($order, 'annulee');
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.'); $this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); 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 * @codeCoverageIgnore Requires live Stripe API
*/ */
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])] #[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'); $this->denyAccessUnlessGranted('ROLE_ORGANIZER');
@@ -927,6 +931,8 @@ class AccountController extends AbstractController
'totalHT' => $order->getTotalHTDecimal(), 'totalHT' => $order->getTotalHTDecimal(),
]); ]);
$billetOrderService->notifyOrganizerCancelled($order, 'remboursee');
$this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.'); $this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.');
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); 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 private function hydrateBilletFromRequest(Billet $billet, Request $request): void
{ {
$billet->setName(trim($request->request->getString('name'))); $billet->setName(trim($request->request->getString('name')));
@@ -1247,7 +1264,16 @@ class AccountController extends AbstractController
$billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet')); $billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet'));
$billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit')); $billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit'));
$billet->setNotBuyable($request->request->getBoolean('not_buyable')); $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); $billet->setDescription(trim($request->request->getString('description')) ?: null);
$pictureFile = $request->files->get('picture'); $pictureFile = $request->files->get('picture');

View File

@@ -210,6 +210,7 @@ class OrderController extends AbstractController
'totalHT' => $order->getTotalHTDecimal(), 'totalHT' => $order->getTotalHTDecimal(),
]); ]);
$billetOrderService->generateAndSendTickets($order); $billetOrderService->generateAndSendTickets($order);
$billetOrderService->notifyOrganizer($order);
} }
$failed = 'failed' === $redirectStatus; $failed = 'failed' === $redirectStatus;
@@ -307,8 +308,13 @@ class OrderController extends AbstractController
continue; continue;
} }
if (!$billet->isUnlimited() && $qty > $billet->getQuantity()) { if (!$billet->isUnlimited()) {
$qty = $billet->getQuantity(); if ($billet->getQuantity() <= 0) {
continue;
}
if ($qty > $billet->getQuantity()) {
$qty = $billet->getQuantity();
}
} }
$orderItem = new BilletBuyerItem(); $orderItem = new BilletBuyerItem();

View File

@@ -3,11 +3,13 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\BilletBuyer; use App\Entity\BilletBuyer;
use App\Entity\BilletOrder;
use App\Entity\Payout; use App\Entity\Payout;
use App\Entity\User; use App\Entity\User;
use App\Service\BilletOrderService; use App\Service\BilletOrderService;
use App\Service\MailerService; use App\Service\MailerService;
use App\Service\PayoutPdfService; use App\Service\PayoutPdfService;
use App\Service\AuditService;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -18,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
class StripeWebhookController extends AbstractController class StripeWebhookController extends AbstractController
{ {
#[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])] #[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(); $payload = $request->getContent();
$signature = $request->headers->get('Stripe-Signature', ''); $signature = $request->headers->get('Stripe-Signature', '');
@@ -34,6 +36,8 @@ class StripeWebhookController extends AbstractController
match ($type) { match ($type) {
'payout.created', 'payout.updated', 'payout.paid', 'payout.failed', 'payout.canceled' => $this->handlePayout($event, $em, $mailerService, $pdfService), '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.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, default => null,
}; };
@@ -137,6 +141,85 @@ class StripeWebhookController extends AbstractController
$billetOrderService->generateOrderTickets($order); $billetOrderService->generateOrderTickets($order);
$billetOrderService->generateAndSendTickets($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 private function handlePayout(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): void

View File

@@ -15,4 +15,18 @@ class BilletBuyerRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, BilletBuyer::class); 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();
}
} }

View File

@@ -35,7 +35,16 @@ class BilletOrderService
{ {
foreach ($order->getItems() as $item) { foreach ($order->getItems() as $item) {
$billet = $item->getBillet(); $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; continue;
} }
@@ -58,6 +67,25 @@ class BilletOrderService
$this->orderIndex->indexOrder($order); $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 public function generatePdf(BilletOrder $ticket): string
{ {
$order = $ticket->getBilletBuyer(); $order = $ticket->getBilletBuyer();
@@ -130,6 +158,24 @@ class BilletOrderService
return $dompdf->output(); 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 public function generateAndSendTickets(BilletBuyer $order): void
{ {
$tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); $tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);

View File

@@ -22,8 +22,8 @@
<label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label> <label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label>
<select id="billet_type" name="type" class="form-input focus:border-indigo-600"> <select id="billet_type" name="type" class="form-input focus:border-indigo-600">
<option value="billet">Billet</option> <option value="billet">Billet</option>
<option value="reservation_brocante">Reservation brocante</option> {% if 'reservation_brocante' in allowedTypes %}<option value="reservation_brocante">Reservation brocante</option>{% endif %}
<option value="vote">Vote</option> {% if 'vote' in allowedTypes %}<option value="vote">Vote</option>{% endif %}
</select> </select>
</div> </div>

View File

@@ -22,8 +22,8 @@
<label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label> <label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label>
<select id="billet_type" name="type" class="form-input focus:border-indigo-600"> <select id="billet_type" name="type" class="form-input focus:border-indigo-600">
<option value="billet" {{ billet.type == 'billet' ? 'selected' : '' }}>Billet</option> <option value="billet" {{ billet.type == 'billet' ? 'selected' : '' }}>Billet</option>
<option value="reservation_brocante" {{ billet.type == 'reservation_brocante' ? 'selected' : '' }}>Reservation brocante</option> {% if 'reservation_brocante' in allowedTypes %}<option value="reservation_brocante" {{ billet.type == 'reservation_brocante' ? 'selected' : '' }}>Reservation brocante</option>{% endif %}
<option value="vote" {{ billet.type == 'vote' ? 'selected' : '' }}>Vote</option> {% if 'vote' in allowedTypes %}<option value="vote" {{ billet.type == 'vote' ? 'selected' : '' }}>Vote</option>{% endif %}
</select> </select>
</div> </div>

View File

@@ -0,0 +1,53 @@
{% extends 'email/base.html.twig' %}
{% block title %}Commande {{ action }} - {{ order.event.title }}{% endblock %}
{% block content %}
<h2>Commande {{ action }}</h2>
<p>Bonjour,</p>
<p>La commande <strong>{{ order.orderNumber }}</strong> pour l'evenement <strong>{{ order.event.title }}</strong> a ete <strong>{{ action }}</strong>.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; border: 2px solid #111827;">
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280; width: 140px;">Commande</td>
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ order.orderNumber }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Statut</td>
<td style="padding: 10px 12px; font-weight: 700; color: #dc2626;">{{ action|upper }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Acheteur</td>
<td style="padding: 10px 12px; font-weight: 700;">{{ order.firstName }} {{ order.lastName }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Email</td>
<td style="padding: 10px 12px;">{{ order.email }}</td>
</tr>
<tr>
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Montant HT</td>
<td style="padding: 10px 12px; font-weight: 900; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</table>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="background: #111827; color: #fff;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total HT</th>
</tr>
</thead>
<tbody>
{% for item in order.items %}
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="font-size: 12px; color: #9ca3af;">Les billets associes a cette commande ont ete invalides. Vous pouvez consulter le detail depuis votre espace organisateur.</p>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends 'email/base.html.twig' %}
{% block title %}Nouvelle commande - {{ order.event.title }}{% endblock %}
{% block content %}
<h2>Nouvelle commande !</h2>
<p>Bonjour,</p>
<p>Une nouvelle commande a ete passee pour votre evenement <strong>{{ order.event.title }}</strong>.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; border: 2px solid #111827;">
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280; width: 140px;">Commande</td>
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ order.orderNumber }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Acheteur</td>
<td style="padding: 10px 12px; font-weight: 700;">{{ order.firstName }} {{ order.lastName }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Email</td>
<td style="padding: 10px 12px;">{{ order.email }}</td>
</tr>
<tr>
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Date</td>
<td style="padding: 10px 12px;">{{ order.paidAt|date('d/m/Y H:i') }}</td>
</tr>
</table>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="background: #111827; color: #fff;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total HT</th>
</tr>
</thead>
<tbody>
{% for item in order.items %}
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="border-top: 3px solid #111827;">
<td colspan="2" style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 13px;">Total HT</td>
<td style="padding: 10px 12px; text-align: right; font-weight: 900; font-size: 16px; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</tfoot>
</table>
<p style="font-size: 12px; color: #9ca3af;">Vous pouvez consulter le detail de cette commande depuis votre espace organisateur, onglet Statistiques.</p>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'email/base.html.twig' %}
{% block title %}Remboursement - {{ order.event.title }}{% endblock %}
{% block content %}
<h2>Votre commande a ete remboursee</h2>
<p>Bonjour {{ order.firstName }},</p>
<p>Votre commande <strong>{{ order.orderNumber }}</strong> pour l'evenement <strong>{{ order.event.title }}</strong> a ete remboursee.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; border: 2px solid #111827;">
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280; width: 140px;">Commande</td>
<td style="padding: 10px 12px; font-weight: 700;">{{ order.orderNumber }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Montant rembourse</td>
<td style="padding: 10px 12px; font-weight: 900; color: #16a34a;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</table>
<p>Le remboursement sera visible sur votre releve bancaire sous 5 a 10 jours ouvrables. Les billets associes a cette commande ont ete invalides.</p>
<p style="font-size: 12px; color: #9ca3af;">Si vous avez des questions, contactez l'organisateur de l'evenement.</p>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'email/base.html.twig' %}
{% block title %}Echec de paiement - {{ order.event.title }}{% endblock %}
{% block content %}
<h2>Echec de paiement</h2>
<p>Bonjour {{ order.firstName }},</p>
<p>Votre paiement pour la commande <strong>{{ order.orderNumber }}</strong> (evenement <strong>{{ order.event.title }}</strong>) n'a pas pu aboutir.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; border: 2px solid #dc2626;">
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280; width: 140px;">Commande</td>
<td style="padding: 10px 12px; font-weight: 700;">{{ order.orderNumber }}</td>
</tr>
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Montant</td>
<td style="padding: 10px 12px; font-weight: 700; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 11px; color: #6b7280;">Motif</td>
<td style="padding: 10px 12px; font-weight: 700; color: #dc2626;">{{ errorMessage }}</td>
</tr>
</table>
<p>Votre commande a ete annulee. Vous pouvez retenter votre achat depuis la page de l'evenement.</p>
<p style="font-size: 12px; color: #9ca3af;">Si vous pensez qu'il s'agit d'une erreur, contactez votre banque ou reessayez avec un autre moyen de paiement.</p>
{% endblock %}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Tests\Command;
use App\Command\ExpirePendingOrdersCommand;
use App\Entity\Billet;
use App\Entity\BilletBuyer;
use App\Entity\BilletBuyerItem;
use App\Entity\BilletOrder;
use App\Entity\Event;
use App\Repository\BilletBuyerRepository;
use App\Service\AuditService;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class ExpirePendingOrdersCommandTest extends TestCase
{
private EntityManagerInterface $em;
private BilletBuyerRepository $buyerRepo;
private AuditService $audit;
private CommandTester $tester;
protected function setUp(): void
{
$this->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());
}
}