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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-09 16:10:08 +02:00
parent f51f28fc0b
commit 6e5e389b7d
7 changed files with 472 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Command;
use App\Entity\Advert;
use App\Entity\AdvertPayment;
use App\Entity\CustomerPaymentMethod;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Twig\Environment;
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;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:advert:auto-payment',
description: 'Preleve automatiquement les avis de paiement envoyes pour les clients avec prelevement auto configure.',
)]
class AdvertAutoPaymentCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
private MailerService $mailer,
private Environment $twig,
#[Autowire(env: 'STRIPE_SK')] private string $stripeSk,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ('' === $this->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;
}
}

View File

@@ -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,
]);

View File

@@ -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']);
}

View File

@@ -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.
*

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['customer_id', 'is_default'], name: 'idx_customer_payment_method_default')]
class CustomerPaymentMethod
{
public const TYPE_SEPA = 'sepa_debit';
public const TYPE_CARD = 'card';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Customer $customer;
#[ORM\Column(length: 255)]
private string $stripePaymentMethodId;
#[ORM\Column(length: 20)]
private string $type;
#[ORM\Column(length: 4, nullable: true)]
private ?string $last4 = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $brand = null;
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column]
private bool $isDefault = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(Customer $customer, string $stripePaymentMethodId, string $type)
{
$this->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;
}
}

View File

@@ -219,6 +219,33 @@
{% endif %}
</div>
{# Moyens de paiement #}
{% if paymentMethods|length > 0 %}
<div class="glass p-5 mt-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Moyens de paiement enregistres</h2>
<div class="space-y-2">
{% for pm in paymentMethods %}
<div class="glass p-3 flex items-center justify-between">
<div class="flex items-center gap-3">
{% if pm.type == 'sepa_debit' %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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>
{% endif %}
<div>
<p class="text-xs font-bold">{{ pm.displayLabel }}</p>
<p class="text-[10px] text-gray-400">Ajoute le {{ pm.createdAt|date('d/m/Y') }}</p>
</div>
</div>
{% if pm.isDefault %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[9px]">Par defaut</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Tab: Contacts #}
{% elseif tab == 'contacts' %}
<section class="glass p-6 mb-6">

View File

@@ -0,0 +1,43 @@
{% 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;">
Nous vous informons qu'un prelevement automatique sera effectue pour votre avis de paiement <strong>{{ advert.orderNumber.numOrder }}</strong>.
</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%;">Avis</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ advert.orderNumber.numOrder }}</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: #fabf04;">{{ amount|number_format(2, ',', ' ') }} &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;">Moyen de paiement</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ methodLabel }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Date du prelevement</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ "now"|date('d/m/Y') }}</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
Ce prelevement sera effectue automatiquement via votre moyen de paiement enregistre. Vous recevrez un email de confirmation une fois le paiement traite.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
Pour toute question : <a href="mailto:client@e-cosplay.fr" style="color: #fabf04;">client@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}