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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 16:13:06 +01:00
parent 3744fb84f1
commit d0391e5fda
11 changed files with 257 additions and 66 deletions

View File

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

View File

@@ -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'
}
})
}

View File

@@ -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'

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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'])]

View File

@@ -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;

View File

@@ -78,6 +78,7 @@
{% block javascripts %}
{{ vite_asset('app.js') }}
{% endblock %}
{% block head %}{% endblock %}
</head>
<body class="min-h-screen flex flex-col bg-[#fbfbfb] text-[#111827]" data-env="{{ app.environment }}">
<header class="sticky top-0 z-50 bg-white border-b-4 border-gray-900">

View File

@@ -2,35 +2,96 @@
{% block title %}Paiement - {{ order.event.title }} - E-Ticket{% endblock %}
{% block head %}
<script src="https://js.stripe.com/v3/" defer></script>
{% 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="w-full md:w-[80%] lg:w-[60%] mx-auto py-12 px-4">
<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 class="card-brutal overflow-hidden mb-6">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Evenement</h2>
</div>
<div class="w-full lg:w-[350px] flex-shrink-0">
{% include 'order/_summary.html.twig' %}
<div class="p-6">
<h1 class="text-2xl font-black uppercase tracking-tighter mb-2">{{ order.event.title }}</h1>
<p class="text-sm font-bold text-gray-600 mb-1">{{ order.event.startAt|date('d/m/Y') }}{{ order.event.startAt|date('H:i') }} a {{ order.event.endAt|date('H:i') }}</p>
<p class="text-sm font-bold text-gray-600 mb-1">{{ order.event.address }}, {{ order.event.zipcode }} {{ order.event.city }}</p>
<p class="text-sm font-bold text-gray-400">Par {{ order.event.account.companyName ?? (order.event.account.firstName ~ ' ' ~ order.event.account.lastName) }}</p>
<p class="text-xs font-mono text-gray-400 mt-3">Commande {{ order.orderNumber }} — Ref: {{ order.reference }}</p>
</div>
</div>
<div class="card-brutal overflow-hidden mb-6">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Acheteur</h2>
</div>
<div class="p-6 flex flex-wrap gap-6">
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Nom</p>
<p class="text-sm font-bold">{{ order.lastName }}</p>
</div>
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Prenom</p>
<p class="text-sm font-bold">{{ order.firstName }}</p>
</div>
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">Email</p>
<p class="text-sm font-bold">{{ order.email }}</p>
</div>
</div>
</div>
<div class="card-brutal overflow-hidden mb-6">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Billets</h2>
</div>
<div class="p-6">
{% for item in order.items %}
<div class="border-2 border-gray-900 bg-white p-4 {{ not loop.last ? 'mb-3' : '' }}">
<div class="flex flex-col md:flex-row md:items-center gap-4">
<div class="flex items-center gap-4 flex-1 min-w-0">
{% if item.billet and item.billet.pictureName %}
<img src="{{ ('/uploads/billets/' ~ item.billet.pictureName) | imagine_filter('thumbnail') }}" alt="{{ item.billetName }}" class="w-16 h-16 object-cover border border-gray-300 flex-shrink-0">
{% endif %}
<div class="min-w-0">
<p class="font-black uppercase text-sm">{{ item.billetName }}</p>
{% if item.billet and item.billet.description %}
<p class="text-xs text-gray-500 font-bold mt-1">{{ item.billet.description }}</p>
{% endif %}
</div>
</div>
<div class="flex items-center gap-6 flex-shrink-0">
<span class="text-sm font-bold text-gray-400">x{{ item.quantity }}</span>
<span class="font-black text-sm">{{ item.unitPriceHTDecimal|number_format(2, ',', ' ') }} &euro;/u</span>
<span class="font-black text-lg text-indigo-600">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
</div>
</div>
</div>
{% endfor %}
<div class="flex justify-between pt-4 mt-4 border-t-3 border-gray-900">
<span class="font-black uppercase tracking-widest">Total</span>
<span class="font-black text-2xl text-indigo-600">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
</div>
</div>
</div>
<div class="card-brutal overflow-hidden" id="payment-card" data-stripe-key="{{ stripe_public_key }}" data-stripe-account="{{ stripe_account }}" data-intent-url="{{ path('app_order_create_intent', {id: order.id}) }}" data-return-url="{{ url('app_order_success', {id: order.id}) }}" data-amount="{{ order.totalHTDecimal|number_format(2, ',', ' ') }}">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Paiement securise</h2>
</div>
<div class="p-6">
<div id="payment-element" class="mb-6"></div>
<div id="payment-message" class="hidden flash-error mb-4">
<p class="font-black text-sm" id="payment-message-text"></p>
</div>
<button type="button" id="payment-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>
<p class="text-[10px] font-bold text-gray-400 mt-4 text-center">Paiement securise par Stripe</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -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'],
},
},