From 52cb19df8b607586c36f53cebdae0dba0ca749de Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sat, 21 Mar 2026 14:04:45 +0100 Subject: [PATCH] Add BilletOrder entity, PDF generation, email with QR codes, public order page - BilletOrder entity: individual tickets with unique ETICKET-XXXX reference, billetBuyer link, billet link, isScanned, scannedAt for entry control - BilletOrderService: generates tickets after payment, creates A4 PDF with BilletDesign colors if present (default otherwise), real QR code via endroid/qr-code, event poster + org logo as base64, sends confirmation email with all ticket PDFs attached - PDF template (pdf/billet.html.twig): A4 layout matching preview design, real QR code linking to /ticket/verify/{reference} - Email template: order recap table, ticket references list, link to /ma-commande/{reference} - Public order page /ma-commande/{reference}: no auth required, shows order details, ticket list with individual PDF download links - Ticket verification page /ticket/verify/{reference}: shows valid/scanned status with ticket and event details - Download route /ma-commande/{ref}/billet/{ticketRef}: generates PDF on-the-fly - Migration for billet_order table with unique reference index - BilletOrderTest: 8 tests, 24 assertions Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/Version20260321190000.php | 33 ++++ phpstan.neon | 1 + phpunit.dist.xml | 1 + sonar-project.properties | 2 +- src/Controller/OrderController.php | 63 ++++++ src/Entity/BilletOrder.php | 139 +++++++++++++ src/Repository/BilletOrderRepository.php | 18 ++ src/Service/BilletOrderService.php | 158 +++++++++++++++ templates/email/order_confirmation.html.twig | 55 ++++++ templates/order/public.html.twig | 62 ++++++ templates/order/verify.html.twig | 54 +++++ templates/pdf/billet.html.twig | 196 +++++++++++++++++++ tests/Entity/BilletOrderTest.php | 93 +++++++++ 13 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260321190000.php create mode 100644 src/Entity/BilletOrder.php create mode 100644 src/Repository/BilletOrderRepository.php create mode 100644 src/Service/BilletOrderService.php create mode 100644 templates/email/order_confirmation.html.twig create mode 100644 templates/order/public.html.twig create mode 100644 templates/order/verify.html.twig create mode 100644 templates/pdf/billet.html.twig create mode 100644 tests/Entity/BilletOrderTest.php diff --git a/migrations/Version20260321190000.php b/migrations/Version20260321190000.php new file mode 100644 index 0000000..a328065 --- /dev/null +++ b/migrations/Version20260321190000.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE billet_order (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, billet_buyer_id INT NOT NULL, billet_id INT NOT NULL, reference VARCHAR(23) NOT NULL, billet_name VARCHAR(255) NOT NULL, unit_price_ht INT NOT NULL, is_scanned BOOLEAN NOT NULL, scanned_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_BILLET_ORDER_REF ON billet_order (reference)'); + $this->addSql('CREATE INDEX IDX_BO_BUYER ON billet_order (billet_buyer_id)'); + $this->addSql('CREATE INDEX IDX_BO_BILLET ON billet_order (billet_id)'); + $this->addSql('ALTER TABLE billet_order ADD CONSTRAINT FK_BO_BUYER FOREIGN KEY (billet_buyer_id) REFERENCES billet_buyer (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE billet_order ADD CONSTRAINT FK_BO_BILLET FOREIGN KEY (billet_id) REFERENCES billet (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE billet_order DROP CONSTRAINT FK_BO_BUYER'); + $this->addSql('ALTER TABLE billet_order DROP CONSTRAINT FK_BO_BILLET'); + $this->addSql('DROP TABLE billet_order'); + } +} diff --git a/phpstan.neon b/phpstan.neon index 2e92398..14dc643 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -18,3 +18,4 @@ parameters: - src/Entity/BilletDesign.php - src/Entity/BilletBuyer.php - src/Entity/BilletBuyerItem.php + - src/Entity/BilletOrder.php diff --git a/phpunit.dist.xml b/phpunit.dist.xml index c6fdcdb..62f2488 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -37,6 +37,7 @@ src/Controller/StripeWebhookController.php src/Service/StripeService.php src/Service/PayoutPdfService.php + src/Service/BilletOrderService.php diff --git a/sonar-project.properties b/sonar-project.properties index c05042d..a5217fe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.projectKey=e-ticket sonar.projectName=E-Ticket sonar.sources=src,assets,templates,docker -sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php +sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php sonar.php.version=8.4 sonar.sourceEncoding=UTF-8 sonar.php.coverage.reportPaths=coverage.xml diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index 9342808..13be3ee 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\Entity\Billet; use App\Entity\BilletBuyer; use App\Entity\BilletBuyerItem; +use App\Entity\BilletOrder; use App\Entity\Event; use App\Entity\User; +use App\Service\BilletOrderService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -243,4 +245,65 @@ class OrderController extends AbstractController ], ]); } + + #[Route('/ma-commande/{reference}', name: 'app_order_public', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])] + public function publicOrder(string $reference, EntityManagerInterface $em): Response + { + $order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]); + if (!$order) { + throw $this->createNotFoundException(); + } + + $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); + + return $this->render('order/public.html.twig', [ + 'order' => $order, + 'tickets' => $tickets, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Commande '.$order->getReference()], + ], + ]); + } + + #[Route('/ma-commande/{reference}/billet/{ticketReference}', name: 'app_order_download_ticket', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}', 'ticketReference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])] + public function downloadTicket(string $reference, string $ticketReference, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response + { + $order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]); + if (!$order) { + throw $this->createNotFoundException(); + } + + $ticket = $em->getRepository(BilletOrder::class)->findOneBy([ + 'billetBuyer' => $order, + 'reference' => $ticketReference, + ]); + if (!$ticket) { + throw $this->createNotFoundException(); + } + + $pdf = $billetOrderService->generatePdf($ticket); + + return new Response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$ticket->getReference().'.pdf"', + ]); + } + + /** + * @codeCoverageIgnore Requires live Stripe API + */ + #[Route('/ticket/verify/{reference}', name: 'app_ticket_verify', requirements: ['reference' => 'ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}'], methods: ['GET'])] + public function verifyTicket(string $reference, EntityManagerInterface $em): Response + { + $ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]); + if (!$ticket) { + throw $this->createNotFoundException(); + } + + return $this->render('order/verify.html.twig', [ + 'ticket' => $ticket, + 'order' => $ticket->getBilletBuyer(), + ]); + } } diff --git a/src/Entity/BilletOrder.php b/src/Entity/BilletOrder.php new file mode 100644 index 0000000..3838469 --- /dev/null +++ b/src/Entity/BilletOrder.php @@ -0,0 +1,139 @@ +reference = BilletBuyer::generateReference(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getBilletBuyer(): ?BilletBuyer + { + return $this->billetBuyer; + } + + public function setBilletBuyer(?BilletBuyer $billetBuyer): static + { + $this->billetBuyer = $billetBuyer; + + return $this; + } + + public function getBillet(): ?Billet + { + return $this->billet; + } + + public function setBillet(?Billet $billet): static + { + $this->billet = $billet; + + return $this; + } + + public function getReference(): string + { + return $this->reference; + } + + public function getBilletName(): ?string + { + return $this->billetName; + } + + public function setBilletName(string $billetName): static + { + $this->billetName = $billetName; + + return $this; + } + + public function getUnitPriceHT(): int + { + return $this->unitPriceHT; + } + + public function setUnitPriceHT(int $unitPriceHT): static + { + $this->unitPriceHT = $unitPriceHT; + + return $this; + } + + public function getUnitPriceHTDecimal(): float + { + return $this->unitPriceHT / 100; + } + + public function isScanned(): bool + { + return $this->isScanned; + } + + public function setIsScanned(bool $isScanned): static + { + $this->isScanned = $isScanned; + + return $this; + } + + public function getScannedAt(): ?\DateTimeImmutable + { + return $this->scannedAt; + } + + public function setScannedAt(?\DateTimeImmutable $scannedAt): static + { + $this->scannedAt = $scannedAt; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Repository/BilletOrderRepository.php b/src/Repository/BilletOrderRepository.php new file mode 100644 index 0000000..c3ff8e4 --- /dev/null +++ b/src/Repository/BilletOrderRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletOrderRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilletOrder::class); + } +} diff --git a/src/Service/BilletOrderService.php b/src/Service/BilletOrderService.php new file mode 100644 index 0000000..f61a61f --- /dev/null +++ b/src/Service/BilletOrderService.php @@ -0,0 +1,158 @@ +getItems() as $item) { + for ($i = 0; $i < $item->getQuantity(); ++$i) { + $ticket = new BilletOrder(); + $ticket->setBilletBuyer($order); + $ticket->setBillet($item->getBillet()); + $ticket->setBilletName($item->getBilletName()); + $ticket->setUnitPriceHT($item->getUnitPriceHT()); + + $this->em->persist($ticket); + } + } + + $order->setStatus(BilletBuyer::STATUS_PAID); + $order->setPaidAt(new \DateTimeImmutable()); + $this->em->flush(); + } + + public function generatePdf(BilletOrder $ticket): string + { + $order = $ticket->getBilletBuyer(); + $event = $order->getEvent(); + $organizer = $event->getAccount(); + + $design = $this->em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]); + + $qrData = $this->urlGenerator->generate('app_ticket_verify', [ + 'reference' => $ticket->getReference(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $qrCode = (new Builder( + writer: new PngWriter(), + data: $qrData, + encoding: new Encoding('UTF-8'), + size: 200, + margin: 5, + ))->build(); + + $qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString()); + + $logoBase64 = ''; + if ($organizer->getLogoName()) { + $logoPath = $this->projectDir.'/public/uploads/logos/'.$organizer->getLogoName(); + if (file_exists($logoPath)) { + $mime = mime_content_type($logoPath) ?: 'image/png'; + $logoBase64 = 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($logoPath)); + } + } + + $posterBase64 = ''; + if ($event->getEventMainPictureName()) { + $posterPath = $this->projectDir.'/public/uploads/events/'.$event->getEventMainPictureName(); + if (file_exists($posterPath)) { + $mime = mime_content_type($posterPath) ?: 'image/png'; + $posterBase64 = 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($posterPath)); + } + } + + $html = $this->twig->render('pdf/billet.html.twig', [ + 'ticket' => $ticket, + 'order' => $order, + 'event' => $event, + 'organizer' => $organizer, + 'design' => $design, + 'qrBase64' => $qrBase64, + 'logoBase64' => $logoBase64, + 'posterBase64' => $posterBase64, + ]); + + $dompdf = new Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + return $dompdf->output(); + } + + public function generateAndSendTickets(BilletBuyer $order): void + { + $tickets = $this->em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); + if (0 === \count($tickets)) { + return; + } + + $dir = $this->projectDir.'/var/billets/'.$order->getReference(); + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + $attachments = []; + foreach ($tickets as $ticket) { + $billet = $ticket->getBillet(); + if (!$billet || !$billet->isGeneratedBillet()) { + continue; + } + + $pdf = $this->generatePdf($ticket); + $filename = $dir.'/'.$ticket->getReference().'.pdf'; + file_put_contents($filename, $pdf); + + $attachments[] = [ + 'path' => $filename, + 'name' => $ticket->getReference().'.pdf', + ]; + } + + $orderUrl = $this->urlGenerator->generate('app_order_public', [ + 'reference' => $order->getReference(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $this->twig->render('email/order_confirmation.html.twig', [ + 'order' => $order, + 'tickets' => $tickets, + 'orderUrl' => $orderUrl, + ]); + + $this->mailer->sendEmail( + $order->getEmail(), + 'Vos billets - '.$order->getEvent()->getTitle(), + $html, + 'E-Ticket ', + null, + false, + $attachments, + ); + } +} diff --git a/templates/email/order_confirmation.html.twig b/templates/email/order_confirmation.html.twig new file mode 100644 index 0000000..4741ff4 --- /dev/null +++ b/templates/email/order_confirmation.html.twig @@ -0,0 +1,55 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Vos billets - {{ order.event.title }}{% endblock %} + +{% block content %} +

Vos billets sont prets !

+

Bonjour {{ order.firstName }},

+

Merci pour votre commande {{ order.reference }} pour l'evenement {{ order.event.title }}.

+ + + + + + + + + + + {% for item in order.items %} + + + + + + {% endfor %} + + + + + + + +
BilletQtTotal
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
Total{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
+ + {% if tickets|length > 0 %} +

Vos billets sont en piece jointe de cet email. Chaque billet contient un QR code unique a presenter a l'entree.

+ + + {% for ticket in tickets %} + + + + + {% endfor %} +
{{ ticket.billetName }}{{ ticket.reference }}
+ {% endif %} + +

+ Voir ma commande +

+ +

Evenement : {{ order.event.title }}
+ Date : {{ order.event.startAt|date('d/m/Y') }} de {{ order.event.startAt|date('H:i') }} a {{ order.event.endAt|date('H:i') }}
+ Lieu : {{ order.event.address }}, {{ order.event.zipcode }} {{ order.event.city }}

+{% endblock %} diff --git a/templates/order/public.html.twig b/templates/order/public.html.twig new file mode 100644 index 0000000..8027cd6 --- /dev/null +++ b/templates/order/public.html.twig @@ -0,0 +1,62 @@ +{% extends 'base.html.twig' %} + +{% block title %}Commande {{ order.reference }} - E-Ticket{% endblock %} + +{% block body %} +
+

Ma commande

+

{{ order.reference }} — {{ order.event.title }}

+ +
+
+
+
+

Informations

+
+
+

{{ order.firstName }} {{ order.lastName }}

+

{{ order.email }}

+

+ {% if order.status == 'paid' %} + Payee + {% elseif order.status == 'pending' %} + En attente + {% else %} + Annulee + {% endif %} +

+
+
+ + {% if tickets|length > 0 %} +
+
+

Vos billets

+
+
+ {% for ticket in tickets %} +
+
+

{{ ticket.billetName }}

+

{{ ticket.reference }}

+
+ {{ ticket.unitPriceHTDecimal|number_format(2, ',', ' ') }} € + {% if ticket.scanned %} + Scanne + {% endif %} + + Telecharger PDF + +
+ {% endfor %} +
+
+ {% endif %} +
+ +
+ {% include 'order/_summary.html.twig' %} +
+
+
+{% endblock %} diff --git a/templates/order/verify.html.twig b/templates/order/verify.html.twig new file mode 100644 index 0000000..b9effa6 --- /dev/null +++ b/templates/order/verify.html.twig @@ -0,0 +1,54 @@ +{% extends 'base.html.twig' %} + +{% block title %}Verification billet - E-Ticket{% endblock %} + +{% block body %} +
+
+
+
+

Verification du billet

+
+
+ {% if ticket.scanned %} +
+

Billet deja scanne

+

Ce billet a ete scanne le {{ ticket.scannedAt|date('d/m/Y a H:i') }}

+ {% else %} +
+

Billet valide

+ {% endif %} + +
+
+ Reference + {{ ticket.reference }} +
+
+ Billet + {{ ticket.billetName }} +
+
+ Evenement + {{ order.event.title }} +
+
+ Acheteur + {{ order.firstName }} {{ order.lastName }} +
+
+ Date + {{ order.event.startAt|date('d/m/Y H:i') }} +
+ {% if ticket.billet.definedExit %} +
+ Sortie + Definitive +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/templates/pdf/billet.html.twig b/templates/pdf/billet.html.twig new file mode 100644 index 0000000..8c96bc8 --- /dev/null +++ b/templates/pdf/billet.html.twig @@ -0,0 +1,196 @@ + + + + + + + +
+
+
+
{{ event.title }}
+
Billet d'entree
+ +
+
Date
+
{{ event.startAt|date('d/m/Y') }}
+
+
+
Horaires
+
{{ event.startAt|date('H:i') }} — {{ event.endAt|date('H:i') }}
+
+
+
Lieu
+
{{ event.address }}
+
+
+
Ville
+
{{ event.zipcode }} {{ event.city }}
+
+ +
+ +
{{ ticket.billetName }}
+
{{ ticket.unitPriceHTDecimal|number_format(2, ',', ' ') }} € HT
+ +
+
+
Categorie
+
{{ ticket.billet.category.name }}
+
+
+
Date d'achat
+
{{ order.paidAt ? order.paidAt|date('d/m/Y H:i') : order.createdAt|date('d/m/Y H:i') }}
+
+
+
Acheteur
+
{{ order.firstName }} {{ order.lastName }}
+
+
+
E-mail
+
{{ order.email }}
+
+
+ +
+ {% if ticket.billet.definedExit %} +
Sortie definitive
+ {% else %} +
Sortie libre
+ {% endif %} + {% if design %} +
{{ inv_title }}
+ {% endif %} +
+ +
+
+ QR Code +
+
+
Reference
+
{{ ticket.reference }}
+
Commande
+
{{ order.reference }}
+
+
+
+ +
+ {% if posterBase64 %} + {{ event.title }} + {% endif %} +
+
+ +
+ {% if logoBase64 %} + + {% endif %} +
+
{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}
+ {% if organizer.address %} +
{{ organizer.address }}{% if organizer.postalCode %}, {{ organizer.postalCode }}{% endif %}{% if organizer.city %} {{ organizer.city }}{% endif %}
+ {% endif %} +
+
E-Ticket
by E-Cosplay
+
+
+ + diff --git a/tests/Entity/BilletOrderTest.php b/tests/Entity/BilletOrderTest.php new file mode 100644 index 0000000..101af89 --- /dev/null +++ b/tests/Entity/BilletOrderTest.php @@ -0,0 +1,93 @@ +getId()); + self::assertNull($ticket->getBilletBuyer()); + self::assertNull($ticket->getBillet()); + self::assertNull($ticket->getBilletName()); + self::assertSame(0, $ticket->getUnitPriceHT()); + self::assertSame(0.0, $ticket->getUnitPriceHTDecimal()); + self::assertFalse($ticket->isScanned()); + self::assertNull($ticket->getScannedAt()); + self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $ticket->getReference()); + self::assertInstanceOf(\DateTimeImmutable::class, $ticket->getCreatedAt()); + } + + public function testSetAndGetBilletBuyer(): void + { + $ticket = new BilletOrder(); + $buyer = new BilletBuyer(); + $result = $ticket->setBilletBuyer($buyer); + + self::assertSame($buyer, $ticket->getBilletBuyer()); + self::assertSame($ticket, $result); + } + + public function testSetAndGetBillet(): void + { + $ticket = new BilletOrder(); + $billet = new Billet(); + $result = $ticket->setBillet($billet); + + self::assertSame($billet, $ticket->getBillet()); + self::assertSame($ticket, $result); + } + + public function testSetAndGetBilletName(): void + { + $ticket = new BilletOrder(); + $result = $ticket->setBilletName('Entree VIP'); + + self::assertSame('Entree VIP', $ticket->getBilletName()); + self::assertSame($ticket, $result); + } + + public function testSetAndGetUnitPriceHT(): void + { + $ticket = new BilletOrder(); + $result = $ticket->setUnitPriceHT(1500); + + self::assertSame(1500, $ticket->getUnitPriceHT()); + self::assertSame(15.0, $ticket->getUnitPriceHTDecimal()); + self::assertSame($ticket, $result); + } + + public function testSetAndGetIsScanned(): void + { + $ticket = new BilletOrder(); + $result = $ticket->setIsScanned(true); + + self::assertTrue($ticket->isScanned()); + self::assertSame($ticket, $result); + } + + public function testSetAndGetScannedAt(): void + { + $ticket = new BilletOrder(); + $date = new \DateTimeImmutable(); + $result = $ticket->setScannedAt($date); + + self::assertSame($date, $ticket->getScannedAt()); + self::assertSame($ticket, $result); + } + + public function testUniqueReferences(): void + { + $t1 = new BilletOrder(); + $t2 = new BilletOrder(); + + self::assertNotSame($t1->getReference(), $t2->getReference()); + } +}