From 7167a58c7ccd73f4855fa652ec38eeb4aed3b458 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sat, 21 Mar 2026 13:54:17 +0100 Subject: [PATCH] Add reservation flow: BilletBuyer, guest checkout, Stripe payment - Create BilletBuyer entity: event, user (nullable for guests), firstName, lastName, email, reference (ETICKET-XXXX-XXXX-XXXX), totalHT, status, stripeSessionId, paidAt, items (OneToMany) - Create BilletBuyerItem entity: billet, billetName (snapshot), quantity, unitPriceHT, line total helpers - OrderController with full checkout flow: - POST /evenement/{id}/commander: create order from cart JSON - GET/POST /commande/{id}/informations: guest form (name, email) - GET /commande/{id}/paiement: payment page with recap - POST /commande/{id}/stripe: Stripe Checkout on connected account with application_fee, productId, and quantities - GET /commande/{id}/confirmation: success page - Cart JS: POST cart data on Commander click, redirect to guest/payment - Templates: guest form, payment page, order summary partial, success page - Stripe payment uses organizer connected account, application_fee based on commissionRate, existing productId when available - Tests: BilletBuyerTest (12), BilletBuyerItemTest (6), cart.test.js (13) Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/modules/cart.js | 36 +++ migrations/Version20260321180000.php | 42 ++++ phpstan.neon | 4 +- src/Controller/OrderController.php | 246 +++++++++++++++++++ src/Entity/BilletBuyer.php | 221 +++++++++++++++++ src/Entity/BilletBuyerItem.php | 112 +++++++++ src/Repository/BilletBuyerItemRepository.php | 18 ++ src/Repository/BilletBuyerRepository.php | 18 ++ templates/home/event_detail.html.twig | 2 +- templates/order/_summary.html.twig | 25 ++ templates/order/guest.html.twig | 48 ++++ templates/order/payment.html.twig | 36 +++ templates/order/success.html.twig | 36 +++ tests/Entity/BilletBuyerItemTest.php | 84 +++++++ tests/Entity/BilletBuyerTest.php | 134 ++++++++++ tests/js/cart.test.js | 70 +++++- 16 files changed, 1128 insertions(+), 4 deletions(-) create mode 100644 migrations/Version20260321180000.php create mode 100644 src/Controller/OrderController.php create mode 100644 src/Entity/BilletBuyer.php create mode 100644 src/Entity/BilletBuyerItem.php create mode 100644 src/Repository/BilletBuyerItemRepository.php create mode 100644 src/Repository/BilletBuyerRepository.php create mode 100644 templates/order/_summary.html.twig create mode 100644 templates/order/guest.html.twig create mode 100644 templates/order/payment.html.twig create mode 100644 templates/order/success.html.twig create mode 100644 tests/Entity/BilletBuyerItemTest.php create mode 100644 tests/Entity/BilletBuyerTest.php diff --git a/assets/modules/cart.js b/assets/modules/cart.js index e819365..64550f6 100644 --- a/assets/modules/cart.js +++ b/assets/modules/cart.js @@ -60,5 +60,41 @@ export function initCart() { }) } + if (checkoutBtn) { + checkoutBtn.addEventListener('click', () => { + const cart = [] + for (const item of items) { + const qty = Number.parseInt(item.querySelector('[data-cart-qty]').value, 10) || 0 + if (qty > 0) { + cart.push({ billetId: item.dataset.billetId, qty }) + } + } + + if (cart.length === 0) return + + const orderUrl = checkoutBtn.dataset.orderUrl + if (!orderUrl) return + + checkoutBtn.disabled = true + checkoutBtn.textContent = 'Chargement...' + + globalThis.fetch(orderUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cart), + }) + .then(r => r.json()) + .then(data => { + if (data.redirect) { + globalThis.location.href = data.redirect + } + }) + .catch(() => { + checkoutBtn.disabled = false + checkoutBtn.textContent = 'Commander' + }) + }) + } + updateTotals() } diff --git a/migrations/Version20260321180000.php b/migrations/Version20260321180000.php new file mode 100644 index 0000000..41c4b81 --- /dev/null +++ b/migrations/Version20260321180000.php @@ -0,0 +1,42 @@ +addSql('CREATE TABLE billet_buyer (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_id INT NOT NULL, user_id INT DEFAULT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, reference VARCHAR(23) NOT NULL, total_ht INT NOT NULL, status VARCHAR(20) NOT NULL, stripe_session_id VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_BILLET_BUYER_REF ON billet_buyer (reference)'); + $this->addSql('CREATE INDEX IDX_BILLET_BUYER_EVENT ON billet_buyer (event_id)'); + $this->addSql('CREATE INDEX IDX_BILLET_BUYER_USER ON billet_buyer (user_id)'); + $this->addSql('ALTER TABLE billet_buyer ADD CONSTRAINT FK_BILLET_BUYER_EVENT FOREIGN KEY (event_id) REFERENCES event (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE billet_buyer ADD CONSTRAINT FK_BILLET_BUYER_USER FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE'); + + $this->addSql('CREATE TABLE billet_buyer_item (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, billet_buyer_id INT NOT NULL, billet_id INT NOT NULL, quantity INT NOT NULL, unit_price_ht INT NOT NULL, billet_name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_BBI_BUYER ON billet_buyer_item (billet_buyer_id)'); + $this->addSql('CREATE INDEX IDX_BBI_BILLET ON billet_buyer_item (billet_id)'); + $this->addSql('ALTER TABLE billet_buyer_item ADD CONSTRAINT FK_BBI_BUYER FOREIGN KEY (billet_buyer_id) REFERENCES billet_buyer (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE billet_buyer_item ADD CONSTRAINT FK_BBI_BILLET FOREIGN KEY (billet_id) REFERENCES billet (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE billet_buyer_item DROP CONSTRAINT FK_BBI_BUYER'); + $this->addSql('ALTER TABLE billet_buyer_item DROP CONSTRAINT FK_BBI_BILLET'); + $this->addSql('DROP TABLE billet_buyer_item'); + $this->addSql('ALTER TABLE billet_buyer DROP CONSTRAINT FK_BILLET_BUYER_EVENT'); + $this->addSql('ALTER TABLE billet_buyer DROP CONSTRAINT FK_BILLET_BUYER_USER'); + $this->addSql('DROP TABLE billet_buyer'); + } +} diff --git a/phpstan.neon b/phpstan.neon index e137b24..2e92398 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,7 +6,7 @@ parameters: - src/Kernel.php ignoreErrors: - - message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign)::\$id .* never assigned#' + message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem)::\$id .* never assigned#' paths: - src/Entity/EmailTracking.php - src/Entity/MessengerLog.php @@ -16,3 +16,5 @@ parameters: - src/Entity/Category.php - src/Entity/Billet.php - src/Entity/BilletDesign.php + - src/Entity/BilletBuyer.php + - src/Entity/BilletBuyerItem.php diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php new file mode 100644 index 0000000..9342808 --- /dev/null +++ b/src/Controller/OrderController.php @@ -0,0 +1,246 @@ + '\d+'], methods: ['POST'])] + public function create(int $id, Request $request, EntityManagerInterface $em): Response + { + $event = $em->getRepository(Event::class)->find($id); + if (!$event || !$event->isOnline()) { + throw $this->createNotFoundException(); + } + + $cart = json_decode($request->getContent(), true); + if (!\is_array($cart) || 0 === \count($cart)) { + $this->addFlash('error', 'Votre panier est vide.'); + + return $this->json(['redirect' => $this->generateUrl('app_event_detail', [ + 'orgaSlug' => $event->getAccount()->getSlug(), + 'id' => $event->getId(), + 'eventSlug' => $event->getSlug(), + ])]); + } + + $order = new BilletBuyer(); + $order->setEvent($event); + + /** @var User|null $user */ + $user = $this->getUser(); + if ($user) { + $order->setUser($user); + $order->setFirstName($user->getFirstName()); + $order->setLastName($user->getLastName()); + $order->setEmail($user->getEmail()); + } + + $totalHT = 0; + foreach ($cart as $item) { + $billetId = (int) ($item['billetId'] ?? 0); + $qty = (int) ($item['qty'] ?? 0); + if ($qty <= 0) { + continue; + } + + $billet = $em->getRepository(Billet::class)->find($billetId); + if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) { + continue; + } + + if ($billet->isNotBuyable()) { + continue; + } + + if (!$billet->isUnlimited() && $qty > $billet->getQuantity()) { + $qty = $billet->getQuantity(); + } + + $orderItem = new BilletBuyerItem(); + $orderItem->setBillet($billet); + $orderItem->setBilletName($billet->getName()); + $orderItem->setQuantity($qty); + $orderItem->setUnitPriceHT($billet->getPriceHT()); + + $order->addItem($orderItem); + $totalHT += $orderItem->getLineTotalHT(); + } + + if ($order->getItems()->isEmpty()) { + return $this->json(['redirect' => $this->generateUrl('app_event_detail', [ + 'orgaSlug' => $event->getAccount()->getSlug(), + 'id' => $event->getId(), + 'eventSlug' => $event->getSlug(), + ])]); + } + + $order->setTotalHT($totalHT); + $em->persist($order); + $em->flush(); + + if ($user) { + return $this->json(['redirect' => $this->generateUrl('app_order_payment', ['id' => $order->getId()])]); + } + + return $this->json(['redirect' => $this->generateUrl('app_order_guest', ['id' => $order->getId()])]); + } + + #[Route('/commande/{id}/informations', name: 'app_order_guest', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] + public function guest(int $id, Request $request, EntityManagerInterface $em): Response + { + $order = $em->getRepository(BilletBuyer::class)->find($id); + if (!$order || BilletBuyer::STATUS_PENDING !== $order->getStatus()) { + throw $this->createNotFoundException(); + } + + if ($order->getUser()) { + return $this->redirectToRoute('app_order_payment', ['id' => $order->getId()]); + } + + if ($request->isMethod('POST')) { + $firstName = trim($request->request->getString('first_name')); + $lastName = trim($request->request->getString('last_name')); + $email = trim($request->request->getString('email')); + + if ('' === $firstName || '' === $lastName || '' === $email) { + $this->addFlash('error', 'Tous les champs sont requis.'); + + return $this->redirectToRoute('app_order_guest', ['id' => $order->getId()]); + } + + $order->setFirstName($firstName); + $order->setLastName($lastName); + $order->setEmail($email); + $em->flush(); + + return $this->redirectToRoute('app_order_payment', ['id' => $order->getId()]); + } + + return $this->render('order/guest.html.twig', [ + 'order' => $order, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => $order->getEvent()->getTitle()], + ['name' => 'Informations'], + ], + ]); + } + + #[Route('/commande/{id}/paiement', name: 'app_order_payment', requirements: ['id' => '\d+'], methods: ['GET'])] + public function payment(int $id, EntityManagerInterface $em): Response + { + $order = $em->getRepository(BilletBuyer::class)->find($id); + if (!$order || BilletBuyer::STATUS_PENDING !== $order->getStatus()) { + throw $this->createNotFoundException(); + } + + if (!$order->getFirstName() || !$order->getEmail()) { + return $this->redirectToRoute('app_order_guest', ['id' => $order->getId()]); + } + + return $this->render('order/payment.html.twig', [ + 'order' => $order, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => $order->getEvent()->getTitle()], + ['name' => 'Paiement'], + ], + ]); + } + + /** + * @codeCoverageIgnore Requires live Stripe API + */ + #[Route('/commande/{id}/stripe', name: 'app_order_stripe', requirements: ['id' => '\d+'], methods: ['POST'])] + public function stripe(int $id, EntityManagerInterface $em, StripeService $stripeService): Response + { + $order = $em->getRepository(BilletBuyer::class)->find($id); + if (!$order || BilletBuyer::STATUS_PENDING !== $order->getStatus() || !$order->getEmail()) { + throw $this->createNotFoundException(); + } + + $organizer = $order->getEvent()->getAccount(); + if (!$organizer->getStripeAccountId()) { + throw $this->createNotFoundException(); + } + + $lineItems = []; + foreach ($order->getItems() as $item) { + $billet = $item->getBillet(); + if ($billet && $billet->getStripeProductId()) { + $lineItems[] = [ + 'price_data' => [ + 'currency' => 'eur', + 'unit_amount' => $item->getUnitPriceHT(), + 'product' => $billet->getStripeProductId(), + ], + 'quantity' => $item->getQuantity(), + ]; + } else { + $lineItems[] = [ + 'price_data' => [ + 'currency' => 'eur', + 'unit_amount' => $item->getUnitPriceHT(), + 'product_data' => [ + 'name' => $item->getBilletName(), + ], + ], + 'quantity' => $item->getQuantity(), + ]; + } + } + + $commissionRate = $organizer->getCommissionRate() ?? 0; + $applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100)); + + $session = $stripeService->getClient()->checkout->sessions->create([ + 'mode' => 'payment', + 'customer_email' => $order->getEmail(), + 'line_items' => $lineItems, + 'payment_intent_data' => [ + 'application_fee_amount' => $applicationFee, + ], + 'success_url' => $this->generateUrl('app_order_success', ['id' => $order->getId()], UrlGeneratorInterface::ABSOLUTE_URL), + 'cancel_url' => $this->generateUrl('app_order_payment', ['id' => $order->getId()], UrlGeneratorInterface::ABSOLUTE_URL), + 'metadata' => [ + 'order_id' => $order->getId(), + 'reference' => $order->getReference(), + ], + ], ['stripe_account' => $organizer->getStripeAccountId()]); + + $order->setStripeSessionId($session->id); + $em->flush(); + + return $this->redirect($session->url); + } + + #[Route('/commande/{id}/confirmation', name: 'app_order_success', requirements: ['id' => '\d+'], methods: ['GET'])] + public function success(int $id, EntityManagerInterface $em): Response + { + $order = $em->getRepository(BilletBuyer::class)->find($id); + if (!$order) { + throw $this->createNotFoundException(); + } + + return $this->render('order/success.html.twig', [ + 'order' => $order, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Confirmation'], + ], + ]); + } +} diff --git a/src/Entity/BilletBuyer.php b/src/Entity/BilletBuyer.php new file mode 100644 index 0000000..b4517b9 --- /dev/null +++ b/src/Entity/BilletBuyer.php @@ -0,0 +1,221 @@ + */ + #[ORM\OneToMany(targetEntity: BilletBuyerItem::class, mappedBy: 'billetBuyer', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + public function __construct() + { + $this->reference = self::generateReference(); + $this->createdAt = new \DateTimeImmutable(); + $this->items = new ArrayCollection(); + } + + public static function generateReference(): string + { + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $part = static fn (): string => substr(str_shuffle($chars), 0, 4); + + return 'ETICKET-'.$part().'-'.$part().'-'.$part(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEvent(): ?Event + { + return $this->event; + } + + public function setEvent(?Event $event): static + { + $this->event = $event; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + public function getReference(): string + { + return $this->reference; + } + + public function getTotalHT(): int + { + return $this->totalHT; + } + + public function setTotalHT(int $totalHT): static + { + $this->totalHT = $totalHT; + + return $this; + } + + public function getTotalHTDecimal(): float + { + return $this->totalHT / 100; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getStripeSessionId(): ?string + { + return $this->stripeSessionId; + } + + public function setStripeSessionId(?string $stripeSessionId): static + { + $this->stripeSessionId = $stripeSessionId; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getPaidAt(): ?\DateTimeImmutable + { + return $this->paidAt; + } + + public function setPaidAt(?\DateTimeImmutable $paidAt): static + { + $this->paidAt = $paidAt; + + return $this; + } + + /** + * @return Collection + */ + public function getItems(): Collection + { + return $this->items; + } + + public function addItem(BilletBuyerItem $item): static + { + if (!$this->items->contains($item)) { + $this->items->add($item); + $item->setBilletBuyer($this); + } + + return $this; + } +} diff --git a/src/Entity/BilletBuyerItem.php b/src/Entity/BilletBuyerItem.php new file mode 100644 index 0000000..34a874a --- /dev/null +++ b/src/Entity/BilletBuyerItem.php @@ -0,0 +1,112 @@ +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 getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): static + { + $this->quantity = $quantity; + + 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 getLineTotalHT(): int + { + return $this->unitPriceHT * $this->quantity; + } + + public function getLineTotalHTDecimal(): float + { + return $this->getLineTotalHT() / 100; + } + + public function getBilletName(): ?string + { + return $this->billetName; + } + + public function setBilletName(string $billetName): static + { + $this->billetName = $billetName; + + return $this; + } +} diff --git a/src/Repository/BilletBuyerItemRepository.php b/src/Repository/BilletBuyerItemRepository.php new file mode 100644 index 0000000..2336993 --- /dev/null +++ b/src/Repository/BilletBuyerItemRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletBuyerItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilletBuyerItem::class); + } +} diff --git a/src/Repository/BilletBuyerRepository.php b/src/Repository/BilletBuyerRepository.php new file mode 100644 index 0000000..0104507 --- /dev/null +++ b/src/Repository/BilletBuyerRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletBuyerRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilletBuyer::class); + } +} diff --git a/templates/home/event_detail.html.twig b/templates/home/event_detail.html.twig index 9543bc0..dc42475 100644 --- a/templates/home/event_detail.html.twig +++ b/templates/home/event_detail.html.twig @@ -133,7 +133,7 @@ Articles 0 - diff --git a/templates/order/_summary.html.twig b/templates/order/_summary.html.twig new file mode 100644 index 0000000..e3a512a --- /dev/null +++ b/templates/order/_summary.html.twig @@ -0,0 +1,25 @@ +
+
+

Recapitulatif

+
+
+

{{ order.event.title }}

+ + {% for item in order.items %} +
+
+

{{ item.billetName }}

+

x{{ item.quantity }} — {{ item.unitPriceHTDecimal|number_format(2, ',', ' ') }} €/u

+
+

{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €

+
+ {% endfor %} + +
+ Total HT + {{ order.totalHTDecimal|number_format(2, ',', ' ') }} € +
+ +

Ref: {{ order.reference }}

+
+
diff --git a/templates/order/guest.html.twig b/templates/order/guest.html.twig new file mode 100644 index 0000000..2478343 --- /dev/null +++ b/templates/order/guest.html.twig @@ -0,0 +1,48 @@ +{% extends 'base.html.twig' %} + +{% block title %}Vos informations - {{ order.event.title }} - E-Ticket{% endblock %} + +{% block body %} +
+

Vos informations

+

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

+ + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} + +
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+
+ +
+ {% include 'order/_summary.html.twig' %} +
+
+
+{% endblock %} diff --git a/templates/order/payment.html.twig b/templates/order/payment.html.twig new file mode 100644 index 0000000..3c427de --- /dev/null +++ b/templates/order/payment.html.twig @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}Paiement - {{ order.event.title }} - E-Ticket{% endblock %} + +{% block body %} +
+

Paiement

+

Commande {{ order.reference }}

+ +
+
+
+
+

Informations

+
+

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

+

{{ order.email }}

+
+ +
+ +
+ +

Paiement securise par Stripe

+
+
+
+ +
+ {% include 'order/_summary.html.twig' %} +
+
+
+{% endblock %} diff --git a/templates/order/success.html.twig b/templates/order/success.html.twig new file mode 100644 index 0000000..f2693ae --- /dev/null +++ b/templates/order/success.html.twig @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}Confirmation - E-Ticket{% endblock %} + +{% block body %} +
+
+
+
+

Commande confirmee

+

Merci {{ order.firstName }} !

+

Votre commande {{ order.reference }} a bien ete enregistree.

+ +
+

Details

+ {% for item in order.items %} +
+ {{ item.billetName }} x{{ item.quantity }} + {{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} € +
+ {% endfor %} +
+ Total + {{ order.totalHTDecimal|number_format(2, ',', ' ') }} € +
+
+ +

Un email de confirmation sera envoye a {{ order.email }}

+ + + Retour a l'accueil + +
+
+
+{% endblock %} diff --git a/tests/Entity/BilletBuyerItemTest.php b/tests/Entity/BilletBuyerItemTest.php new file mode 100644 index 0000000..2a87c1d --- /dev/null +++ b/tests/Entity/BilletBuyerItemTest.php @@ -0,0 +1,84 @@ +getId()); + self::assertNull($item->getBilletBuyer()); + self::assertNull($item->getBillet()); + self::assertNull($item->getBilletName()); + self::assertSame(1, $item->getQuantity()); + self::assertSame(0, $item->getUnitPriceHT()); + self::assertSame(0.0, $item->getUnitPriceHTDecimal()); + self::assertSame(0, $item->getLineTotalHT()); + self::assertSame(0.0, $item->getLineTotalHTDecimal()); + } + + public function testSetAndGetBilletBuyer(): void + { + $item = new BilletBuyerItem(); + $buyer = new BilletBuyer(); + $result = $item->setBilletBuyer($buyer); + + self::assertSame($buyer, $item->getBilletBuyer()); + self::assertSame($item, $result); + } + + public function testSetAndGetBillet(): void + { + $item = new BilletBuyerItem(); + $billet = new Billet(); + $result = $item->setBillet($billet); + + self::assertSame($billet, $item->getBillet()); + self::assertSame($item, $result); + } + + public function testSetAndGetQuantity(): void + { + $item = new BilletBuyerItem(); + $result = $item->setQuantity(3); + + self::assertSame(3, $item->getQuantity()); + self::assertSame($item, $result); + } + + public function testSetAndGetUnitPriceHT(): void + { + $item = new BilletBuyerItem(); + $result = $item->setUnitPriceHT(1500); + + self::assertSame(1500, $item->getUnitPriceHT()); + self::assertSame(15.0, $item->getUnitPriceHTDecimal()); + self::assertSame($item, $result); + } + + public function testSetAndGetBilletName(): void + { + $item = new BilletBuyerItem(); + $result = $item->setBilletName('Entree VIP'); + + self::assertSame('Entree VIP', $item->getBilletName()); + self::assertSame($item, $result); + } + + public function testLineTotalHT(): void + { + $item = new BilletBuyerItem(); + $item->setUnitPriceHT(1500); + $item->setQuantity(3); + + self::assertSame(4500, $item->getLineTotalHT()); + self::assertSame(45.0, $item->getLineTotalHTDecimal()); + } +} diff --git a/tests/Entity/BilletBuyerTest.php b/tests/Entity/BilletBuyerTest.php new file mode 100644 index 0000000..6ab464f --- /dev/null +++ b/tests/Entity/BilletBuyerTest.php @@ -0,0 +1,134 @@ +getId()); + self::assertNull($buyer->getEvent()); + self::assertNull($buyer->getUser()); + self::assertNull($buyer->getFirstName()); + self::assertNull($buyer->getLastName()); + self::assertNull($buyer->getEmail()); + self::assertSame(0, $buyer->getTotalHT()); + self::assertSame(0.0, $buyer->getTotalHTDecimal()); + self::assertSame(BilletBuyer::STATUS_PENDING, $buyer->getStatus()); + self::assertNull($buyer->getStripeSessionId()); + self::assertNull($buyer->getPaidAt()); + self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $buyer->getReference()); + self::assertInstanceOf(\DateTimeImmutable::class, $buyer->getCreatedAt()); + self::assertCount(0, $buyer->getItems()); + } + + public function testSetAndGetEvent(): void + { + $buyer = new BilletBuyer(); + $event = new Event(); + $result = $buyer->setEvent($event); + + self::assertSame($event, $buyer->getEvent()); + self::assertSame($buyer, $result); + } + + public function testSetAndGetUser(): void + { + $buyer = new BilletBuyer(); + $user = new User(); + $result = $buyer->setUser($user); + + self::assertSame($user, $buyer->getUser()); + self::assertSame($buyer, $result); + + $buyer->setUser(null); + self::assertNull($buyer->getUser()); + } + + public function testSetAndGetNames(): void + { + $buyer = new BilletBuyer(); + $buyer->setFirstName('Jean'); + $buyer->setLastName('Dupont'); + $buyer->setEmail('jean@exemple.fr'); + + self::assertSame('Jean', $buyer->getFirstName()); + self::assertSame('Dupont', $buyer->getLastName()); + self::assertSame('jean@exemple.fr', $buyer->getEmail()); + } + + public function testSetAndGetTotalHT(): void + { + $buyer = new BilletBuyer(); + $result = $buyer->setTotalHT(2500); + + self::assertSame(2500, $buyer->getTotalHT()); + self::assertSame(25.0, $buyer->getTotalHTDecimal()); + self::assertSame($buyer, $result); + } + + public function testSetAndGetStatus(): void + { + $buyer = new BilletBuyer(); + $result = $buyer->setStatus(BilletBuyer::STATUS_PAID); + + self::assertSame(BilletBuyer::STATUS_PAID, $buyer->getStatus()); + self::assertSame($buyer, $result); + } + + public function testSetAndGetStripeSessionId(): void + { + $buyer = new BilletBuyer(); + $result = $buyer->setStripeSessionId('cs_test_123'); + + self::assertSame('cs_test_123', $buyer->getStripeSessionId()); + self::assertSame($buyer, $result); + } + + public function testSetAndGetPaidAt(): void + { + $buyer = new BilletBuyer(); + $date = new \DateTimeImmutable(); + $result = $buyer->setPaidAt($date); + + self::assertSame($date, $buyer->getPaidAt()); + self::assertSame($buyer, $result); + } + + public function testAddItem(): void + { + $buyer = new BilletBuyer(); + $item = new BilletBuyerItem(); + $result = $buyer->addItem($item); + + self::assertCount(1, $buyer->getItems()); + self::assertSame($buyer, $item->getBilletBuyer()); + self::assertSame($buyer, $result); + } + + public function testAddItemDoesNotDuplicate(): void + { + $buyer = new BilletBuyer(); + $item = new BilletBuyerItem(); + $buyer->addItem($item); + $buyer->addItem($item); + + self::assertCount(1, $buyer->getItems()); + } + + public function testGenerateReferenceUnique(): void + { + $b1 = new BilletBuyer(); + $b2 = new BilletBuyer(); + + self::assertNotSame($b1->getReference(), $b2->getReference()); + } +} diff --git a/tests/js/cart.test.js b/tests/js/cart.test.js index 1b340f9..af96779 100644 --- a/tests/js/cart.test.js +++ b/tests/js/cart.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { initCart } from '../../assets/modules/cart.js' function createBilletterie(billets) { @@ -15,7 +15,7 @@ function createBilletterie(billets) { ` } - html += '' + html += '' document.body.innerHTML = html } @@ -128,4 +128,70 @@ describe('initCart', () => { document.querySelector('[data-cart-minus]').click() expect(document.getElementById('cart-checkout').disabled).toBe(true) }) + + it('posts cart data on checkout click', () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ redirect: '/commande/1/informations' }), + }) + globalThis.fetch = fetchMock + + createBilletterie([ + { id: 1, price: '10.00', max: 5 }, + { id: 2, price: '20.00', max: 3 }, + ]) + initCart() + + const plusBtns = document.querySelectorAll('[data-cart-plus]') + plusBtns[0].click() + plusBtns[0].click() + plusBtns[1].click() + + document.getElementById('cart-checkout').click() + + expect(fetchMock).toHaveBeenCalledWith('/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([ + { billetId: '1', qty: 2 }, + { billetId: '2', qty: 1 }, + ]), + }) + }) + + it('does not post when cart is empty on checkout', () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + + createBilletterie([{ id: 1, price: '10.00', max: 5 }]) + initCart() + + document.getElementById('cart-checkout').disabled = false + document.getElementById('cart-checkout').click() + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not post without order url', () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + + document.body.innerHTML = ` +
+
+ + + + +
+ + +
+ ` + initCart() + + document.querySelector('[data-cart-plus]').click() + document.getElementById('cart-checkout').click() + + expect(fetchMock).not.toHaveBeenCalled() + }) })