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

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