feat(Stripe): Intègre Stripe pour la gestion des paiements et les webhooks

Ajoute Stripe pour la synchronisation des clients et la configuration des webhooks.
Crée une commande pour synchroniser les clients locaux avec Stripe.
Ajoute un champ customerId à l'entité Customer.
```
This commit is contained in:
Serreau Jovann
2026-01-16 13:15:42 +01:00
parent 4f43dc9066
commit 890da18c15
16 changed files with 610 additions and 4 deletions

9
.env
View File

@@ -79,11 +79,12 @@ NOTIFUSE_EMAIL=
NOTIFUSE_ACCOUNT=
NOTIFUSE_LIST=
STRIPE_PK=
STRIPE_SK=
STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE79Tr8treeHX9KMcZtvcQZ0X8VSm00Q6GQ365V
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET=
SIGN_URL=
STRIPE_BASEURL=https://e3358705e82c.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=
@@ -91,3 +92,7 @@ MINIO_S3_CLIENT_SECRET=
MINIO_S3_CLIENT_BUCKET=
ESY_SEARCH_KEY=b09d9a708b427d495c39fe6e8fc5361fe33fee57a0435f3e1bf3ed8155f2a277
###> stripe/stripe-php ###
STRIPE_SECRET_KEY=sk_test_***
###< stripe/stripe-php ###

View File

@@ -55,6 +55,7 @@
"spomky-labs/web-push-bundle": "^3.1",
"stancer/stancer": ">=2.0.1",
"stevenmaguire/oauth2-keycloak": "^5.1",
"stripe/stripe-php": "^19.1",
"symfony/amazon-mailer": "7.3.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",

61
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f476989731711ededed94397298a39d6",
"content-hash": "bae39e4278669ceaf6f28005f1d75605",
"packages": [
{
"name": "async-aws/core",
@@ -9393,6 +9393,65 @@
},
"time": "2023-10-24T06:10:44+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v19.1.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "4e3de7211645699b1f5b5f1f1b45bd9faf369426"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/4e3de7211645699b1f5b5f1f1b45bd9faf369426",
"reference": "4e3de7211645699b1f5b5f1f1b45bd9faf369426",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v19.1.0"
},
"time": "2025-12-16T19:48:17+00:00"
},
{
"name": "symfony/amazon-mailer",
"version": "v7.3.0",

View File

@@ -0,0 +1,7 @@
services:
stripe.client:
class: 'Stripe\StripeClient'
arguments:
- '%env(STRIPE_SECRET_KEY)%'
Stripe\StripeClient: '@stripe.client'

View File

@@ -0,0 +1,32 @@
<?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 Version20260116114457 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 customer ADD customer_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('CREATE SCHEMA public');
$this->addSql('ALTER TABLE customer DROP customer_id');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260116121121 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('CREATE TABLE stripe_config (id SERIAL NOT NULL, name VARCHAR(255) DEFAULT NULL, webhook_id INT NOT NULL, secret VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE stripe_config');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260116121156 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 stripe_config ALTER webhook_id TYPE VARCHAR(255)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE stripe_config ALTER webhook_id TYPE INT');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Customer;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
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: 'app:stripe:sync',
description: 'Synchronise les clients locaux vers Stripe et configure les Webhooks'
)]
class StripeCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Client $client,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Synchronisation Stripe : Clients & Webhooks');
// 1. Synchronisation des clients manquants
$io->section('Synchronisation des clients');
$customers = $this->entityManager->getRepository(Customer::class)->findBy(['customerId' => null]);
if (empty($customers)) {
$io->success('Tous les clients sont déjà synchronisés.');
} else {
$io->progressStart(count($customers));
foreach ($customers as $customer) {
$result = $this->client->createCustomer($customer);
if ($result['state']) {
$this->entityManager->persist($customer);
} else {
$io->error(sprintf('Échec pour %s : %s', $customer->getEmail(), $result['message']));
}
$io->progressAdvance();
}
$this->entityManager->flush();
$io->progressFinish();
$io->success('Synchronisation des clients terminée.');
}
// 2. Configuration des Webhooks
$io->section('Configuration des Webhooks');
$this->client->webhooks();
$io->success('La configuration Stripe est à jour.');
return Command::SUCCESS;
}
}

View File

@@ -34,6 +34,9 @@ class Customer
#[ORM\Column(length: 255, nullable: true)]
private ?string $siret = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customerId = null;
public function getId(): ?int
{
return $this->id;
@@ -122,4 +125,16 @@ class Customer
return $this;
}
public function getCustomerId(): ?string
{
return $this->customerId;
}
public function setCustomerId(?string $customerId): static
{
$this->customerId = $customerId;
return $this;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Entity;
use App\Repository\StripeConfigRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: StripeConfigRepository::class)]
class StripeConfig
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255)]
private ?string $webhookId = null;
#[ORM\Column(length: 255)]
private ?string $secret = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getWebhookId(): ?int
{
return $this->webhookId;
}
public function setWebhookId(string $webhookId): static
{
$this->webhookId = $webhookId;
return $this;
}
public function getSecret(): ?string
{
return $this->secret;
}
public function setSecret(string $secret): static
{
$this->secret = $secret;
return $this;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\StripeConfig;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StripeConfig>
*/
class StripeConfigRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StripeConfig::class);
}
// /**
// * @return StripeConfig[] Returns an array of StripeConfig objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('s.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?StripeConfig
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Service\Stripe;
use App\Entity\Customer;
use App\Entity\StripeConfig;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Exception\ApiConnectionException;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\AuthenticationException;
use Stripe\StripeClient;
class Client
{
private StripeClient $client;
public function __construct(
private EntityManagerInterface $em
) {
// Récupération de la clé secrète depuis le .env
$stripeSk = $_ENV['STRIPE_SK'] ?? '';
$this->client = new StripeClient($stripeSk);
}
/**
* Vérifie la connexion avec l'API Stripe
*/
public function check(): array
{
try {
// Appel léger pour valider la clé API
$this->client->accounts->all(['limit' => 1]);
return [
'state' => true,
'message' => 'Connexion établie avec Stripe'
];
} catch (AuthenticationException $e) {
return ['state' => false, 'message' => 'Clé API Stripe invalide ou expirée.'];
} catch (ApiConnectionException $e) {
return ['state' => false, 'message' => 'Problème de connexion réseau avec Stripe.'];
} catch (\Exception $e) {
return ['state' => false, 'message' => 'Erreur : ' . $e->getMessage()];
}
}
/**
* Crée un client sur Stripe et met à jour l'entité locale avec l'ID Stripe
*/
public function createCustomer(Customer $customer): array
{
try {
$stripeCustomer = $this->client->customers->create([
'name' => sprintf('%s %s', $customer->getSurname(), $customer->getName()),
'email' => $customer->getEmail(),
'phone' => $customer->getPhone(),
'metadata' => [
'internal_id' => $customer->getId(),
'type' => $customer->getType(),
],
'description' => 'Client synchronisé depuis Ludikevent Intranet',
]);
$customer->setCustomerId($stripeCustomer->id);
// Note: Le flush est à faire dans le contrôleur pour valider la transaction globale
return [
'state' => true,
'message' => 'Client synchronisé avec succès.',
'id' => $stripeCustomer->id
];
} catch (ApiErrorException $e) {
return ['state' => false, 'message' => 'Erreur Stripe : ' . $e->getMessage()];
}
}
/**
* Configure, met à jour et sauvegarde les secrets des Webhooks
*/
public function webhooks(): array
{
$baseUrl = $_ENV['STRIPE_BASEURL'] ?? 'https://votre-domaine.fr';
// Configuration des routes attendues
$configs = [
'refund' => [
'url' => $baseUrl . '/webhooks/refund',
'events' => ['refund.created', 'refund.failed', 'refund.updated']
],
'payment' => [
'url' => $baseUrl . '/webhooks/payment-intent',
'events' => [
'payment_intent.created',
'payment_intent.canceled',
'payment_intent.succeeded',
'payment_intent.amount_capturable_updated'
]
]
];
$report = [];
try {
// Récupération des endpoints existants chez Stripe
$existingEndpoints = $this->client->webhookEndpoints->all(['limit' => 100]);
foreach ($configs as $name => $config) {
$stripeEndpoint = null;
// On cherche si l'URL est déjà enregistrée chez Stripe
foreach ($existingEndpoints->data as $endpoint) {
if ($endpoint->url === $config['url']) {
$stripeEndpoint = $endpoint;
break;
}
}
// Recherche de la config correspondante en BDD
$dbConfig = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $name]);
if (!$dbConfig) {
$dbConfig = new StripeConfig();
$dbConfig->setName($name);
}
if ($stripeEndpoint) {
// MISE À JOUR de l'endpoint chez Stripe
$this->client->webhookEndpoints->update($stripeEndpoint->id, [
'enabled_events' => $config['events']
]);
$dbConfig->setWebhookId($stripeEndpoint->id);
$report[$name] = ['status' => 'updated', 'url' => $config['url']];
} else {
// CRÉATION de l'endpoint chez Stripe
$newEndpoint = $this->client->webhookEndpoints->create([
'url' => $config['url'],
'enabled_events' => $config['events'],
'description' => 'Ludikevent Webhook - ' . $name
]);
$dbConfig->setWebhookId($newEndpoint->id);
$dbConfig->setSecret($newEndpoint->secret); // On sauve le secret whsec_...
$report[$name] = ['status' => 'created', 'url' => $config['url']];
}
$this->em->persist($dbConfig);
}
$this->em->flush();
return ['state' => true, 'data' => $report];
} catch (ApiErrorException $e) {
return ['state' => false, 'message' => 'Erreur API Stripe : ' . $e->getMessage()];
} catch (\Exception $e) {
return ['state' => false, 'message' => 'Erreur système : ' . $e->getMessage()];
}
}
/**
* Accès direct au client Stripe pour des besoins spécifiques
*/
public function getNativeClient(): StripeClient
{
return $this->client;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Twig;
use App\Service\Stripe\Client;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class StripeExtension extends AbstractExtension
{
public function __construct(private readonly Client $client)
{
}
public function getFunctions()
{
return [
new TwigFunction('syncStripe', [$this, 'syncStripe']),
];
}
public function syncStripe(): array
{
return $this->client->check();
}
}

View File

@@ -194,6 +194,18 @@
"spomky-labs/web-push-bundle": {
"version": "3.1.2"
},
"stripe/stripe-php": {
"version": "19.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "19.0",
"ref": "d6829c693e3927a8972c7671d74a1a5c505712b0"
},
"files": [
"config/packages/stripe.yaml"
]
},
"symfony/amazon-mailer": {
"version": "7.3",
"recipe": {

View File

@@ -128,6 +128,23 @@
</div>
</div>
{# MESSAGE D'ERREUR STRIPE #}
{% if syncStripe().state == false %}
<div class="mb-8 flex items-center p-6 backdrop-blur-xl bg-rose-500/5 border border-rose-500/20 rounded-[2rem] shadow-xl shadow-rose-500/5 animate-in fade-in slide-in-from-top-4 duration-500">
<div class="flex-shrink-0 w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-rose-500 mr-5">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h4 class="text-[10px] font-black text-rose-500 uppercase tracking-[0.2em] mb-1">Erreur de synchronisation Stripe</h4>
<p class="text-sm text-slate-400 font-medium leading-relaxed italic">
"{{ syncStripe().message }}"
</p>
</div>
</div>
{% endif %}
<div class="w-full">
{% block body %}{% endblock %}
</div>

View File

@@ -46,15 +46,39 @@
{# 1. IDENTITÉ #}
<td class="px-8 py-6 whitespace-nowrap">
<div class="flex items-center">
{# Avatar avec initiales #}
<div class="h-10 w-10 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 flex flex-shrink-0 items-center justify-center text-white font-black text-xs border border-white/10 shadow-lg">
{{ customer.surname|first|upper }}{{ customer.name|first|upper }}
</div>
<div class="ml-4">
{# Nom et Civilité #}
<div class="text-sm font-bold text-white">
<span class="text-slate-500 text-[10px] uppercase mr-1">{{ customer.civ }}</span>
{{ customer.surname|upper }} {{ customer.name }}
</div>
<div class="text-[10px] text-blue-400 font-medium tracking-tight">Client ID: #{{ customer.id }}</div>
{# ID Interne et État Stripe #}
<div class="flex items-center mt-1 space-x-2">
{# Badge ID Interne #}
<span class="text-[9px] font-bold text-slate-500 tracking-tighter">
ID: #{{ customer.id }}
</span>
{% if customer.customerId %}
{# ÉTAT : SYNCHRONISÉ (VERT) #}
<div class="flex items-center text-[8px] font-black text-emerald-400 uppercase tracking-[0.1em] bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30 shadow-sm shadow-emerald-500/10">
<span class="flex h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2"></span>
Stripe synchronisé
</div>
{% else %}
{# ÉTAT : NON SYNCHRONISÉ (ROUGE) #}
<div class="flex items-center text-[8px] font-black text-rose-500 uppercase tracking-[0.1em] bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 shadow-sm shadow-rose-500/10">
<span class="flex h-1.5 w-1.5 rounded-full bg-rose-500 mr-2 animate-pulse"></span>
Stripe non synchronisé
</div>
{% endif %}
</div>
</div>
</div>
</td>