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:
@@ -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
|
||||
{
|
||||
|
||||
76
src/Controller/WebhookStripeController.php
Normal file
76
src/Controller/WebhookStripeController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
165
src/Service/StripeWebhookService.php
Normal file
165
src/Service/StripeWebhookService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>• /webhooks/stripe/main/light - evenements legers (customer, product, invoice, subscription)</p>
|
||||
<p>• /webhooks/stripe/main/instant - evenements critiques (paiements, checkout, litiges)</p>
|
||||
<p>• /webhooks/stripe/connect/light - evenements Connect (account, transfer, payout)</p>
|
||||
<p>• /webhooks/stripe/connect/instant - evenements Connect critiques (paiements, checkout)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user