diff --git a/migrations/Version20260319193353.php b/migrations/Version20260319193353.php new file mode 100644 index 0000000..36c10e7 --- /dev/null +++ b/migrations/Version20260319193353.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Command/StripeSyncCommand.php b/src/Command/StripeSyncCommand.php new file mode 100644 index 0000000..6efd741 --- /dev/null +++ b/src/Command/StripeSyncCommand.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/src/Controller/StripeWebhookController.php b/src/Controller/StripeWebhookController.php new file mode 100644 index 0000000..9d69ca7 --- /dev/null +++ b/src/Controller/StripeWebhookController.php @@ -0,0 +1,27 @@ +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); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index b35a20d..102cf89 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; diff --git a/src/Service/StripeService.php b/src/Service/StripeService.php new file mode 100644 index 0000000..0195be8 --- /dev/null +++ b/src/Service/StripeService.php @@ -0,0 +1,82 @@ +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; + } +} diff --git a/tests/Command/StripeSyncCommandTest.php b/tests/Command/StripeSyncCommandTest.php new file mode 100644 index 0000000..0ffbf04 --- /dev/null +++ b/tests/Command/StripeSyncCommandTest.php @@ -0,0 +1,19 @@ +createMock(StripeService::class); + $command = new StripeSyncCommand($stripeService); + + self::assertSame('stripe:sync', $command->getName()); + self::assertSame('Create or update the Stripe webhook endpoint', $command->getDescription()); + } +} diff --git a/tests/Controller/StripeWebhookControllerTest.php b/tests/Controller/StripeWebhookControllerTest.php new file mode 100644 index 0000000..92938d0 --- /dev/null +++ b/tests/Controller/StripeWebhookControllerTest.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index a5e475d..31086cd 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -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(); diff --git a/tests/Service/StripeServiceTest.php b/tests/Service/StripeServiceTest.php new file mode 100644 index 0000000..561a5f4 --- /dev/null +++ b/tests/Service/StripeServiceTest.php @@ -0,0 +1,63 @@ +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); + } +}