From 6e5e389b7dcae683d940e2db61a41495d8f6b0c6 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 9 Apr 2026 16:10:08 +0200 Subject: [PATCH] feat: CustomerPaymentMethod + prelevement auto avis dernier jour du mois Entity CustomerPaymentMethod: - customer, stripePaymentMethodId, type (sepa_debit/card) - last4, brand, country, isDefault - getDisplayLabel() pour affichage Sauvegarde automatique du moyen de paiement: - Contrat SEPA setup: cree CustomerPaymentMethod type SEPA - Contrat CB premier paiement: webhook sauvegarde la carte - Retire le default des anciens moyens de paiement Commande cron app:advert:auto-payment: - S'execute uniquement le dernier jour du mois - Trouve les avis envoyes (state=send) avec client ayant un moyen de paiement par defaut - Envoie un email d'annonce de prelevement au client - Cree un PaymentIntent off_session avec le moyen de paiement - Le webhook payment_intent.succeeded traite le paiement Admin fiche client tab info: - Affiche les moyens de paiement enregistres (type, last4, defaut) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Command/AdvertAutoPaymentCommand.php | 171 ++++++++++++++++++ src/Controller/Admin/ClientsController.php | 2 + src/Controller/ContratProcessController.php | 21 +++ src/Controller/WebhookStripeController.php | 62 +++++++ src/Entity/CustomerPaymentMethod.php | 146 +++++++++++++++ templates/admin/clients/show.html.twig | 27 +++ .../advert_auto_payment_notice.html.twig | 43 +++++ 7 files changed, 472 insertions(+) create mode 100644 src/Command/AdvertAutoPaymentCommand.php create mode 100644 src/Entity/CustomerPaymentMethod.php create mode 100644 templates/emails/advert_auto_payment_notice.html.twig diff --git a/src/Command/AdvertAutoPaymentCommand.php b/src/Command/AdvertAutoPaymentCommand.php new file mode 100644 index 0000000..5faa1c7 --- /dev/null +++ b/src/Command/AdvertAutoPaymentCommand.php @@ -0,0 +1,171 @@ +stripeSk) { + $io->error('STRIPE_SK non configure.'); + + return Command::FAILURE; + } + + // Verifier si c'est le dernier jour du mois + $today = new \DateTimeImmutable('today'); + $lastDay = new \DateTimeImmutable($today->format('Y-m-t')); + + if ($today->format('Y-m-d') !== $lastDay->format('Y-m-d')) { + $io->info('Pas le dernier jour du mois ('.$today->format('d/m/Y').'). Rien a faire.'); + + return Command::SUCCESS; + } + + \Stripe\Stripe::setApiKey($this->stripeSk); + + // Trouver tous les avis envoyes (state = send) + $adverts = $this->em->createQuery( + 'SELECT a FROM App\Entity\Advert a + WHERE a.state = :state + ORDER BY a.createdAt ASC' + ) + ->setParameter('state', Advert::STATE_SEND) + ->getResult(); + + $created = 0; + $skipped = 0; + $errors = 0; + + /** @var Advert $advert */ + foreach ($adverts as $advert) { + $customer = $advert->getCustomer(); + if (null === $customer) { + ++$skipped; + + continue; + } + + // Chercher le moyen de paiement par defaut du client + $defaultPm = $this->em->getRepository(CustomerPaymentMethod::class)->findOneBy([ + 'customer' => $customer, + 'isDefault' => true, + ]); + + if (null === $defaultPm) { + ++$skipped; + + continue; + } + + $stripeCustomerId = $customer->getStripeCustomerId(); + if (null === $stripeCustomerId) { + ++$skipped; + + continue; + } + + // Calculer le montant restant a payer + $totalTtc = (float) $advert->getTotalTtc(); + $totalPaid = 0.0; + foreach ($advert->getPayments() as $payment) { + if (AdvertPayment::TYPE_SUCCESS === $payment->getType()) { + $totalPaid += (float) $payment->getAmount(); + } + } + + $remaining = $totalTtc - $totalPaid; + if ($remaining <= 0) { + ++$skipped; + + continue; + } + + $amountCents = (int) round($remaining * 100); + + // Envoyer un mail d'annonce de prelevement + if (null !== $customer->getEmail()) { + try { + $this->mailer->sendEmail( + $customer->getEmail(), + 'Prelevement automatique - Avis '.$advert->getOrderNumber()->getNumOrder(), + $this->twig->render('emails/advert_auto_payment_notice.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'amount' => $remaining, + 'methodLabel' => $defaultPm->getDisplayLabel(), + ]), + null, + null, + false, + ); + } catch (\Throwable) { + // silencieux + } + } + + try { + $pi = \Stripe\PaymentIntent::create([ + 'amount' => $amountCents, + 'currency' => 'eur', + 'customer' => $stripeCustomerId, + 'payment_method' => $defaultPm->getStripePaymentMethodId(), + 'off_session' => true, + 'confirm' => true, + 'payment_method_types' => [$defaultPm->getType()], + 'metadata' => [ + 'advert_id' => (string) $advert->getId(), + 'auto_payment' => '1', + 'payment_method' => $defaultPm->getType(), + ], + 'description' => 'Avis '.$advert->getOrderNumber()->getNumOrder().' - Prelevement auto', + ]); + + ++$created; + $this->logger->info('Auto-payment: PI cree pour avis '.$advert->getOrderNumber()->getNumOrder(), [ + 'pi_id' => $pi->id, + 'amount' => number_format($remaining, 2), + 'method' => $defaultPm->getTypeLabel(), + ]); + } catch (\Throwable $e) { + ++$errors; + $this->logger->error('Auto-payment: erreur pour avis '.$advert->getOrderNumber()->getNumOrder().': '.$e->getMessage()); + $io->warning($advert->getOrderNumber()->getNumOrder().': '.$e->getMessage()); + } + } + + $io->success($created.' prelevement(s) lance(s), '.$skipped.' ignore(s), '.$errors.' erreur(s).'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/ClientsController.php b/src/Controller/Admin/ClientsController.php index d6add04..4b08d8a 100644 --- a/src/Controller/Admin/ClientsController.php +++ b/src/Controller/Admin/ClientsController.php @@ -369,6 +369,7 @@ class ClientsController extends AbstractController $echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $contratsList = $em->getRepository(\App\Entity\Contrat::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); + $paymentMethods = $em->getRepository(\App\Entity\CustomerPaymentMethod::class)->findBy(['customer' => $customer], ['isDefault' => 'DESC', 'createdAt' => 'DESC']); $trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer); @@ -384,6 +385,7 @@ class ClientsController extends AbstractController 'echeancierList' => $echeancierList, 'eflexList' => $eflexList, 'contratsList' => $contratsList, + 'paymentMethods' => $paymentMethods, 'tab' => $tab, 'trustStatus' => $trustStatus, ]); diff --git a/src/Controller/ContratProcessController.php b/src/Controller/ContratProcessController.php index c143f68..b4defc0 100644 --- a/src/Controller/ContratProcessController.php +++ b/src/Controller/ContratProcessController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Entity\Contrat; +use App\Entity\CustomerPaymentMethod; use App\Service\DocuSealService; use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; @@ -250,11 +251,31 @@ class ContratProcessController extends AbstractController \Stripe\Customer::update($stripeCustomerId, [ 'invoice_settings' => ['default_payment_method' => $paymentMethodId], ]); + + // Sauvegarder le moyen de paiement + $sepa = $pm->sepa_debit ?? null; + $cpm = new CustomerPaymentMethod($customer, $paymentMethodId, CustomerPaymentMethod::TYPE_SEPA); + $cpm->setIsDefault(true); + if (null !== $sepa) { + $cpm->setLast4($sepa->last4 ?? null); + $cpm->setBrand($sepa->bank_code ?? null); + $cpm->setCountry($sepa->country ?? null); + } + + // Retirer le default des autres + $existingMethods = $this->em->getRepository(CustomerPaymentMethod::class)->findBy(['customer' => $customer]); + foreach ($existingMethods as $m) { + $m->setIsDefault(false); + } + + $this->em->persist($cpm); } catch (\Throwable $e) { return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); } // @codeCoverageIgnoreEnd + $this->em->flush(); + return new JsonResponse(['status' => 'ok']); } diff --git a/src/Controller/WebhookStripeController.php b/src/Controller/WebhookStripeController.php index 57cc8d6..c1f4d76 100644 --- a/src/Controller/WebhookStripeController.php +++ b/src/Controller/WebhookStripeController.php @@ -4,6 +4,8 @@ namespace App\Controller; use App\Entity\Advert; use App\Entity\AdvertPayment; +use App\Entity\Contrat; +use App\Entity\CustomerPaymentMethod; use App\Entity\Echeancier; use App\Entity\EcheancierLine; use App\Entity\Facture; @@ -124,6 +126,13 @@ class WebhookStripeController extends AbstractController return $this->handleEFlexPaymentSucceeded($paymentIntent, (int) $eflexId, (int) $eflexLineId, $channel); } + // Gestion premier paiement contrat (sauvegarde CB comme moyen de paiement) + $contratId = $metadata['contrat_id'] ?? null; + $firstPayment = $metadata['first_payment'] ?? null; + if (null !== $contratId && '1' === $firstPayment) { + $this->saveContratPaymentMethod($paymentIntent, (int) $contratId); + } + $advertId = $metadata['advert_id'] ?? null; $advert = null !== $advertId ? $this->em->getRepository(Advert::class)->find((int) $advertId) : null; @@ -855,6 +864,59 @@ class WebhookStripeController extends AbstractController return new JsonResponse(['status' => 'ok', 'action' => 'eflex_failed', 'position' => $line->getPosition()]); } + /** + * Sauvegarde le moyen de paiement CB du premier paiement contrat. + * + * @codeCoverageIgnore + */ + private function saveContratPaymentMethod(object $paymentIntent, int $contratId): void + { + $contrat = $this->em->getRepository(Contrat::class)->find($contratId); + if (null === $contrat || null === $contrat->getCustomer()) { + return; + } + + $customer = $contrat->getCustomer(); + $pmId = $paymentIntent->payment_method ?? null; + if (null === $pmId) { + return; + } + + // Verifier si ce moyen de paiement existe deja + $existing = $this->em->getRepository(CustomerPaymentMethod::class)->findOneBy([ + 'customer' => $customer, + 'stripePaymentMethodId' => (string) $pmId, + ]); + if (null !== $existing) { + return; + } + + // Retirer le default des autres + $existingMethods = $this->em->getRepository(CustomerPaymentMethod::class)->findBy(['customer' => $customer]); + foreach ($existingMethods as $m) { + $m->setIsDefault(false); + } + + $cpm = new CustomerPaymentMethod($customer, (string) $pmId, CustomerPaymentMethod::TYPE_CARD); + $cpm->setIsDefault(true); + + // Essayer de recuperer les details de la carte + try { + $pm = \Stripe\PaymentMethod::retrieve((string) $pmId); + $card = $pm->card ?? null; + if (null !== $card) { + $cpm->setLast4($card->last4 ?? null); + $cpm->setBrand($card->brand ?? null); + $cpm->setCountry($card->country ?? null); + } + } catch (\Throwable) { + // silencieux + } + + $this->em->persist($cpm); + $this->em->flush(); + } + /** * Cree un AdvertPayment pour une ligne d'echeancier payee, si un avis est lie. * diff --git a/src/Entity/CustomerPaymentMethod.php b/src/Entity/CustomerPaymentMethod.php new file mode 100644 index 0000000..438e831 --- /dev/null +++ b/src/Entity/CustomerPaymentMethod.php @@ -0,0 +1,146 @@ +customer = $customer; + $this->stripePaymentMethodId = $stripePaymentMethodId; + $this->type = $type; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCustomer(): Customer + { + return $this->customer; + } + + public function getStripePaymentMethodId(): string + { + return $this->stripePaymentMethodId; + } + + public function getType(): string + { + return $this->type; + } + + public function getTypeLabel(): string + { + return match ($this->type) { + self::TYPE_SEPA => 'Prelevement SEPA', + self::TYPE_CARD => 'Carte bancaire', + default => $this->type, + }; + } + + public function getLast4(): ?string + { + return $this->last4; + } + + public function setLast4(?string $last4): static + { + $this->last4 = $last4; + + return $this; + } + + public function getBrand(): ?string + { + return $this->brand; + } + + public function setBrand(?string $brand): static + { + $this->brand = $brand; + + return $this; + } + + public function getCountry(): ?string + { + return $this->country; + } + + public function setCountry(?string $country): static + { + $this->country = $country; + + return $this; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): static + { + $this->isDefault = $isDefault; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getDisplayLabel(): string + { + $label = $this->getTypeLabel(); + if (null !== $this->last4) { + $label .= ' **** '.$this->last4; + } + if (null !== $this->brand) { + $label .= ' ('.$this->brand.')'; + } + + return $label; + } +} diff --git a/templates/admin/clients/show.html.twig b/templates/admin/clients/show.html.twig index 546de9a..6cfa722 100644 --- a/templates/admin/clients/show.html.twig +++ b/templates/admin/clients/show.html.twig @@ -219,6 +219,33 @@ {% endif %} + {# Moyens de paiement #} + {% if paymentMethods|length > 0 %} +
+

Moyens de paiement enregistres

+
+ {% for pm in paymentMethods %} +
+
+ {% if pm.type == 'sepa_debit' %} + + {% else %} + + {% endif %} +
+

{{ pm.displayLabel }}

+

Ajoute le {{ pm.createdAt|date('d/m/Y') }}

+
+
+ {% if pm.isDefault %} + Par defaut + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {# Tab: Contacts #} {% elseif tab == 'contacts' %}
diff --git a/templates/emails/advert_auto_payment_notice.html.twig b/templates/emails/advert_auto_payment_notice.html.twig new file mode 100644 index 0000000..a0a341d --- /dev/null +++ b/templates/emails/advert_auto_payment_notice.html.twig @@ -0,0 +1,43 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Nous vous informons qu'un prelevement automatique sera effectue pour votre avis de paiement {{ advert.orderNumber.numOrder }}. +

+ + + + + + + + + + + + + + + + + + +
Avis{{ advert.orderNumber.numOrder }}
Montant{{ amount|number_format(2, ',', ' ') }} €
Moyen de paiement{{ methodLabel }}
Date du prelevement{{ "now"|date('d/m/Y') }}
+ +

+ Ce prelevement sera effectue automatiquement via votre moyen de paiement enregistre. Vous recevrez un email de confirmation une fois le paiement traite. +

+ +

+ Pour toute question : client@e-cosplay.fr +

+
+{% endblock %}