Add Stripe integration: webhook controller, service, sync command, Connect account

- Create StripeService: webhook sync, signature verification, save secret to .env.local
- Create StripeWebhookController with signature verification (400 on invalid)
- Create stripe:sync command to auto-create webhook endpoint via Stripe API
- Webhook listens to all events (configurable later)
- Save webhook secret automatically to .env.local on creation
- Add stripeAccountId field to User entity for Stripe Connect + migration
- Tests: StripeServiceTest (5), StripeWebhookControllerTest (2), StripeSyncCommandTest (1), UserTest updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 20:37:16 +01:00
parent 100ff96c70
commit 65887fb3da
9 changed files with 331 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260319193353 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD stripe_account_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP stripe_account_id');
}
}

View File

@@ -0,0 +1,42 @@
<?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();
}
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

@@ -0,0 +1,27 @@
<?php
namespace App\Controller;
use App\Service\StripeService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class StripeWebhookController extends AbstractController
{
#[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])]
public function webhook(Request $request, StripeService $stripeService): Response
{
$payload = $request->getContent();
$signature = $request->headers->get('Stripe-Signature', '');
$event = $stripeService->verifyWebhookSignature($payload, $signature);
if (!$event) {
return new Response('Invalid signature', 400);
}
return new Response('OK', 200);
}
}

View File

@@ -85,6 +85,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?float $commissionRate = null; private ?float $commissionRate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeAccountId = null;
#[ORM\Column(length: 64, nullable: true)] #[ORM\Column(length: 64, nullable: true)]
private ?string $emailVerificationToken = null; private ?string $emailVerificationToken = null;
@@ -296,6 +299,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getStripeAccountId(): ?string
{
return $this->stripeAccountId;
}
public function setStripeAccountId(?string $stripeAccountId): static
{
$this->stripeAccountId = $stripeAccountId;
return $this;
}
public function getResetCode(): ?string public function getResetCode(): ?string
{ {
return $this->resetCode; return $this->resetCode;

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Service;
use Stripe\Event;
use Stripe\Exception\SignatureVerificationException;
use Stripe\StripeClient;
use Stripe\Webhook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class StripeService
{
private StripeClient $stripe;
public function __construct(
#[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';
}
/**
* @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) ? file_get_contents($envLocalPath) : '';
if (false === $content) {
$content = '';
}
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 {
return Webhook::constructEvent($payload, $signature, $this->webhookSecret);
} catch (SignatureVerificationException) {
return null;
}
}
public function getClient(): StripeClient
{
return $this->stripe;
}
}

View File

@@ -0,0 +1,19 @@
<?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

@@ -0,0 +1,41 @@
<?php
namespace App\Tests\Controller;
use App\Service\StripeService;
use Stripe\Event;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class StripeWebhookControllerTest extends WebTestCase
{
public function testWebhookWithValidSignature(): void
{
$client = static::createClient();
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn(new Event());
static::getContainer()->set(StripeService::class, $stripeService);
$client->request('POST', '/stripe/webhook', [], [], [
'HTTP_STRIPE_SIGNATURE' => 'valid',
], '{}');
self::assertResponseIsSuccessful();
self::assertSame('OK', $client->getResponse()->getContent());
}
public function testWebhookWithInvalidSignature(): void
{
$client = static::createClient();
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn(null);
static::getContainer()->set(StripeService::class, $stripeService);
$client->request('POST', '/stripe/webhook', [], [], [
'HTTP_STRIPE_SIGNATURE' => 'invalid',
], '{}');
self::assertResponseStatusCodeSame(400);
}
}

View File

@@ -146,6 +146,17 @@ class UserTest extends TestCase
self::assertSame(1.5, $user->getCommissionRate()); self::assertSame(1.5, $user->getCommissionRate());
} }
public function testStripeAccountIdField(): void
{
$user = new User();
self::assertNull($user->getStripeAccountId());
$result = $user->setStripeAccountId('acct_1234567890');
self::assertSame($user, $result);
self::assertSame('acct_1234567890', $user->getStripeAccountId());
}
public function testEmailVerificationFields(): void public function testEmailVerificationFields(): void
{ {
$user = new User(); $user = new User();

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Tests\Service;
use App\Service\StripeService;
use PHPUnit\Framework\TestCase;
class StripeServiceTest extends TestCase
{
public function testGetWebhookUrl(): void
{
$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());
}
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);
}
}