diff --git a/assets/app.js b/assets/app.js index c8e5a98..a40dfe1 100644 --- a/assets/app.js +++ b/assets/app.js @@ -9,6 +9,7 @@ import { initSortable } from "./modules/sortable.js" import { initBilletDesigner } from "./modules/billet-designer.js" import { initCommissionCalculator } from "./modules/commission-calculator.js" import { initCart } from "./modules/cart.js" +import { initStripePayment } from "./modules/stripe-payment.js" document.addEventListener('DOMContentLoaded', () => { initMobileMenu() @@ -21,4 +22,5 @@ document.addEventListener('DOMContentLoaded', () => { initBilletDesigner() initCommissionCalculator() initCart() + initStripePayment() }) diff --git a/assets/modules/stripe-payment.js b/assets/modules/stripe-payment.js new file mode 100644 index 0000000..927a8b7 --- /dev/null +++ b/assets/modules/stripe-payment.js @@ -0,0 +1,98 @@ +function waitForStripe() { + return new Promise(resolve => { + if (typeof globalThis.Stripe !== 'undefined') { + resolve() + + return + } + const interval = setInterval(() => { + if (typeof globalThis.Stripe !== 'undefined') { + clearInterval(interval) + resolve() + } + }, 100) + }) +} + +export function initStripePayment() { + const container = document.getElementById('payment-card') + if (!container) return + + const publicKey = container.dataset.stripeKey + const stripeAccount = container.dataset.stripeAccount + const intentUrl = container.dataset.intentUrl + const returnUrl = container.dataset.returnUrl + const amount = container.dataset.amount + + if (!publicKey || !intentUrl) return + + const submitBtn = document.getElementById('payment-submit') + const messageEl = document.getElementById('payment-message') + const messageText = document.getElementById('payment-message-text') + + let stripe + let elements + + waitForStripe().then(() => { + /* global Stripe */ + stripe = Stripe(publicKey, { + stripeAccount: stripeAccount || undefined, + }) + + return globalThis.fetch(intentUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + }) + .then(r => r.json()) + .then(data => { + elements = stripe.elements({ + clientSecret: data.clientSecret, + appearance: { + theme: 'flat', + variables: { + colorPrimary: '#4f46e5', + fontFamily: 'system-ui, sans-serif', + fontWeightNormal: '700', + borderRadius: '0px', + colorBackground: '#ffffff', + }, + rules: { + '.Input': { + border: '2px solid #111827', + boxShadow: 'none', + }, + '.Input:focus': { + border: '2px solid #4f46e5', + boxShadow: 'none', + }, + }, + }, + }) + + const paymentElement = elements.create('payment', { layout: 'tabs' }) + paymentElement.mount('#payment-element') + }) + + submitBtn.addEventListener('click', async () => { + if (!stripe || !elements) return + + submitBtn.disabled = true + submitBtn.textContent = 'Traitement...' + messageEl.classList.add('hidden') + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: returnUrl, + }, + }) + + if (error) { + messageText.textContent = error.message + messageEl.classList.remove('hidden') + submitBtn.disabled = false + submitBtn.textContent = 'Payer ' + amount + ' \u20AC' + } + }) +} diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 0490cd8..43f4223 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -34,6 +34,7 @@ nelmio_security: - 'https://static.cloudflareinsights.com' - 'https://challenges.cloudflare.com' - 'https://cdn.jsdelivr.net' + - 'https://js.stripe.com' - 'unsafe-inline' style-src: - 'self' @@ -58,6 +59,7 @@ nelmio_security: - 'https://challenges.cloudflare.com' - 'https://nominatim.openstreetmap.org' - 'https://cdn.jsdelivr.net' + - 'https://api.stripe.com' font-src: - 'self' - 'https://cdnjs.cloudflare.com' diff --git a/config/services.yaml b/config/services.yaml index 25ff796..1d11cc2 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,7 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + stripe_pk: '%env(STRIPE_PK)%' services: # default configuration for services in *this* file diff --git a/migrations/Version20260321210000.php b/migrations/Version20260321210000.php new file mode 100644 index 0000000..44d5b19 --- /dev/null +++ b/migrations/Version20260321210000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE billet_buyer ADD COLUMN IF NOT EXISTS order_number VARCHAR(20) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS UNIQ_BB_ORDER_NUMBER ON billet_buyer (order_number)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IF EXISTS UNIQ_BB_ORDER_NUMBER'); + $this->addSql('ALTER TABLE billet_buyer DROP COLUMN IF EXISTS order_number'); + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 1129d49..e5f8a5e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.projectKey=e-ticket sonar.projectName=E-Ticket sonar.sources=src,assets,templates,docker -sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php +sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,assets/modules/stripe-payment.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php sonar.php.version=8.4 sonar.sourceEncoding=UTF-8 sonar.php.coverage.reportPaths=coverage.xml diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index 18481fc..cd880cd 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -61,6 +61,9 @@ class OrderController extends AbstractController return $this->json(['redirect' => $eventUrl]); } + $count = $em->getRepository(BilletBuyer::class)->count([]) + 1; + $order->setOrderNumber(date('Y-m-d').'-'.$count); + $em->persist($order); $em->flush(); @@ -125,8 +128,17 @@ class OrderController extends AbstractController return $this->redirectToRoute('app_order_guest', ['id' => $order->getId()]); } + $organizer = $order->getEvent()->getAccount(); + if (!$organizer->getStripeAccountId()) { + throw $this->createNotFoundException(); + } + + $stripePublicKey = $this->getParameter('stripe_pk'); + return $this->render('order/payment.html.twig', [ 'order' => $order, + 'stripe_public_key' => $stripePublicKey, + 'stripe_account' => $organizer->getStripeAccountId(), 'breadcrumbs' => [ ['name' => 'Accueil', 'url' => '/'], ['name' => $order->getEvent()->getTitle()], @@ -138,8 +150,8 @@ class OrderController extends AbstractController /** * @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 + #[Route('/commande/{id}/create-payment-intent', name: 'app_order_create_intent', requirements: ['id' => '\d+'], methods: ['POST'])] + public function createPaymentIntent(int $id, EntityManagerInterface $em, StripeService $stripeService): Response { $order = $em->getRepository(BilletBuyer::class)->find($id); if (!$order || BilletBuyer::STATUS_PENDING !== $order->getStatus() || !$order->getEmail()) { @@ -151,54 +163,25 @@ class OrderController extends AbstractController 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), + $paymentIntent = $stripeService->getClient()->paymentIntents->create([ + 'amount' => $order->getTotalHT(), + 'currency' => 'eur', + 'automatic_payment_methods' => ['enabled' => true], + 'application_fee_amount' => $applicationFee, 'metadata' => [ 'order_id' => $order->getId(), 'reference' => $order->getReference(), ], + 'receipt_email' => $order->getEmail(), ], ['stripe_account' => $organizer->getStripeAccountId()]); - $order->setStripeSessionId($session->id); + $order->setStripeSessionId($paymentIntent->id); $em->flush(); - return $this->redirect($session->url); + return $this->json(['clientSecret' => $paymentIntent->client_secret]); } #[Route('/commande/{id}/confirmation', name: 'app_order_success', requirements: ['id' => '\d+'], methods: ['GET'])] diff --git a/src/Entity/BilletBuyer.php b/src/Entity/BilletBuyer.php index b4517b9..8da093f 100644 --- a/src/Entity/BilletBuyer.php +++ b/src/Entity/BilletBuyer.php @@ -39,6 +39,9 @@ class BilletBuyer #[ORM\Column(length: 23, unique: true)] private string $reference; + #[ORM\Column(length: 20, unique: true, nullable: true)] + private ?string $orderNumber = null; + #[ORM\Column] private int $totalHT = 0; @@ -143,6 +146,18 @@ class BilletBuyer return $this->reference; } + public function getOrderNumber(): ?string + { + return $this->orderNumber; + } + + public function setOrderNumber(string $orderNumber): static + { + $this->orderNumber = $orderNumber; + + return $this; + } + public function getTotalHT(): int { return $this->totalHT; diff --git a/templates/base.html.twig b/templates/base.html.twig index 8478f9d..396b3d1 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -78,6 +78,7 @@ {% block javascripts %} {{ vite_asset('app.js') }} {% endblock %} + {% block head %}{% endblock %}
Commande {{ order.reference }}
+{{ order.firstName }} {{ order.lastName }}
-{{ order.email }}
-Paiement securise par Stripe
-{{ order.event.startAt|date('d/m/Y') }} — {{ order.event.startAt|date('H:i') }} a {{ order.event.endAt|date('H:i') }}
+{{ order.event.address }}, {{ order.event.zipcode }} {{ order.event.city }}
+Par {{ order.event.account.companyName ?? (order.event.account.firstName ~ ' ' ~ order.event.account.lastName) }}
+Commande {{ order.orderNumber }} — Ref: {{ order.reference }}
Nom
+{{ order.lastName }}
+Prenom
+{{ order.firstName }}
+{{ order.email }}
+{{ item.billetName }}
+ {% if item.billet and item.billet.description %} +{{ item.billet.description }}
+ {% endif %} +Paiement securise par Stripe
+