Refactor Stripe integration: single Connect webhook, account pages, cleanup

Stripe webhook:
- Single webhook endpoint /stripe/webhook for Connect + payment events
- v2 Connect events configured manually in Stripe Dashboard (not via API)
- account.updated syncs charges_enabled/payouts_enabled via API retrieve
- Remove StripeSyncCommand and saveWebhookSecret (secret managed via Ansible vault)

Account page (/mon-compte):
- Buyer tabs: Billets, Achats, Factures, Parametres
- Organizer tabs: Evenements/Brocantes, Sous-comptes, Virements + buyer tabs
- Stripe Connect status banner: setup required, pending verification, active, refused
- Stripe Connect onboarding: create account, complete verification (GET links)
- Dashboard Stripe: opens in new tab via createLoginLink (Express dashboard)
- Cancel/close Stripe account: deletes via API + resets local fields
- Stripe required message on events/subaccounts/payouts tabs when not active
- Settings: organizer fields locked (name, address), email/phone editable
- Return/refresh routes for Stripe Connect onboarding flow
- Error handling with flash messages on all Stripe operations
- Auto-sync Stripe status on /mon-compte visit

StripeService cleanup:
- Remove syncWebhook, saveWebhookSecret, getWebhookUrl, projectDir
- Add deleteAccount method
- Keep: verifyWebhookSignature, createAccountConnect, createAccountLink, createLoginLink

Security:
- Add connect.stripe.com and dashboard.stripe.com to nelmio whitelist
- Add STRIPE_SK, STRIPE_WEBHOOK_SECRET, OUTSIDE_URL to .env.test

Tests: 19 AccountControllerTest, 4 StripeWebhookControllerTest, 1 StripeServiceTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 22:41:31 +01:00
parent d618c21309
commit 93e5ae67c0
11 changed files with 643 additions and 176 deletions

View File

@@ -6,3 +6,6 @@ MEILISEARCH_API_KEY=test
SONARQUBE_URL=https://sn.esy-web.dev
SONARQUBE_BADGE_TOKEN=test
SONARQUBE_PROJECT_KEY=e-ticket
STRIPE_SK=sk_test_fake
STRIPE_WEBHOOK_SECRET=whsec_test
OUTSIDE_URL=https://test.example.com

View File

@@ -72,6 +72,8 @@ nelmio_security:
- cloudflareinsights.com
- static.cloudflareinsights.com
- stripe.com
- connect.stripe.com
- checkout.stripe.com
- hooks.stripe.com
- dashboard.stripe.com
- auth.esy-web.dev

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Command;
use App\Service\StripeService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'stripe:sync',
description: 'Create or update the Stripe webhook endpoint',
)]
class StripeSyncCommand extends Command
{
public function __construct(
private StripeService $stripeService,
) {
parent::__construct();
}
/**
* @codeCoverageIgnore Requires live Stripe API
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->info(sprintf('Webhook URL: %s', $this->stripeService->getWebhookUrl()));
$result = $this->stripeService->syncWebhook();
if ($result['created']) {
$this->stripeService->saveWebhookSecret($result['secret']);
$io->success(sprintf('Webhook cree: %s', $result['id']));
$io->success('STRIPE_WEBHOOK_SECRET sauvegarde dans .env.local');
} else {
$io->success(sprintf('Webhook mis a jour: %s', $result['id']));
}
return Command::SUCCESS;
}
}

View File

@@ -2,7 +2,12 @@
namespace App\Controller;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -11,8 +16,142 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AccountController extends AbstractController
{
#[Route('/mon-compte', name: 'app_account')]
public function index(): Response
public function index(Request $request, StripeService $stripeService, EntityManagerInterface $em): Response
{
return $this->render('account/index.html.twig');
/** @var User $user */
$user = $this->getUser();
$isOrganizer = $this->isGranted('ROLE_ORGANIZER');
$defaultTab = $isOrganizer ? 'events' : 'tickets';
$tab = $request->query->getString('tab', $defaultTab);
if ($isOrganizer && $user->getStripeAccountId() && (!$user->isStripeChargesEnabled() || !$user->isStripePayoutsEnabled())) {
try {
$account = $stripeService->getClient()->accounts->retrieve($user->getStripeAccountId());
$user->setStripeChargesEnabled((bool) $account->charges_enabled);
$user->setStripePayoutsEnabled((bool) $account->payouts_enabled);
$em->flush();
} catch (\Throwable) {
// Stripe API unavailable, keep current status
}
}
return $this->render('account/index.html.twig', [
'tab' => $tab,
'isOrganizer' => $isOrganizer,
]);
}
#[Route('/mon-compte/parametres', name: 'app_account_settings', methods: ['POST'])]
public function settings(Request $request, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
$isOrganizer = $this->isGranted('ROLE_ORGANIZER');
if (!$isOrganizer) {
$user->setFirstName(trim($request->request->getString('first_name')));
$user->setLastName(trim($request->request->getString('last_name')));
}
$user->setEmail(trim($request->request->getString('email')));
$user->setPhone(trim($request->request->getString('phone')));
if (!$isOrganizer) {
$user->setAddress(trim($request->request->getString('address')));
$user->setPostalCode(trim($request->request->getString('postal_code')));
$user->setCity(trim($request->request->getString('city')));
}
$em->flush();
$this->addFlash('success', 'Parametres mis a jour.');
return $this->redirectToRoute('app_account', ['tab' => 'settings']);
}
#[Route('/mon-compte/stripe-connect', name: 'app_account_stripe_connect')]
public function stripeConnect(StripeService $stripeService, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isGranted('ROLE_ORGANIZER')) {
return $this->redirectToRoute('app_account');
}
try {
if (!$user->getStripeAccountId()) {
$accountId = $stripeService->createAccountConnect($user);
$user->setStripeAccountId($accountId);
$em->flush();
}
$link = $stripeService->createAccountLink($user->getStripeAccountId());
return $this->redirect($link);
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur lors de la connexion a Stripe : '.$e->getMessage());
return $this->redirectToRoute('app_account');
}
}
#[Route('/mon-compte/stripe-cancel', name: 'app_account_stripe_cancel', methods: ['POST'])]
public function stripeCancel(StripeService $stripeService, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
if ($this->isGranted('ROLE_ORGANIZER') && $user->getStripeAccountId()) {
try {
$stripeService->deleteAccount($user->getStripeAccountId());
} catch (\Throwable) {
// Account may already be deleted on Stripe side
}
$user->setStripeAccountId(null);
$user->setStripeChargesEnabled(false);
$user->setStripePayoutsEnabled(false);
$em->flush();
$this->addFlash('success', 'Compte Stripe cloture.');
}
return $this->redirectToRoute('app_account');
}
#[Route('/stripe/connect/return', name: 'app_stripe_connect_return')]
public function stripeConnectReturn(): Response
{
$this->addFlash('success', 'Configuration Stripe terminee.');
return $this->redirectToRoute('app_account');
}
#[Route('/stripe/connect/refresh', name: 'app_stripe_connect_refresh')]
public function stripeConnectRefresh(): Response
{
return $this->redirectToRoute('app_account_stripe_connect');
}
#[Route('/mon-compte/stripe-dashboard', name: 'app_account_stripe_dashboard')]
public function stripeDashboard(StripeService $stripeService): Response
{
/** @var User $user */
$user = $this->getUser();
if (!$this->isGranted('ROLE_ORGANIZER') || !$user->getStripeAccountId()) {
return $this->redirectToRoute('app_account');
}
try {
$link = $stripeService->createLoginLink($user->getStripeAccountId());
return $this->redirect($link);
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
return $this->redirectToRoute('app_account');
}
}
}

View File

@@ -25,20 +25,33 @@ class StripeWebhookController extends AbstractController
}
if ('account.updated' === $event->type) {
$account = $event->data->object;
$accountId = $account->id ?? null;
if ($accountId) {
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
if ($user) {
$user->setStripeChargesEnabled((bool) ($account->charges_enabled ?? false));
$user->setStripePayoutsEnabled((bool) ($account->payouts_enabled ?? false));
$em->flush();
}
}
$this->handleAccountSync($event, $stripeService, $em);
}
return new Response('OK', 200);
}
private function handleAccountSync(\Stripe\Event $event, StripeService $stripeService, EntityManagerInterface $em): void
{
$accountId = $event->data->object->id ?? $event->account ?? null;
if (!$accountId) {
return;
}
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
if (!$user) {
return;
}
try {
$account = $stripeService->getClient()->accounts->retrieve($accountId);
$user->setStripeChargesEnabled((bool) $account->charges_enabled);
$user->setStripePayoutsEnabled((bool) $account->payouts_enabled);
$em->flush();
} catch (\Throwable) {
// Stripe API unavailable
}
}
}

View File

@@ -17,54 +17,10 @@ class StripeService
#[Autowire(env: 'STRIPE_SK')] private string $stripeSecret,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] private string $webhookSecret,
#[Autowire(env: 'OUTSIDE_URL')] private string $outsideUrl,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {
$this->stripe = new StripeClient($this->stripeSecret);
}
public function getWebhookUrl(): string
{
return rtrim($this->outsideUrl, '/').'/stripe/webhook';
}
/**
* @codeCoverageIgnore Requires live Stripe API
*
* @return array{created: bool, id: string, secret: string|null}
*/
public function syncWebhook(): array
{
$webhookUrl = $this->getWebhookUrl();
$endpoints = $this->stripe->webhookEndpoints->all(['limit' => 100]);
foreach ($endpoints->data as $endpoint) {
if ($endpoint->url === $webhookUrl) {
return ['created' => false, 'id' => $endpoint->id, 'secret' => null];
}
}
$newEndpoint = $this->stripe->webhookEndpoints->create([
'url' => $webhookUrl,
'enabled_events' => ['*'],
]);
return ['created' => true, 'id' => $newEndpoint->id, 'secret' => $newEndpoint->secret];
}
public function saveWebhookSecret(string $secret): void
{
$envLocalPath = $this->projectDir.'/.env.local';
$content = file_exists($envLocalPath) ? (string) file_get_contents($envLocalPath) : '';
if (preg_match('/^STRIPE_WEBHOOK_SECRET=.*/m', $content)) {
$content = preg_replace('/^STRIPE_WEBHOOK_SECRET=.*/m', 'STRIPE_WEBHOOK_SECRET='.$secret, $content);
} else {
$content = rtrim($content, "\n")."\nSTRIPE_WEBHOOK_SECRET=".$secret."\n";
}
file_put_contents($envLocalPath, $content);
}
public function verifyWebhookSignature(string $payload, string $signature): ?Event
{
try {
@@ -74,6 +30,14 @@ class StripeService
}
}
/**
* @codeCoverageIgnore Requires live Stripe API
*/
public function deleteAccount(string $accountId): void
{
$this->stripe->accounts->delete($accountId);
}
/**
* @codeCoverageIgnore Requires live Stripe API
*/
@@ -112,6 +76,16 @@ class StripeService
return $link->url;
}
/**
* @codeCoverageIgnore Requires live Stripe API
*/
public function createLoginLink(string $accountId): string
{
$link = $this->stripe->accounts->createLoginLink($accountId);
return $link->url;
}
/**
* @codeCoverageIgnore Simple getter
*/

View File

@@ -3,5 +3,216 @@
{% block title %}Mon compte - E-Ticket{% endblock %}
{% block body %}
<div style="max-width:60rem;margin:0 auto;padding:3rem 1rem;">
<div style="margin-bottom:2rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Mon compte</h1>
<p class="font-bold text-gray-500 italic">Bonjour {{ app.user.firstName }}, bienvenue sur votre espace.</p>
</div>
{% for message in app.flashes('success') %}
<div style="border:4px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#d1fae5;padding:1rem 1.5rem;margin-bottom:1.5rem;">
<p class="font-black text-sm">{{ message }}</p>
</div>
{% endfor %}
{% for message in app.flashes('error') %}
<div style="border:4px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fee2e2;padding:1rem 1.5rem;margin-bottom:1.5rem;">
<p class="font-black text-sm">{{ message }}</p>
</div>
{% endfor %}
{% if isOrganizer %}
{% if not app.user.stripeAccountId %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Configuration Stripe requise</h2>
<p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Pour creer vos evenements, vendre des billets et recevoir vos paiements, vous devez creer votre compte vendeur via Stripe.</p>
<a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Creer mon compte Stripe</a>
</div>
{% elseif not app.user.stripeChargesEnabled and not app.user.stripePayoutsEnabled %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fef3c7;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;">Verification Stripe en cours</h2>
<p class="text-sm font-bold text-gray-700" style="margin-bottom:1rem;">Votre compte Stripe est en cours de verification. Merci de patienter, vous serez notifie une fois la verification terminee.</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ path('app_account_stripe_connect') }}" style="padding:0.5rem 1rem;border:3px solid #111827;background:white;display:inline-block;" class="font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Completer ma verification</a>
<form method="post" action="{{ path('app_account_stripe_cancel') }}" data-confirm="Etes-vous sur de vouloir annuler la creation de votre compte Stripe ?" style="display:inline;">
<button type="submit" style="padding:0.5rem 1rem;border:3px solid #991b1b;background:#dc2626;color:white;cursor:pointer;" class="font-black uppercase text-xs tracking-widest hover:bg-red-800 transition-all">Annuler la creation</button>
</form>
</div>
</div>
{% elseif app.user.stripeChargesEnabled and app.user.stripePayoutsEnabled %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#d1fae5;padding:1rem 1.5rem;margin-bottom:2rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<p class="font-black text-sm">Stripe Connect actif — Paiements et virements actives.</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<a href="{{ path('app_account_stripe_dashboard') }}" target="_blank" style="padding:0.4rem 0.75rem;border:2px solid #111827;background:white;display:inline-block;" class="text-xs font-black uppercase tracking-widest hover:bg-gray-100 transition-all">Dashboard Stripe</a>
<form method="post" action="{{ path('app_account_stripe_cancel') }}" data-confirm="Etes-vous sur de vouloir cloturer votre compte Stripe ? Vous ne pourrez plus recevoir de paiements." style="display:inline;">
<button type="submit" style="padding:0.4rem 0.75rem;border:2px solid #991b1b;background:#dc2626;color:white;cursor:pointer;" class="text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all">Cloturer</button>
</form>
</div>
</div>
{% else %}
<div style="border:4px solid #991b1b;box-shadow:6px 6px 0 rgba(0,0,0,1);background:#fee2e2;padding:1.5rem;margin-bottom:2rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:0.5rem;color:#991b1b;">Compte Stripe refuse</h2>
<p class="text-sm font-bold text-gray-700">Stripe a refuse votre compte vendeur. Vous ne pouvez pas utiliser nos services de vente pour le moment. Contactez <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a> pour plus d'informations.</p>
</div>
{% endif %}
{% endif %}
<div style="display:flex;gap:0;margin-bottom:2rem;flex-wrap:wrap;">
{% if isOrganizer %}
<a href="{{ path('app_account', {tab: 'events'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'events' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Evenements / Brocantes</a>
<a href="{{ path('app_account', {tab: 'subaccounts'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'subaccounts' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Sous-comptes</a>
<a href="{{ path('app_account', {tab: 'payouts'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'payouts' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Virements</a>
{% endif %}
<a href="{{ path('app_account', {tab: 'tickets'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'tickets' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Billets</a>
<a href="{{ path('app_account', {tab: 'purchases'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'purchases' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Achats</a>
<a href="{{ path('app_account', {tab: 'invoices'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;border-right:none;{{ tab == 'invoices' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Factures</a>
<a href="{{ path('app_account', {tab: 'settings'}) }}" style="flex:1;min-width:100px;text-align:center;padding:0.75rem;border:3px solid #111827;{{ tab == 'settings' ? 'background:#fabf04;' : 'background:white;' }}" class="font-black uppercase text-xs tracking-widest transition-all">Parametres</a>
</div>
{% if tab == 'tickets' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes billets</h2>
</div>
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun billet pour le moment.</p>
</div>
</div>
{% elseif tab == 'purchases' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes achats</h2>
</div>
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun achat pour le moment.</p>
</div>
</div>
{% elseif tab == 'invoices' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes factures</h2>
</div>
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucune facture pour le moment.</p>
</div>
</div>
{% elseif tab == 'events' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes evenements / Brocantes / Reservations</h2>
</div>
{% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %}
<div style="padding:1.5rem;background:#fef3c7;border-bottom:2px solid #e5e7eb;">
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
</div>
{% endif %}
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun evenement pour le moment.</p>
</div>
</div>
{% elseif tab == 'subaccounts' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Sous-comptes</h2>
</div>
{% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %}
<div style="padding:1.5rem;background:#fef3c7;border-bottom:2px solid #e5e7eb;">
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
</div>
{% endif %}
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun sous-compte pour le moment.</p>
</div>
</div>
{% elseif tab == 'payouts' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;">
<div style="padding:0.75rem 1.5rem;background:#111827;">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes virements</h2>
</div>
{% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %}
<div style="padding:1.5rem;background:#fef3c7;border-bottom:2px solid #e5e7eb;">
<p class="font-black text-sm">Configuration Stripe ou validation est requise !</p>
</div>
{% endif %}
<div style="padding:3rem;text-align:center;">
<p class="text-gray-400 font-bold text-sm">Aucun virement pour le moment.</p>
</div>
</div>
{% elseif tab == 'settings' %}
<div style="border:4px solid #111827;box-shadow:6px 6px 0 rgba(0,0,0,1);background:white;padding:1.5rem;">
<h2 class="text-sm font-black uppercase tracking-widest" style="margin-bottom:1rem;">Parametres du compte</h2>
<form method="post" action="{{ path('app_account_settings') }}" style="display:flex;flex-direction:column;gap:1.5rem;">
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;">
<div style="flex:1;min-width:200px;">
<label for="settings_last_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Nom</label>
<input type="text" id="settings_last_name" name="last_name" value="{{ app.user.lastName }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;{{ isOrganizer ? 'background:#f3f4f6;' : '' }}"
{{ isOrganizer ? 'disabled' : '' }}>
</div>
<div style="flex:1;min-width:200px;">
<label for="settings_first_name" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Prenom</label>
<input type="text" id="settings_first_name" name="first_name" value="{{ app.user.firstName }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;{{ isOrganizer ? 'background:#f3f4f6;' : '' }}"
{{ isOrganizer ? 'disabled' : '' }}>
</div>
</div>
<div>
<label for="settings_email" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Email</label>
<input type="email" id="settings_email" name="email" value="{{ app.user.email }}" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
<div>
<label for="settings_phone" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Telephone</label>
<input type="tel" id="settings_phone" name="phone" value="{{ app.user.phone }}"
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
{% if not isOrganizer %}
<div>
<label for="settings_address" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Adresse</label>
<input type="text" id="settings_address" name="address" value="{{ app.user.address }}"
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;">
<div style="flex:1;min-width:120px;">
<label for="settings_postal" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Code postal</label>
<input type="text" id="settings_postal" name="postal_code" value="{{ app.user.postalCode }}" maxlength="5"
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
<div style="flex:2;min-width:200px;">
<label for="settings_city" style="font-size:10px;letter-spacing:0.1em;display:block;margin-bottom:0.5rem;" class="font-black uppercase text-gray-400">Ville</label>
<input type="text" id="settings_city" name="city" value="{{ app.user.city }}"
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;">
</div>
</div>
{% endif %}
{% if isOrganizer %}
<div style="border:4px solid #111827;background:#f9fafb;padding:1rem 1.5rem;">
<p class="text-xs font-bold text-gray-500">Les informations de votre organisation (raison sociale, SIRET, adresse) ne peuvent etre modifiees que par l'equipe E-Ticket. Contactez <a href="mailto:contact@e-cosplay.fr" class="text-indigo-600 hover:underline">contact@e-cosplay.fr</a> pour toute modification.</p>
</div>
{% endif %}
<div>
<button type="submit"
style="padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);background:#fabf04;cursor:pointer;"
class="font-black uppercase text-sm tracking-widest hover:bg-green-500 hover:text-black transition-all">
Enregistrer
</button>
</div>
</form>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Tests\Command;
use App\Command\StripeSyncCommand;
use App\Service\StripeService;
use PHPUnit\Framework\TestCase;
class StripeSyncCommandTest extends TestCase
{
public function testCommandIsConfigured(): void
{
$stripeService = $this->createMock(StripeService::class);
$command = new StripeSyncCommand($stripeService);
self::assertSame('stripe:sync', $command->getName());
self::assertSame('Create or update the Stripe webhook endpoint', $command->getDescription());
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Tests\Controller;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -27,7 +28,225 @@ class AccountControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
private function createUser(): User
public function testAccountTicketsTab(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=tickets');
self::assertResponseIsSuccessful();
}
public function testAccountPurchasesTab(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=purchases');
self::assertResponseIsSuccessful();
}
public function testAccountInvoicesTab(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=invoices');
self::assertResponseIsSuccessful();
}
public function testAccountSettingsTab(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=settings');
self::assertResponseIsSuccessful();
}
public function testAccountSettingsSubmit(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('POST', '/mon-compte/parametres', [
'first_name' => 'Updated',
'last_name' => 'Name',
'email' => $user->getEmail(),
'phone' => '0699887766',
'address' => '1 rue Test',
'postal_code' => '75001',
'city' => 'Paris',
]);
self::assertResponseRedirects('/mon-compte?tab=settings');
}
public function testOrganizerEventsTab(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=events');
self::assertResponseIsSuccessful();
}
public function testOrganizerSubaccountsTab(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=subaccounts');
self::assertResponseIsSuccessful();
}
public function testOrganizerPayoutsTab(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=payouts');
self::assertResponseIsSuccessful();
}
public function testOrganizerSettingsDisablesNameFields(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('POST', '/mon-compte/parametres', [
'email' => $user->getEmail(),
'phone' => '0699887766',
]);
self::assertResponseRedirects('/mon-compte?tab=settings');
}
public function testOrganizerDefaultTabIsEvents(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/mon-compte');
self::assertResponseIsSuccessful();
}
public function testStripeConnectRedirectsForNonOrganizer(): void
{
$client = static::createClient();
$user = $this->createUser();
$client->loginUser($user);
$client->request('POST', '/mon-compte/stripe-connect');
self::assertResponseRedirects('/mon-compte');
}
public function testOrganizerWithoutStripeShowsSetupMessage(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$crawler = $client->request('GET', '/mon-compte');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Configuration Stripe requise');
}
public function testOrganizerWithStripePendingShowsMessage(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']);
$user->setStripeAccountId('acct_pending');
$em->flush();
$client->loginUser($user);
$crawler = $client->request('GET', '/mon-compte');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'en cours de verification');
}
public function testOrganizerWithStripeActiveShowsSuccess(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']);
$user->setStripeAccountId('acct_active');
$user->setStripeChargesEnabled(true);
$user->setStripePayoutsEnabled(true);
$em->flush();
$client->loginUser($user);
$crawler = $client->request('GET', '/mon-compte');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Stripe Connect actif');
}
public function testStripeConnectReturn(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/stripe/connect/return');
self::assertResponseRedirects('/mon-compte');
}
public function testStripeConnectRefresh(): void
{
$client = static::createClient();
$user = $this->createUser(['ROLE_ORGANIZER']);
$client->loginUser($user);
$client->request('GET', '/stripe/connect/refresh');
self::assertResponseRedirects('/mon-compte/stripe-connect');
}
public function testStripeCancelResetsAccount(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER']);
$user->setStripeAccountId('acct_cancel');
$em->flush();
$client->loginUser($user);
$client->request('POST', '/mon-compte/stripe-cancel');
self::assertResponseRedirects('/mon-compte');
$em->refresh($user);
self::assertNull($user->getStripeAccountId());
}
/**
* @param list<string> $roles
*/
private function createUser(array $roles = []): User
{
$em = static::getContainer()->get(EntityManagerInterface::class);
@@ -36,6 +255,7 @@ class AccountControllerTest extends WebTestCase
$user->setFirstName('Test');
$user->setLastName('User');
$user->setPassword('$2y$13$hashed');
$user->setRoles($roles);
$em->persist($user);
$em->flush();

View File

@@ -5,7 +5,9 @@ namespace App\Tests\Controller;
use App\Entity\User;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Account;
use Stripe\Event;
use Stripe\StripeClient;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class StripeWebhookControllerTest extends WebTestCase
@@ -49,25 +51,38 @@ class StripeWebhookControllerTest extends WebTestCase
'type' => 'account.updated',
'data' => [
'object' => [
'id' => 'acct_test123',
'id' => 'acct_test_webhook',
'charges_enabled' => true,
'payouts_enabled' => true,
],
],
]);
$account = Account::constructFrom([
'id' => 'acct_test_webhook',
'charges_enabled' => true,
'payouts_enabled' => true,
]);
$accountsService = $this->createMock(\Stripe\Service\AccountService::class);
$accountsService->method('retrieve')->willReturn($account);
$stripeClient = $this->createMock(StripeClient::class);
$stripeClient->accounts = $accountsService;
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
$stripeService->method('getClient')->willReturn($stripeClient);
static::getContainer()->set(StripeService::class, $stripeService);
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = new User();
$user->setEmail('test-stripe-'.uniqid().'@example.com');
$user->setEmail('test-stripe-wh-'.uniqid().'@example.com');
$user->setFirstName('Stripe');
$user->setLastName('Test');
$user->setPassword('$2y$13$hashed');
$user->setStripeAccountId('acct_test123');
$user->setStripeAccountId('acct_test_webhook');
$em->persist($user);
$em->flush();
@@ -78,7 +93,7 @@ class StripeWebhookControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updatedUser = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_test123']);
$updatedUser = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_test_webhook']);
self::assertNotNull($updatedUser);
self::assertTrue($updatedUser->isStripeChargesEnabled());
self::assertTrue($updatedUser->isStripePayoutsEnabled());
@@ -93,8 +108,6 @@ class StripeWebhookControllerTest extends WebTestCase
'data' => [
'object' => [
'id' => 'acct_unknown',
'charges_enabled' => true,
'payouts_enabled' => true,
],
],
]);

View File

@@ -7,57 +7,13 @@ use PHPUnit\Framework\TestCase;
class StripeServiceTest extends TestCase
{
public function testGetWebhookUrl(): void
private function createService(): StripeService
{
$service = new StripeService('sk_test', 'whsec_test', 'https://example.com', '/tmp');
self::assertSame('https://example.com/stripe/webhook', $service->getWebhookUrl());
}
public function testGetWebhookUrlTrimsTrailingSlash(): void
{
$service = new StripeService('sk_test', 'whsec_test', 'https://example.com/', '/tmp');
self::assertSame('https://example.com/stripe/webhook', $service->getWebhookUrl());
return new StripeService('sk_test', 'whsec_test', 'https://example.com');
}
public function testVerifyWebhookSignatureReturnsNullOnInvalid(): void
{
$service = new StripeService('sk_test', 'whsec_test', 'https://example.com', '/tmp');
self::assertNull($service->verifyWebhookSignature('{}', 'invalid'));
}
public function testSaveWebhookSecretCreatesEntry(): void
{
$tmpDir = sys_get_temp_dir().'/stripe_test_'.uniqid();
mkdir($tmpDir);
file_put_contents($tmpDir.'/.env.local', "APP_ENV=test\n");
$service = new StripeService('sk_test', 'whsec_test', 'https://example.com', $tmpDir);
$service->saveWebhookSecret('whsec_new123');
$content = file_get_contents($tmpDir.'/.env.local');
self::assertStringContainsString('STRIPE_WEBHOOK_SECRET=whsec_new123', $content);
unlink($tmpDir.'/.env.local');
rmdir($tmpDir);
}
public function testSaveWebhookSecretUpdatesExisting(): void
{
$tmpDir = sys_get_temp_dir().'/stripe_test_'.uniqid();
mkdir($tmpDir);
file_put_contents($tmpDir.'/.env.local', "APP_ENV=test\nSTRIPE_WEBHOOK_SECRET=old_secret\n");
$service = new StripeService('sk_test', 'whsec_test', 'https://example.com', $tmpDir);
$service->saveWebhookSecret('whsec_updated');
$content = file_get_contents($tmpDir.'/.env.local');
self::assertStringContainsString('STRIPE_WEBHOOK_SECRET=whsec_updated', $content);
self::assertStringNotContainsString('old_secret', $content);
unlink($tmpDir.'/.env.local');
rmdir($tmpDir);
self::assertNull($this->createService()->verifyWebhookSignature('{}', 'invalid'));
}
}