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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?: 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"
|
||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"eslint": "9",
|
||||
|
||||
71
src/Command/ExpirePendingOrdersCommand.php
Normal file
71
src/Command/ExpirePendingOrdersCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<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">
|
||||
<option value="billet">Billet</option>
|
||||
<option value="reservation_brocante">Reservation brocante</option>
|
||||
<option value="vote">Vote</option>
|
||||
{% if 'reservation_brocante' in allowedTypes %}<option value="reservation_brocante">Reservation brocante</option>{% endif %}
|
||||
{% if 'vote' in allowedTypes %}<option value="vote">Vote</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<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">
|
||||
<option value="billet" {{ billet.type == 'billet' ? 'selected' : '' }}>Billet</option>
|
||||
<option value="reservation_brocante" {{ billet.type == 'reservation_brocante' ? 'selected' : '' }}>Reservation brocante</option>
|
||||
<option value="vote" {{ billet.type == 'vote' ? 'selected' : '' }}>Vote</option>
|
||||
{% if 'reservation_brocante' in allowedTypes %}<option value="reservation_brocante" {{ billet.type == 'reservation_brocante' ? 'selected' : '' }}>Reservation brocante</option>{% endif %}
|
||||
{% if 'vote' in allowedTypes %}<option value="vote" {{ billet.type == 'vote' ? 'selected' : '' }}>Vote</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
53
templates/email/order_cancelled_orga.html.twig
Normal file
53
templates/email/order_cancelled_orga.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
55
templates/email/order_notification_orga.html.twig
Normal file
55
templates/email/order_notification_orga.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
24
templates/email/order_refunded.html.twig
Normal file
24
templates/email/order_refunded.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
28
templates/email/payment_failed.html.twig
Normal file
28
templates/email/payment_failed.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
123
tests/Command/ExpirePendingOrdersCommandTest.php
Normal file
123
tests/Command/ExpirePendingOrdersCommandTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user