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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
42
migrations/Version20260321180000.php
Normal file
42
migrations/Version20260321180000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
246
src/Controller/OrderController.php
Normal file
246
src/Controller/OrderController.php
Normal 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
221
src/Entity/BilletBuyer.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/Entity/BilletBuyerItem.php
Normal file
112
src/Entity/BilletBuyerItem.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Repository/BilletBuyerItemRepository.php
Normal file
18
src/Repository/BilletBuyerItemRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Repository/BilletBuyerRepository.php
Normal file
18
src/Repository/BilletBuyerRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
25
templates/order/_summary.html.twig
Normal file
25
templates/order/_summary.html.twig
Normal 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, ',', ' ') }} €/u</p>
|
||||||
|
</div>
|
||||||
|
<p class="font-black text-sm text-indigo-600">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</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, ',', ' ') }} €</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-[10px] font-bold text-gray-400 mt-2">Ref: {{ order.reference }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
48
templates/order/guest.html.twig
Normal file
48
templates/order/guest.html.twig
Normal 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 %}
|
||||||
36
templates/order/payment.html.twig
Normal file
36
templates/order/payment.html.twig
Normal 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, ',', ' ') }} €
|
||||||
|
</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 %}
|
||||||
36
templates/order/success.html.twig
Normal file
36
templates/order/success.html.twig
Normal 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">✓</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||||
84
tests/Entity/BilletBuyerItemTest.php
Normal file
84
tests/Entity/BilletBuyerItemTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/Entity/BilletBuyerTest.php
Normal file
134
tests/Entity/BilletBuyerTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user