add ticket purchase page

Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Reviewed-on: http://git.simonis.lol/projects/abiball/pulls/13
Reviewed-by: jank1619 <jan@kjan.email>
This commit is contained in:
2025-01-31 10:05:25 +00:00
parent f5ef5968eb
commit fc2a2c7873
42 changed files with 3637 additions and 176 deletions

View File

@ -0,0 +1,63 @@
<?php
namespace App\Controller;
use App\DataObjects\TicketFormData;
use App\Service\TicketService;
use Stripe\Stripe;
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\Serializer\SerializerInterface;
final class TicketController extends AbstractController
{
public function __construct(
private TicketService $service,
private SerializerInterface $serializer,
) {
}
#[Route('/ticket', name: 'app_ticket')]
public function index(): Response
{
return $this->render('ticket/index.html.twig');
}
#[Route(path: '/ticket/submit', name: 'app_submit', methods: Request::METHOD_POST)]
public function submit(Request $request): Response
{
$ticketData = $this->serializer->deserialize($request->getContent(), TicketFormData::class, 'json');
return $this->json(['id' => $this->service->handleTicketData($ticketData)->id]);
}
#[Route(path: '/success', name: 'app_success', methods: Request::METHOD_GET)]
public function success(Request $request): Response
{
$sessionId = $request->query->get('session_id');
if (!$sessionId) {
noty()->error('Etwas ist schiefgelaufen');
return $this->redirectToRoute('app_ticket');
}
if (!$this->service->completePayment($sessionId)) {
noty()->error('Etwas ist schiefgelaufen');
return $this->redirectToRoute('app_ticket');
}
return $this->render('ticket/success.html.twig');
}
#[Route(path: '/cancelled', name: 'app_cancelled', methods: Request::METHOD_GET)]
public function cancel(): Response
{
noty()->error('Bezahlung abgebrochen');
return $this->redirectToRoute('app_ticket');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\DataObjects;
class PersonalData
{
public function __construct(
public string $firstname,
public string $lastname,
public string $email,
public string $phone,
) {
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\DataObjects;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
class TicketData
{
public function __construct(
#[Assert\Range(min: 1, max: 3)]
#[SerializedName('ticket')]
public int $ticketType = 0,
#[Assert\Range(min: 1, max: 3)]
#[SerializedName('food')]
public int $foodType = 0,
public string $note = ''
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\DataObjects;
use Symfony\Component\Serializer\Attribute\SerializedName;
class TicketFormData
{
/**
* @param PersonalData $personal
* @param TicketData[] $tickets
*/
public function __construct(
#[SerializedName('personal')]
public PersonalData $personal,
#[SerializedName('tickets')]
public array $tickets,
) {
}
}

0
src/Entity/.gitignore vendored Normal file
View File

152
src/Entity/Customer.php Normal file
View File

@ -0,0 +1,152 @@
<?php
namespace App\Entity;
use App\Repository\CustomerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 255)]
private ?string $firstname = null;
#[ORM\Column(length: 255)]
private ?string $lastname = null;
/**
* @var Collection<int, Ticket>
*/
#[ORM\OneToMany(targetEntity: Ticket::class, mappedBy: 'customer', cascade: ['persist'], fetch: 'EAGER', orphanRemoval: true)]
private Collection $tickets;
#[ORM\OneToOne(mappedBy: 'customer', cascade: ['persist', 'remove'])]
private ?Payment $payment = null;
public function __construct()
{
$this->tickets = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
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;
}
/**
* @return Collection<int, Ticket>
*/
public function getTickets(): Collection
{
return $this->tickets;
}
public function addTicket(Ticket $ticket): static
{
if (!$this->tickets->contains($ticket)) {
$this->tickets->add($ticket);
$ticket->setCustomer($this);
}
return $this;
}
public function removeTicket(Ticket $ticket): static
{
if ($this->tickets->removeElement($ticket)) {
// set the owning side to null (unless already changed)
if ($ticket->getCustomer() === $this) {
$ticket->setCustomer(null);
}
}
return $this;
}
public function addTickets(array $tickets): static
{
foreach ($tickets as $ticket) {
$this->addTicket($ticket);
}
return $this;
}
public function getPayment(): ?Payment
{
return $this->payment;
}
public function setPayment(Payment $payment): static
{
// set the owning side of the relation if necessary
if ($payment->getCustomer() !== $this) {
$payment->setCustomer($this);
}
$this->payment = $payment;
return $this;
}
}

66
src/Entity/Payment.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Entity;
use App\Repository\PaymentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PaymentRepository::class)]
class Payment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $sessionId = null;
#[ORM\Column]
private ?bool $completed = null;
#[ORM\OneToOne(inversedBy: 'payment', cascade: ['persist', 'remove'], fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
public function getId(): ?int
{
return $this->id;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function setSessionId(string $sessionId): static
{
$this->sessionId = $sessionId;
return $this;
}
public function isCompleted(): ?bool
{
return $this->completed;
}
public function setCompleted(bool $completed): static
{
$this->completed = $completed;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(Customer $customer): static
{
$this->customer = $customer;
return $this;
}
}

81
src/Entity/Ticket.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace App\Entity;
use App\Repository\TicketRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TicketRepository::class)]
class Ticket
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private ?int $type = null;
#[ORM\Column]
private ?int $foodType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $note = null;
#[ORM\ManyToOne(inversedBy: 'tickets')]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?int
{
return $this->type;
}
public function setType(int $type): static
{
$this->type = $type;
return $this;
}
public function getFoodType(): ?int
{
return $this->foodType;
}
public function setFoodType(int $foodType): static
{
$this->foodType = $foodType;
return $this;
}
public function getNote(): ?string
{
return $this->note;
}
public function setNote(?string $note): static
{
$this->note = $note;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
}

25
src/Enum/TicketData.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Enum;
class TicketData
{
public const TICKET_DATA = [
1 => [
'name' => 'Ticket',
'price' => 50,
],
2 => [
'name' => 'After-Show Ticket',
'price' => 20,
],
3 => [
'name' => 'Kind (6-12 Jahre)',
'price' => 0,
],
4 => [
'name' => 'Kind (0-6 Jahre)',
'price' => 0,
],
];
}

0
src/Repository/.gitignore vendored Normal file
View File

View File

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

View File

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

View File

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

View File

@ -0,0 +1,129 @@
<?php
namespace App\Service;
use App\DataObjects\TicketData;
use App\DataObjects\TicketFormData;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Entity\Ticket;
use App\Enum\TicketData as TicketEnum;
use App\Repository\PaymentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TicketService
{
public function __construct(
private readonly UrlGeneratorInterface $generator,
private readonly EntityManagerInterface $em,
private readonly PaymentRepository $paymentRepository,
#[Autowire(env: 'STRIPE_SECRET_KEY')]
string $stripeKey
) {
Stripe::setApiKey($stripeKey);
}
public function handleTicketData(TicketFormData $data): Session
{
$session = $this->createSession($this->getLineItems($data->tickets), $data->personal->email);
$this->saveTicketData($data, $session->id);
return $session;
}
public function saveTicketData(TicketFormData $data, string $sessionId): void
{
$payment = (new Payment())
->setSessionId($sessionId)
->setCompleted(false)
->setCustomer($this->createEntityFromData($data));
$this->em->persist($payment);
$this->em->flush();
}
public function completePayment(string $sessionId): bool
{
if (!$payment = $this->paymentRepository->findOneBy(['sessionId' => $sessionId])) {
return false;
}
$payment->setCompleted(true);
$this->em->flush();
return true;
}
private function getLineItems(array $tickets): array
{
$lineItems = [];
foreach ($tickets as $ticket) {
$ticketData = TicketEnum::TICKET_DATA[$ticket->ticketType];
$lineItems[] = [
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $ticketData['name'],
],
'unit_amount' => $ticketData['price'] * 100,
],
'quantity' => 1,
];
}
return $lineItems;
}
private function createSession(array $lineItems, string $email): Session
{
return Session::create([
'line_items' => $lineItems,
'mode' => 'payment',
'customer_email' => $email,
'success_url' => $this->generator->generate('app_success', [], 0) . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->generator->generate('app_cancelled', [], 0),
]);
}
private function createEntityFromData(TicketFormData $ticketData): Customer
{
$personalData = $ticketData->personal;
$entity = (new Customer())
->setFirstname($personalData->firstname)
->setLastname($personalData->lastname)
->setEmail($personalData->email)
->setPhone($personalData->phone);
$entity->addTickets($this->createTicketEntities($ticketData->tickets));
$this->em->persist($entity);
$this->em->flush();
return $entity;
}
/**
* @param TicketData[] $tickets
*/
private function createTicketEntities(array $tickets): array
{
$entities = [];
foreach ($tickets as $ticket) {
$entities[] = (new Ticket())
->setType($ticket->ticketType)
->setFoodType($ticket->foodType)
->setNote($ticket->note);
}
return $entities;
}
}

19
src/Twig/Environment.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class Environment extends AbstractExtension
{
public function getFunctions(): array
{
return [new TwigFunction('env', $this->getVar(...))];
}
public function getVar(string $name): string
{
return $_ENV[$name];
}
}