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:
31
migrations/Version20260319193353.php
Normal file
31
migrations/Version20260319193353.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Command/StripeSyncCommand.php
Normal file
42
src/Command/StripeSyncCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Controller/StripeWebhookController.php
Normal file
27
src/Controller/StripeWebhookController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
82
src/Service/StripeService.php
Normal file
82
src/Service/StripeService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/Command/StripeSyncCommandTest.php
Normal file
19
tests/Command/StripeSyncCommandTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/Controller/StripeWebhookControllerTest.php
Normal file
41
tests/Controller/StripeWebhookControllerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
63
tests/Service/StripeServiceTest.php
Normal file
63
tests/Service/StripeServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user