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

@@ -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');