From d0391e5fda7e4bcc4029c3bdd241785f848573ee Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sat, 21 Mar 2026 16:13:06 +0100 Subject: [PATCH] Replace Stripe Checkout with Stripe Elements for in-page payment - PaymentIntent instead of Checkout Session on connected account - Stripe Elements Payment Element with neo-brutalist theme - stripe-payment.js module with waitForStripe() for deferred loading - No inline scripts (CSP compliant), data attributes on container - Add order_number (YYYY-MM-DD-increment) to BilletBuyer - Payment page redesign: full-width vertical layout with event info, buyer info, billet listing with images/descriptions, payment form - CSP: add js.stripe.com to script-src, api.stripe.com to connect-src - Add stripe_pk parameter in services.yaml - Add head block to base.html.twig for page-specific scripts Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/app.js | 2 + assets/modules/stripe-payment.js | 98 +++++++++++++++++++++++ config/packages/nelmio_security.yaml | 2 + config/services.yaml | 1 + migrations/Version20260321210000.php | 28 +++++++ sonar-project.properties | 2 +- src/Controller/OrderController.php | 61 ++++++--------- src/Entity/BilletBuyer.php | 15 ++++ templates/base.html.twig | 1 + templates/order/payment.html.twig | 111 +++++++++++++++++++++------ vitest.config.js | 2 +- 11 files changed, 257 insertions(+), 66 deletions(-) create mode 100644 assets/modules/stripe-payment.js create mode 100644 migrations/Version20260321210000.php 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 %}
diff --git a/templates/order/payment.html.twig b/templates/order/payment.html.twig index 3c427de..2f4b3b9 100644 --- a/templates/order/payment.html.twig +++ b/templates/order/payment.html.twig @@ -2,35 +2,96 @@ {% block title %}Paiement - {{ order.event.title }} - E-Ticket{% endblock %} +{% block head %} + +{% endblock %} + {% block body %} -
-

Paiement

-

Commande {{ order.reference }}

+
-
-
-
-
-

Informations

-
-

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

-

{{ order.email }}

-
- -
- -
- -

Paiement securise par Stripe

-
-
+
+
+

Evenement

- -
- {% include 'order/_summary.html.twig' %} +
+

{{ order.event.title }}

+

{{ 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 }}

+ +
+
+

Acheteur

+
+
+
+

Nom

+

{{ order.lastName }}

+
+
+

Prenom

+

{{ order.firstName }}

+
+
+

Email

+

{{ order.email }}

+
+
+
+ +
+
+

Billets

+
+
+ {% for item in order.items %} +
+
+
+ {% if item.billet and item.billet.pictureName %} + {{ item.billetName }} + {% endif %} +
+

{{ item.billetName }}

+ {% if item.billet and item.billet.description %} +

{{ item.billet.description }}

+ {% endif %} +
+
+
+ x{{ item.quantity }} + {{ item.unitPriceHTDecimal|number_format(2, ',', ' ') }} €/u + {{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} € +
+
+
+ {% endfor %} + +
+ Total + {{ order.totalHTDecimal|number_format(2, ',', ' ') }} € +
+
+
+ +
+
+

Paiement securise

+
+
+
+ + +

Paiement securise par Stripe

+
+
+
{% endblock %} diff --git a/vitest.config.js b/vitest.config.js index d30be44..1f7a768 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -7,7 +7,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['assets/**/*.js'], - exclude: ['assets/modules/editor.js', 'assets/modules/event-map.js', 'assets/modules/billet-designer.js'], + exclude: ['assets/modules/editor.js', 'assets/modules/event-map.js', 'assets/modules/billet-designer.js', 'assets/modules/stripe-payment.js'], reporter: ['text', 'lcov'], }, },