feat: webhook Stripe invoice.paid/failed pour echeancier + fix DevisController

Webhook Stripe :
- handleInvoicePaid : trouve echeancier par subscriptionId, marque la
  prochaine ligne en attente comme payee, envoie email confirmation,
  passe echeancier en 'completed' si toutes les echeances payees
- handleInvoiceFailed : marque ligne en echec avec raison, envoie email
  echec au client + notification admin, passe en 'default' apres 2 echecs

Emails :
- echeancier_echeance_payee.html.twig : confirmation prelevement reussi
- echeancier_echeance_echec.html.twig : notification echec prelevement

Fix DevisController :
- Route #[Route] deplacee de sendDevisSignEmail vers createAdvert
  (erreur "controller not callable" corrigee)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-08 19:35:42 +02:00
parent 0f2712bb36
commit f0bdc60b99
4 changed files with 254 additions and 1 deletions

View File

@@ -435,7 +435,6 @@ class DevisController extends AbstractController
]); ]);
} }
#[Route('/{id}/create-advert', name: 'create_advert', requirements: ['id' => '\d+'], methods: ['POST'])]
/** @codeCoverageIgnore */ /** @codeCoverageIgnore */
private function sendDevisSignEmail(Devis $devis, \App\Entity\Customer $customer, MailerService $mailer, Environment $twig, UrlGeneratorInterface $urlGenerator, string $subject): void private function sendDevisSignEmail(Devis $devis, \App\Entity\Customer $customer, MailerService $mailer, Environment $twig, UrlGeneratorInterface $urlGenerator, string $subject): void
{ {
@@ -455,6 +454,7 @@ class DevisController extends AbstractController
); );
} }
#[Route('/{id}/create-advert', name: 'create_advert', requirements: ['id' => '\d+'], methods: ['POST'])]
public function createAdvert(int $id, AdvertService $advertService): Response public function createAdvert(int $id, AdvertService $advertService): Response
{ {
$devis = $this->em->getRepository(Devis::class)->find($id); $devis = $this->em->getRepository(Devis::class)->find($id);

View File

@@ -4,6 +4,8 @@ namespace App\Controller;
use App\Entity\Advert; use App\Entity\Advert;
use App\Entity\AdvertPayment; use App\Entity\AdvertPayment;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use App\Entity\Facture; use App\Entity\Facture;
use App\Entity\StripeWebhookSecret; use App\Entity\StripeWebhookSecret;
use App\Repository\StripeWebhookSecretRepository; use App\Repository\StripeWebhookSecretRepository;
@@ -91,6 +93,8 @@ class WebhookStripeController extends AbstractController
return match ($event->type) { return match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event, $channel), 'payment_intent.succeeded' => $this->handlePaymentSucceeded($event, $channel),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event, $channel), 'payment_intent.payment_failed' => $this->handlePaymentFailed($event, $channel),
'invoice.paid' => $this->handleInvoicePaid($event, $channel),
'invoice.payment_failed' => $this->handleInvoiceFailed($event, $channel),
default => new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]), default => new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]),
}; };
} }
@@ -317,6 +321,172 @@ class WebhookStripeController extends AbstractController
return new JsonResponse(['status' => 'ok', 'action' => 'payment_failed', 'advert' => $numOrder, 'method' => $method]); return new JsonResponse(['status' => 'ok', 'action' => 'payment_failed', 'advert' => $numOrder, 'method' => $method]);
} }
/**
* Traite une invoice Stripe payee (echeancier).
*
* @codeCoverageIgnore
*/
private function handleInvoicePaid(\Stripe\Event $event, string $channel): JsonResponse
{
$invoice = $event->data->object;
$subscriptionId = $invoice->subscription ?? null;
if (null === $subscriptionId) {
return new JsonResponse(['status' => 'ok', 'action' => 'no_subscription']);
}
$echeancier = $this->em->getRepository(Echeancier::class)->findOneBy(['stripeSubscriptionId' => $subscriptionId]);
if (null === $echeancier) {
$this->logger->info('Stripe invoice.paid ['.$channel.']: echeancier non trouve pour subscription '.$subscriptionId);
return new JsonResponse(['status' => 'ok', 'action' => 'echeancier_not_found']);
}
// Trouver la prochaine ligne en attente
$nextLine = null;
foreach ($echeancier->getLines() as $line) {
if (EcheancierLine::STATE_PREPARED === $line->getState()) {
$nextLine = $line;
break;
}
}
if (null === $nextLine) {
$this->logger->warning('Stripe invoice.paid ['.$channel.']: aucune ligne en attente pour echeancier '.$echeancier->getId());
return new JsonResponse(['status' => 'ok', 'action' => 'no_pending_line']);
}
$nextLine->setState(EcheancierLine::STATE_OK);
$nextLine->setPaidAt(new \DateTimeImmutable());
$nextLine->setStripeInvoiceId($invoice->id);
$this->em->flush();
// Verifier si toutes les echeances sont payees
if ($echeancier->getNbPaid() >= $echeancier->getNbLines()) {
$echeancier->setState(Echeancier::STATE_COMPLETED);
$this->em->flush();
$this->logger->info('Stripe invoice.paid ['.$channel.']: echeancier '.$echeancier->getId().' termine');
}
// Notification client
$customer = $echeancier->getCustomer();
if (null !== $customer->getEmail()) {
try {
$amount = number_format((float) $nextLine->getAmount(), 2, ',', ' ');
$this->mailer->sendEmail(
$customer->getEmail(),
'Echeance '.$nextLine->getPosition().'/'.$echeancier->getNbLines().' payee - '.$amount.' EUR',
$this->twig->render('emails/echeancier_echeance_payee.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $nextLine,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe invoice.paid: erreur envoi mail: '.$e->getMessage());
}
}
$this->logger->info('Stripe invoice.paid ['.$channel.']: echeance '.$nextLine->getPosition().' payee pour echeancier '.$echeancier->getId());
return new JsonResponse(['status' => 'ok', 'action' => 'echeance_paid', 'position' => $nextLine->getPosition()]);
}
/**
* Traite un echec de paiement d'invoice Stripe (echeancier).
*
* @codeCoverageIgnore
*/
private function handleInvoiceFailed(\Stripe\Event $event, string $channel): JsonResponse
{
$invoice = $event->data->object;
$subscriptionId = $invoice->subscription ?? null;
if (null === $subscriptionId) {
return new JsonResponse(['status' => 'ok', 'action' => 'no_subscription']);
}
$echeancier = $this->em->getRepository(Echeancier::class)->findOneBy(['stripeSubscriptionId' => $subscriptionId]);
if (null === $echeancier) {
return new JsonResponse(['status' => 'ok', 'action' => 'echeancier_not_found']);
}
// Trouver la prochaine ligne en attente
$nextLine = null;
foreach ($echeancier->getLines() as $line) {
if (EcheancierLine::STATE_PREPARED === $line->getState()) {
$nextLine = $line;
break;
}
}
if (null === $nextLine) {
return new JsonResponse(['status' => 'ok', 'action' => 'no_pending_line']);
}
$errorMessage = $invoice->last_finalization_error->message ?? ($invoice->status_transitions->finalized_at ? 'Paiement refuse' : 'Echec prelevement');
$nextLine->setState(EcheancierLine::STATE_KO);
$nextLine->setFailureReason($errorMessage);
$nextLine->setStripeInvoiceId($invoice->id);
// Si trop d'echecs, passer en defaut
if ($echeancier->getNbFailed() >= 2) {
$echeancier->setState(Echeancier::STATE_DEFAULT);
}
$this->em->flush();
// Notification client + admin
$customer = $echeancier->getCustomer();
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echec prelevement echeance '.$nextLine->getPosition().'/'.$echeancier->getNbLines(),
$this->twig->render('emails/echeancier_echeance_echec.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $nextLine,
'errorMessage' => $errorMessage,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe invoice.payment_failed: erreur envoi mail: '.$e->getMessage());
}
}
// Notification admin
try {
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
'Echec echeance '.$nextLine->getPosition().' - '.$customer->getFullName(),
$this->twig->render('emails/echeancier_echeance_echec.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $nextLine,
'errorMessage' => $errorMessage,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe invoice.payment_failed: erreur envoi mail admin: '.$e->getMessage());
}
$this->logger->warning('Stripe invoice.payment_failed ['.$channel.']: echeance '.$nextLine->getPosition().' echouee pour echeancier '.$echeancier->getId().' - '.$errorMessage);
return new JsonResponse(['status' => 'ok', 'action' => 'echeance_failed', 'position' => $nextLine->getPosition()]);
}
/** /**
* Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail. * Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail.
* *

View File

@@ -0,0 +1,44 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
{% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %}
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">{{ greeting }},</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Le prelevement de votre echeance <strong>{{ line.position }}/{{ echeancier.nbLines }}</strong> a echoue.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 35%;">Echeance</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ line.label }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Montant</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700;">{{ line.amount }} &euro;</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Statut</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #dc2626;">Echec</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Raison</td>
<td style="padding: 10px 16px; font-size: 13px; color: #dc2626;">{{ errorMessage }}</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Veuillez verifier votre moyen de paiement et contacter notre service si le probleme persiste.
Une nouvelle tentative de prelevement sera effectuee automatiquement par Stripe.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
{% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %}
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">{{ greeting }},</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Votre echeance <strong>{{ line.position }}/{{ echeancier.nbLines }}</strong> a ete prelevee avec succes.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 35%;">Echeance</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ line.label }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Montant</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #16a34a;">{{ line.amount }} &euro;</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Statut</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #16a34a;">Paye</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Progression</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} echeances payees ({{ echeancier.progress }}%)</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}