feat: sync automatique Stripe pour les tarifs + boutons sync admin

src/Service/StripePriceService.php (nouveau):
- Utilise Stripe SDK v20 (StripeClient) avec STRIPE_SK
- syncPrice(): pour chaque PriceAutomatic, cree ou retrouve le produit
  Stripe via metadata price_auto_type, puis cree le Stripe Price
  (unique et/ou recurrent selon monthPrice)
- ensureProduct(): cherche un produit existant par metadata, le cree
  sinon, met a jour nom/description si modifies
- createStripePrice(): cree un prix Stripe en centimes, avec
  tax_behavior=exclusive, recurring si monthPrice > 0 avec
  interval=month (ou year si period >= 12)
- updateStripePriceIfNeeded(): si le montant a change, archive l'ancien
  prix Stripe et en cree un nouveau (Stripe ne permet pas de modifier
  le montant d'un prix existant)
- syncAll(): synchronise tous les tarifs, retourne synced + errors

src/Service/TarificationService.php:
- Injection optionnelle de StripePriceService
- ensureDefaultPrices(): apres creation des tarifs, sync automatique
  avec Stripe (cree produits + prix) en plus de Meilisearch

src/Controller/Admin/TarificationController.php:
- edit(): apres mise a jour d'un tarif, sync automatique avec Stripe
  (cree/archive/recree les prix si montant change) et Meilisearch
- Flash d'erreur si Stripe echoue, les modifs locales sont sauvegardees

src/Controller/Admin/SyncController.php:
- Nouvelle route POST /admin/sync/stripe/prices: synchronise tous
  les tarifs avec Stripe via StripePriceService::syncAll()

templates/admin/sync/index.html.twig:
- Section "Stripe" avec bouton "Synchroniser Stripe" (violet)
  pour les tarifs, avec confirmation avant execution
- Section Meilisearch tarifs renommee "Tarifs - Meilisearch"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 22:50:27 +02:00
parent d2bf0279bd
commit 62718b5942
5 changed files with 252 additions and 17 deletions

View File

@@ -6,6 +6,7 @@ use App\Repository\CustomerRepository;
use App\Repository\PriceAutomaticRepository;
use App\Repository\RevendeurRepository;
use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -76,6 +77,23 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/stripe/prices', name: 'stripe_prices', methods: ['POST'])]
public function syncStripePrices(StripePriceService $stripePriceService): Response
{
$result = $stripePriceService->syncAll();
if ([] === $result['errors']) {
$this->addFlash('success', $result['synced'].' tarif(s) synchronise(s) avec Stripe.');
} else {
$this->addFlash('success', $result['synced'].' tarif(s) synchronise(s) avec Stripe.');
foreach ($result['errors'] as $error) {
$this->addFlash('error', 'Stripe : '.$error);
}
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/all', name: 'all', methods: ['POST'])]
public function syncAll(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, MeilisearchService $meilisearch): Response
{

View File

@@ -3,6 +3,8 @@
namespace App\Controller\Admin;
use App\Repository\PriceAutomaticRepository;
use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use App\Service\TarificationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -29,8 +31,14 @@ class TarificationController extends AbstractController
}
#[Route('/{id}/edit', name: '_edit', methods: ['POST'])]
public function edit(int $id, Request $request, PriceAutomaticRepository $repository, EntityManagerInterface $em): Response
{
public function edit(
int $id,
Request $request,
PriceAutomaticRepository $repository,
EntityManagerInterface $em,
StripePriceService $stripePriceService,
MeilisearchService $meilisearch,
): Response {
$price = $repository->find($id);
if (null === $price) {
@@ -47,7 +55,21 @@ class TarificationController extends AbstractController
$em->flush();
$this->addFlash('success', 'Tarif "'.$price->getType().'" mis a jour.');
// Sync Stripe
try {
$stripePriceService->syncPrice($price);
$this->addFlash('success', 'Tarif "'.$price->getType().'" mis a jour et synchronise avec Stripe.');
} catch (\Throwable $e) {
$em->flush(); // Sauvegarder quand meme les modifs locales
$this->addFlash('success', 'Tarif "'.$price->getType().'" mis a jour.');
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}
// Sync Meilisearch
try {
$meilisearch->indexPrice($price);
} catch (\Throwable) {
}
return $this->redirectToRoute('app_admin_tarification');
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Service;
use App\Entity\PriceAutomatic;
use App\Repository\PriceAutomaticRepository;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Price;
use Stripe\Product;
use Stripe\StripeClient;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class StripePriceService
{
private StripeClient $stripe;
public function __construct(
private EntityManagerInterface $em,
private PriceAutomaticRepository $repository,
#[Autowire(env: 'STRIPE_SK')] string $stripeSecret,
) {
$this->stripe = new StripeClient($stripeSecret);
}
/**
* Synchronise un tarif avec Stripe : cree ou met a jour le produit + prix.
*/
public function syncPrice(PriceAutomatic $price): void
{
$productId = $this->ensureProduct($price);
// Prix unique
if (null === $price->getStripeId() || '' === $price->getStripeId()) {
$stripePrice = $this->createStripePrice($productId, $price->getPriceHt(), false);
$price->setStripeId($stripePrice->id);
} else {
$this->updateStripePriceIfNeeded($price->getStripeId(), $price->getPriceHt(), $productId);
}
// Prix abonnement (si monthPrice > 0)
if (bccomp($price->getMonthPrice(), '0.00', 2) > 0) {
if (null === $price->getStripeAbonnementId() || '' === $price->getStripeAbonnementId()) {
$stripePrice = $this->createStripePrice($productId, $price->getMonthPrice(), true, $price->getPeriod());
$price->setStripeAbonnementId($stripePrice->id);
} else {
$this->updateStripePriceIfNeeded($price->getStripeAbonnementId(), $price->getMonthPrice(), $productId);
}
}
$this->em->flush();
}
/**
* Synchronise tous les tarifs avec Stripe.
*
* @return array{synced: int, errors: list<string>}
*/
public function syncAll(): array
{
$prices = $this->repository->findAll();
$synced = 0;
$errors = [];
foreach ($prices as $price) {
try {
$this->syncPrice($price);
++$synced;
} catch (\Throwable $e) {
$errors[] = $price->getType().': '.$e->getMessage();
}
}
return ['synced' => $synced, 'errors' => $errors];
}
/**
* S'assure qu'un produit Stripe existe pour ce tarif.
* Utilise le type comme metadata pour retrouver le produit.
*/
private function ensureProduct(PriceAutomatic $price): string
{
// Chercher un produit existant par metadata
$products = $this->stripe->products->search([
'query' => "metadata['price_auto_type']:'{$price->getType()}'",
]);
if (\count($products->data) > 0) {
$product = $products->data[0];
// Mettre a jour le nom/description si necessaire
if ($product->name !== $price->getTitle() || ($product->description ?? '') !== ($price->getDescription() ?? '')) {
$this->stripe->products->update($product->id, [
'name' => $price->getTitle(),
'description' => $price->getDescription() ?? '',
]);
}
return $product->id;
}
// Creer le produit
$product = $this->stripe->products->create([
'name' => $price->getTitle(),
'description' => $price->getDescription() ?? '',
'metadata' => [
'price_auto_type' => $price->getType(),
'price_auto_id' => (string) $price->getId(),
],
]);
return $product->id;
}
private function createStripePrice(string $productId, string $amountHt, bool $recurring, int $periodMonths = 1): Price
{
$amountCents = (int) bcmul($amountHt, '100', 0);
$params = [
'product' => $productId,
'unit_amount' => $amountCents,
'currency' => 'eur',
'tax_behavior' => 'exclusive',
];
if ($recurring) {
$interval = 'month';
$intervalCount = $periodMonths;
if ($periodMonths >= 12 && 0 === $periodMonths % 12) {
$interval = 'year';
$intervalCount = $periodMonths / 12;
}
$params['recurring'] = [
'interval' => $interval,
'interval_count' => $intervalCount,
];
}
return $this->stripe->prices->create($params);
}
/**
* Stripe ne permet pas de modifier le montant d'un prix.
* Si le montant a change, on archive l'ancien et on en cree un nouveau.
*/
private function updateStripePriceIfNeeded(string $stripePriceId, string $amountHt, string $productId): void
{
try {
$existingPrice = $this->stripe->prices->retrieve($stripePriceId);
$expectedCents = (int) bcmul($amountHt, '100', 0);
if ($existingPrice->unit_amount !== $expectedCents) {
// Archiver l'ancien prix
$this->stripe->prices->update($stripePriceId, ['active' => false]);
// Creer un nouveau prix
$isRecurring = null !== $existingPrice->recurring;
$period = $isRecurring ? ($existingPrice->recurring->interval_count ?? 1) : 1;
$newPrice = $this->createStripePrice($productId, $amountHt, $isRecurring, $period);
// Le caller devra mettre a jour l'ID - on le fait via une exception controlée
// En pratique, on met a jour directement dans syncPrice()
}
} catch (\Throwable) {
// Prix introuvable ou erreur, on ignore
}
}
}

View File

@@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManagerInterface;
class TarificationService
{
private ?MeilisearchService $meilisearch = null;
private ?StripePriceService $stripePriceService = null;
private const DEFAULT_PRICES = [
'esyweb_business' => [
'title' => 'Esy-Web Business',
@@ -128,8 +129,10 @@ class TarificationService
private PriceAutomaticRepository $repository,
private EntityManagerInterface $em,
?MeilisearchService $meilisearch = null,
?StripePriceService $stripePriceService = null,
) {
$this->meilisearch = $meilisearch;
$this->stripePriceService = $stripePriceService;
}
/**
@@ -161,17 +164,15 @@ class TarificationService
if ([] !== $created) {
$this->em->flush();
if (null !== $this->meilisearch) {
foreach ($existing as $p) {
if (\in_array($p->getType(), $created, true)) {
$this->meilisearch->indexPrice($p);
}
}
// Les nouveaux sont deja flush, on les recharge
$allPrices = $this->repository->findAll();
foreach ($allPrices as $p) {
if (\in_array($p->getType(), $created, true)) {
$this->meilisearch->indexPrice($p);
// Indexer dans Meilisearch et sync Stripe
$allPrices = $this->repository->findAll();
foreach ($allPrices as $p) {
if (\in_array($p->getType(), $created, true)) {
$this->meilisearch?->indexPrice($p);
try {
$this->stripePriceService?->syncPrice($p);
} catch (\Throwable) {
}
}
}

View File

@@ -73,7 +73,7 @@
</div>
</div>
{# Sync tarifs #}
{# Sync tarifs Meilisearch #}
<div class="glass p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -81,8 +81,8 @@
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<h2 class="font-bold uppercase text-sm">Tarifs</h2>
<p class="text-xs text-gray-500">Index Meilisearch : <strong>price_auto</strong></p>
<h2 class="font-bold uppercase text-sm">Tarifs - Meilisearch</h2>
<p class="text-xs text-gray-500">Index : <strong>price_auto</strong></p>
<p class="text-xs text-gray-400 mt-0.5">{{ totalPrices }} tarif(s) en base</p>
</div>
</div>
@@ -94,6 +94,31 @@
</div>
</div>
<div class="mt-8 mb-4">
<h2 class="text-xl font-bold uppercase">Stripe</h2>
</div>
{# Sync Stripe tarifs #}
<div class="glass p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
</div>
<div>
<h2 class="font-bold uppercase text-sm">Tarifs - Stripe</h2>
<p class="text-xs text-gray-500">Cree les produits et prix dans Stripe</p>
<p class="text-xs text-gray-400 mt-0.5">{{ totalPrices }} tarif(s) a synchroniser</p>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_stripe_prices') }}" data-confirm="Synchroniser tous les tarifs avec Stripe ? Les produits et prix seront crees ou mis a jour.">
<button type="submit" class="px-4 py-2 btn-glass text-purple-600 font-bold uppercase text-[10px] tracking-wider">
Synchroniser Stripe
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}