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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 13:54:17 +01:00
parent 5e099b8af6
commit 7167a58c7c
16 changed files with 1128 additions and 4 deletions

View File

@@ -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() updateTotals()
} }

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260321180000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create billet_buyer and billet_buyer_item tables';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -6,7 +6,7 @@ parameters:
- src/Kernel.php - src/Kernel.php
ignoreErrors: 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: paths:
- src/Entity/EmailTracking.php - src/Entity/EmailTracking.php
- src/Entity/MessengerLog.php - src/Entity/MessengerLog.php
@@ -16,3 +16,5 @@ parameters:
- src/Entity/Category.php - src/Entity/Category.php
- src/Entity/Billet.php - src/Entity/Billet.php
- src/Entity/BilletDesign.php - src/Entity/BilletDesign.php
- src/Entity/BilletBuyer.php
- src/Entity/BilletBuyerItem.php

View File

@@ -0,0 +1,246 @@
<?php
namespace App\Controller;
use App\Entity\Billet;
use App\Entity\BilletBuyer;
use App\Entity\BilletBuyerItem;
use App\Entity\Event;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class OrderController extends AbstractController
{
#[Route('/evenement/{id}/commander', name: 'app_order_create', requirements: ['id' => '\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'],
],
]);
}
}

221
src/Entity/BilletBuyer.php Normal file
View File

@@ -0,0 +1,221 @@
<?php
namespace App\Entity;
use App\Repository\BilletBuyerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BilletBuyerRepository::class)]
class BilletBuyer
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Event::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Event $event = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $firstName = null;
#[ORM\Column(length: 255)]
private ?string $lastName = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 23, unique: true)]
private string $reference;
#[ORM\Column]
private int $totalHT = 0;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeSessionId = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
/** @var Collection<int, BilletBuyerItem> */
#[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<int, BilletBuyerItem>
*/
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;
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Entity;
use App\Repository\BilletBuyerItemRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BilletBuyerItemRepository::class)]
class BilletBuyerItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: BilletBuyer::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?BilletBuyer $billetBuyer = null;
#[ORM\ManyToOne(targetEntity: Billet::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Billet $billet = null;
#[ORM\Column]
private int $quantity = 1;
#[ORM\Column]
private int $unitPriceHT = 0;
#[ORM\Column(length: 255)]
private ?string $billetName = null;
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 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;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\BilletBuyerItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BilletBuyerItem>
*/
class BilletBuyerItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BilletBuyerItem::class);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\BilletBuyer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<BilletBuyer>
*/
class BilletBuyerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BilletBuyer::class);
}
}

View File

@@ -133,7 +133,7 @@
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Articles</span> <span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Articles</span>
<span class="font-black text-sm" id="cart-count">0</span> <span class="font-black text-sm" id="cart-count">0</span>
</div> </div>
<button type="button" id="cart-checkout" disabled class="w-full btn-brutal font-black uppercase text-sm tracking-widest bg-indigo-600 text-white hover:bg-indigo-800 transition-all disabled:opacity-30 disabled:cursor-not-allowed"> <button type="button" id="cart-checkout" disabled data-order-url="{{ path('app_order_create', {id: event.id}) }}" class="w-full btn-brutal font-black uppercase text-sm tracking-widest bg-indigo-600 text-white hover:bg-indigo-800 transition-all disabled:opacity-30 disabled:cursor-not-allowed">
Commander Commander
</button> </button>
</div> </div>

View File

@@ -0,0 +1,25 @@
<div class="card-brutal overflow-hidden">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Recapitulatif</h2>
</div>
<div class="p-6">
<p class="font-black uppercase text-sm tracking-widest mb-4">{{ order.event.title }}</p>
{% for item in order.items %}
<div class="flex justify-between py-2 {{ not loop.last ? 'border-b border-gray-200' : '' }}">
<div>
<p class="font-bold text-sm">{{ item.billetName }}</p>
<p class="text-xs text-gray-400 font-bold">x{{ item.quantity }}{{ item.unitPriceHTDecimal|number_format(2, ',', ' ') }} &euro;/u</p>
</div>
<p class="font-black text-sm text-indigo-600">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</p>
</div>
{% endfor %}
<div class="flex justify-between pt-4 mt-4 border-t-2 border-gray-900">
<span class="font-black uppercase text-sm">Total HT</span>
<span class="font-black text-lg text-indigo-600">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
</div>
<p class="text-[10px] font-bold text-gray-400 mt-2">Ref: {{ order.reference }}</p>
</div>
</div>

View File

@@ -0,0 +1,48 @@
{% extends 'base.html.twig' %}
{% block title %}Vos informations - {{ order.event.title }} - E-Ticket{% endblock %}
{% block body %}
<div class="page-container">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Vos informations</h1>
<p class="font-bold text-gray-600 italic mb-8">Commande {{ order.reference }}{{ order.event.title }}</p>
{% for message in app.flashes('error') %}
<div class="flash-error"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1">
<div class="card-brutal">
<form method="post" action="{{ path('app_order_guest', {id: order.id}) }}" class="form-col">
<div class="form-row">
<div class="form-group">
<label for="guest_last_name" class="text-xs font-black uppercase tracking-widest form-label">Nom</label>
<input type="text" id="guest_last_name" name="last_name" required class="form-input focus:border-indigo-600" placeholder="Dupont" value="{{ order.lastName }}">
</div>
<div class="form-group">
<label for="guest_first_name" class="text-xs font-black uppercase tracking-widest form-label">Prenom</label>
<input type="text" id="guest_first_name" name="first_name" required class="form-input focus:border-indigo-600" placeholder="Jean" value="{{ order.firstName }}">
</div>
</div>
<div>
<label for="guest_email" class="text-xs font-black uppercase tracking-widest form-label">Email</label>
<input type="email" id="guest_email" name="email" required class="form-input focus:border-indigo-600" placeholder="jean.dupont@exemple.fr" value="{{ order.email }}">
</div>
<div>
<button type="submit" class="w-full btn-brutal font-black uppercase text-sm tracking-widest bg-indigo-600 text-white hover:bg-indigo-800 transition-all">
Continuer vers le paiement
</button>
</div>
</form>
</div>
</div>
<div class="w-full lg:w-[350px] flex-shrink-0">
{% include 'order/_summary.html.twig' %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends 'base.html.twig' %}
{% block title %}Paiement - {{ order.event.title }} - E-Ticket{% endblock %}
{% block body %}
<div class="page-container">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Paiement</h1>
<p class="font-bold text-gray-600 italic mb-8">Commande {{ order.reference }}</p>
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1">
<div class="card-brutal">
<div class="p-6">
<h2 class="font-black uppercase text-sm tracking-widest mb-4">Informations</h2>
<div class="space-y-2 mb-6">
<p class="text-sm font-bold">{{ order.firstName }} {{ order.lastName }}</p>
<p class="text-sm font-bold text-gray-500">{{ order.email }}</p>
</div>
<form method="post" action="{{ path('app_order_stripe', {id: order.id}) }}">
<button type="submit" class="w-full btn-brutal font-black uppercase text-sm tracking-widest bg-indigo-600 text-white hover:bg-indigo-800 transition-all">
Payer {{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;
</button>
</form>
<p class="text-[10px] font-bold text-gray-400 mt-4 text-center">Paiement securise par Stripe</p>
</div>
</div>
</div>
<div class="w-full lg:w-[350px] flex-shrink-0">
{% include 'order/_summary.html.twig' %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends 'base.html.twig' %}
{% block title %}Confirmation - E-Ticket{% endblock %}
{% block body %}
<div class="page-container">
<div class="max-w-xl mx-auto text-center">
<div class="card-brutal p-8">
<div class="text-6xl mb-4">&#10003;</div>
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-4">Commande confirmee</h1>
<p class="font-bold text-gray-600 mb-2">Merci {{ order.firstName }} !</p>
<p class="text-sm font-bold text-gray-500 mb-6">Votre commande <span class="font-mono text-gray-900">{{ order.reference }}</span> a bien ete enregistree.</p>
<div class="border-2 border-gray-900 p-4 bg-gray-50 text-left mb-6">
<p class="text-xs font-black uppercase tracking-widest text-gray-500 mb-2">Details</p>
{% for item in order.items %}
<div class="flex justify-between py-1 text-sm font-bold">
<span>{{ item.billetName }} x{{ item.quantity }}</span>
<span>{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
</div>
{% endfor %}
<div class="flex justify-between pt-2 mt-2 border-t-2 border-gray-900 font-black">
<span>Total</span>
<span class="text-indigo-600">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
</div>
</div>
<p class="text-xs font-bold text-gray-400 mb-6">Un email de confirmation sera envoye a {{ order.email }}</p>
<a href="{{ path('app_home') }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Retour a l'accueil
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Billet;
use App\Entity\BilletBuyer;
use App\Entity\BilletBuyerItem;
use PHPUnit\Framework\TestCase;
class BilletBuyerItemTest extends TestCase
{
public function testDefaults(): void
{
$item = new BilletBuyerItem();
self::assertNull($item->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());
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Tests\Entity;
use App\Entity\BilletBuyer;
use App\Entity\BilletBuyerItem;
use App\Entity\Event;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class BilletBuyerTest extends TestCase
{
public function testDefaults(): void
{
$buyer = new BilletBuyer();
self::assertNull($buyer->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());
}
}

View File

@@ -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' import { initCart } from '../../assets/modules/cart.js'
function createBilletterie(billets) { function createBilletterie(billets) {
@@ -15,7 +15,7 @@ function createBilletterie(billets) {
` `
} }
html += '<span id="cart-total"></span><span id="cart-count"></span><button id="cart-checkout" disabled></button></div>' html += '<span id="cart-total"></span><span id="cart-count"></span><button id="cart-checkout" disabled data-order-url="/order"></button></div>'
document.body.innerHTML = html document.body.innerHTML = html
} }
@@ -128,4 +128,70 @@ describe('initCart', () => {
document.querySelector('[data-cart-minus]').click() document.querySelector('[data-cart-minus]').click()
expect(document.getElementById('cart-checkout').disabled).toBe(true) 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 = `
<div id="billetterie">
<div data-cart-item data-billet-id="1" data-price="10" data-max="5">
<button data-cart-minus></button>
<input data-cart-qty type="number" min="0" max="5" value="0" readonly>
<button data-cart-plus></button>
<span data-cart-line-total></span>
</div>
<span id="cart-total"></span><span id="cart-count"></span>
<button id="cart-checkout" disabled></button>
</div>
`
initCart()
document.querySelector('[data-cart-plus]').click()
document.getElementById('cart-checkout').click()
expect(fetchMock).not.toHaveBeenCalled()
})
}) })