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:
171
src/Command/AdvertAutoPaymentCommand.php
Normal file
171
src/Command/AdvertAutoPaymentCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
146
src/Entity/CustomerPaymentMethod.php
Normal file
146
src/Entity/CustomerPaymentMethod.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
43
templates/emails/advert_auto_payment_notice.html.twig
Normal file
43
templates/emails/advert_auto_payment_notice.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
Reference in New Issue
Block a user