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