feat: creation automatique des webhooks Stripe + controllers de reception

src/Service/StripeWebhookService.php (nouveau):
- createAllWebhooks(baseUrl): cree 4 webhook endpoints dans Stripe:
  - /webhooks/stripe/main/light: customer.created/updated/deleted,
    product.created/updated, price.created/updated, invoice.created/
    finalized/payment_succeeded/payment_failed, subscription.created/
    updated/deleted
  - /webhooks/stripe/main/instant: checkout.session.completed/expired,
    payment_intent.succeeded/payment_failed, charge.succeeded/failed/
    refunded/dispute.created, invoice.paid/payment_failed,
    customer.subscription.trial_will_end/deleted
  - /webhooks/stripe/connect/light: account.updated/application.
    authorized/deauthorized, transfer.created/updated, payout.created/
    paid/failed
  - /webhooks/stripe/connect/instant: payment_intent.succeeded/
    payment_failed, charge.succeeded/failed/refunded,
    checkout.session.completed
- Verification si le webhook existe deja par URL avant creation
  (pas de doublon)
- listWebhooks(): liste tous les webhooks du compte
- deleteWebhook(): supprime un webhook par ID

src/Controller/WebhookStripeController.php (nouveau):
- 4 routes POST sans authentification (firewall webhooks):
  /webhooks/stripe/main/light, /webhooks/stripe/main/instant,
  /webhooks/stripe/connect/light, /webhooks/stripe/connect/instant
- Verification de signature Stripe via Stripe\Webhook::constructEvent()
- Log de chaque evenement avec channel, type et event_id
- TODO pour dispatcher vers les handlers specifiques

src/Controller/Admin/SyncController.php:
- Nouvelle route POST /admin/sync/stripe/webhooks: prend une URL
  de base en parametre, appelle createAllWebhooks(), affiche les
  resultats (cree/existe deja) en flash messages

templates/admin/sync/index.html.twig:
- Nouveau bloc "Webhooks Stripe" (bleu) avec champ URL de base
  (pre-rempli avec l'URL courante), bouton "Creer les webhooks",
  et liste des 4 endpoints avec leurs evenements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 22:53:29 +02:00
parent 62718b5942
commit 0ab2c8d0aa
4 changed files with 302 additions and 0 deletions

View File

@@ -7,7 +7,9 @@ use App\Repository\PriceAutomaticRepository;
use App\Repository\RevendeurRepository;
use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use App\Service\StripeWebhookService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -77,6 +79,35 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/stripe/webhooks', name: 'stripe_webhooks', methods: ['POST'])]
public function syncStripeWebhooks(Request $request, StripeWebhookService $webhookService): Response
{
$baseUrl = trim($request->request->getString('base_url'));
if ('' === $baseUrl) {
$this->addFlash('error', 'URL de base requise.');
return $this->redirectToRoute('app_admin_sync_index');
}
$result = $webhookService->createAllWebhooks(rtrim($baseUrl, '/'));
foreach ($result['created'] as $wh) {
$status = $wh['status'] ?? 'created';
if ('exists' === $status) {
$this->addFlash('success', $wh['type'].' : deja configure ('.$wh['id'].')');
} else {
$this->addFlash('success', $wh['type'].' : cree ('.$wh['id'].')');
}
}
foreach ($result['errors'] as $error) {
$this->addFlash('error', 'Stripe Webhook : '.$error);
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/stripe/prices', name: 'stripe_prices', methods: ['POST'])]
public function syncStripePrices(StripePriceService $stripePriceService): Response
{

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Controller;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WebhookStripeController extends AbstractController
{
public function __construct(
private LoggerInterface $logger,
) {
}
#[Route('/webhooks/stripe/main/light', name: 'app_webhook_stripe_main_light', methods: ['POST'])]
public function mainLight(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'main_light');
}
#[Route('/webhooks/stripe/main/instant', name: 'app_webhook_stripe_main_instant', methods: ['POST'])]
public function mainInstant(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'main_instant');
}
#[Route('/webhooks/stripe/connect/light', name: 'app_webhook_stripe_connect_light', methods: ['POST'])]
public function connectLight(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'connect_light');
}
#[Route('/webhooks/stripe/connect/instant', name: 'app_webhook_stripe_connect_instant', methods: ['POST'])]
public function connectInstant(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'connect_instant');
}
private function handleWebhook(Request $request, string $secret, string $channel): Response
{
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature', '');
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\Stripe\Exception\SignatureVerificationException) {
return new JsonResponse(['error' => 'Invalid signature'], Response::HTTP_BAD_REQUEST);
} catch (\Throwable) {
return new JsonResponse(['error' => 'Invalid payload'], Response::HTTP_BAD_REQUEST);
}
$this->logger->info('Stripe webhook ['.$channel.']: '.$event->type, [
'event_id' => $event->id,
'channel' => $channel,
'type' => $event->type,
]);
// TODO: dispatcher les evenements vers les handlers specifiques
// Ex: match ($event->type) { 'invoice.paid' => ..., }
return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]);
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Service;
use Stripe\StripeClient;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class StripeWebhookService
{
private StripeClient $stripe;
/** Evenements legers (statuts, metadata) */
private const LIGHT_EVENTS = [
'customer.created',
'customer.updated',
'customer.deleted',
'product.created',
'product.updated',
'price.created',
'price.updated',
'invoice.created',
'invoice.finalized',
'invoice.payment_succeeded',
'invoice.payment_failed',
'subscription.created',
'subscription.updated',
'subscription.deleted',
];
/** Evenements instantanes (paiements, actions critiques) */
private const INSTANT_EVENTS = [
'checkout.session.completed',
'checkout.session.expired',
'payment_intent.succeeded',
'payment_intent.payment_failed',
'charge.succeeded',
'charge.failed',
'charge.refunded',
'charge.dispute.created',
'invoice.paid',
'invoice.payment_failed',
'customer.subscription.trial_will_end',
'customer.subscription.deleted',
];
/** Evenements Connect legers */
private const CONNECT_LIGHT_EVENTS = [
'account.updated',
'account.application.authorized',
'account.application.deauthorized',
'transfer.created',
'transfer.updated',
'payout.created',
'payout.paid',
'payout.failed',
];
/** Evenements Connect instantanes */
private const CONNECT_INSTANT_EVENTS = [
'payment_intent.succeeded',
'payment_intent.payment_failed',
'charge.succeeded',
'charge.failed',
'charge.refunded',
'checkout.session.completed',
];
public function __construct(
#[Autowire(env: 'STRIPE_SK')] string $stripeSecret,
) {
$this->stripe = new StripeClient($stripeSecret);
}
/**
* Cree les 4 webhooks Stripe (main light/instant + connect light/instant).
*
* @return array{created: list<array{type: string, url: string, id: string}>, errors: list<string>}
*/
public function createAllWebhooks(string $baseUrl): array
{
$created = [];
$errors = [];
$webhooks = [
['url' => $baseUrl.'/webhooks/stripe/main/light', 'events' => self::LIGHT_EVENTS, 'connect' => false, 'type' => 'Main Light'],
['url' => $baseUrl.'/webhooks/stripe/main/instant', 'events' => self::INSTANT_EVENTS, 'connect' => false, 'type' => 'Main Instant'],
['url' => $baseUrl.'/webhooks/stripe/connect/light', 'events' => self::CONNECT_LIGHT_EVENTS, 'connect' => true, 'type' => 'Connect Light'],
['url' => $baseUrl.'/webhooks/stripe/connect/instant', 'events' => self::CONNECT_INSTANT_EVENTS, 'connect' => true, 'type' => 'Connect Instant'],
];
foreach ($webhooks as $wh) {
try {
$existing = $this->findWebhookByUrl($wh['url']);
if (null !== $existing) {
$created[] = ['type' => $wh['type'], 'url' => $wh['url'], 'id' => $existing->id, 'status' => 'exists'];
continue;
}
$endpoint = $this->stripe->webhookEndpoints->create([
'url' => $wh['url'],
'enabled_events' => $wh['events'],
'connect' => $wh['connect'],
'description' => 'CRM SITECONSEIL - '.$wh['type'],
]);
$created[] = ['type' => $wh['type'], 'url' => $wh['url'], 'id' => $endpoint->id, 'status' => 'created', 'secret' => $endpoint->secret];
} catch (\Throwable $e) {
$errors[] = $wh['type'].': '.$e->getMessage();
}
}
return ['created' => $created, 'errors' => $errors];
}
/**
* Liste tous les webhooks existants.
*
* @return list<array{id: string, url: string, status: string, events: list<string>, connect: bool}>
*/
public function listWebhooks(): array
{
$endpoints = $this->stripe->webhookEndpoints->all(['limit' => 100]);
$result = [];
foreach ($endpoints->data as $ep) {
$result[] = [
'id' => $ep->id,
'url' => $ep->url,
'status' => $ep->status,
'events' => $ep->enabled_events ?? [],
'connect' => 'connect' === ($ep->application ?? ''),
];
}
return $result;
}
/**
* Supprime un webhook par son ID.
*/
public function deleteWebhook(string $webhookId): bool
{
try {
$this->stripe->webhookEndpoints->delete($webhookId);
return true;
} catch (\Throwable) {
return false;
}
}
private function findWebhookByUrl(string $url): ?object
{
$endpoints = $this->stripe->webhookEndpoints->all(['limit' => 100]);
foreach ($endpoints->data as $ep) {
if ($ep->url === $url) {
return $ep;
}
}
return null;
}
}

View File

@@ -119,6 +119,36 @@
</div>
</div>
{# Sync Stripe Webhooks #}
<div class="glass p-6">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<h2 class="font-bold uppercase text-sm">Webhooks Stripe</h2>
<p class="text-xs text-gray-500">Cree les 4 endpoints : main light/instant + connect light/instant</p>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_stripe_webhooks') }}" class="flex items-end gap-3" data-confirm="Creer les webhooks Stripe ? Les endpoints existants ne seront pas dupliques.">
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">URL de base</label>
<input type="text" name="base_url" value="{{ app.request.schemeAndHttpHost }}" required class="input-glass px-3 py-2 text-sm font-medium font-mono w-64" placeholder="https://crm.siteconseil.fr">
</div>
<button type="submit" class="px-4 py-2 btn-glass text-blue-600 font-bold uppercase text-[10px] tracking-wider">
Creer les webhooks
</button>
</form>
</div>
<div class="mt-3 text-[10px] text-gray-400 space-y-0.5">
<p>&#8226; /webhooks/stripe/main/light - evenements legers (customer, product, invoice, subscription)</p>
<p>&#8226; /webhooks/stripe/main/instant - evenements critiques (paiements, checkout, litiges)</p>
<p>&#8226; /webhooks/stripe/connect/light - evenements Connect (account, transfer, payout)</p>
<p>&#8226; /webhooks/stripe/connect/instant - evenements Connect critiques (paiements, checkout)</p>
</div>
</div>
</div>
</div>
{% endblock %}