feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients

Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion

Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie

Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed

Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie

Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du

Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)

Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr

Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction

Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees

E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec

Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-09 07:45:22 +02:00
parent 5b3706e282
commit 18daf096fa
108 changed files with 9017 additions and 115 deletions

View File

@@ -34,3 +34,27 @@ vich_uploader:
uri_prefix: /uploads/echeanciers/audit
upload_destination: '%kernel.project_dir%/public/uploads/echeanciers/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
eflex_pdf:
uri_prefix: /uploads/eflex
upload_destination: '%kernel.project_dir%/public/uploads/eflex'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
eflex_signed_pdf:
uri_prefix: /uploads/eflex/signed
upload_destination: '%kernel.project_dir%/public/uploads/eflex/signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
eflex_audit_pdf:
uri_prefix: /uploads/eflex/audit
upload_destination: '%kernel.project_dir%/public/uploads/eflex/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
attestation_custom_pdf:
uri_prefix: /uploads/attestations
upload_destination: '%kernel.project_dir%/public/uploads/attestations'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
attestation_custom_signed_pdf:
uri_prefix: /uploads/attestations/signed
upload_destination: '%kernel.project_dir%/public/uploads/attestations/signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
attestation_custom_audit_pdf:
uri_prefix: /uploads/attestations/audit
upload_destination: '%kernel.project_dir%/public/uploads/attestations/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408192621 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier ADD stripe_payment_method_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier DROP stripe_payment_method_id');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408193306 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier_line ADD stripe_payment_intent_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier_line DROP stripe_payment_intent_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408194313 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier ADD stripe_sepa_last4 VARCHAR(4) DEFAULT NULL');
$this->addSql('ALTER TABLE echeancier ADD stripe_sepa_bank_name VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE echeancier ADD stripe_sepa_country VARCHAR(2) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier DROP stripe_sepa_last4');
$this->addSql('ALTER TABLE echeancier DROP stripe_sepa_bank_name');
$this->addSql('ALTER TABLE echeancier DROP stripe_sepa_country');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408194549 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier ADD advert_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE echeancier ADD CONSTRAINT FK_4694F00CD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_4694F00CD07ECCB6 ON echeancier (advert_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier DROP CONSTRAINT FK_4694F00CD07ECCB6');
$this->addSql('DROP INDEX IDX_4694F00CD07ECCB6');
$this->addSql('ALTER TABLE echeancier DROP advert_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408201451 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer ADD warning_level VARCHAR(10) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD warning_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer DROP warning_level');
$this->addSql('ALTER TABLE customer DROP warning_at');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260408204544 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE eflex (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, description VARCHAR(500) NOT NULL, total_amount NUMERIC(10, 2) NOT NULL, state VARCHAR(20) DEFAULT \'draft\' NOT NULL, payment_method VARCHAR(20) DEFAULT \'sepa\' NOT NULL, stripe_customer_id VARCHAR(255) DEFAULT NULL, stripe_payment_method_id VARCHAR(255) DEFAULT NULL, stripe_sepa_last4 VARCHAR(4) DEFAULT NULL, stripe_sepa_bank_name VARCHAR(100) DEFAULT NULL, stripe_sepa_country VARCHAR(2) DEFAULT NULL, submission_id VARCHAR(255) DEFAULT NULL, pdf_unsigned VARCHAR(255) DEFAULT NULL, pdf_signed VARCHAR(255) DEFAULT NULL, pdf_audit VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, customer_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_8E24F3579395C3F3 ON eflex (customer_id)');
$this->addSql('CREATE TABLE eflex_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, position SMALLINT NOT NULL, amount NUMERIC(10, 2) NOT NULL, scheduled_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, state VARCHAR(20) DEFAULT \'prepared\' NOT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, failure_reason VARCHAR(255) DEFAULT NULL, paid_method VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, eflex_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_8953586C20AB24 ON eflex_line (eflex_id)');
$this->addSql('CREATE INDEX idx_eflex_line_state ON eflex_line (eflex_id, state)');
$this->addSql('ALTER TABLE eflex ADD CONSTRAINT FK_8E24F3579395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE eflex_line ADD CONSTRAINT FK_8953586C20AB24 FOREIGN KEY (eflex_id) REFERENCES eflex (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE eflex DROP CONSTRAINT FK_8E24F3579395C3F3');
$this->addSql('ALTER TABLE eflex_line DROP CONSTRAINT FK_8953586C20AB24');
$this->addSql('DROP TABLE eflex');
$this->addSql('DROP TABLE eflex_line');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260409053555 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE attestation_custom (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, items JSON NOT NULL, state VARCHAR(20) DEFAULT \'draft\' NOT NULL, hmac VARCHAR(64) NOT NULL, pdf_unsigned VARCHAR(255) DEFAULT NULL, pdf_signed VARCHAR(255) DEFAULT NULL, pdf_audit VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, signed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE attestation_custom');
}
}

Binary file not shown.

Binary file not shown.

BIN
public/facture/Part 11.stl Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Command;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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:echeancier:process-payments',
description: 'Traite les echeances dues : cree un PaymentIntent Stripe pour chaque ligne a prelever.',
)]
class EcheancierProcessPaymentsCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
#[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;
}
\Stripe\Stripe::setApiKey($this->stripeSk);
$today = new \DateTimeImmutable('today');
$lines = $this->em->createQuery(
'SELECT l FROM App\Entity\EcheancierLine l
JOIN l.echeancier e
WHERE l.state = :state
AND l.scheduledAt <= :today
AND l.stripePaymentIntentId IS NULL
AND e.state = :activeState
AND e.stripePaymentMethodId IS NOT NULL
AND e.stripeCustomerId IS NOT NULL
ORDER BY l.scheduledAt ASC'
)
->setParameter('state', EcheancierLine::STATE_PREPARED)
->setParameter('today', $today)
->setParameter('activeState', Echeancier::STATE_ACTIVE)
->getResult();
$created = 0;
$errors = 0;
/** @var EcheancierLine $line */
foreach ($lines as $line) {
$echeancier = $line->getEcheancier();
try {
$pi = \Stripe\PaymentIntent::create([
'amount' => (int) round((float) $line->getAmount() * 100),
'currency' => 'eur',
'customer' => $echeancier->getStripeCustomerId(),
'payment_method' => $echeancier->getStripePaymentMethodId(),
'off_session' => true,
'confirm' => true,
'payment_method_types' => ['sepa_debit'],
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'echeancier_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $echeancier->getReference(),
],
'description' => $line->getLabel().' - '.$echeancier->getReference(),
]);
$line->setStripePaymentIntentId($pi->id);
$this->em->flush();
++$created;
$this->logger->info('Echeancier cron: PI cree pour '.$echeancier->getReference().' echeance '.$line->getPosition(), [
'pi_id' => $pi->id,
'amount' => $line->getAmount(),
]);
} catch (\Throwable $e) {
++$errors;
$this->logger->error('Echeancier cron: erreur PI pour '.$echeancier->getReference().' echeance '.$line->getPosition().': '.$e->getMessage());
$io->warning($echeancier->getReference().' echeance '.$line->getPosition().': '.$e->getMessage());
}
}
// E-Flex : meme logique
$eflexLines = $this->em->createQuery(
'SELECT l FROM App\Entity\EFlexLine l
JOIN l.eflex e
WHERE l.state = :state
AND l.scheduledAt <= :today
AND l.stripePaymentIntentId IS NULL
AND e.state = :activeState
AND e.stripePaymentMethodId IS NOT NULL
AND e.stripeCustomerId IS NOT NULL
AND e.paymentMethod = :sepa
ORDER BY l.scheduledAt ASC'
)
->setParameter('state', \App\Entity\EFlexLine::STATE_PREPARED)
->setParameter('today', $today)
->setParameter('activeState', \App\Entity\EFlex::STATE_ACTIVE)
->setParameter('sepa', \App\Entity\EFlex::METHOD_SEPA)
->getResult();
/** @var \App\Entity\EFlexLine $line */
foreach ($eflexLines as $line) {
$eflex = $line->getEflex();
try {
$pi = \Stripe\PaymentIntent::create([
'amount' => (int) round((float) $line->getAmount() * 100),
'currency' => 'eur',
'customer' => $eflex->getStripeCustomerId(),
'payment_method' => $eflex->getStripePaymentMethodId(),
'off_session' => true,
'confirm' => true,
'payment_method_types' => ['sepa_debit'],
'metadata' => [
'eflex_id' => (string) $eflex->getId(),
'eflex_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $eflex->getReference(),
],
'description' => $line->getLabel().' - '.$eflex->getReference(),
]);
$line->setStripePaymentIntentId($pi->id);
$this->em->flush();
++$created;
} catch (\Throwable $e) {
++$errors;
$io->warning($eflex->getReference().' echeance '.$line->getPosition().': '.$e->getMessage());
}
}
$io->success($created.' prelevement(s) cree(s), '.$errors.' erreur(s).');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Controller\Admin;
use App\Entity\AttestationCustom;
use App\Service\DocuSealService;
use App\Service\Pdf\AttestationCustomPdf;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/attestations', name: 'app_admin_attestation_custom_')]
#[IsGranted('ROLE_ROOT')]
class AttestationCustomController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('', name: 'index')]
public function index(): Response
{
$attestations = $this->em->getRepository(AttestationCustom::class)->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/attestation_custom/index.html.twig', [
'attestations' => $attestations,
]);
}
#[Route('/create', name: 'create', methods: ['POST'])]
public function create(Request $request, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): Response
{
$title = trim($request->request->getString('title'));
$itemsRaw = $request->request->all('items');
// Filtrer les items vides
$items = array_values(array_filter(array_map('trim', $itemsRaw), fn (string $v) => '' !== $v));
if ('' === $title || [] === $items) {
$this->addFlash('error', 'Le titre et au moins un element sont requis.');
return $this->redirectToRoute('app_admin_attestation_custom_index');
}
$attestation = new AttestationCustom($title, $items);
$this->em->persist($attestation);
$this->em->flush();
// Generer le PDF
$this->generatePdfForAttestation($attestation, $kernel, $urlGenerator);
$this->addFlash('success', 'Attestation '.$attestation->getReference().' creee.');
return $this->redirectToRoute('app_admin_attestation_custom_show', ['id' => $attestation->getId()]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
$attestation = $this->em->getRepository(AttestationCustom::class)->find($id);
if (null === $attestation) {
throw $this->createNotFoundException('Attestation introuvable');
}
return $this->render('admin/attestation_custom/show.html.twig', [
'attestation' => $attestation,
]);
}
#[Route('/{id}/regenerate-pdf', name: 'regenerate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function regeneratePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): Response
{
$attestation = $this->em->getRepository(AttestationCustom::class)->find($id);
if (null === $attestation) {
throw $this->createNotFoundException('Attestation introuvable');
}
$this->generatePdfForAttestation($attestation, $kernel, $urlGenerator);
$this->addFlash('success', 'PDF regenere.');
return $this->redirectToRoute('app_admin_attestation_custom_show', ['id' => $id]);
}
/**
* Envoie le PDF a DocuSeal pour signature manuelle et redirige vers DocuSeal.
*/
#[Route('/{id}/sign', name: 'sign', requirements: ['id' => '\d+'], methods: ['POST'])]
public function sign(
int $id,
DocuSealService $docuSeal,
#[\Symfony\Component\DependencyInjection\Attribute\Autowire('%kernel.project_dir%')] string $projectDir,
#[\Symfony\Component\DependencyInjection\Attribute\Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl,
\Symfony\Component\Routing\Generator\UrlGeneratorInterface $urlGenerator,
): Response {
$attestation = $this->em->getRepository(AttestationCustom::class)->find($id);
if (null === $attestation) {
throw $this->createNotFoundException('Attestation introuvable');
}
if (null === $attestation->getPdfUnsigned()) {
$this->addFlash('error', 'Le PDF doit etre genere avant la signature.');
return $this->redirectToRoute('app_admin_attestation_custom_show', ['id' => $id]);
}
$pdfPath = $projectDir.'/public/uploads/attestations/'.$attestation->getPdfUnsigned();
if (!file_exists($pdfPath)) {
$this->addFlash('error', 'Fichier PDF introuvable.');
return $this->redirectToRoute('app_admin_attestation_custom_show', ['id' => $id]);
}
/** @var \App\Entity\User $user */
$user = $this->getUser();
$redirectUrl = $urlGenerator->generate('app_admin_attestation_custom_show', [
'id' => $attestation->getId(),
], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($pdfPath));
$result = $docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Attestation '.$attestation->getReference().' - '.$attestation->getTitle(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'attestation-'.$attestation->getReference().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'send_email' => false,
'completed_redirect_url' => $redirectUrl,
'metadata' => [
'doc_type' => 'attestation_custom',
'attestation_custom_id' => $attestation->getId(),
],
]],
]);
// DocuSeal retourne soit {submitters: [{id}]} soit [{id}]
$submitterId = $result['submitters'][0]['id']
?? ($result[0]['id'] ?? ($result['id'] ?? null));
if (null !== $submitterId) {
$slug = $docuSeal->getSubmitterSlug((int) $submitterId);
if (null !== $slug) {
return $this->redirect(rtrim($docuSealUrl, '/').'/s/'.$slug);
}
}
$this->addFlash('error', 'Erreur DocuSeal : impossible de recuperer le lien de signature. Reponse: '.json_encode($result));
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_attestation_custom_show', ['id' => $id]);
}
#[Route('/{id}/delete', name: 'delete', requirements: ['id' => '\d+'], methods: ['POST'])]
public function delete(int $id): Response
{
$attestation = $this->em->getRepository(AttestationCustom::class)->find($id);
if (null === $attestation) {
throw $this->createNotFoundException('Attestation introuvable');
}
$this->em->remove($attestation);
$this->em->flush();
$this->addFlash('success', 'Attestation supprimee.');
return $this->redirectToRoute('app_admin_attestation_custom_index');
}
private function generatePdfForAttestation(AttestationCustom $attestation, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): void
{
$pdf = new AttestationCustomPdf($kernel, $attestation, $urlGenerator);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'att_custom_').'.pdf';
$pdf->Output('F', $tmpPath);
$attestation->setPdfUnsignedFile(new UploadedFile(
$tmpPath,
'attestation-'.$attestation->getReference().'.pdf',
'application/pdf',
null,
true,
));
$attestation->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\Customer;
use App\Entity\Domain;
use App\Entity\Echeancier;
use App\Entity\User;
use App\Repository\CustomerRepository;
use App\Repository\RevendeurRepository;
@@ -365,6 +367,9 @@ class ClientsController extends AbstractController
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,
@@ -376,10 +381,85 @@ class ClientsController extends AbstractController
'advertsList' => $advertsList,
'facturesList' => $facturesList,
'echeancierList' => $echeancierList,
'eflexList' => $eflexList,
'tab' => $tab,
'trustStatus' => $trustStatus,
]);
}
/**
* Calcule le statut de confiance du client.
*
* Confiant : 0 impaye
* Attention : 1 impaye (avis ou echeance)
* Danger : echeancier annule avec rejets, ou 3+ avis impayes, ou 2+ impayes
*
* @param list<Advert> $adverts
* @param list<Echeancier> $echeanciers
*
* @return array{status: string, label: string, color: string, reason: string}
*/
private function computeTrustStatus(array $adverts, array $echeanciers, Customer $customer): array
{
// Avertissements : 2nd = Attention, last = Danger
$warningLevel = $customer->getWarningLevel();
if ('last' === $warningLevel) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Dernier avertissement envoye'];
}
if ('2nd' === $warningLevel) {
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '2eme avertissement envoye'];
}
// Compter les avis impayes (envoyes mais pas acceptes/refuses/annules)
$nbUnpaidAdverts = 0;
foreach ($adverts as $advert) {
if (\in_array($advert->getState(), [Advert::STATE_CREATED, Advert::STATE_SEND], true)) {
++$nbUnpaidAdverts;
}
}
// Verifier les echeanciers annules avec rejets
$hasCancelledWithRejects = false;
$nbUnpaidEcheances = 0;
foreach ($echeanciers as $echeancier) {
if (Echeancier::STATE_CANCELLED === $echeancier->getState() && $echeancier->getNbFailed() > 0) {
$hasCancelledWithRejects = true;
}
if (\in_array($echeancier->getState(), [Echeancier::STATE_ACTIVE, Echeancier::STATE_PENDING_SETUP, Echeancier::STATE_SIGNED], true)) {
foreach ($echeancier->getLines() as $line) {
if ('ok' !== $line->getState()) {
++$nbUnpaidEcheances;
}
}
}
}
$totalUnpaid = $nbUnpaidAdverts + ($nbUnpaidEcheances > 0 ? 1 : 0);
// Danger
if ($hasCancelledWithRejects) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Echeancier annule suite a des rejets de prelevement'];
}
if ($nbUnpaidAdverts >= 3) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $nbUnpaidAdverts.' avis de paiement impayes'];
}
if ($totalUnpaid >= 2) {
return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $totalUnpaid.' impayes (avis + echeanciers)'];
}
// Attention (1er avertissement ou 1 impaye)
if ('1st' === $warningLevel) {
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '1er avertissement envoye'];
}
if ($totalUnpaid >= 1) {
$reason = $nbUnpaidAdverts > 0 ? $nbUnpaidAdverts.' avis impaye(s)' : 'Echeancier en cours';
return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => $reason];
}
return ['status' => 'confiant', 'label' => 'Confiant', 'color' => 'green', 'reason' => 'Aucun impaye'];
}
private function handleContactForm(Request $request, Customer $customer, EntityManagerInterface $em): Response
{
$action = $request->request->getString('contact_action');
@@ -606,4 +686,246 @@ class ClientsController extends AbstractController
return $this->redirectToRoute('app_admin_clients_index');
}
/**
* Envoie un avertissement au client (1st, 2nd, last).
*/
#[Route('/{id}/send-warning/{level}', name: 'send_warning', requirements: ['id' => '\d+', 'level' => '1st|2nd|last'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function sendWarning(
int $id,
string $level,
Request $request,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
$reasons = $request->request->all('reasons');
// Generer le PDF
$pdf = new \App\Service\Pdf\ClientWarningPdf($kernel, $customer, $level, $reasons);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'warning_').'.pdf';
$pdf->Output('F', $tmpPath);
$warningLabels = [
'1st' => '1er avertissement',
'2nd' => '2eme avertissement',
'last' => 'Dernier avertissement avant suspension',
];
// Envoyer a DocuSeal pour auto-signature
// Le webhook (doc_type=client_warning) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Avertissement '.$warningLabels[$level].' - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'avertissement-'.$level.'-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_warning',
'customer_id' => $customer->getId(),
'level' => $level,
'reasons' => implode(',', $reasons),
],
]],
]);
$this->addFlash('success', $warningLabels[$level].' envoye pour signature. Le client recevra le PDF signe automatiquement.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
@unlink($tmpPath);
$customer->setWarningLevel($level);
$customer->setWarningAt(new \DateTimeImmutable());
$em->flush();
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
/**
* Reinitialise les avertissements du client.
*/
/**
* Envoie la notification de cloture (PDF signe via DocuSeal) - n'effectue PAS la suppression.
*/
#[Route('/{id}/close-account', name: 'close_account', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function closeAccount(
int $id,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
// Generer le PDF de cloture
$pdf = new \App\Service\Pdf\ClientClosurePdf($kernel, $customer);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'closure_').'.pdf';
$pdf->Output('F', $tmpPath);
// Envoyer a DocuSeal pour auto-signature
// Le webhook (doc_type=client_closure) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Cloture compte - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'cloture-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_closure',
'customer_id' => $customer->getId(),
],
]],
]);
$this->addFlash('success', 'Notification de cloture envoyee pour signature. Le client recevra le PDF signe.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
@unlink($tmpPath);
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
/**
* Effectue la suspension reelle du compte (state = suspended).
*/
#[Route('/{id}/suspend-account', name: 'suspend_account', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function suspendAccount(int $id, EntityManagerInterface $em): Response
{
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$customer->setState('suspended');
$customer->setUpdatedAt(new \DateTimeImmutable());
$em->flush();
$this->addFlash('success', 'Compte de '.$customer->getFullName().' suspendu.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
#[Route('/{id}/reset-warning', name: 'reset_warning', requirements: ['id' => '\d+'], methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function resetWarning(
int $id,
EntityManagerInterface $em,
\App\Service\DocuSealService $docuSeal,
\Symfony\Component\HttpKernel\KernelInterface $kernel,
): Response {
$customer = $em->getRepository(Customer::class)->find($id);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$customer->setWarningLevel(null);
$customer->setWarningAt(null);
$em->flush();
// Generer le PDF de levee d'avertissement et envoyer a DocuSeal
// Le webhook (doc_type=client_warning_reset) enverra le mail avec le PDF signe
// @codeCoverageIgnoreStart
if (null !== $customer->getEmail()) {
try {
$pdf = new \App\Service\Pdf\ClientWarningResetPdf($kernel, $customer);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'reset_').'.pdf';
$pdf->Output('F', $tmpPath);
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Levee avertissement - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'levee-avertissement-'.$customer->getId().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'client_warning_reset',
'customer_id' => $customer->getId(),
],
]],
]);
@unlink($tmpPath);
$this->addFlash('success', 'Avertissements reinitialises. Le client recevra le PDF signe de levee d\'avertissement.');
} catch (\Throwable $e) {
$this->addFlash('warning', 'Avertissements reinitialises mais erreur DocuSeal : '.$e->getMessage());
}
} else {
$this->addFlash('success', 'Avertissements reinitialises.');
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']);
}
}

View File

@@ -121,10 +121,26 @@ class ComptabiliteController extends AbstractController
);
}
/**
* Export echeanciers (compatible SAGE).
*/
#[Route('/export/echeanciers', name: 'export_echeanciers')]
public function exportEcheanciers(Request $request): Response
{
[$from, $to] = $this->helper->resolvePeriod($request);
$format = $request->query->getString('format', 'csv');
return $this->helper->exportResponse(
$this->exportService->buildEcheancierData($from, $to),
'echeanciers_'.$from->format('Ymd').'_'.$to->format('Ymd'),
$format,
);
}
/**
* Export PDF d'un type donne (telecharge directement).
*/
#[Route('/export-pdf/{type}', name: 'export_pdf', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services'])]
#[Route('/export-pdf/{type}', name: 'export_pdf', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services|echeanciers'])]
public function exportPdf(string $type, Request $request): Response
{
[$from, $to] = $this->helper->resolvePeriod($request);
@@ -151,7 +167,7 @@ class ComptabiliteController extends AbstractController
/**
* Export PDF + envoi a DocuSeal pour signature, puis redirection vers DocuSeal.
*/
#[Route('/export-pdf/{type}/sign', name: 'export_pdf_sign', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services'])]
#[Route('/export-pdf/{type}/sign', name: 'export_pdf_sign', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services|echeanciers'])]
public function exportPdfSign(string $type, Request $request, DocuSealService $docuSeal): Response
{
[$from, $to] = $this->helper->resolvePeriod($request);
@@ -210,7 +226,7 @@ class ComptabiliteController extends AbstractController
/**
* Callback apres signature DocuSeal : telecharge le PDF signe et l'envoie par email.
*/
#[Route('/sign-callback/{type}', name: 'sign_callback', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services|rapport-financier'])]
#[Route('/sign-callback/{type}', name: 'sign_callback', requirements: ['type' => 'journal-ventes|grand-livre|fec|balance-agee|reglements|commissions-stripe|couts-services|echeanciers|rapport-financier'])]
public function signCallback(string $type, Request $request, DocuSealService $docuSeal, MailerService $mailer, \Twig\Environment $twig): Response
{
$submitterId = $request->getSession()->get('compta_submitter_id');
@@ -532,6 +548,7 @@ class ComptabiliteController extends AbstractController
'reglements' => 'Liste des reglements',
'commissions-stripe' => ComptaExportService::LABEL_COMMISSIONS_STRIPE,
'couts-services' => 'Couts services E-Cosplay',
'echeanciers' => 'Echeanciers de paiement',
];
}
}

View File

@@ -0,0 +1,350 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\EFlex;
use App\Entity\EFlexLine;
use App\Service\DocuSealService;
use App\Service\MailerService;
use App\Service\Pdf\EFlexPdf;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/eflex', name: 'app_admin_eflex_')]
#[IsGranted('ROLE_EMPLOYE')]
class EFlexController extends AbstractController
{
private const MSG_NOT_FOUND = 'E-Flex introuvable';
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('/create/{customerId}', name: 'create', requirements: ['customerId' => '\d+'], methods: ['POST'])]
public function create(int $customerId, Request $request, KernelInterface $kernel): Response
{
$customer = $this->em->getRepository(Customer::class)->find($customerId);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$description = trim($request->request->getString('description'));
$totalAmount = $request->request->getString('totalAmount');
$nbEcheances = $request->request->getInt('nbEcheances');
$startDate = $request->request->getString('startDate');
$paymentMethod = $request->request->getString('paymentMethod', EFlex::METHOD_SEPA);
if ('' === $description || $nbEcheances < 2 || $nbEcheances > 36 || '' === $startDate) {
$this->addFlash('error', 'Donnees invalides. Minimum 2 echeances, maximum 36.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'esyflex']);
}
$totalFloat = (float) str_replace(',', '.', $totalAmount);
$monthlyAmount = round($totalFloat / $nbEcheances, 2);
$eflex = new EFlex($customer, $description, number_format($totalFloat, 2, '.', ''));
$eflex->setPaymentMethod($paymentMethod);
$start = new \DateTimeImmutable($startDate);
for ($i = 1; $i <= $nbEcheances; ++$i) {
$scheduledAt = $start->modify('+'.($i - 1).' months');
$amount = $i === $nbEcheances
? number_format($totalFloat - ($monthlyAmount * ($nbEcheances - 1)), 2, '.', '')
: number_format($monthlyAmount, 2, '.', '');
$line = new EFlexLine($eflex, $i, $amount, $scheduledAt);
$eflex->addLine($line);
$this->em->persist($line);
}
$this->em->persist($eflex);
$this->em->flush();
// Generer le PDF
$this->generateEFlexPdf($eflex, $kernel);
$this->addFlash('success', 'E-Flex '.$eflex->getReference().' cree avec '.$nbEcheances.' echeances de '.$monthlyAmount.' EUR/mois.');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $eflex->getId()]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
return $this->render('admin/eflex/show.html.twig', [
'eflex' => $eflex,
'customer' => $eflex->getCustomer(),
]);
}
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
public function generatePdf(int $id, KernelInterface $kernel): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$this->generateEFlexPdf($eflex, $kernel);
$this->addFlash('success', 'PDF E-Flex regenere.');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
/**
* Envoie le PDF pour signature via DocuSeal (2 parties : Company auto-signe + Client signe).
*/
#[Route('/{id}/send-signature', name: 'send_signature', requirements: ['id' => '\d+'], methods: ['POST'])]
public function sendSignature(
int $id,
DocuSealService $docuSeal,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
#[Autowire('%kernel.project_dir%')] string $projectDir = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $eflex->getCustomer();
if (null === $eflex->getPdfUnsigned() || null === $customer->getEmail()) {
$this->addFlash('error', null === $eflex->getPdfUnsigned() ? 'Le PDF doit etre genere.' : 'Email client introuvable.');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
$pdfPath = $projectDir.'/public/uploads/eflex/'.$eflex->getPdfUnsigned();
if (!file_exists($pdfPath)) {
$this->addFlash('error', 'Fichier PDF introuvable.');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
$signedRedirectUrl = $urlGenerator->generate('app_eflex_signed', [
'id' => $eflex->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
try {
$pdfBase64 = base64_encode(file_get_contents($pdfPath));
$result = $docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'E-Flex '.$eflex->getReference().' - '.$customer->getFullName(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'eflex-'.$eflex->getReference().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [
[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'Company',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => ['doc_type' => 'eflex', 'eflex_id' => $eflex->getId()],
],
[
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
'role' => 'First Party',
'send_email' => false,
'completed_redirect_url' => $signedRedirectUrl,
'metadata' => ['doc_type' => 'eflex', 'eflex_id' => $eflex->getId()],
],
],
]);
$submitterId = $result['submitters'][1]['id'] ?? ($result[1]['id'] ?? null);
if (null !== $submitterId) {
$eflex->setSubmissionId((string) $submitterId);
$this->em->flush();
// Envoyer email au client avec lien vers la page de detail
$processUrl = $urlGenerator->generate('app_eflex_process', [
'id' => $eflex->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$customer->getEmail(),
'Contrat E-Flex '.$eflex->getReference().' a signer',
$twig->render('emails/eflex_signature.html.twig', [
'customer' => $customer,
'eflex' => $eflex,
'processUrl' => $processUrl,
]),
null,
null,
false,
);
$this->addFlash('success', 'E-Flex envoye pour signature a '.$customer->getEmail().'.');
} else {
$this->addFlash('error', 'Erreur DocuSeal : aucun submitter retourne.');
}
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$eflex->setState(EFlex::STATE_CANCELLED);
$this->em->flush();
$this->addFlash('success', 'E-Flex annule.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $eflex->getCustomer()->getId(),
'tab' => 'esyflex',
]);
}
/**
* Force le prelevement d'une echeance.
*/
#[Route('/{id}/force-payment/{lineId}', name: 'force_payment', requirements: ['id' => '\d+', 'lineId' => '\d+'], methods: ['POST'])]
public function forcePayment(
int $id,
int $lineId,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$line = $this->em->getRepository(EFlexLine::class)->find($lineId);
if (null === $line || $line->getEflex()->getId() !== $eflex->getId()) {
throw $this->createNotFoundException('Echeance introuvable');
}
if (null === $eflex->getStripePaymentMethodId() || null === $eflex->getStripeCustomerId()) {
$this->addFlash('error', 'SEPA non configure.');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
// @codeCoverageIgnoreStart
try {
\Stripe\Stripe::setApiKey($stripeSk);
$pi = \Stripe\PaymentIntent::create([
'amount' => (int) round((float) $line->getAmount() * 100),
'currency' => 'eur',
'customer' => $eflex->getStripeCustomerId(),
'payment_method' => $eflex->getStripePaymentMethodId(),
'off_session' => true,
'confirm' => true,
'payment_method_types' => ['sepa_debit'],
'metadata' => [
'eflex_id' => (string) $eflex->getId(),
'eflex_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $eflex->getReference(),
],
'description' => $line->getLabel().' - '.$eflex->getReference(),
]);
if (EFlexLine::STATE_KO === $line->getState()) {
$line->setState(EFlexLine::STATE_PREPARED);
$line->setFailureReason(null);
}
$line->setStripePaymentIntentId($pi->id);
$this->em->flush();
$this->addFlash('success', 'Prelevement lance pour l\'echeance '.$line->getPosition().'.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
/**
* Enregistre un paiement manuel (virement, CB externe, etc.).
*/
#[Route('/{id}/manual-payment/{lineId}', name: 'manual_payment', requirements: ['id' => '\d+', 'lineId' => '\d+'], methods: ['POST'])]
public function manualPayment(int $id, int $lineId, Request $request): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$line = $this->em->getRepository(EFlexLine::class)->find($lineId);
if (null === $line || $line->getEflex()->getId() !== $eflex->getId()) {
throw $this->createNotFoundException('Echeance introuvable');
}
$method = $request->request->getString('method', 'virement');
$line->setState(EFlexLine::STATE_OK);
$line->setPaidAt(new \DateTimeImmutable());
$line->setPaidMethod($method);
$this->em->flush();
// Verifier si toutes les echeances sont payees
if ($eflex->getNbPaid() >= $eflex->getNbLines()) {
$eflex->setState(EFlex::STATE_COMPLETED);
$this->em->flush();
}
$this->addFlash('success', 'Echeance '.$line->getPosition().' marquee comme payee ('.$method.').');
return $this->redirectToRoute('app_admin_eflex_show', ['id' => $id]);
}
private function generateEFlexPdf(EFlex $eflex, KernelInterface $kernel): void
{
$pdf = new EFlexPdf($kernel, $eflex);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'eflex_').'.pdf';
$pdf->Output('F', $tmpPath);
$eflex->setPdfUnsignedFile(new UploadedFile(
$tmpPath,
'eflex-'.$eflex->getReference().'.pdf',
'application/pdf',
null,
true,
));
$eflex->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\AdvertPayment;
use App\Entity\Customer;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
@@ -39,6 +41,13 @@ class EcheancierController extends AbstractController
throw $this->createNotFoundException('Client introuvable');
}
// Bloquer si statut Danger
if ($this->isCustomerDanger($customer)) {
$this->addFlash('error', 'Creation bloquee : le client est en statut Danger (impayes ou echeancier annule avec rejets).');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'echeancier']);
}
$description = trim($request->request->getString('description'));
$totalHt = $request->request->getString('totalHt');
$nbEcheances = $request->request->getInt('nbEcheances');
@@ -57,6 +66,15 @@ class EcheancierController extends AbstractController
$echeancier = new Echeancier($customer, $description, number_format($totalHtFloat, 2, '.', ''));
// Lier a un avis de paiement si selectionne
$advertId = $request->request->getInt('advertId');
if ($advertId > 0) {
$advert = $this->em->getRepository(Advert::class)->find($advertId);
if (null !== $advert) {
$echeancier->setAdvert($advert);
}
}
/** @var \App\Entity\User|null $currentUser */
$currentUser = $this->getUser();
$echeancier->setSubmitterCompanyId($currentUser?->getId());
@@ -195,6 +213,40 @@ class EcheancierController extends AbstractController
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
private function isCustomerDanger(Customer $customer): bool
{
// Dernier avertissement = Danger
if ('last' === $customer->getWarningLevel()) {
return true;
}
// Compter avis impayes
$adverts = $this->em->getRepository(Advert::class)->findBy(['customer' => $customer]);
$nbUnpaidAdverts = 0;
foreach ($adverts as $advert) {
if (\in_array($advert->getState(), [Advert::STATE_CREATED, Advert::STATE_SEND], true)) {
++$nbUnpaidAdverts;
}
}
// Verifier echeanciers
$echeanciers = $this->em->getRepository(Echeancier::class)->findBy(['customer' => $customer]);
$hasCancelledWithRejects = false;
$hasUnpaidEcheancier = false;
foreach ($echeanciers as $echeancier) {
if (Echeancier::STATE_CANCELLED === $echeancier->getState() && $echeancier->getNbFailed() > 0) {
$hasCancelledWithRejects = true;
}
if (\in_array($echeancier->getState(), [Echeancier::STATE_ACTIVE, Echeancier::STATE_PENDING_SETUP, Echeancier::STATE_SIGNED], true)) {
$hasUnpaidEcheancier = true;
}
}
$totalUnpaid = $nbUnpaidAdverts + ($hasUnpaidEcheancier ? 1 : 0);
return $hasCancelledWithRejects || $nbUnpaidAdverts >= 3 || $totalUnpaid >= 2;
}
private function generateEcheancierPdf(Echeancier $echeancier, KernelInterface $kernel): void
{
$pdf = new EcheancierPdf($kernel, $echeancier);
@@ -328,11 +380,127 @@ class EcheancierController extends AbstractController
}
/**
* Annule un echeancier (et la subscription Stripe si active).
* Envoie une attestation d'etat de l'echeancier au client.
*/
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(
#[Route('/{id}/send-attestation', name: 'send_attestation', requirements: ['id' => '\d+'], methods: ['POST'])]
public function sendAttestation(
int $id,
MailerService $mailer,
Environment $twig,
KernelInterface $kernel,
DocuSealService $docuSeal,
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
// Generer le PDF attestation
$pdf = new \App\Service\Pdf\EcheancierAttestationPdf($kernel, $echeancier);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'ech_att_').'.pdf';
$pdf->Output('F', $tmpPath);
// Envoyer a DocuSeal pour auto-signature
// Le mail sera envoye au retour du webhook form.completed (doc_type=echeancier_attestation)
// @codeCoverageIgnoreStart
try {
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$docuSeal->getApi()->createSubmissionFromPdf([
'name' => 'Attestation '.$echeancier->getReference(),
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'attestation-'.$echeancier->getReference().'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $docuSeal->getLogoBase64()],
'metadata' => [
'doc_type' => 'echeancier_attestation',
'echeancier_id' => $echeancier->getId(),
],
]],
]);
$this->addFlash('success', 'Attestation envoyee pour signature. Le client recevra le PDF signe automatiquement.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
@unlink($tmpPath);
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Reinitialise le moyen de paiement SEPA et renvoie le lien de configuration au client.
*/
#[Route('/{id}/reset-sepa', name: 'reset_sepa', requirements: ['id' => '\d+'], methods: ['POST'])]
public function resetSepa(
int $id,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$echeancier->setStripePaymentMethodId(null);
$echeancier->setState(Echeancier::STATE_PENDING_SETUP);
$this->em->flush();
$customer = $echeancier->getCustomer();
if (null !== $customer->getEmail()) {
$setupUrl = $urlGenerator->generate('app_echeancier_setup_payment', [
'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$customer->getEmail(),
'Configurez votre prelevement SEPA - Echeancier '.$echeancier->getReference(),
$twig->render('emails/echeancier_stripe_setup.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'setupUrl' => $setupUrl,
]),
null,
null,
false,
);
$this->addFlash('success', 'Moyen de paiement reinitialise. Nouveau lien SEPA envoye a '.$customer->getEmail().'.');
} else {
$this->addFlash('success', 'Moyen de paiement reinitialise.');
}
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Force le prelevement d'une echeance via PaymentIntent.
*/
#[Route('/{id}/force-payment/{lineId}', name: 'force_payment', requirements: ['id' => '\d+', 'lineId' => '\d+'], methods: ['POST'])]
public function forcePayment(
int $id,
int $lineId,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
@@ -340,17 +508,73 @@ class EcheancierController extends AbstractController
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$line = $this->em->getRepository(EcheancierLine::class)->find($lineId);
if (null === $line || $line->getEcheancier()->getId() !== $echeancier->getId()) {
throw $this->createNotFoundException('Echeance introuvable');
}
if (null === $echeancier->getStripePaymentMethodId() || null === $echeancier->getStripeCustomerId()) {
$this->addFlash('error', 'SEPA non configure pour cet echeancier.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
if ('' === $stripeSk) {
$this->addFlash('error', 'Stripe non configure.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
// @codeCoverageIgnoreStart
if (null !== $echeancier->getStripeSubscriptionId() && '' !== $stripeSk) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
\Stripe\Subscription::retrieve($echeancier->getStripeSubscriptionId())->cancel();
} catch (\Throwable) {
// Best effort
try {
\Stripe\Stripe::setApiKey($stripeSk);
$pi = \Stripe\PaymentIntent::create([
'amount' => (int) round((float) $line->getAmount() * 100),
'currency' => 'eur',
'customer' => $echeancier->getStripeCustomerId(),
'payment_method' => $echeancier->getStripePaymentMethodId(),
'off_session' => true,
'confirm' => true,
'payment_method_types' => ['sepa_debit'],
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'echeancier_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $echeancier->getReference(),
],
'description' => $line->getLabel().' - '.$echeancier->getReference(),
]);
// Remettre en prepared si echoue precedemment
if (EcheancierLine::STATE_KO === $line->getState()) {
$line->setState(EcheancierLine::STATE_PREPARED);
$line->setFailureReason(null);
}
$line->setStripePaymentIntentId($pi->id);
$this->em->flush();
$this->addFlash('success', 'Prelevement lance pour l\'echeance '.$line->getPosition().' ('.$line->getAmount().' EUR). Le resultat sera recu via webhook.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Annule un echeancier.
*/
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
@@ -363,11 +587,14 @@ class EcheancierController extends AbstractController
}
/**
* Active la subscription Stripe apres signature du client.
* Envoie le lien de configuration SEPA au client.
*/
#[Route('/{id}/activate', name: 'activate', requirements: ['id' => '\d+'], methods: ['POST'])]
public function activate(
#[Route('/{id}/send-sepa', name: 'send_sepa', requirements: ['id' => '\d+'], methods: ['POST'])]
public function sendSepa(
int $id,
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
@@ -375,13 +602,19 @@ class EcheancierController extends AbstractController
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
if (Echeancier::STATE_SIGNED !== $echeancier->getState()) {
$this->addFlash('error', 'L\'echeancier doit etre signe avant activation.');
if (!\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_PENDING_SETUP], true)) {
$this->addFlash('error', 'L\'echeancier doit etre signe pour envoyer le lien SEPA.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$customer = $echeancier->getCustomer();
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
if ('' === $stripeSk) {
$this->addFlash('error', 'Stripe non configure.');
@@ -392,22 +625,6 @@ class EcheancierController extends AbstractController
try {
\Stripe\Stripe::setApiKey($stripeSk);
// Creer un prix Stripe pour le montant mensuel
$monthlyAmountCents = (int) round($echeancier->getMonthlyAmount() * 100);
$price = \Stripe\Price::create([
'unit_amount' => $monthlyAmountCents,
'currency' => 'eur',
'recurring' => ['interval' => 'month'],
'product_data' => [
'name' => 'Echeancier - '.$customer->getFullName(),
'metadata' => ['echeancier_id' => $echeancier->getId()],
],
]);
$echeancier->setStripePriceId($price->id);
// Utiliser le customer Stripe existant ou en creer un
$stripeCustomerId = $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
@@ -419,33 +636,27 @@ class EcheancierController extends AbstractController
}
$echeancier->setStripeCustomerId($stripeCustomerId);
// Creer la subscription avec nombre fixe d'echeances
$nbLines = $echeancier->getNbLines();
$firstLine = $echeancier->getLines()->first();
$billingAnchor = false !== $firstLine ? $firstLine->getScheduledAt()->getTimestamp() : time();
$subscription = \Stripe\Subscription::create([
'customer' => $stripeCustomerId,
'items' => [['price' => $price->id]],
'billing_cycle_anchor' => $billingAnchor,
'cancel_at' => (new \DateTimeImmutable())->modify('+'.$nbLines.' months')->getTimestamp(),
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'customer_email' => $customer->getEmail(),
'nb_echeances' => (string) $nbLines,
],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [
'payment_method_types' => ['sepa_debit', 'card'],
],
]);
$echeancier->setStripeSubscriptionId($subscription->id);
$echeancier->setState(Echeancier::STATE_ACTIVE);
$echeancier->setState(Echeancier::STATE_PENDING_SETUP);
$this->em->flush();
$this->addFlash('success', 'Subscription Stripe activee. '.$nbLines.' echeances de '.number_format($echeancier->getMonthlyAmount(), 2, ',', ' ').' EUR/mois.');
$setupUrl = $urlGenerator->generate('app_echeancier_setup_payment', [
'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$customer->getEmail(),
'Configurez votre prelevement SEPA - Echeancier '.$echeancier->getReference(),
$twig->render('emails/echeancier_stripe_setup.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'setupUrl' => $setupUrl,
]),
null,
null,
false,
);
$this->addFlash('success', 'Lien de configuration SEPA envoye a '.$customer->getEmail().'.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}

View File

@@ -94,6 +94,7 @@ class FactureController extends AbstractController
MailerService $mailer,
Environment $twig,
UrlGeneratorInterface $urlGenerator,
KernelInterface $kernel,
#[Autowire('%kernel.project_dir%')] string $projectDir,
): Response {
$facture = $this->em->getRepository(Facture::class)->find($id);
@@ -101,13 +102,25 @@ class FactureController extends AbstractController
throw $this->createNotFoundException('Facture introuvable');
}
// Auto-generer le PDF si il n'existe pas
if (null === $facture->getFacturePdf()) {
$this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.');
$pdf = new FacturePdf($kernel, $facture, $urlGenerator, $twig);
$pdf->generate();
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $facture->getCustomer()?->getId() ?? 0,
'tab' => 'factures',
]);
$tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf';
$pdf->Output('F', $tmpPath);
$facture->setFacturePdfFile(new UploadedFile(
$tmpPath,
'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf',
'application/pdf',
null,
true
));
$facture->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpPath);
}
$customer = $facture->getCustomer();

View File

@@ -3,6 +3,8 @@
namespace App\Controller\Admin;
use App\Entity\AdvertPayment;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use App\Entity\Facture;
use App\Entity\FacturePrestataire;
use Doctrine\ORM\EntityManagerInterface;
@@ -91,6 +93,9 @@ class StatsController extends AbstractController
// Services dynamiques depuis les lignes de factures payees
$services = $this->getServiceStats($from, $to);
// Echeanciers en cours avec echeances impayees
$echeancierStats = $this->getEcheancierStats();
return $this->render('admin/stats/index.html.twig', [
'period' => $period,
'dateFrom' => $dateFrom,
@@ -99,6 +104,7 @@ class StatsController extends AbstractController
'services' => $services,
'evolution' => $monthlyEvolution,
'payments' => $paymentStats,
'echeancierStats' => $echeancierStats,
]);
}
@@ -339,6 +345,57 @@ class StatsController extends AbstractController
return $total;
}
/**
* @return array{nbEcheanciers: int, nbEcheancesImpayees: int, montantImpaye: float, echeanciers: list<array{reference: string, customer: string, restant: float, nbPending: int, id: int}>}
*/
private function getEcheancierStats(): array
{
$echeanciers = $this->em->createQuery(
'SELECT e FROM App\Entity\Echeancier e
WHERE e.state IN (:states)
ORDER BY e.createdAt DESC'
)
->setParameter('states', [Echeancier::STATE_ACTIVE, Echeancier::STATE_PENDING_SETUP, Echeancier::STATE_SIGNED])
->getResult();
$nbEcheancesImpayees = 0;
$montantImpaye = 0.0;
$list = [];
/** @var Echeancier $echeancier */
foreach ($echeanciers as $echeancier) {
$nbPending = 0;
$restant = 0.0;
foreach ($echeancier->getLines() as $line) {
if (EcheancierLine::STATE_OK !== $line->getState()) {
++$nbPending;
$restant += (float) $line->getAmount();
}
}
if ($nbPending > 0) {
$nbEcheancesImpayees += $nbPending;
$montantImpaye += $restant;
$list[] = [
'id' => $echeancier->getId(),
'reference' => $echeancier->getReference(),
'customer' => $echeancier->getCustomer()->getFullName(),
'restant' => $restant,
'nbPending' => $nbPending,
];
}
}
return [
'nbEcheanciers' => \count($list),
'nbEcheancesImpayees' => $nbEcheancesImpayees,
'montantImpaye' => $montantImpaye,
'echeanciers' => $list,
];
}
private function resolveStatus(float $margeNette, float $caHt): string
{
if ($caHt <= 0) {

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Controller;
use App\Entity\AttestationCustom;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AttestationCustomVerifyController extends AbstractController
{
#[Route('/attestation/verify/{id}/{hmac}', name: 'app_attestation_custom_verify', requirements: ['id' => '\d+'])]
public function verify(int $id, string $hmac, EntityManagerInterface $em): Response
{
$attestation = $em->getRepository(AttestationCustom::class)->find($id);
$valid = null !== $attestation && hash_equals($attestation->getHmac(), $hmac);
return $this->render('attestation_custom/verify.html.twig', [
'valid' => $valid,
'attestation' => $valid ? $attestation : null,
]);
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Controller;
use App\Entity\EFlex;
use App\Entity\EFlexLine;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class EFlexProcessController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private MailerService $mailer,
private Environment $twig,
) {
}
#[Route('/eflex/verify/{id}', name: 'app_eflex_verify', requirements: ['id' => '\d+'])]
public function verify(int $id, Request $request): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
$customer = $eflex->getCustomer();
$session = $request->getSession();
$error = null;
// Verification code
if ($request->isMethod('POST')) {
$code = $request->request->getString('code');
$storedCode = $session->get('eflex_code_'.$id);
$expires = $session->get('eflex_code_expires_'.$id, 0);
if (time() > $expires) {
$error = 'Code expire. Veuillez en demander un nouveau.';
} elseif ($code !== $storedCode) {
$error = 'Code incorrect.';
} else {
$session->set('eflex_verified_'.$id, true);
return $this->redirectToRoute('app_eflex_process', ['id' => $id]);
}
}
// Envoyer un code si pas encore fait
if (null === $session->get('eflex_code_'.$id) && null !== $customer->getEmail()) {
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
$session->set('eflex_code_'.$id, $code);
$session->set('eflex_code_expires_'.$id, time() + 900);
$this->mailer->sendEmail(
$customer->getEmail(),
'Code de verification - E-Flex '.$eflex->getReference(),
$this->twig->render('emails/eflex_verify_code.html.twig', [
'customer' => $customer,
'code' => $code,
]),
null,
null,
false,
);
}
return $this->render('eflex/verify.html.twig', [
'eflex' => $eflex,
'customer' => $customer,
'error' => $error,
]);
}
#[Route('/eflex/verify/{id}/resend', name: 'app_eflex_resend_code', requirements: ['id' => '\d+'], methods: ['POST'])]
public function resendCode(int $id, Request $request): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
$customer = $eflex->getCustomer();
$session = $request->getSession();
if (null !== $customer->getEmail()) {
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
$session->set('eflex_code_'.$id, $code);
$session->set('eflex_code_expires_'.$id, time() + 900);
$this->mailer->sendEmail(
$customer->getEmail(),
'Nouveau code - E-Flex '.$eflex->getReference(),
$this->twig->render('emails/eflex_verify_code.html.twig', [
'customer' => $customer,
'code' => $code,
]),
null,
null,
false,
);
}
return $this->redirectToRoute('app_eflex_verify', ['id' => $id]);
}
#[Route('/eflex/process/{id}', name: 'app_eflex_process', requirements: ['id' => '\d+'])]
public function process(int $id, Request $request): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
$session = $request->getSession();
if (!$session->get('eflex_verified_'.$eflex->getId(), false)) {
return $this->redirectToRoute('app_eflex_verify', ['id' => $id]);
}
return $this->render('eflex/process.html.twig', [
'eflex' => $eflex,
'customer' => $eflex->getCustomer(),
]);
}
#[Route('/eflex/sign/{id}', name: 'app_eflex_sign', requirements: ['id' => '\d+'])]
public function sign(
int $id,
\App\Service\DocuSealService $docuSeal,
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex || null === $eflex->getSubmissionId()) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
// @codeCoverageIgnoreStart
$slug = $docuSeal->getSubmitterSlug((int) $eflex->getSubmissionId());
if (null !== $slug) {
return $this->redirect(rtrim($docuSealUrl, '/').'/s/'.$slug);
}
// @codeCoverageIgnoreEnd
throw $this->createNotFoundException('Lien de signature introuvable.');
}
#[Route('/eflex/signed/{id}', name: 'app_eflex_signed', requirements: ['id' => '\d+'])]
public function signed(int $id, Request $request): Response
{
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
return $this->render('eflex/signed.html.twig', [
'eflex' => $eflex,
'customer' => $eflex->getCustomer(),
]);
}
/**
* Page de configuration SEPA pour E-Flex.
*/
#[Route('/eflex/setup-payment/{id}', name: 'app_eflex_setup_payment', requirements: ['id' => '\d+'])]
public function setupPayment(
int $id,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
#[Autowire(env: 'STRIPE_PK')] string $stripePk = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
$session = $request->getSession();
if (!$session->get('eflex_verified_'.$eflex->getId(), false)) {
return $this->redirectToRoute('app_eflex_verify', ['id' => $id]);
}
// Si SEPA deja configure, afficher la page de confirmation
if (null !== $eflex->getStripePaymentMethodId()) {
return $this->render('eflex/setup_payment_done.html.twig', [
'eflex' => $eflex,
'customer' => $eflex->getCustomer(),
]);
}
if (!\in_array($eflex->getState(), [EFlex::STATE_ACTIVE, EFlex::STATE_PENDING_SETUP], true)) {
return $this->redirectToRoute('app_eflex_process', ['id' => $id]);
}
$customer = $eflex->getCustomer();
// @codeCoverageIgnoreStart
\Stripe\Stripe::setApiKey($stripeSk);
$stripeCustomerId = $eflex->getStripeCustomerId() ?? $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
$eflex->setStripeCustomerId($stripeCustomerId);
$this->em->flush();
}
$setupIntent = \Stripe\SetupIntent::create([
'customer' => $stripeCustomerId,
'payment_method_types' => ['sepa_debit'],
'metadata' => ['eflex_id' => (string) $eflex->getId()],
]);
// @codeCoverageIgnoreEnd
return $this->render('eflex/setup_payment.html.twig', [
'eflex' => $eflex,
'customer' => $customer,
'clientSecret' => $setupIntent->client_secret,
'stripePk' => $stripePk,
]);
}
#[Route('/eflex/setup-payment/{id}/confirm', name: 'app_eflex_setup_payment_confirm', requirements: ['id' => '\d+'], methods: ['POST'])]
public function setupPaymentConfirm(
int $id,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
return new JsonResponse(['error' => 'E-Flex introuvable'], Response::HTTP_NOT_FOUND);
}
$session = $request->getSession();
if (!$session->get('eflex_verified_'.$eflex->getId(), false)) {
return new JsonResponse(['error' => 'Non autorise'], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
$paymentMethodId = $data['payment_method'] ?? null;
if (null === $paymentMethodId) {
return new JsonResponse(['error' => 'payment_method manquant'], Response::HTTP_BAD_REQUEST);
}
// @codeCoverageIgnoreStart
try {
\Stripe\Stripe::setApiKey($stripeSk);
$stripeCustomerId = $eflex->getStripeCustomerId();
$pm = \Stripe\PaymentMethod::retrieve($paymentMethodId);
$pm->attach(['customer' => $stripeCustomerId]);
\Stripe\Customer::update($stripeCustomerId, [
'invoice_settings' => ['default_payment_method' => $paymentMethodId],
]);
$sepa = $pm->sepa_debit ?? null;
if (null !== $sepa) {
$eflex->setStripeSepaLast4($sepa->last4 ?? null);
$eflex->setStripeSepaBankName($sepa->bank_code ?? null);
$eflex->setStripeSepaCountry($sepa->country ?? null);
}
} catch (\Throwable $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
// @codeCoverageIgnoreEnd
$eflex->setStripePaymentMethodId($paymentMethodId);
$eflex->setState(EFlex::STATE_ACTIVE);
$this->em->flush();
return new JsonResponse(['status' => 'ok']);
}
/**
* Paiement CB pour une echeance E-Flex via Stripe Checkout.
*/
#[Route('/eflex/pay/{id}/{lineId}', name: 'app_eflex_pay', requirements: ['id' => '\d+', 'lineId' => '\d+'])]
public function pay(
int $id,
int $lineId,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$eflex = $this->em->getRepository(EFlex::class)->find($id);
if (null === $eflex) {
throw $this->createNotFoundException('E-Flex introuvable.');
}
$session = $request->getSession();
if (!$session->get('eflex_verified_'.$eflex->getId(), false)) {
return $this->redirectToRoute('app_eflex_verify', ['id' => $id]);
}
$line = $this->em->getRepository(EFlexLine::class)->find($lineId);
if (null === $line || $line->getEflex()->getId() !== $eflex->getId()) {
throw $this->createNotFoundException('Echeance introuvable.');
}
if (EFlexLine::STATE_OK === $line->getState()) {
return $this->redirectToRoute('app_eflex_process', ['id' => $id]);
}
// @codeCoverageIgnoreStart
\Stripe\Stripe::setApiKey($stripeSk);
$successUrl = $this->generateUrl('app_eflex_process', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL);
$cancelUrl = $successUrl;
$checkoutSession = \Stripe\Checkout\Session::create([
'mode' => 'payment',
'payment_method_types' => ['card'],
'customer_email' => $eflex->getCustomer()->getEmail(),
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'unit_amount' => (int) round((float) $line->getAmount() * 100),
'product_data' => [
'name' => $line->getLabel().' - '.$eflex->getReference(),
],
],
'quantity' => 1,
]],
'payment_intent_data' => [
'metadata' => [
'eflex_id' => (string) $eflex->getId(),
'eflex_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $eflex->getReference(),
],
],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
return $this->redirect($checkoutSession->url);
// @codeCoverageIgnoreEnd
}
}

View File

@@ -8,9 +8,11 @@ use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class EcheancierProcessController extends AbstractController
@@ -149,6 +151,10 @@ class EcheancierProcessController extends AbstractController
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
if (Echeancier::STATE_PENDING_SETUP === $echeancier->getState()) {
return $this->redirectToRoute('app_echeancier_setup_payment', ['id' => $id]);
}
if (\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_ACTIVE, Echeancier::STATE_COMPLETED], true)) {
return $this->render('echeancier/signed.html.twig', [
'echeancier' => $echeancier,
@@ -210,6 +216,47 @@ class EcheancierProcessController extends AbstractController
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
$customer = $echeancier->getCustomer();
$ref = $echeancier->getReference();
// Notification email au client
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echeancier '.$ref.' refuse',
$this->twig->render('emails/echeancier_refused_client.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'reason' => null,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
// Notification email a l'admin
try {
$this->mailer->sendEmail(
'monitor@e-cosplay.fr',
'Echeancier '.$ref.' refuse par '.$customer->getFullName(),
$this->twig->render('emails/echeancier_refused_admin.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'reason' => null,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
return $this->render('echeancier/refused.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
@@ -217,16 +264,250 @@ class EcheancierProcessController extends AbstractController
}
/**
* Callback DocuSeal apres signature du client.
* Page de configuration du prelevement SEPA (saisie IBAN).
*/
#[Route('/echeancier/signed/{id}', name: 'app_echeancier_signed', requirements: ['id' => '\d+'])]
public function signed(int $id): Response
#[Route('/echeancier/setup-payment/{id}', name: 'app_echeancier_setup_payment', requirements: ['id' => '\d+'])]
public function setupPayment(
int $id,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
#[Autowire(env: 'STRIPE_PK')] string $stripePk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
// Verifier si authentifie
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
// Si le SEPA est deja configure, afficher la confirmation
if (null !== $echeancier->getStripePaymentMethodId()) {
return $this->render('echeancier/setup_payment_done.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
if (!\in_array($echeancier->getState(), [Echeancier::STATE_SIGNED, Echeancier::STATE_PENDING_SETUP], true)) {
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
}
$customer = $echeancier->getCustomer();
// @codeCoverageIgnoreStart
\Stripe\Stripe::setApiKey($stripeSk);
// Creer le customer Stripe si besoin
$stripeCustomerId = $echeancier->getStripeCustomerId() ?? $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
$echeancier->setStripeCustomerId($stripeCustomerId);
$this->em->flush();
}
$setupIntent = \Stripe\SetupIntent::create([
'customer' => $stripeCustomerId,
'payment_method_types' => ['sepa_debit'],
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
],
]);
// @codeCoverageIgnoreEnd
return $this->render('echeancier/setup_payment.html.twig', [
'echeancier' => $echeancier,
'customer' => $customer,
'clientSecret' => $setupIntent->client_secret,
'stripePk' => $stripePk,
]);
}
/**
* Confirmation SEPA : recoit le payment_method ID apres confirmation Stripe.js.
*/
#[Route('/echeancier/setup-payment/{id}/confirm', name: 'app_echeancier_setup_payment_confirm', requirements: ['id' => '\d+'], methods: ['POST'])]
public function setupPaymentConfirm(
int $id,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
return new JsonResponse(['error' => 'Echeancier introuvable'], Response::HTTP_NOT_FOUND);
}
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return new JsonResponse(['error' => 'Non autorise'], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
$paymentMethodId = $data['payment_method'] ?? null;
if (null === $paymentMethodId) {
return new JsonResponse(['error' => 'payment_method manquant'], Response::HTTP_BAD_REQUEST);
}
// @codeCoverageIgnoreStart
try {
\Stripe\Stripe::setApiKey($stripeSk);
$stripeCustomerId = $echeancier->getStripeCustomerId();
// Attacher le moyen de paiement au client
$pm = \Stripe\PaymentMethod::retrieve($paymentMethodId);
$pm->attach(['customer' => $stripeCustomerId]);
\Stripe\Customer::update($stripeCustomerId, [
'invoice_settings' => ['default_payment_method' => $paymentMethodId],
]);
// Sauvegarder les infos SEPA
$sepa = $pm->sepa_debit ?? null;
if (null !== $sepa) {
$echeancier->setStripeSepaLast4($sepa->last4 ?? null);
$echeancier->setStripeSepaBankName($sepa->bank_code ?? null);
$echeancier->setStripeSepaCountry($sepa->country ?? null);
}
} catch (\Throwable $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
// @codeCoverageIgnoreEnd
$echeancier->setStripePaymentMethodId($paymentMethodId);
$echeancier->setState(Echeancier::STATE_ACTIVE);
$this->em->flush();
return new JsonResponse(['status' => 'ok']);
}
/**
* Regularisation d'une echeance echouee par CB via Stripe Checkout.
*/
#[Route('/echeancier/regularize/{id}/{lineId}', name: 'app_echeancier_regularize', requirements: ['id' => '\d+', 'lineId' => '\d+'])]
public function regularize(
int $id,
int $lineId,
Request $request,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
$line = $this->em->getRepository(EcheancierLine::class)->find($lineId);
if (null === $line || $line->getEcheancier()->getId() !== $echeancier->getId()) {
throw $this->createNotFoundException('Echeance introuvable.');
}
if (EcheancierLine::STATE_OK === $line->getState()) {
return $this->redirectToRoute('app_echeancier_process', ['id' => $id]);
}
$customer = $echeancier->getCustomer();
// @codeCoverageIgnoreStart
\Stripe\Stripe::setApiKey($stripeSk);
$successUrl = $this->generateUrl('app_echeancier_regularize_success', [
'id' => $id,
'lineId' => $lineId,
], UrlGeneratorInterface::ABSOLUTE_URL);
$cancelUrl = $this->generateUrl('app_echeancier_process', [
'id' => $id,
], UrlGeneratorInterface::ABSOLUTE_URL);
$checkoutSession = \Stripe\Checkout\Session::create([
'mode' => 'payment',
'payment_method_types' => ['card'],
'customer_email' => $customer->getEmail(),
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'unit_amount' => (int) round((float) $line->getAmount() * 100),
'product_data' => [
'name' => $line->getLabel().' - '.$echeancier->getReference(),
],
],
'quantity' => 1,
]],
'payment_intent_data' => [
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'echeancier_line_id' => (string) $line->getId(),
'position' => (string) $line->getPosition(),
'reference' => $echeancier->getReference(),
'regularization' => '1',
],
],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
return $this->redirect($checkoutSession->url);
// @codeCoverageIgnoreEnd
}
/**
* Page de succes apres regularisation CB.
*/
#[Route('/echeancier/regularize/{id}/{lineId}/success', name: 'app_echeancier_regularize_success', requirements: ['id' => '\d+', 'lineId' => '\d+'])]
public function regularizeSuccess(int $id, int $lineId, Request $request): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
$line = $this->em->getRepository(EcheancierLine::class)->find($lineId);
if (null === $line || $line->getEcheancier()->getId() !== $echeancier->getId()) {
throw $this->createNotFoundException('Echeance introuvable.');
}
return $this->render('echeancier/regularize_success.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
'line' => $line,
]);
}
/**
* Callback DocuSeal apres signature du client.
*/
#[Route('/echeancier/signed/{id}', name: 'app_echeancier_signed', requirements: ['id' => '\d+'])]
public function signed(int $id, Request $request): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException('Echeancier introuvable.');
}
// Verifier si authentifie (protection anti brute-force)
$session = $request->getSession();
if (!$session->get('echeancier_verified_'.$echeancier->getId(), false)) {
return $this->redirectToRoute('app_echeancier_verify', ['id' => $id]);
}
if (Echeancier::STATE_SIGNED !== $echeancier->getState()) {
$echeancier->setState(Echeancier::STATE_SIGNED);
$this->em->flush();

View File

@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Attestation;
use App\Entity\Devis;
use App\Entity\DocusealEvent;
use App\Entity\Echeancier;
use App\Repository\AttestationRepository;
use App\Repository\DevisRepository;
use App\Service\DocuSealService;
@@ -16,6 +17,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class WebhookDocuSealController extends AbstractController
@@ -35,6 +37,7 @@ class WebhookDocuSealController extends AbstractController
#[Autowire(env: 'DOCUSEAL_WEBHOOKS_SECRET_HEADER')] string $secretHeader,
#[Autowire(env: 'DOCUSEAL_WEBHOOKS_SECRET')] string $secret,
#[Autowire('%kernel.project_dir%')] string $projectDir,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$payload = $this->parseAndValidate($request, $secretHeader, $secret);
if ($payload instanceof Response) {
@@ -61,6 +64,34 @@ class WebhookDocuSealController extends AbstractController
}
// Dispatch par type de document
if ('attestation_custom' === $docType) {
return $this->handleAttestationCustomEvent($eventType, $data, $metadata, $em, $projectDir);
}
if ('eflex' === $docType) {
return $this->handleEFlexEvent($eventType, $data, $metadata, $mailer, $twig, $em, $projectDir, $stripeSk);
}
if ('client_closure' === $docType) {
return $this->handleClientClosureEvent($eventType, $data, $metadata, $mailer, $twig, $em);
}
if ('client_warning' === $docType) {
return $this->handleClientWarningEvent($eventType, $data, $metadata, $mailer, $twig, $em);
}
if ('client_warning_reset' === $docType) {
return $this->handleClientWarningResetEvent($eventType, $data, $metadata, $mailer, $twig, $em);
}
if ('echeancier_attestation' === $docType) {
return $this->handleEcheancierAttestationEvent($eventType, $data, $metadata, $mailer, $twig, $em, $projectDir);
}
if ('echeancier' === $docType) {
return $this->handleEcheancierEvent($eventType, $data, $metadata, $docuSealService, $mailer, $twig, $em, $projectDir, $stripeSk);
}
if ('devis' === $docType) {
return $this->handleDevisEvent($eventType, $data, $metadata, $devisRepository, $docuSealService, $mailer, $twig, $em, $projectDir);
}
@@ -152,6 +183,844 @@ class WebhookDocuSealController extends AbstractController
}
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleAttestationCustomEvent(
string $eventType,
array $data,
array $metadata,
EntityManagerInterface $em,
string $projectDir,
): JsonResponse {
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'attestation_custom']);
}
$attId = $metadata['attestation_custom_id'] ?? null;
if (null === $attId) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'attestation_custom_id missing']);
}
$attestation = $em->getRepository(\App\Entity\AttestationCustom::class)->find((int) $attId);
if (null === $attestation) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'attestation not found']);
}
// Telecharger le PDF signe
$tmpFiles = [];
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmp = tempnam(sys_get_temp_dir(), 'att_c_signed_').'.pdf';
file_put_contents($tmp, $content);
$attestation->setPdfSignedFile(new \Symfony\Component\HttpFoundation\File\UploadedFile($tmp, 'attestation-signee-'.$attestation->getReference().'.pdf', 'application/pdf', null, true));
$tmpFiles[] = $tmp;
}
}
$auditUrl = $data['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmp = tempnam(sys_get_temp_dir(), 'att_c_audit_').'.pdf';
file_put_contents($tmp, $auditContent);
$attestation->setPdfAuditFile(new \Symfony\Component\HttpFoundation\File\UploadedFile($tmp, 'audit-'.$attestation->getReference().'.pdf', 'application/pdf', null, true));
$tmpFiles[] = $tmp;
}
}
$attestation->setState(\App\Entity\AttestationCustom::STATE_SIGNED);
$attestation->setSignedAt(new \DateTimeImmutable());
$em->flush();
foreach ($tmpFiles as $f) {
@unlink($f);
}
return new JsonResponse(['status' => 'ok', 'event' => 'attestation_signed', 'reference' => $attestation->getReference()]);
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleEFlexEvent(
string $eventType,
array $data,
array $metadata,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
string $stripeSk,
): JsonResponse {
$eflexId = $metadata['eflex_id'] ?? null;
$eflex = null !== $eflexId ? $em->getRepository(\App\Entity\EFlex::class)->find((int) $eflexId) : null;
if (null === $eflex) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'eflex not found'], Response::HTTP_NOT_FOUND);
}
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'eflex' => $eflex->getReference()]);
}
// Telecharger le PDF signe et l'audit
$this->downloadEFlexSignedDocuments($data, $eflex, $projectDir);
$eflex->setState(\App\Entity\EFlex::STATE_PENDING_SETUP);
$em->flush();
// Preparer Stripe Customer
$customer = $eflex->getCustomer();
if ('' !== $stripeSk) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
$stripeCustomerId = $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
}
$eflex->setStripeCustomerId($stripeCustomerId);
$em->flush();
} catch (\Throwable) {
// Best effort
}
}
// Envoyer le mail au client avec PDF signe + coordonnees bancaires + lien page paiement
$ref = $eflex->getReference();
$attachments = [];
$tmpFiles = [];
if (null !== $eflex->getPdfSigned()) {
$signedPath = $projectDir.'/public/uploads/eflex/signed/'.$eflex->getPdfSigned();
if (file_exists($signedPath)) {
$attachments[] = ['path' => $signedPath, 'name' => 'eflex-signe-'.$ref.'.pdf'];
}
}
if (null !== $eflex->getPdfAudit()) {
$auditPath = $projectDir.'/public/uploads/eflex/audit/'.$eflex->getPdfAudit();
if (file_exists($auditPath)) {
$attachments[] = ['path' => $auditPath, 'name' => 'audit-'.$ref.'.pdf'];
}
}
if (null !== $customer->getEmail()) {
try {
$processUrl = $this->generateUrl('app_eflex_verify', [
'id' => $eflex->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$customer->getEmail(),
'E-Flex '.$ref.' signe - Configurez vos paiements',
$twig->render('emails/eflex_signed_client.html.twig', [
'customer' => $customer,
'eflex' => $eflex,
'processUrl' => $processUrl,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
// Mail admin
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'E-Flex '.$ref.' signe par '.$customer->getFullName(),
$twig->render('emails/eflex_signed_admin.html.twig', [
'customer' => $customer,
'eflex' => $eflex,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
return new JsonResponse(['status' => 'ok', 'event' => 'completed', 'eflex' => $ref]);
}
/**
* @param array<string, mixed> $data
*
* @codeCoverageIgnore
*/
private function downloadEFlexSignedDocuments(array $data, \App\Entity\EFlex $eflex, string $projectDir): void
{
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmpSigned = tempnam(sys_get_temp_dir(), 'eflex_signed_').'.pdf';
file_put_contents($tmpSigned, $content);
$eflex->setPdfSignedFile(new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpSigned, 'eflex-signe-'.$eflex->getReference().'.pdf', 'application/pdf', null, true));
@unlink($tmpSigned);
}
}
$auditUrl = $data['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmpAudit = tempnam(sys_get_temp_dir(), 'eflex_audit_').'.pdf';
file_put_contents($tmpAudit, $auditContent);
$eflex->setPdfAuditFile(new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpAudit, 'audit-'.$eflex->getReference().'.pdf', 'application/pdf', null, true));
@unlink($tmpAudit);
}
}
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleClientClosureEvent(
string $eventType,
array $data,
array $metadata,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
): JsonResponse {
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'client_closure']);
}
$customerId = $metadata['customer_id'] ?? null;
if (null === $customerId) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer_id missing'], Response::HTTP_NOT_FOUND);
}
$customer = $em->getRepository(\App\Entity\Customer::class)->find((int) $customerId);
if (null === $customer || null === $customer->getEmail()) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer not found']);
}
// Telecharger le PDF signe
$attachments = [];
$tmpFiles = [];
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmp = tempnam(sys_get_temp_dir(), 'closure_signed_').'.pdf';
file_put_contents($tmp, $content);
$attachments[] = ['path' => $tmp, 'name' => 'notification-cloture-signee.pdf'];
$tmpFiles[] = $tmp;
}
}
$auditUrl = $data['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmp = tempnam(sys_get_temp_dir(), 'closure_audit_').'.pdf';
file_put_contents($tmp, $auditContent);
$attachments[] = ['path' => $tmp, 'name' => 'audit-cloture.pdf'];
$tmpFiles[] = $tmp;
}
}
// Mail au client
try {
$mailer->sendEmail(
$customer->getEmail(),
'Notification de cloture definitive de votre compte - Association E-Cosplay',
$twig->render('emails/client_closure.html.twig', [
'customer' => $customer,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
// Mail admin + direction
foreach ([self::MONITOR_EMAIL, 'direction@e-cosplay.fr'] as $adminEmail) {
try {
$mailer->sendEmail(
$adminEmail,
'Cloture compte '.$customer->getFullName().' - Notification envoyee',
$twig->render('emails/client_closure.html.twig', [
'customer' => $customer,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
foreach ($tmpFiles as $tmp) {
@unlink($tmp);
}
return new JsonResponse(['status' => 'ok', 'event' => 'closure_sent', 'customer' => $customer->getFullName()]);
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleClientWarningEvent(
string $eventType,
array $data,
array $metadata,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
): JsonResponse {
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'client_warning']);
}
$customerId = $metadata['customer_id'] ?? null;
if (null === $customerId) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer_id missing'], Response::HTTP_NOT_FOUND);
}
$customer = $em->getRepository(\App\Entity\Customer::class)->find((int) $customerId);
if (null === $customer || null === $customer->getEmail()) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer not found']);
}
$level = $metadata['level'] ?? '1st';
$reasons = isset($metadata['reasons']) && '' !== $metadata['reasons'] ? explode(',', $metadata['reasons']) : [];
$warningLabels = [
'1st' => '1er avertissement',
'2nd' => '2eme avertissement',
'last' => 'Dernier avertissement avant suspension',
];
// Telecharger le PDF signe
$attachments = [];
$tmpFiles = [];
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmp = tempnam(sys_get_temp_dir(), 'warn_signed_').'.pdf';
file_put_contents($tmp, $content);
$attachments[] = ['path' => $tmp, 'name' => 'avertissement-signe-'.$level.'.pdf'];
$tmpFiles[] = $tmp;
}
}
$auditUrl = $data['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmp = tempnam(sys_get_temp_dir(), 'warn_audit_').'.pdf';
file_put_contents($tmp, $auditContent);
$attachments[] = ['path' => $tmp, 'name' => 'audit-avertissement.pdf'];
$tmpFiles[] = $tmp;
}
}
// Mail au client avec PDF signe
try {
$mailer->sendEmail(
$customer->getEmail(),
($warningLabels[$level] ?? 'Avertissement').' - Association E-Cosplay',
$twig->render('emails/client_warning.html.twig', [
'customer' => $customer,
'level' => $level,
'warningLabel' => $warningLabels[$level] ?? 'Avertissement',
'reasons' => $reasons,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
// Mail admin
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Avertissement '.$level.' envoye a '.$customer->getFullName(),
$twig->render('emails/client_warning.html.twig', [
'customer' => $customer,
'level' => $level,
'warningLabel' => $warningLabels[$level] ?? 'Avertissement',
'reasons' => $reasons,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
foreach ($tmpFiles as $tmp) {
@unlink($tmp);
}
return new JsonResponse(['status' => 'ok', 'event' => 'warning_sent', 'customer' => $customer->getFullName(), 'level' => $level]);
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleClientWarningResetEvent(
string $eventType,
array $data,
array $metadata,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
): JsonResponse {
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'client_warning_reset']);
}
$customerId = $metadata['customer_id'] ?? null;
if (null === $customerId) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer_id missing'], Response::HTTP_NOT_FOUND);
}
$customer = $em->getRepository(\App\Entity\Customer::class)->find((int) $customerId);
if (null === $customer || null === $customer->getEmail()) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'customer not found']);
}
// Telecharger le PDF signe
$attachments = [];
$tmpFiles = [];
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmp = tempnam(sys_get_temp_dir(), 'reset_signed_').'.pdf';
file_put_contents($tmp, $content);
$attachments[] = ['path' => $tmp, 'name' => 'levee-avertissement-signe.pdf'];
$tmpFiles[] = $tmp;
}
}
// Mail au client
try {
$mailer->sendEmail(
$customer->getEmail(),
'Regularisation de votre situation - Association E-Cosplay',
$twig->render('emails/client_warning_reset.html.twig', [
'customer' => $customer,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
foreach ($tmpFiles as $tmp) {
@unlink($tmp);
}
return new JsonResponse(['status' => 'ok', 'event' => 'warning_reset_sent', 'customer' => $customer->getFullName()]);
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleEcheancierAttestationEvent(
string $eventType,
array $data,
array $metadata,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
): JsonResponse {
if ('form.completed' !== $eventType) {
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'echeancier_attestation']);
}
$echeancier = $this->resolveEcheancier($data, $metadata, $em);
if (null === $echeancier) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'echeancier not found'], Response::HTTP_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
$ref = $echeancier->getReference();
// Telecharger le PDF signe depuis DocuSeal
$attachments = [];
$tmpFiles = [];
$documents = $data['documents'] ?? [];
$pdfUrl = $documents[0]['url'] ?? null;
if (null !== $pdfUrl) {
$content = @file_get_contents($pdfUrl);
if (false !== $content && str_starts_with($content, '%PDF')) {
$tmpSigned = tempnam(sys_get_temp_dir(), 'att_signed_').'.pdf';
file_put_contents($tmpSigned, $content);
$attachments[] = ['path' => $tmpSigned, 'name' => 'attestation-signee-'.$ref.'.pdf'];
$tmpFiles[] = $tmpSigned;
}
}
$auditUrl = $data['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmpAudit = tempnam(sys_get_temp_dir(), 'att_audit_').'.pdf';
file_put_contents($tmpAudit, $auditContent);
$attachments[] = ['path' => $tmpAudit, 'name' => 'audit-attestation-'.$ref.'.pdf'];
$tmpFiles[] = $tmpAudit;
}
}
$remaining = $echeancier->getTotalWithMajoration() - $echeancier->getTotalPaid();
// Mail client avec PDF signe en piece jointe
if (null !== $customer->getEmail()) {
try {
$mailer->sendEmail(
$customer->getEmail(),
'Attestation echeancier '.$ref,
$twig->render('emails/echeancier_attestation.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'remaining' => $remaining,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
// Mail admin
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Attestation '.$ref.' signee - '.$customer->getFullName(),
$twig->render('emails/echeancier_attestation.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'remaining' => $remaining,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
// Nettoyage fichiers temporaires
foreach ($tmpFiles as $tmp) {
@unlink($tmp);
}
return new JsonResponse(['status' => 'ok', 'event' => 'attestation_sent', 'echeancier' => $ref]);
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function handleEcheancierEvent(
string $eventType,
array $data,
array $metadata,
DocuSealService $docuSealService,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
string $stripeSk,
): JsonResponse {
$echeancier = $this->resolveEcheancier($data, $metadata, $em);
if (null === $echeancier) {
return new JsonResponse(['status' => 'ignored', 'reason' => 'echeancier not found'], Response::HTTP_NOT_FOUND);
}
return match ($eventType) {
'form.completed' => $this->handleEcheancierCompleted($echeancier, $docuSealService, $mailer, $twig, $em, $projectDir, $stripeSk),
'form.declined' => $this->handleEcheancierDeclined($echeancier, $data, $mailer, $twig, $em),
default => new JsonResponse(['status' => 'ok', 'event' => $eventType, 'echeancier' => $echeancier->getReference()]),
};
}
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
private function resolveEcheancier(array $data, array $metadata, EntityManagerInterface $em): ?Echeancier
{
$echeancierId = $metadata['echeancier_id'] ?? null;
if (null !== $echeancierId) {
$echeancier = $em->getRepository(Echeancier::class)->find((int) $echeancierId);
if (null !== $echeancier) {
return $echeancier;
}
}
$submitterId = $data['id'] ?? null;
if (null !== $submitterId) {
return $em->getRepository(Echeancier::class)->findOneBy(['submissionId' => (string) $submitterId]);
}
return null;
}
private function handleEcheancierCompleted(
Echeancier $echeancier,
DocuSealService $docuSealService,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
string $stripeSk,
): JsonResponse {
$echeancier->setState(Echeancier::STATE_SIGNED);
$em->flush();
// Telecharger le PDF signe et l'audit depuis DocuSeal
$docuSealService->downloadSignedEcheancier($echeancier);
// Preparer le client Stripe et envoyer le lien SEPA
$this->prepareSepaForEcheancier($echeancier, $stripeSk, $mailer, $twig, $em);
// Notifications email : admin + client
$this->sendEcheancierSignedNotifications($echeancier, $mailer, $twig, $projectDir);
return new JsonResponse(['status' => 'ok', 'event' => 'completed', 'echeancier' => $echeancier->getReference()]);
}
/**
* Prepare le client Stripe et envoie le lien de configuration SEPA au client.
*
* @codeCoverageIgnore Interaction Stripe API
*/
private function prepareSepaForEcheancier(
Echeancier $echeancier,
string $stripeSk,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
): void {
if ('' === $stripeSk) {
return;
}
$customer = $echeancier->getCustomer();
try {
\Stripe\Stripe::setApiKey($stripeSk);
$stripeCustomerId = $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
}
$echeancier->setStripeCustomerId($stripeCustomerId);
$echeancier->setState(Echeancier::STATE_PENDING_SETUP);
$em->flush();
// Envoyer le lien de configuration SEPA au client
if (null !== $customer->getEmail()) {
$setupUrl = $this->generateUrl('app_echeancier_setup_payment', [
'id' => $echeancier->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$customer->getEmail(),
'Configurez votre prelevement SEPA - Echeancier '.$echeancier->getReference(),
$twig->render('emails/echeancier_stripe_setup.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'setupUrl' => $setupUrl,
]),
null,
null,
false,
);
}
} catch (\Throwable) {
// Best effort
}
}
/**
* @param array<string, mixed> $data
*/
private function handleEcheancierDeclined(
Echeancier $echeancier,
array $data,
MailerService $mailer,
Environment $twig,
EntityManagerInterface $em,
): JsonResponse {
$echeancier->setState(Echeancier::STATE_CANCELLED);
$em->flush();
$reason = isset($data['decline_reason']) && '' !== $data['decline_reason']
? (string) $data['decline_reason']
: null;
// Notifications email : admin + client
$this->sendEcheancierRefusedNotifications($echeancier, $reason, $mailer, $twig);
return new JsonResponse(['status' => 'ok', 'event' => 'declined', 'echeancier' => $echeancier->getReference()]);
}
private function sendEcheancierSignedNotifications(Echeancier $echeancier, MailerService $mailer, Environment $twig, string $projectDir): void
{
$customer = $echeancier->getCustomer();
$ref = $echeancier->getReference();
$attachments = [];
if (null !== $echeancier->getPdfSigned()) {
$signedPath = $projectDir.'/public/uploads/echeanciers/signed/'.$echeancier->getPdfSigned();
if (file_exists($signedPath)) {
$attachments[] = ['path' => $signedPath, 'name' => 'echeancier-signe-'.$ref.'.pdf'];
}
}
if (null !== $echeancier->getPdfAudit()) {
$auditPath = $projectDir.'/public/uploads/echeanciers/audit/'.$echeancier->getPdfAudit();
if (file_exists($auditPath)) {
$attachments[] = ['path' => $auditPath, 'name' => 'audit-'.$ref.'.pdf'];
}
}
// Mail client
if (null !== $customer->getEmail()) {
try {
$mailer->sendEmail(
$customer->getEmail(),
'Echeancier '.$ref.' signe avec succes',
$twig->render('emails/echeancier_signed_client.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
// Mail admin
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Echeancier '.$ref.' signe par '.$customer->getFullName(),
$twig->render('emails/echeancier_signed_admin.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]),
null,
null,
false,
$attachments,
);
} catch (\Throwable) {
// silencieux
}
}
private function sendEcheancierRefusedNotifications(Echeancier $echeancier, ?string $reason, MailerService $mailer, Environment $twig): void
{
$customer = $echeancier->getCustomer();
$ref = $echeancier->getReference();
// Mail client
if (null !== $customer->getEmail()) {
try {
$mailer->sendEmail(
$customer->getEmail(),
'Echeancier '.$ref.' refuse',
$twig->render('emails/echeancier_refused_client.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'reason' => $reason,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
// Mail admin
try {
$mailer->sendEmail(
self::MONITOR_EMAIL,
'Echeancier '.$ref.' refuse par '.$customer->getFullName(),
$twig->render('emails/echeancier_refused_admin.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'reason' => $reason,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
/**
* Envoie 2 emails : "Vous avez signe votre devis" au client + notification admin avec PDFs en piece jointe.
*/

View File

@@ -9,6 +9,7 @@ use App\Entity\EcheancierLine;
use App\Entity\Facture;
use App\Entity\StripeWebhookSecret;
use App\Repository\StripeWebhookSecretRepository;
use App\Service\DocuSealService;
use App\Service\FactureService;
use App\Service\MailerService;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,6 +38,7 @@ class WebhookStripeController extends AbstractController
private FactureService $factureService,
private KernelInterface $kernel,
private UrlGeneratorInterface $urlGenerator,
private DocuSealService $docuSealService,
) {
}
@@ -107,6 +109,21 @@ class WebhookStripeController extends AbstractController
$paymentIntent = $event->data->object;
/** @phpstan-ignore-next-line */
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
// Gestion echeancier SEPA
$echeancierId = $metadata['echeancier_id'] ?? null;
$lineId = $metadata['echeancier_line_id'] ?? null;
if (null !== $echeancierId && null !== $lineId) {
return $this->handleEcheancierPaymentSucceeded($paymentIntent, (int) $echeancierId, (int) $lineId, $channel);
}
// Gestion E-Flex
$eflexId = $metadata['eflex_id'] ?? null;
$eflexLineId = $metadata['eflex_line_id'] ?? null;
if (null !== $eflexId && null !== $eflexLineId) {
return $this->handleEFlexPaymentSucceeded($paymentIntent, (int) $eflexId, (int) $eflexLineId, $channel);
}
$advertId = $metadata['advert_id'] ?? null;
$advert = null !== $advertId ? $this->em->getRepository(Advert::class)->find((int) $advertId) : null;
@@ -243,6 +260,21 @@ class WebhookStripeController extends AbstractController
$paymentIntent = $event->data->object;
/** @phpstan-ignore-next-line */
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
// Gestion echeancier SEPA
$echeancierId = $metadata['echeancier_id'] ?? null;
$lineId = $metadata['echeancier_line_id'] ?? null;
if (null !== $echeancierId && null !== $lineId) {
return $this->handleEcheancierPaymentFailed($paymentIntent, (int) $echeancierId, (int) $lineId, $channel);
}
// Gestion E-Flex
$eflexId = $metadata['eflex_id'] ?? null;
$eflexLineId = $metadata['eflex_line_id'] ?? null;
if (null !== $eflexId && null !== $eflexLineId) {
return $this->handleEFlexPaymentFailed($paymentIntent, (int) $eflexId, (int) $eflexLineId, $channel);
}
$advertId = $metadata['advert_id'] ?? null;
if (null === $advertId) {
@@ -487,6 +519,422 @@ class WebhookStripeController extends AbstractController
return new JsonResponse(['status' => 'ok', 'action' => 'echeance_failed', 'position' => $nextLine->getPosition()]);
}
/**
* Traite un PaymentIntent reussi pour une echeance SEPA.
*
* @codeCoverageIgnore
*/
private function handleEcheancierPaymentSucceeded(object $paymentIntent, int $echeancierId, int $lineId, string $channel): JsonResponse
{
$line = $this->em->getRepository(EcheancierLine::class)->find($lineId);
if (null === $line || $line->getEcheancier()->getId() !== $echeancierId) {
return new JsonResponse(['status' => 'ok', 'action' => 'line_not_found']);
}
if (EcheancierLine::STATE_OK === $line->getState()) {
return new JsonResponse(['status' => 'ok', 'action' => 'already_paid']);
}
$echeancier = $line->getEcheancier();
$line->setState(EcheancierLine::STATE_OK);
$line->setPaidAt(new \DateTimeImmutable());
$line->setStripePaymentIntentId($paymentIntent->id);
$this->em->flush();
// Creer l'AdvertPayment si un avis est lie
$this->createAdvertPaymentForLine($echeancier, $line);
// Verifier si toutes les echeances sont payees
if ($echeancier->getNbPaid() >= $echeancier->getNbLines()) {
$echeancier->setState(Echeancier::STATE_COMPLETED);
$this->em->flush();
// Passer l'avis en accepted si lie
$this->markAdvertAsAccepted($echeancier);
// Envoyer l'attestation de fin de paiement
$this->sendEcheancierCompletedNotifications($echeancier);
}
// Notification client echeance payee
$customer = $echeancier->getCustomer();
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echeance '.$line->getPosition().'/'.$echeancier->getNbLines().' payee - '.$echeancier->getReference(),
$this->twig->render('emails/echeancier_echeance_payee.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $line,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe echeancier PI succeeded: erreur envoi mail: '.$e->getMessage());
}
}
$this->logger->info('Stripe PI succeeded ['.$channel.']: echeance '.$line->getPosition().' payee pour '.$echeancier->getReference());
return new JsonResponse(['status' => 'ok', 'action' => 'echeance_paid', 'position' => $line->getPosition(), 'reference' => $echeancier->getReference()]);
}
/**
* Traite un PaymentIntent echoue pour une echeance SEPA.
*
* @codeCoverageIgnore
*/
private function handleEcheancierPaymentFailed(object $paymentIntent, int $echeancierId, int $lineId, string $channel): JsonResponse
{
$line = $this->em->getRepository(EcheancierLine::class)->find($lineId);
if (null === $line || $line->getEcheancier()->getId() !== $echeancierId) {
return new JsonResponse(['status' => 'ok', 'action' => 'line_not_found']);
}
$echeancier = $line->getEcheancier();
$errorMessage = $paymentIntent->last_payment_error->message ?? 'Echec prelevement SEPA';
$line->setState(EcheancierLine::STATE_KO);
$line->setFailureReason($errorMessage);
$line->setStripePaymentIntentId($paymentIntent->id);
$this->em->flush();
// Si 2 echecs ou plus : annuler l'echeancier
if ($echeancier->getNbFailed() >= 2) {
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
// Inserer les paiements deja recus dans l'avis lie
$this->syncPaidLinesToAdvert($echeancier);
// Notifications annulation
$this->sendEcheancierCancelledAfterRejectsNotifications($echeancier);
}
// Notification client avec lien de regularisation CB
$customer = $echeancier->getCustomer();
$regularizeUrl = $this->urlGenerator->generate('app_echeancier_regularize', [
'id' => $echeancier->getId(),
'lineId' => $line->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echec prelevement echeance '.$line->getPosition().'/'.$echeancier->getNbLines().' - '.$echeancier->getReference(),
$this->twig->render('emails/echeancier_echeance_echec.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $line,
'errorMessage' => $errorMessage,
'regularizeUrl' => $regularizeUrl,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe echeancier PI failed: erreur envoi mail client: '.$e->getMessage());
}
}
// Notification admin
try {
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
'Echec echeance '.$line->getPosition().' - '.$echeancier->getReference().' - '.$customer->getFullName(),
$this->twig->render('emails/echeancier_echeance_echec.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
'line' => $line,
'errorMessage' => $errorMessage,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Stripe echeancier PI failed: erreur envoi mail admin: '.$e->getMessage());
}
$this->logger->warning('Stripe PI failed ['.$channel.']: echeance '.$line->getPosition().' echouee pour '.$echeancier->getReference().' - '.$errorMessage);
return new JsonResponse(['status' => 'ok', 'action' => 'echeance_failed', 'position' => $line->getPosition(), 'reference' => $echeancier->getReference()]);
}
/**
* @codeCoverageIgnore
*/
private function handleEFlexPaymentSucceeded(object $paymentIntent, int $eflexId, int $lineId, string $channel): JsonResponse
{
$line = $this->em->getRepository(\App\Entity\EFlexLine::class)->find($lineId);
if (null === $line || $line->getEflex()->getId() !== $eflexId) {
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_line_not_found']);
}
if (\App\Entity\EFlexLine::STATE_OK === $line->getState()) {
return new JsonResponse(['status' => 'ok', 'action' => 'already_paid']);
}
$eflex = $line->getEflex();
$line->setState(\App\Entity\EFlexLine::STATE_OK);
$line->setPaidAt(new \DateTimeImmutable());
$line->setStripePaymentIntentId($paymentIntent->id);
$line->setPaidMethod('stripe');
$this->em->flush();
if ($eflex->getNbPaid() >= $eflex->getNbLines()) {
$eflex->setState(\App\Entity\EFlex::STATE_COMPLETED);
$this->em->flush();
}
$customer = $eflex->getCustomer();
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echeance '.$line->getPosition().'/'.$eflex->getNbLines().' payee - '.$eflex->getReference(),
$this->twig->render('emails/eflex_echeance_payee.html.twig', [
'customer' => $customer,
'eflex' => $eflex,
'line' => $line,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_paid', 'position' => $line->getPosition()]);
}
/**
* @codeCoverageIgnore
*/
private function handleEFlexPaymentFailed(object $paymentIntent, int $eflexId, int $lineId, string $channel): JsonResponse
{
$line = $this->em->getRepository(\App\Entity\EFlexLine::class)->find($lineId);
if (null === $line || $line->getEflex()->getId() !== $eflexId) {
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_line_not_found']);
}
$eflex = $line->getEflex();
$errorMessage = $paymentIntent->last_payment_error->message ?? 'Echec prelevement';
$line->setState(\App\Entity\EFlexLine::STATE_KO);
$line->setFailureReason($errorMessage);
$line->setStripePaymentIntentId($paymentIntent->id);
$this->em->flush();
if ($eflex->getNbFailed() >= 2) {
$eflex->setState(\App\Entity\EFlex::STATE_CANCELLED);
$this->em->flush();
}
$customer = $eflex->getCustomer();
if (null !== $customer->getEmail()) {
try {
$payUrl = $this->urlGenerator->generate('app_eflex_pay', [
'id' => $eflex->getId(),
'lineId' => $line->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$this->mailer->sendEmail(
$customer->getEmail(),
'Echec prelevement echeance '.$line->getPosition().'/'.$eflex->getNbLines().' - '.$eflex->getReference(),
$this->twig->render('emails/eflex_echeance_echec.html.twig', [
'customer' => $customer,
'eflex' => $eflex,
'line' => $line,
'errorMessage' => $errorMessage,
'payUrl' => $payUrl,
]),
null,
null,
false,
);
} catch (\Throwable) {
// silencieux
}
}
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_failed', 'position' => $line->getPosition()]);
}
/**
* Cree un AdvertPayment pour une ligne d'echeancier payee, si un avis est lie.
*
* @codeCoverageIgnore
*/
private function createAdvertPaymentForLine(Echeancier $echeancier, EcheancierLine $line): void
{
$advert = $echeancier->getAdvert();
if (null === $advert) {
return;
}
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_SUCCESS, $line->getAmount());
$payment->setMethod('sepa_debit');
$payment->setEcheancier($echeancier);
$this->em->persist($payment);
$this->em->flush();
}
/**
* Passe l'avis en accepted si un avis est lie a l'echeancier complete.
*
* @codeCoverageIgnore
*/
private function markAdvertAsAccepted(Echeancier $echeancier): void
{
$advert = $echeancier->getAdvert();
if (null === $advert) {
return;
}
$advert->setState(Advert::STATE_ACCEPTED);
$this->em->flush();
}
/**
* Insere les paiements deja recus (lignes OK) dans l'avis lie.
* Evite les doublons en verifiant si un AdvertPayment existe deja pour cette echeancier+ligne.
*
* @codeCoverageIgnore
*/
private function syncPaidLinesToAdvert(Echeancier $echeancier): void
{
$advert = $echeancier->getAdvert();
if (null === $advert) {
return;
}
// Verifier les paiements deja lies a cet echeancier pour eviter les doublons
$existingPayments = $this->em->getRepository(AdvertPayment::class)->findBy([
'advert' => $advert,
'echeancier' => $echeancier,
]);
$existingCount = \count($existingPayments);
$paidLines = 0;
foreach ($echeancier->getLines() as $line) {
if (EcheancierLine::STATE_OK === $line->getState()) {
++$paidLines;
// Ne creer que les paiements manquants
if ($paidLines > $existingCount) {
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_SUCCESS, $line->getAmount());
$payment->setMethod('sepa_debit');
$payment->setEcheancier($echeancier);
$this->em->persist($payment);
}
}
}
$this->em->flush();
}
/**
* Envoie les notifications de fin d'echeancier (attestation) au client et a l'admin.
*
* @codeCoverageIgnore
*/
private function sendEcheancierCompletedNotifications(Echeancier $echeancier): void
{
$ref = $echeancier->getReference();
// Generer le PDF attestation et l'envoyer a DocuSeal pour auto-signature
// Le webhook DocuSeal (doc_type=echeancier_attestation) enverra le mail avec le PDF signe
try {
$pdf = new \App\Service\Pdf\EcheancierAttestationPdf($this->kernel, $echeancier);
$pdf->generate();
$tmpPath = tempnam(sys_get_temp_dir(), 'ech_att_').'.pdf';
$pdf->Output('F', $tmpPath);
$pdfBase64 = base64_encode(file_get_contents($tmpPath));
$this->docuSealService->getApi()->createSubmissionFromPdf([
'name' => 'Attestation '.$ref,
'send_email' => false,
'flatten' => true,
'documents' => [[
'name' => 'attestation-'.$ref.'.pdf',
'file' => 'data:application/pdf;base64,'.$pdfBase64,
]],
'submitters' => [[
'email' => 'contact@e-cosplay.fr',
'name' => 'Association E-Cosplay',
'role' => 'First Party',
'completed' => true,
'send_email' => false,
'values' => ['Sign' => $this->docuSealService->getLogoBase64()],
'metadata' => [
'doc_type' => 'echeancier_attestation',
'echeancier_id' => $echeancier->getId(),
],
]],
]);
@unlink($tmpPath);
$this->logger->info('Echeancier completed: attestation '.$ref.' envoyee a DocuSeal pour auto-signature');
} catch (\Throwable $e) {
$this->logger->error('Echeancier completed: erreur envoi attestation DocuSeal: '.$e->getMessage());
}
}
/**
* Envoie les notifications d'annulation apres 2 rejets au client et a l'admin.
*
* @codeCoverageIgnore
*/
private function sendEcheancierCancelledAfterRejectsNotifications(Echeancier $echeancier): void
{
$customer = $echeancier->getCustomer();
$ref = $echeancier->getReference();
if (null !== $customer->getEmail()) {
try {
$this->mailer->sendEmail(
$customer->getEmail(),
'Echeancier '.$ref.' annule - Rejets de prelevement',
$this->twig->render('emails/echeancier_cancelled_rejects_client.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Echeancier cancelled rejects: erreur envoi mail client: '.$e->getMessage());
}
}
try {
$this->mailer->sendEmail(
self::NOTIFICATION_EMAIL,
'Echeancier '.$ref.' annule (2 rejets) - '.$customer->getFullName(),
$this->twig->render('emails/echeancier_cancelled_rejects_admin.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]),
null,
null,
false,
);
} catch (\Throwable $e) {
$this->logger->error('Echeancier cancelled rejects: erreur envoi mail admin: '.$e->getMessage());
}
}
/**
* Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail.
*

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity]
#[Vich\Uploadable]
class AttestationCustom
{
public const STATE_DRAFT = 'draft';
public const STATE_SIGNED = 'signed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
/** @var list<string> */
#[ORM\Column(type: 'json')]
private array $items = [];
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
private string $state = self::STATE_DRAFT;
#[ORM\Column(length: 64)]
private string $hmac;
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfUnsigned = null;
#[Vich\UploadableField(mapping: 'attestation_custom_pdf', fileNameProperty: 'pdfUnsigned')]
private ?File $pdfUnsignedFile = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfSigned = null;
#[Vich\UploadableField(mapping: 'attestation_custom_signed_pdf', fileNameProperty: 'pdfSigned')]
private ?File $pdfSignedFile = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfAudit = null;
#[Vich\UploadableField(mapping: 'attestation_custom_audit_pdf', fileNameProperty: 'pdfAudit')]
private ?File $pdfAuditFile = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $signedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/**
* @param list<string> $items
*/
public function __construct(string $title, array $items)
{
$this->title = $title;
$this->items = $items;
$this->createdAt = new \DateTimeImmutable();
$this->hmac = $this->generateHmac();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
/** @return list<string> */
public function getItems(): array
{
return $this->items;
}
/** @param list<string> $items */
public function setItems(array $items): static
{
$this->items = $items;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getHmac(): string
{
return $this->hmac;
}
public function getReference(): string
{
return 'ATT_'.str_pad((string) ($this->id ?? 0), 5, '0', \STR_PAD_LEFT);
}
public function getPdfUnsigned(): ?string
{
return $this->pdfUnsigned;
}
public function setPdfUnsigned(?string $pdfUnsigned): static
{
$this->pdfUnsigned = $pdfUnsigned;
return $this;
}
public function getPdfUnsignedFile(): ?File
{
return $this->pdfUnsignedFile;
}
public function setPdfUnsignedFile(?File $file): static
{
$this->pdfUnsignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfSigned(): ?string
{
return $this->pdfSigned;
}
public function setPdfSigned(?string $pdfSigned): static
{
$this->pdfSigned = $pdfSigned;
return $this;
}
public function getPdfSignedFile(): ?File
{
return $this->pdfSignedFile;
}
public function setPdfSignedFile(?File $file): static
{
$this->pdfSignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfAudit(): ?string
{
return $this->pdfAudit;
}
public function setPdfAudit(?string $pdfAudit): static
{
$this->pdfAudit = $pdfAudit;
return $this;
}
public function getPdfAuditFile(): ?File
{
return $this->pdfAuditFile;
}
public function setPdfAuditFile(?File $file): static
{
$this->pdfAuditFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getSignedAt(): ?\DateTimeImmutable
{
return $this->signedAt;
}
public function setSignedAt(?\DateTimeImmutable $signedAt): static
{
$this->signedAt = $signedAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function verifyHmac(): bool
{
return hash_equals($this->hmac, $this->generateHmac());
}
private function generateHmac(): string
{
$payload = implode('|', [
'attestation_custom',
$this->title,
$this->createdAt->format('Y-m-d\TH:i:s'),
json_encode($this->items),
]);
return hash_hmac('sha256', $payload, 'ecosplay_attestation_secret');
}
}

View File

@@ -118,6 +118,13 @@ class Customer
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** Niveau d'avertissement : null=aucun, 1st=1er avertissement, 2nd=2eme, last=dernier avant suspension */
#[ORM\Column(length: 10, nullable: true)]
private ?string $warningLevel = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $warningAt = null;
public function __construct(User $user)
{
$this->user = $user;
@@ -440,4 +447,28 @@ class Customer
return $this;
}
public function getWarningLevel(): ?string
{
return $this->warningLevel;
}
public function setWarningLevel(?string $warningLevel): static
{
$this->warningLevel = $warningLevel;
return $this;
}
public function getWarningAt(): ?\DateTimeImmutable
{
return $this->warningAt;
}
public function setWarningAt(?\DateTimeImmutable $warningAt): static
{
$this->warningAt = $warningAt;
return $this;
}
}

413
src/Entity/EFlex.php Normal file
View File

@@ -0,0 +1,413 @@
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity]
#[Vich\Uploadable]
class EFlex
{
public const STATE_DRAFT = 'draft';
public const STATE_ACTIVE = 'active';
public const STATE_COMPLETED = 'completed';
public const STATE_CANCELLED = 'cancelled';
public const STATE_PENDING_SETUP = 'pending_setup';
public const METHOD_SEPA = 'sepa';
public const METHOD_CB = 'cb';
public const METHOD_VIREMENT = 'virement';
#[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: 500)]
private string $description;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $totalAmount;
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
private string $state = self::STATE_DRAFT;
#[ORM\Column(length: 20, options: ['default' => 'sepa'])]
private string $paymentMethod = self::METHOD_SEPA;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeCustomerId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePaymentMethodId = null;
#[ORM\Column(length: 4, nullable: true)]
private ?string $stripeSepaLast4 = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $stripeSepaBankName = null;
#[ORM\Column(length: 2, nullable: true)]
private ?string $stripeSepaCountry = null;
#[ORM\Column(nullable: true)]
private ?string $submissionId = null;
// ── PDF Unsigned ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfUnsigned = null;
#[Vich\UploadableField(mapping: 'eflex_pdf', fileNameProperty: 'pdfUnsigned')]
private ?File $pdfUnsignedFile = null;
// ── PDF Signed ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfSigned = null;
#[Vich\UploadableField(mapping: 'eflex_signed_pdf', fileNameProperty: 'pdfSigned')]
private ?File $pdfSignedFile = null;
// ── PDF Audit ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfAudit = null;
#[Vich\UploadableField(mapping: 'eflex_audit_pdf', fileNameProperty: 'pdfAudit')]
private ?File $pdfAuditFile = null;
/** @var Collection<int, EFlexLine> */
#[ORM\OneToMany(targetEntity: EFlexLine::class, mappedBy: 'eflex', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $lines;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct(Customer $customer, string $description, string $totalAmount)
{
$this->customer = $customer;
$this->description = $description;
$this->totalAmount = $totalAmount;
$this->lines = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getTotalAmount(): string
{
return $this->totalAmount;
}
public function setTotalAmount(string $totalAmount): static
{
$this->totalAmount = $totalAmount;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getPaymentMethod(): string
{
return $this->paymentMethod;
}
public function setPaymentMethod(string $paymentMethod): static
{
$this->paymentMethod = $paymentMethod;
return $this;
}
public function getStripeCustomerId(): ?string
{
return $this->stripeCustomerId;
}
public function setStripeCustomerId(?string $stripeCustomerId): static
{
$this->stripeCustomerId = $stripeCustomerId;
return $this;
}
public function getStripePaymentMethodId(): ?string
{
return $this->stripePaymentMethodId;
}
public function setStripePaymentMethodId(?string $stripePaymentMethodId): static
{
$this->stripePaymentMethodId = $stripePaymentMethodId;
return $this;
}
public function getStripeSepaLast4(): ?string
{
return $this->stripeSepaLast4;
}
public function setStripeSepaLast4(?string $stripeSepaLast4): static
{
$this->stripeSepaLast4 = $stripeSepaLast4;
return $this;
}
public function getStripeSepaBankName(): ?string
{
return $this->stripeSepaBankName;
}
public function setStripeSepaBankName(?string $stripeSepaBankName): static
{
$this->stripeSepaBankName = $stripeSepaBankName;
return $this;
}
public function getStripeSepaCountry(): ?string
{
return $this->stripeSepaCountry;
}
public function setStripeSepaCountry(?string $stripeSepaCountry): static
{
$this->stripeSepaCountry = $stripeSepaCountry;
return $this;
}
/** @return Collection<int, EFlexLine> */
public function getLines(): Collection
{
return $this->lines;
}
public function addLine(EFlexLine $line): static
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;
}
public function setSubmissionId(?string $submissionId): static
{
$this->submissionId = $submissionId;
return $this;
}
public function getPdfUnsigned(): ?string
{
return $this->pdfUnsigned;
}
public function setPdfUnsigned(?string $pdfUnsigned): static
{
$this->pdfUnsigned = $pdfUnsigned;
return $this;
}
public function getPdfUnsignedFile(): ?File
{
return $this->pdfUnsignedFile;
}
public function setPdfUnsignedFile(?File $pdfUnsignedFile): static
{
$this->pdfUnsignedFile = $pdfUnsignedFile;
if (null !== $pdfUnsignedFile) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfSigned(): ?string
{
return $this->pdfSigned;
}
public function setPdfSigned(?string $pdfSigned): static
{
$this->pdfSigned = $pdfSigned;
return $this;
}
public function setPdfSignedFile(?File $pdfSignedFile): static
{
$this->pdfSignedFile = $pdfSignedFile;
if (null !== $pdfSignedFile) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfAudit(): ?string
{
return $this->pdfAudit;
}
public function setPdfAudit(?string $pdfAudit): static
{
$this->pdfAudit = $pdfAudit;
return $this;
}
public function setPdfAuditFile(?File $pdfAuditFile): static
{
$this->pdfAuditFile = $pdfAuditFile;
if (null !== $pdfAuditFile) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
/**
* Reference unique (E_FLEX_00001).
*/
public function getReference(): string
{
return 'E_FLEX_'.str_pad((string) ($this->id ?? 0), 5, '0', \STR_PAD_LEFT);
}
public function getNbLines(): int
{
return $this->lines->count();
}
/**
* Montant mensuel (total / nb echeances).
*/
public function getMonthlyAmount(): float
{
$nb = $this->getNbLines();
return $nb > 0 ? round((float) $this->totalAmount / $nb, 2) : 0.0;
}
public function getNbPaid(): int
{
$count = 0;
foreach ($this->lines as $line) {
if (EFlexLine::STATE_OK === $line->getState()) {
++$count;
}
}
return $count;
}
public function getNbFailed(): int
{
$count = 0;
foreach ($this->lines as $line) {
if (EFlexLine::STATE_KO === $line->getState()) {
++$count;
}
}
return $count;
}
public function getTotalPaid(): float
{
$total = 0.0;
foreach ($this->lines as $line) {
if (EFlexLine::STATE_OK === $line->getState()) {
$total += (float) $line->getAmount();
}
}
return $total;
}
public function getProgress(): int
{
$nb = $this->getNbLines();
return $nb > 0 ? (int) round($this->getNbPaid() / $nb * 100) : 0;
}
public function getPaymentMethodLabel(): string
{
return match ($this->paymentMethod) {
self::METHOD_SEPA => 'Prelevement SEPA',
self::METHOD_CB => 'Carte bancaire',
self::METHOD_VIREMENT => 'Virement bancaire',
default => $this->paymentMethod,
};
}
}

180
src/Entity/EFlexLine.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['eflex_id', 'state'], name: 'idx_eflex_line_state')]
class EFlexLine
{
public const STATE_PREPARED = 'prepared';
public const STATE_OK = 'ok';
public const STATE_KO = 'ko';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EFlex::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private EFlex $eflex;
#[ORM\Column(type: 'smallint')]
private int $position;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $amount;
#[ORM\Column]
private \DateTimeImmutable $scheduledAt;
#[ORM\Column(length: 20, options: ['default' => 'prepared'])]
private string $state = self::STATE_PREPARED;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePaymentIntentId = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(length: 30, nullable: true)]
private ?string $paidMethod = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(EFlex $eflex, int $position, string $amount, \DateTimeImmutable $scheduledAt)
{
$this->eflex = $eflex;
$this->position = $position;
$this->amount = $amount;
$this->scheduledAt = $scheduledAt;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEflex(): EFlex
{
return $this->eflex;
}
public function getPosition(): int
{
return $this->position;
}
public function getAmount(): string
{
return $this->amount;
}
public function setAmount(string $amount): static
{
$this->amount = $amount;
return $this;
}
public function getScheduledAt(): \DateTimeImmutable
{
return $this->scheduledAt;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getStripePaymentIntentId(): ?string
{
return $this->stripePaymentIntentId;
}
public function setStripePaymentIntentId(?string $stripePaymentIntentId): static
{
$this->stripePaymentIntentId = $stripePaymentIntentId;
return $this;
}
public function getPaidAt(): ?\DateTimeImmutable
{
return $this->paidAt;
}
public function setPaidAt(?\DateTimeImmutable $paidAt): static
{
$this->paidAt = $paidAt;
return $this;
}
public function getFailureReason(): ?string
{
return $this->failureReason;
}
public function setFailureReason(?string $failureReason): static
{
$this->failureReason = $failureReason;
return $this;
}
public function getPaidMethod(): ?string
{
return $this->paidMethod;
}
public function setPaidMethod(?string $paidMethod): static
{
$this->paidMethod = $paidMethod;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getLabel(): string
{
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
$month = $months[(int) $this->scheduledAt->format('n')] ?? '';
$total = $this->eflex->getNbLines();
return 'Echeance '.$this->position.'/'.$total.' - '.$month.' '.$this->scheduledAt->format('Y');
}
public function isPaid(): bool
{
return self::STATE_OK === $this->state;
}
public function isFailed(): bool
{
return self::STATE_KO === $this->state;
}
public function isPending(): bool
{
return self::STATE_PREPARED === $this->state;
}
}

View File

@@ -19,6 +19,7 @@ class Echeancier
public const STATE_COMPLETED = 'completed';
public const STATE_CANCELLED = 'cancelled';
public const STATE_DEFAULT = 'default';
public const STATE_PENDING_SETUP = 'pending_setup';
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -29,6 +30,10 @@ class Echeancier
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Customer $customer;
#[ORM\ManyToOne(targetEntity: Advert::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Advert $advert = null;
#[ORM\Column(length: 500)]
private string $description;
@@ -53,6 +58,18 @@ class Echeancier
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePriceId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePaymentMethodId = null;
#[ORM\Column(length: 4, nullable: true)]
private ?string $stripeSepaLast4 = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $stripeSepaBankName = null;
#[ORM\Column(length: 2, nullable: true)]
private ?string $stripeSepaCountry = null;
#[ORM\Column(nullable: true)]
private ?string $submissionId = null;
@@ -107,6 +124,18 @@ class Echeancier
return $this->customer;
}
public function getAdvert(): ?Advert
{
return $this->advert;
}
public function setAdvert(?Advert $advert): static
{
$this->advert = $advert;
return $this;
}
public function getDescription(): string
{
return $this->description;
@@ -204,6 +233,54 @@ class Echeancier
return $this;
}
public function getStripePaymentMethodId(): ?string
{
return $this->stripePaymentMethodId;
}
public function setStripePaymentMethodId(?string $stripePaymentMethodId): static
{
$this->stripePaymentMethodId = $stripePaymentMethodId;
return $this;
}
public function getStripeSepaLast4(): ?string
{
return $this->stripeSepaLast4;
}
public function setStripeSepaLast4(?string $stripeSepaLast4): static
{
$this->stripeSepaLast4 = $stripeSepaLast4;
return $this;
}
public function getStripeSepaBankName(): ?string
{
return $this->stripeSepaBankName;
}
public function setStripeSepaBankName(?string $stripeSepaBankName): static
{
$this->stripeSepaBankName = $stripeSepaBankName;
return $this;
}
public function getStripeSepaCountry(): ?string
{
return $this->stripeSepaCountry;
}
public function setStripeSepaCountry(?string $stripeSepaCountry): static
{
$this->stripeSepaCountry = $stripeSepaCountry;
return $this;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;
@@ -417,4 +494,12 @@ class Echeancier
return $nb > 0 ? round($this->getTotalWithMajoration() / $nb, 2) : 0.0;
}
/**
* Reference unique de l'echeancier (EC_ECH_00001).
*/
public function getReference(): string
{
return 'EC_ECH_'.str_pad((string) ($this->id ?? 0), 5, '0', \STR_PAD_LEFT);
}
}

View File

@@ -36,6 +36,9 @@ class EcheancierLine
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeInvoiceId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePaymentIntentId = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
@@ -124,6 +127,18 @@ class EcheancierLine
return $this;
}
public function getStripePaymentIntentId(): ?string
{
return $this->stripePaymentIntentId;
}
public function setStripePaymentIntentId(?string $stripePaymentIntentId): static
{
$this->stripePaymentIntentId = $stripePaymentIntentId;
return $this;
}
public function getPaidAt(): ?\DateTimeImmutable
{
return $this->paidAt;

View File

@@ -3,6 +3,8 @@
namespace App\Service;
use App\Entity\AdvertPayment;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use App\Entity\Facture;
use App\Entity\FacturePrestataire;
use Doctrine\ORM\EntityManagerInterface;
@@ -52,6 +54,7 @@ class ComptaExportService
'reglements' => $this->buildReglementsData($from, $to),
'commissions-stripe' => $this->buildCommissionsStripeData($from, $to),
'couts-services' => $this->buildCoutsServicesData($from, $to),
'echeanciers' => $this->buildEcheancierData($from, $to),
default => [],
};
}
@@ -550,4 +553,79 @@ class ComptaExportService
}
}
/**
* Export des echeanciers et de leurs echeances sur la periode.
*
* @return list<array<string, string>>
*/
public function buildEcheancierData(\DateTimeImmutable $from, \DateTimeImmutable $to): array
{
$echeanciers = $this->em->createQuery(
'SELECT e FROM App\Entity\Echeancier e
WHERE e.createdAt BETWEEN :from AND :to
ORDER BY e.createdAt ASC'
)
->setParameter('from', $from)
->setParameter('to', $to)
->getResult();
$rows = [];
$stateLabels = [
Echeancier::STATE_DRAFT => 'Brouillon',
Echeancier::STATE_SEND => 'Envoye',
Echeancier::STATE_SIGNED => 'Signe',
Echeancier::STATE_PENDING_SETUP => 'En attente SEPA',
Echeancier::STATE_ACTIVE => 'Actif',
Echeancier::STATE_COMPLETED => 'Termine',
Echeancier::STATE_CANCELLED => 'Annule',
Echeancier::STATE_DEFAULT => 'Defaut',
];
/** @var Echeancier $echeancier */
foreach ($echeanciers as $echeancier) {
$customer = $echeancier->getCustomer();
$remaining = $echeancier->getTotalWithMajoration() - $echeancier->getTotalPaid();
// Compter les paiements AdvertPayment lies a cet echeancier
$advertPayments = $this->em->getRepository(AdvertPayment::class)->findBy(['echeancier' => $echeancier]);
$nbAdvertPayments = \count($advertPayments);
$totalAdvertPayments = 0.0;
foreach ($advertPayments as $ap) {
$totalAdvertPayments += (float) $ap->getAmount();
}
/** @var EcheancierLine $line */
foreach ($echeancier->getLines() as $line) {
$lineState = match ($line->getState()) {
EcheancierLine::STATE_OK => 'Paye',
EcheancierLine::STATE_KO => 'Echoue',
default => 'En attente',
};
$rows[] = [
'Reference' => $echeancier->getReference(),
'Client' => $customer->getFullName(),
'Email' => $customer->getEmail() ?? '',
'Statut echeancier' => $stateLabels[$echeancier->getState()] ?? $echeancier->getState(),
'Motif' => $echeancier->getDescription(),
'Creance HT' => number_format((float) $echeancier->getTotalAmountHt(), 2, '.', ''),
'Majoration' => number_format($echeancier->getMajoration(), 2, '.', ''),
'Total majore' => number_format($echeancier->getTotalWithMajoration(), 2, '.', ''),
'Total paye' => number_format($echeancier->getTotalPaid(), 2, '.', ''),
'Restant du' => number_format($remaining, 2, '.', ''),
'Nb paiements' => (string) $nbAdvertPayments,
'Total paiements avis' => number_format($totalAdvertPayments, 2, '.', ''),
'Echeance N' => (string) $line->getPosition(),
'Date prevue' => $line->getScheduledAt()->format(self::DATE_FORMAT_FR),
'Montant echeance' => number_format((float) $line->getAmount(), 2, '.', ''),
'Statut echeance' => $lineState,
'Paye le' => null !== $line->getPaidAt() ? $line->getPaidAt()->format(self::DATE_FORMAT_FR) : '',
'Stripe PI' => $line->getStripePaymentIntentId() ?? '',
'Avis lie' => null !== $echeancier->getAdvert() ? $echeancier->getAdvert()->getOrderNumber()->getNumOrder() : '',
];
}
}
return $rows;
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Service;
use App\Entity\Attestation;
use App\Entity\Devis;
use App\Entity\Echeancier;
use Doctrine\ORM\EntityManagerInterface;
use Docuseal\Api;
use Psr\Log\LoggerInterface;
@@ -427,6 +428,66 @@ class DocuSealService
}
}
/**
* Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit d'un echeancier depuis DocuSeal.
*/
public function downloadSignedEcheancier(Echeancier $echeancier): bool
{
$submitterId = (int) ($echeancier->getSubmissionId() ?? '0');
if ($submitterId <= 0) {
return false;
}
try {
return $this->fetchAndStoreEcheancierDocuments($submitterId, $echeancier);
} catch (\Throwable $e) {
$this->logger->error('DocuSeal: erreur telechargement echeancier signe: '.$e->getMessage(), [
'echeancier_id' => $echeancier->getId(),
'exception' => $e,
]);
return false;
}
}
private function fetchAndStoreEcheancierDocuments(int $submitterId, Echeancier $echeancier): bool
{
$submitter = $this->api->getSubmitter($submitterId);
$pdfUrl = $submitter['documents'][0]['url'] ?? null;
$content = null !== $pdfUrl ? @file_get_contents($pdfUrl) : false;
if (false === $content || !str_starts_with($content, '%PDF')) {
return false;
}
$ref = $echeancier->getReference();
$tmpSigned = tempnam(sys_get_temp_dir(), 'ech_signed_').'.pdf';
file_put_contents($tmpSigned, $content);
$echeancier->setPdfSignedFile(new UploadedFile($tmpSigned, 'echeancier-signe-'.$ref.'.pdf', 'application/pdf', null, true));
$tmpAudit = null;
$auditUrl = $submitter['audit_log_url'] ?? null;
if (null !== $auditUrl) {
$auditContent = @file_get_contents($auditUrl);
if (false !== $auditContent) {
$tmpAudit = tempnam(sys_get_temp_dir(), 'ech_audit_').'.pdf';
file_put_contents($tmpAudit, $auditContent);
$echeancier->setPdfAuditFile(new UploadedFile($tmpAudit, 'audit-'.$ref.'.pdf', 'application/pdf', null, true));
}
}
$echeancier->setUpdatedAt(new \DateTimeImmutable());
$this->em->flush();
@unlink($tmpSigned);
if (null !== $tmpAudit) {
@unlink($tmpAudit);
}
return true;
}
/**
* @codeCoverageIgnore
*

View File

@@ -0,0 +1,238 @@
<?php
namespace App\Service\Pdf;
use App\Entity\AttestationCustom;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AttestationCustomPdf extends Fpdi
{
private string $qrBase64 = '';
private string $verifyUrl = '';
public function __construct(
private readonly KernelInterface $kernel,
private readonly AttestationCustom $attestation,
?UrlGeneratorInterface $urlGenerator = null,
) {
parent::__construct();
$this->SetTitle($this->enc('Attestation '.$this->attestation->getReference().' - '.$this->attestation->getTitle()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
if (null !== $urlGenerator) {
$this->verifyUrl = $urlGenerator->generate('app_attestation_custom_verify', [
'id' => $attestation->getId(),
'hmac' => $attestation->getHmac(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$builder = new Builder(
writer: new PngWriter(),
data: $this->verifyUrl,
size: 200,
margin: 10,
);
$this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString());
}
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeBody();
$this->writeQrCode();
$this->writeSignature();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('ATTESTATION'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
$this->SetXY(60, 18);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 5, $this->enc('Ref. : '.$this->attestation->getReference().' - HMAC : '.substr($this->attestation->getHmac(), 0, 12).'...'), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 24);
$this->Cell(0, 5, $this->enc('Emise a Beautor, le '.$formatter->format($this->attestation->getCreatedAt())), 0, 1, 'L');
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeBody(): void
{
$this->SetY(45);
// Titre de l'attestation
$this->SetFont('Arial', 'B', 13);
$this->SetFillColor(250, 191, 4);
$this->Cell(0, 10, $this->enc(' OBJET : '.$this->attestation->getTitle()), 0, 1, 'L', true);
$this->Ln(8);
// Phrase d'ouverture
$this->SetFont('Arial', '', 11);
$this->MultiCell(0, 6, $this->enc(
'Je soussigne(e), President(e) de l\'Association E-Cosplay et le bureau de l\'association, '
.'atteste les elements suivants :'
), 0, 'L');
$this->Ln(5);
// Liste des elements
$this->SetFont('Arial', '', 10);
foreach ($this->attestation->getItems() as $i => $item) {
$this->SetFont('Arial', 'B', 10);
$this->Cell(8, 6, ($i + 1).'.', 0, 0, 'R');
$this->SetFont('Arial', '', 10);
$this->Cell(3, 6, '', 0, 0);
$this->MultiCell(0, 6, $this->enc($item), 0, 'L');
$this->Ln(2);
}
$this->Ln(5);
// Phrase de cloture
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(5);
$this->SetFont('Arial', '', 11);
$this->MultiCell(0, 6, $this->enc(
'La presente attestation est etablie pour faire valoir les droits de l\'Association E-Cosplay. '
.'Les elements presentes ci-dessus sont conformes et veridiques.'
), 0, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 9);
$this->SetTextColor(150, 150, 150);
$this->MultiCell(0, 4, $this->enc(
'Toute fausse declaration est susceptible de poursuites conformement aux articles 441-1 et suivants du Code penal. '
.'Ce document est protege par un code HMAC d\'integrite.'
), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeQrCode(): void
{
if ('' === $this->qrBase64) {
return;
}
if ($this->GetY() + 45 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$y = $this->GetY();
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png';
$pngData = base64_decode(str_replace('data:image/png;base64,', '', $this->qrBase64));
file_put_contents($tmpQr, $pngData);
$this->Image($tmpQr, 15, $y, 25, 25);
@unlink($tmpQr);
$this->SetXY(45, $y);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Verification publique de cette attestation'), 0, 1, 'L');
$this->SetX(45);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(100, 100, 100);
$this->Cell(0, 4, $this->enc('Scannez le QR code ou utilisez le lien ci-dessous'), 0, 1, 'L');
$this->SetX(45);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 4, $this->enc($this->verifyUrl), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->SetY($y + 28);
$this->Ln(3);
}
/** @codeCoverageIgnore */
private function writeSignature(): void
{
if ($this->GetY() + 40 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Pour le bureau de l\'Association E-Cosplay :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Customer;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
class ClientClosurePdf extends Fpdi
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly Customer $customer,
) {
parent::__construct();
$this->SetTitle($this->enc('Notification de cloture - '.$this->customer->getFullName()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeContent();
$this->writeLegal();
$this->writeSignature();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('NOTIFICATION DE CLOTURE'), 0, 1, 'L');
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 19);
$this->Cell(0, 5, $this->enc('Emise a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->SetFont('Arial', 'I', 9);
$this->SetXY(60, 25);
$this->SetTextColor(220, 38, 38);
$this->Cell(0, 5, $this->enc('LETTRE RECOMMANDEE AVEC ACCUSE DE RECEPTION'), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
// Destinataire
$y = 35;
$this->SetFont('Arial', 'B', 11);
$this->SetXY(120, $y);
$name = $this->customer->getRaisonSociale() ?: $this->customer->getFullName();
$this->Cell(0, 5, $this->enc($name), 0, 1, 'L');
if ($address = $this->customer->getAddress()) {
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc($address), 0, 1, 'L');
}
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$cityLine = ($this->customer->getZipCode() ?? '').' '.($this->customer->getCity() ?? '');
$this->Cell(0, 5, $this->enc(trim($cityLine)), 0, 1, 'L');
if ($this->customer->getEmail()) {
$y += 5;
$this->SetXY(120, $y);
$this->Cell(0, 5, $this->enc($this->customer->getEmail()), 0, 1, 'L');
}
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeContent(): void
{
$this->SetY(65);
// Bandeau rouge
$this->SetFillColor(127, 29, 29);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 10, $this->enc(' CLOTURE DEFINITIVE DU COMPTE'), 0, 1, 'L', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
$greeting = $this->customer->getRaisonSociale()
? 'Chez '.$this->customer->getRaisonSociale()
: ($this->customer->getFirstName() ?? $this->customer->getFullName());
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->enc($greeting.','), 0, 'L');
$this->Ln(3);
$this->MultiCell(0, 5, $this->enc(
'Malgre les avertissements qui vous ont ete adresses et en l\'absence de changement de votre part, '
.'le bureau de l\'Association E-Cosplay, reuni a huis clos, a decide d\'effectuer la procedure '
.'de cloture definitive de votre compte.'
), 0, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(220, 38, 38);
$this->MultiCell(0, 5, $this->enc('Les mesures suivantes seront appliquees :'), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$items = [
'La totalite de vos services (sites internet, emails, noms de domaine) seront supprimes et detruits dans un delai de 24 heures, sans possibilite de recuperation.',
'Un depot aupres d\'une societe de recouvrement sera effectue pour les factures restant dues.',
'Une mise en demeure sera deposee aupres d\'un commissaire de justice.',
'En cas de manque de respect ou d\'insultes constatees, un depot aupres des forces de l\'ordre sera effectue.',
'L\'ensemble de vos donnees sera supprime conformement au RGPD, a l\'exception des donnees necessaires aux procedures de recouvrement.',
];
foreach ($items as $item) {
$this->Cell(5, 5, '', 0, 0);
$x = $this->GetX();
$this->Cell(5, 5, '-', 0, 0);
$this->MultiCell(0, 5, $this->enc($item), 0, 'L');
$this->Ln(1);
}
$this->Ln(3);
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(220, 38, 38);
$this->MultiCell(0, 5, $this->enc('Cette decision est definitive et irrevocable.'), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeLegal(): void
{
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->MultiCell(0, 4, $this->enc(
'Le present document constitue une notification officielle de cloture de compte de l\'Association E-Cosplay. '
.'Il fait foi en cas de litige. '
.'Toutes les decisions relatives aux avertissements et clotures sont prises par le bureau de l\'association a huis clos. '
.'Toute contestation devra etre adressee a direction@e-cosplay.fr dans un delai de 24 heures suivant la reception de ce document.'
), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeSignature(): void
{
if ($this->GetY() + 35 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Pour le bureau de l\'Association E-Cosplay :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Customer;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
class ClientWarningPdf extends Fpdi
{
private const REASON_LABELS = [
'impayes' => 'Impayes et/ou rejets de prelevement',
'irrespect' => 'Manque de respect et/ou insultes envers notre equipe',
'hors_horaires' => 'Appels repetes hors des heures d\'ouverture avec refus de payer le service hors horaire',
'gratuit' => 'Exigence de services gratuits non prevus dans votre contrat',
];
private const LEVEL_LABELS = [
'1st' => '1er avertissement',
'2nd' => '2eme avertissement - Procedure de suspension engagee',
'last' => 'Dernier avertissement avant suspension',
];
/**
* @param list<string> $reasons
*/
public function __construct(
private readonly KernelInterface $kernel,
private readonly Customer $customer,
private readonly string $level,
private readonly array $reasons = [],
) {
parent::__construct();
$this->SetTitle($this->enc('Avertissement - '.$this->customer->getFullName()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeLevelBanner();
$this->writeReasons();
$this->writeContent();
$this->writeLegalFooter();
$this->writeSignature();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('AVERTISSEMENT'), 0, 1, 'L');
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 19);
$this->Cell(0, 5, $this->enc('Emis a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
// Destinataire
$y = 32;
$this->SetFont('Arial', 'B', 11);
$this->SetXY(120, $y);
$name = $this->customer->getRaisonSociale() ?: $this->customer->getFullName();
$this->Cell(0, 5, $this->enc($name), 0, 1, 'L');
if ($address = $this->customer->getAddress()) {
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc($address), 0, 1, 'L');
}
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$cityLine = ($this->customer->getZipCode() ?? '').' '.($this->customer->getCity() ?? '');
$this->Cell(0, 5, $this->enc(trim($cityLine)), 0, 1, 'L');
if ($this->customer->getEmail()) {
$y += 5;
$this->SetXY(120, $y);
$this->Cell(0, 5, $this->enc($this->customer->getEmail()), 0, 1, 'L');
}
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeLevelBanner(): void
{
$this->SetY(60);
$label = self::LEVEL_LABELS[$this->level] ?? $this->level;
if ('last' === $this->level) {
$this->SetFillColor(220, 38, 38);
} elseif ('2nd' === $this->level) {
$this->SetFillColor(234, 88, 12);
} else {
$this->SetFillColor(245, 158, 11);
}
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 10, $this->enc(' '.mb_strtoupper($label)), 0, 1, 'L', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
// Texte introductif
$greeting = $this->customer->getRaisonSociale()
? 'Chez '.$this->customer->getRaisonSociale()
: $this->customer->getFirstName() ?? $this->customer->getFullName();
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->enc($greeting.','), 0, 'L');
$this->Ln(3);
if ('1st' === $this->level) {
$this->MultiCell(0, 5, $this->enc('Nous constatons des manquements sur votre compte. Nous vous invitons a regulariser votre situation dans les meilleurs delais.'), 0, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 10);
$this->MultiCell(0, 5, $this->enc('En cas de repetition, un 2eme avertissement sera decide et pourra entrainer la suspension de vos services.'), 0, 'L');
} elseif ('2nd' === $this->level) {
$this->MultiCell(0, 5, $this->enc('Malgre notre precedent avertissement, nous constatons que votre situation n\'a pas ete regularisee. La procedure de suspension a ete preparee :'), 0, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(5, 5, '', 0, 0);
$this->Cell(0, 5, $this->enc('- La resiliation de vos services (sites internet, emails, noms de domaine) a ete preparee'), 0, 1, 'L');
$this->Cell(5, 5, '', 0, 0);
$this->Cell(0, 5, $this->enc('- La fermeture de votre compte a ete programmee'), 0, 1, 'L');
$this->Cell(5, 5, '', 0, 0);
$this->Cell(0, 5, $this->enc('- Ces mesures seront effectives au prochain avertissement en l\'absence de regularisation'), 0, 1, 'L');
} else {
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(220, 38, 38);
$this->MultiCell(0, 5, $this->enc('Malgre nos precedents avertissements, votre situation n\'a toujours pas ete regularisee. Ceci est votre dernier avertissement. Sans regularisation sous 48 heures, nous procederons a la suspension immediate de votre compte et de l\'ensemble de vos services.'), 0, 'L');
$this->SetTextColor(0, 0, 0);
}
$this->SetFont('Arial', '', 10);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeReasons(): void
{
if ([] === $this->reasons) {
return;
}
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('MOTIFS CONSTATES'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
foreach ($this->reasons as $reason) {
$label = self::REASON_LABELS[$reason] ?? $reason;
$this->Cell(5, 5, '', 0, 0);
$this->Cell(0, 5, $this->enc('- '.$label), 0, 1, 'L');
}
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeContent(): void
{
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->enc('Nous vous invitons a regulariser votre situation dans les meilleurs delais ou a contacter notre service pour trouver une solution.'), 0, 'L');
$this->Ln(3);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc('Contact : contact@e-cosplay.fr'), 0, 1, 'L');
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeLegalFooter(): void
{
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->MultiCell(0, 4, $this->enc(
'Le present document constitue un avertissement officiel de l\'Association E-Cosplay. Il fait foi en cas de litige. '
.'Toutes les decisions relatives aux avertissements sont prises par le bureau de l\'association a huis clos. '
.'Toute contestation devra etre adressee a direction@e-cosplay.fr'
), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeSignature(): void
{
if ($this->GetY() + 35 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Pour le bureau de l\'Association E-Cosplay :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Customer;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
class ClientWarningResetPdf extends Fpdi
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly Customer $customer,
) {
parent::__construct();
$this->SetTitle($this->enc('Levee d\'avertissement - '.$this->customer->getFullName()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeContent();
$this->writeSignature();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('LEVEE D\'AVERTISSEMENT'), 0, 1, 'L');
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 19);
$this->Cell(0, 5, $this->enc('Emise a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
// Destinataire
$y = 32;
$this->SetFont('Arial', 'B', 11);
$this->SetXY(120, $y);
$name = $this->customer->getRaisonSociale() ?: $this->customer->getFullName();
$this->Cell(0, 5, $this->enc($name), 0, 1, 'L');
if ($address = $this->customer->getAddress()) {
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc($address), 0, 1, 'L');
}
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$cityLine = ($this->customer->getZipCode() ?? '').' '.($this->customer->getCity() ?? '');
$this->Cell(0, 5, $this->enc(trim($cityLine)), 0, 1, 'L');
if ($this->customer->getEmail()) {
$y += 5;
$this->SetXY(120, $y);
$this->Cell(0, 5, $this->enc($this->customer->getEmail()), 0, 1, 'L');
}
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeContent(): void
{
$this->SetY(60);
// Bandeau vert
$this->SetFillColor(22, 163, 74);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 10, $this->enc(' SITUATION REGULARISEE'), 0, 1, 'L', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
$greeting = $this->customer->getRaisonSociale()
? 'Chez '.$this->customer->getRaisonSociale()
: $this->customer->getFirstName() ?? $this->customer->getFullName();
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->enc($greeting.','), 0, 'L');
$this->Ln(3);
$this->MultiCell(0, 5, $this->enc('Nous vous confirmons que votre situation a ete regularisee. Les avertissements precedemment emis ont ete leves.'), 0, 'L');
$this->Ln(3);
$this->MultiCell(0, 5, $this->enc('Vos services restent actifs et votre compte est en regle. Nous vous remercions pour votre regularisation.'), 0, 'L');
$this->Ln(5);
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->MultiCell(0, 4, $this->enc(
'Le present document annule et remplace tout avertissement precedemment emis. '
.'Toutes les decisions sont prises par le bureau de l\'association a huis clos. '
.'Toute contestation devra etre adressee a direction@e-cosplay.fr'
), 0, 'L');
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeSignature(): void
{
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 5, $this->enc('Pour le bureau de l\'Association E-Cosplay :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Service\Pdf;
use App\Entity\EFlex;
use App\Entity\EFlexLine;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128)); // @codeCoverageIgnore
}
class EFlexPdf extends Fpdi
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly EFlex $eflex,
) {
parent::__construct();
$this->SetTitle($this->enc('E-Flex '.$this->eflex->getReference().' - '.$this->eflex->getCustomer()->getFullName()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeContextBlock();
$this->writeEcheancesTable();
$this->writeConditions();
$this->writeSignatures();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('CONTRAT E-FLEX'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
$this->SetXY(60, 18);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 5, $this->enc('Ref. : '.$this->eflex->getReference()), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 24);
$this->Cell(0, 5, $this->enc('Emis a Beautor, le '.$formatter->format($this->eflex->getCreatedAt())), 0, 1, 'L');
// Client
$customer = $this->eflex->getCustomer();
$y = 35;
$this->SetFont('Arial', 'B', 11);
$this->SetXY(120, $y);
$name = $customer->getRaisonSociale() ?: $customer->getFullName();
$this->Cell(0, 5, $this->enc($name), 0, 1, 'L');
if ($address = $customer->getAddress()) {
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc($address), 0, 1, 'L');
}
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? '');
$this->Cell(0, 5, $this->enc(trim($cityLine)), 0, 1, 'L');
if ($customer->getEmail()) {
$y += 5;
$this->SetXY(120, $y);
$this->Cell(0, 5, $this->enc($customer->getEmail()), 0, 1, 'L');
}
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeContextBlock(): void
{
$this->SetY(60);
$this->SetFillColor(250, 191, 4);
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 8, $this->enc(' E-FLEX - FINANCEMENT DE SERVICES'), 0, 1, 'L', true);
$this->Ln(5);
$labelW = 55;
$this->SetFont('Arial', 'B', 9);
$this->Cell($labelW, 6, $this->enc('Description :'), 0, 0, 'L');
$this->SetFont('Arial', '', 9);
$this->MultiCell(0, 6, $this->enc($this->eflex->getDescription()), 0, 'L');
$this->Ln(1);
$this->SetFont('Arial', 'B', 9);
$this->Cell($labelW, 6, $this->enc('Montant total :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, number_format((float) $this->eflex->getTotalAmount(), 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetFont('Arial', 'B', 9);
$this->Cell($labelW, 6, $this->enc('Nombre d\'echeances :'), 0, 0, 'L');
$this->SetFont('Arial', '', 9);
$this->Cell(0, 6, (string) $this->eflex->getNbLines().' mois', 0, 1, 'L');
$this->SetFont('Arial', 'B', 9);
$this->Cell($labelW, 6, $this->enc('Mensualite :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->SetTextColor(250, 191, 4);
$this->Cell(0, 6, number_format($this->eflex->getMonthlyAmount(), 2, ',', ' ').' '.EURO.'/mois', 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', 'B', 9);
$this->Cell($labelW, 6, $this->enc('Methode de paiement :'), 0, 0, 'L');
$this->SetFont('Arial', '', 9);
$this->Cell(0, 6, $this->enc($this->eflex->getPaymentMethodLabel()), 0, 1, 'L');
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeEcheancesTable(): void
{
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
$this->Cell(15, 7, $this->enc('N'), 1, 0, 'C', true);
$this->Cell(60, 7, $this->enc('Date prevue'), 1, 0, 'C', true);
$this->Cell(40, 7, $this->enc('Montant'), 1, 0, 'C', true);
$this->Cell(55, 7, $this->enc('Statut'), 1, 1, 'C', true);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 9);
$fill = false;
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
foreach ($this->eflex->getLines() as $line) {
$this->SetFillColor(245, 245, 240);
$monthName = $months[(int) $line->getScheduledAt()->format('n')] ?? '';
$dateLabel = $line->getScheduledAt()->format('d').' '.$monthName.' '.$line->getScheduledAt()->format('Y');
$this->Cell(15, 6, (string) $line->getPosition(), 'B', 0, 'C', $fill);
$this->Cell(60, 6, $this->enc($dateLabel), 'B', 0, 'L', $fill);
$this->Cell(40, 6, number_format((float) $line->getAmount(), 2, ',', ' ').' '.EURO, 'B', 0, 'R', $fill);
$this->Cell(55, 6, $this->enc('A prelever'), 'B', 1, 'C', $fill);
$fill = !$fill;
}
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeConditions(): void
{
$this->SetFont('Arial', 'B', 9);
$this->Cell(0, 6, $this->enc('CONDITIONS'), 0, 1, 'L');
$this->SetFont('Arial', '', 8);
$this->SetTextColor(100, 100, 100);
$conditions = [
'1. Le financement E-Flex permet d\'etaler le paiement de vos services sur plusieurs mois sans frais supplementaires.',
'2. Les prelevements seront effectues automatiquement a chaque date prevue selon la methode choisie.',
'3. En cas d\'echec de prelevement, une relance sera envoyee par email.',
'4. Apres 2 echecs consecutifs, le contrat E-Flex pourra etre resilie.',
'5. Les services finances restent actifs pendant toute la duree du financement.',
];
foreach ($conditions as $condition) {
$this->MultiCell(0, 4, $this->enc($condition), 0, 'L');
$this->Ln(1);
}
$this->SetTextColor(0, 0, 0);
$this->Ln(3);
}
/** @codeCoverageIgnore */
private function writeSignatures(): void
{
if ($this->GetY() + 40 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$colWidth = 85;
$this->SetFont('Arial', 'B', 9);
$this->Cell($colWidth, 5, $this->enc('Pour Association E-Cosplay :'), 0, 0, 'L');
$this->Cell(10, 5, '', 0, 0);
$this->Cell($colWidth, 5, $this->enc('Le client :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell($colWidth, 20, '{{Sign;type=signature;role=Company}}', 0, 0, 'L');
$this->Cell(10, 20, '', 0, 0);
$this->Cell($colWidth, 20, '{{SignClient;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(5);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -0,0 +1,369 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use setasign\Fpdi\Fpdi;
use Symfony\Component\HttpKernel\KernelInterface;
if (!\defined('EURO')) {
\define('EURO', \chr(128)); // @codeCoverageIgnore
}
class EcheancierAttestationPdf extends Fpdi
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly Echeancier $echeancier,
) {
parent::__construct();
$this->SetTitle($this->enc('Attestation echeancier '.$this->echeancier->getReference()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
public function generate(): void
{
$this->AliasNbPages();
$this->AddPage();
$this->writeHeader();
$this->writeStatusBlock();
$this->writeFinancialSummary();
$this->writeEcheancesTable();
$this->writeSepaInfo();
$this->writeFooterAttestation();
}
/** @codeCoverageIgnore */
public function Header(): void
{
}
/** @codeCoverageIgnore */
public function Footer(): void
{
$this->SetY(-22);
$this->SetDrawColor(253, 140, 4);
$this->Line(15, $this->GetY(), 195, $this->GetY());
$this->Ln(3);
$this->SetFont('Arial', '', 7);
$this->SetTextColor(0, 0, 0);
$this->Cell(190, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
$this->Cell(190, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(190, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
}
/** @codeCoverageIgnore */
private function writeHeader(): void
{
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
if (file_exists($logo)) {
$this->Image($logo, 10, 8, 45);
}
$this->SetFont('Arial', 'B', 16);
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('ATTESTATION'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
$this->SetXY(60, 18);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 5, $this->enc('Ref. : '.$this->echeancier->getReference()), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 24);
$this->Cell(0, 5, $this->enc('Emise a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
// Client
$customer = $this->echeancier->getCustomer();
$y = 35;
$this->SetFont('Arial', 'B', 11);
$this->SetXY(120, $y);
$name = $customer->getRaisonSociale() ?: $customer->getFullName();
$this->Cell(0, 5, $this->enc($name), 0, 1, 'L');
if ($address = $customer->getAddress()) {
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->enc($address), 0, 1, 'L');
}
$y += 5;
$this->SetXY(120, $y);
$this->SetFont('Arial', '', 10);
$cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? '');
$this->Cell(0, 5, $this->enc(trim($cityLine)), 0, 1, 'L');
if ($customer->getEmail()) {
$y += 5;
$this->SetXY(120, $y);
$this->Cell(0, 5, $this->enc($customer->getEmail()), 0, 1, 'L');
}
$this->Ln(10);
}
/** @codeCoverageIgnore */
private function writeStatusBlock(): void
{
$this->SetY(65);
$state = $this->echeancier->getState();
$stateLabel = match ($state) {
Echeancier::STATE_COMPLETED => 'TERMINE',
Echeancier::STATE_ACTIVE => 'EN COURS',
Echeancier::STATE_CANCELLED => 'ANNULE',
Echeancier::STATE_PENDING_SETUP => 'EN ATTENTE SEPA',
Echeancier::STATE_SIGNED => 'SIGNE',
default => mb_strtoupper($state),
};
// Bandeau statut
if (Echeancier::STATE_COMPLETED === $state) {
$this->SetFillColor(22, 163, 74);
} elseif (Echeancier::STATE_CANCELLED === $state) {
$this->SetFillColor(220, 38, 38);
} else {
$this->SetFillColor(37, 99, 235);
}
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 10, $this->enc(' STATUT : '.$stateLabel), 0, 1, 'L', true);
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
// Motif
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('OBJET'), 0, 1, 'L');
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->enc($this->echeancier->getDescription()), 0, 'L');
$this->Ln(3);
}
/** @codeCoverageIgnore */
private function writeFinancialSummary(): void
{
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('RESUME FINANCIER'), 0, 1, 'L');
$this->Ln(2);
$labelW = 60;
$remaining = $this->echeancier->getTotalWithMajoration() - $this->echeancier->getTotalPaid();
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Creance initiale :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 6, number_format((float) $this->echeancier->getTotalAmountHt(), 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Majoration (5%) :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(220, 38, 38);
$this->Cell(0, 6, '+ '.number_format($this->echeancier->getMajoration(), 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Total a payer :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, number_format($this->echeancier->getTotalWithMajoration(), 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Total paye :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
$this->SetTextColor(22, 163, 74);
$this->Cell(0, 6, number_format($this->echeancier->getTotalPaid(), 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Restant du :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 11);
if ($remaining > 0) {
$this->SetTextColor(220, 38, 38);
} else {
$this->SetTextColor(22, 163, 74);
}
$this->Cell(0, 6, number_format($remaining, 2, ',', ' ').' '.EURO, 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Progression :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 6, $this->echeancier->getNbPaid().'/'.$this->echeancier->getNbLines().' echeances ('.$this->echeancier->getProgress().'%)', 0, 1, 'L');
$this->Ln(2);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(3);
}
/** @codeCoverageIgnore */
private function writeEcheancesTable(): void
{
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('DETAIL DES ECHEANCES'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
$this->Cell(15, 7, $this->enc('N'), 1, 0, 'C', true);
$this->Cell(40, 7, $this->enc('Date prevue'), 1, 0, 'C', true);
$this->Cell(35, 7, $this->enc('Montant'), 1, 0, 'C', true);
$this->Cell(30, 7, $this->enc('Statut'), 1, 0, 'C', true);
$this->Cell(50, 7, $this->enc('Paye le'), 1, 1, 'C', true);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 9);
$fill = false;
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
foreach ($this->echeancier->getLines() as $line) {
$this->SetFillColor(245, 245, 240);
$monthName = $months[(int) $line->getScheduledAt()->format('n')] ?? '';
$dateLabel = $line->getScheduledAt()->format('d').' '.$monthName.' '.$line->getScheduledAt()->format('Y');
$statusLabel = match ($line->getState()) {
EcheancierLine::STATE_OK => 'Paye',
EcheancierLine::STATE_KO => 'Echoue',
default => 'En attente',
};
$paidDate = null !== $line->getPaidAt() ? $line->getPaidAt()->format('d/m/Y H:i') : '-';
$this->Cell(15, 6, (string) $line->getPosition(), 'B', 0, 'C', $fill);
$this->Cell(40, 6, $this->enc($dateLabel), 'B', 0, 'L', $fill);
$this->Cell(35, 6, number_format((float) $line->getAmount(), 2, ',', ' ').' '.EURO, 'B', 0, 'R', $fill);
if (EcheancierLine::STATE_OK === $line->getState()) {
$this->SetTextColor(22, 163, 74);
} elseif (EcheancierLine::STATE_KO === $line->getState()) {
$this->SetTextColor(220, 38, 38);
} else {
$this->SetTextColor(180, 130, 0);
}
$this->Cell(30, 6, $this->enc($statusLabel), 'B', 0, 'C', $fill);
$this->SetTextColor(0, 0, 0);
$this->Cell(50, 6, $this->enc($paidDate), 'B', 1, 'C', $fill);
$fill = !$fill;
}
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeSepaInfo(): void
{
if (null === $this->echeancier->getStripeSepaLast4()) {
return;
}
$this->SetFont('Arial', 'B', 11);
$this->Cell(0, 6, $this->enc('INFORMATIONS SEPA'), 0, 1, 'L');
$this->Ln(2);
$labelW = 50;
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, 'IBAN :', 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 6, '**** **** **** '.$this->echeancier->getStripeSepaLast4(), 0, 1, 'L');
if (null !== $this->echeancier->getStripeSepaBankName()) {
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, $this->enc('Code banque :'), 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 6, $this->echeancier->getStripeSepaBankName(), 0, 1, 'L');
}
if (null !== $this->echeancier->getStripeSepaCountry()) {
$this->SetFont('Arial', '', 10);
$this->Cell($labelW, 6, 'Pays :', 0, 0, 'L');
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 6, $this->echeancier->getStripeSepaCountry(), 0, 1, 'L');
}
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeFooterAttestation(): void
{
if ($this->GetY() + 30 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$this->SetDrawColor(200, 200, 200);
$this->Cell(0, 0.5, '', 'T', 1, 'L');
$this->Ln(5);
$this->SetFont('Arial', 'I', 9);
$this->SetTextColor(100, 100, 100);
$completed = Echeancier::STATE_COMPLETED === $this->echeancier->getState();
if ($completed) {
$this->MultiCell(0, 5, $this->enc(
'La presente attestation certifie que l\'integralite des echeances de l\'echeancier '
.$this->echeancier->getReference().' a ete reglee. Le debiteur est libere de toute obligation '
.'de paiement au titre de cet echeancier.'
), 0, 'L');
} else {
$remaining = $this->echeancier->getTotalWithMajoration() - $this->echeancier->getTotalPaid();
$this->MultiCell(0, 5, $this->enc(
'La presente attestation est un document de situation de l\'echeancier '
.$this->echeancier->getReference().'. Le solde restant du est de '
.number_format($remaining, 2, ',', ' ').' EUR.'
), 0, 'L');
}
$this->SetTextColor(0, 0, 0);
$this->Ln(5);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::LONG,
\IntlDateFormatter::NONE,
'Europe/Paris',
\IntlDateFormatter::GREGORIAN
);
$this->SetFont('Arial', '', 9);
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
$this->Ln(5);
$this->SetFont('Arial', 'B', 9);
$this->Cell(85, 5, $this->enc('Pour Association E-Cosplay :'), 0, 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
$this->Ln(5);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 4, $this->enc('Signature electronique via DocuSeal - Valeur juridique (reglement eIDAS, art. 1367 Code civil)'), 0, 1, 'C');
$this->SetTextColor(0, 0, 0);
}
private function enc(string $text): string
{
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
}
}

View File

@@ -17,7 +17,7 @@ class EcheancierPdf extends Fpdi
private readonly Echeancier $echeancier,
) {
parent::__construct();
$this->SetTitle($this->enc('Echeancier de paiement - '.$this->echeancier->getCustomer()->getFullName()));
$this->SetTitle($this->enc('Echeancier '.$this->echeancier->getReference().' - '.$this->echeancier->getCustomer()->getFullName()));
$this->SetAuthor($this->enc('Association E-Cosplay'));
}
@@ -66,6 +66,12 @@ class EcheancierPdf extends Fpdi
$this->SetXY(60, 10);
$this->Cell(0, 8, $this->enc('ECHEANCIER DE PAIEMENT'), 0, 1, 'L');
$this->SetFont('Arial', '', 9);
$this->SetXY(60, 18);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 5, $this->enc('Ref. : '.$this->echeancier->getReference()), 0, 1, 'L');
$this->SetTextColor(0, 0, 0);
$formatter = new \IntlDateFormatter(
'fr_FR',
\IntlDateFormatter::FULL,
@@ -75,7 +81,7 @@ class EcheancierPdf extends Fpdi
);
$this->SetFont('Arial', '', 10);
$this->SetXY(60, 19);
$this->SetXY(60, 24);
$this->Cell(0, 5, $this->enc('Emis a Beautor, le '.$formatter->format($this->echeancier->getCreatedAt())), 0, 1, 'L');
// Client

View File

@@ -97,6 +97,10 @@
<svg class="w-4 h-4 flex-shrink-0" 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>
Tarification
</a>
<a href="{{ path('app_admin_attestation_custom_index') }}" class="sidebar-nav-item {{ current_route starts with 'app_admin_attestation_custom' ? 'active-danger' : '' }}" style="color: {{ current_route starts with 'app_admin_attestation_custom' ? 'white' : 'rgba(248,113,113,0.7)' }}">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Attestations
</a>
</div>
{% endif %}
</nav>

View File

@@ -0,0 +1,121 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Attestations - Association E-Cosplay{% endblock %}
{% block admin_content %}
<div class="page-container">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold heading-page">Attestations</h1>
<button type="button" data-modal-open="modal-attestation" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer une attestation</button>
</div>
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="mb-4 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}">{{ message }}</div>
{% endfor %}
{% endfor %}
{% if attestations|length > 0 %}
<div class="glass overflow-x-auto overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Reference</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Titre</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Elements</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for att in attestations %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold text-[10px]">{{ att.reference }}</td>
<td class="px-4 py-3 font-bold text-xs">{{ att.title|length > 50 ? att.title[:50] ~ '...' : att.title }}</td>
<td class="px-4 py-3 text-center text-xs">{{ att.items|length }}</td>
<td class="px-4 py-3 text-center">
{% if att.state == 'signed' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Signee</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Brouillon</span>
{% endif %}
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ att.createdAt|date('d/m/Y H:i') }}</td>
<td class="px-4 py-3 text-center">
<a href="{{ path('app_admin_attestation_custom_show', {id: att.id}) }}" class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucune attestation.</div>
{% endif %}
{# Modal creation #}
<div id="modal-attestation" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="glass-heavy p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold uppercase mb-4">Nouvelle attestation</h2>
<form method="post" action="{{ path('app_admin_attestation_custom_create') }}">
<div class="mb-4">
<label for="att-title" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Titre de l'attestation *</label>
<input type="text" id="att-title" name="title" required class="input-glass w-full px-3 py-2 text-xs font-bold" placeholder="Ex: Attestation de conformite des services">
</div>
<div class="mb-4">
<label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Elements a attester *</label>
<div id="items-container" class="space-y-2">
<div class="flex gap-2">
<input type="text" name="items[]" required class="input-glass flex-1 px-3 py-2 text-xs font-bold" placeholder="Element 1">
<button type="button" class="remove-item px-2 py-1 bg-red-500/20 text-red-700 font-bold text-xs hidden">X</button>
</div>
</div>
<button type="button" id="add-item-btn" class="mt-2 px-3 py-1 glass text-xs font-bold uppercase tracking-wider hover:bg-gray-900 hover:text-white transition-all">+ Ajouter un element</button>
</div>
<div class="glass p-3 mb-4 text-[10px] text-gray-400 leading-relaxed">
<strong>Apercu :</strong> "Je soussigne(e), President(e) de l'Association E-Cosplay et le bureau de l'association, atteste les elements suivants : [vos elements]. La presente attestation est etablie pour faire valoir les droits de l'association. Les elements presentes sont conformes et veridiques."
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-attestation" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button>
</div>
</form>
</div>
</div>
</div>
<script nonce="{{ csp_nonce('script') }}">
(function() {
var container = document.getElementById('items-container');
var addBtn = document.getElementById('add-item-btn');
var count = 1;
function updateRemoveButtons() {
var items = container.querySelectorAll('.flex');
items.forEach(function(item) {
var btn = item.querySelector('.remove-item');
if (btn) btn.classList.toggle('hidden', items.length <= 1);
});
}
addBtn.addEventListener('click', function() {
count++;
var div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = '<input type="text" name="items[]" required class="input-glass flex-1 px-3 py-2 text-xs font-bold" placeholder="Element ' + count + '">'
+ '<button type="button" class="remove-item px-2 py-1 bg-red-500/20 text-red-700 font-bold text-xs">X</button>';
container.appendChild(div);
div.querySelector('.remove-item').addEventListener('click', function() { div.remove(); updateRemoveButtons(); });
updateRemoveButtons();
});
container.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-item')) { e.target.parentElement.remove(); updateRemoveButtons(); }
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,107 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Attestation {{ attestation.reference }} - Association E-Cosplay{% endblock %}
{% block admin_content %}
<div class="page-container">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold heading-page">{{ attestation.reference }}</h1>
<p class="text-xs text-gray-400 mt-1">{{ attestation.title }}</p>
</div>
<div class="flex items-center gap-3">
{% if attestation.state == 'signed' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Signee</span>
{% else %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs">Brouillon</span>
{% endif %}
<a href="{{ path('app_admin_attestation_custom_index') }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
</div>
</div>
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="mb-4 p-4 glass font-medium text-sm {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}">{{ message }}</div>
{% endfor %}
{% endfor %}
{# Contenu de l'attestation #}
<div class="glass p-6 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">{{ attestation.title }}</h2>
<p class="text-sm text-gray-600 mb-4 italic">
"Je soussigne(e), President(e) de l'Association E-Cosplay et le bureau de l'association, atteste les elements suivants :"
</p>
<div class="space-y-2 mb-4">
{% for item in attestation.items %}
<div class="flex items-start gap-3 glass p-3">
<span class="text-xs font-bold text-gray-400 mt-0.5">{{ loop.index }}.</span>
<p class="text-sm text-gray-700">{{ item }}</p>
</div>
{% endfor %}
</div>
<p class="text-sm text-gray-600 italic">
"La presente attestation est etablie pour faire valoir les droits de l'Association E-Cosplay. Les elements presentes ci-dessus sont conformes et veridiques."
</p>
</div>
{# Infos techniques #}
<div class="glass p-5 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Reference</p>
<p class="font-mono font-bold mt-1">{{ attestation.reference }}</p>
</div>
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">HMAC</p>
<p class="font-mono font-bold mt-1 text-[10px]">{{ attestation.hmac[:16] }}...</p>
</div>
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Creee le</p>
<p class="font-bold mt-1">{{ attestation.createdAt|date('d/m/Y H:i') }}</p>
</div>
{% if attestation.signedAt %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Signee le</p>
<p class="font-bold mt-1 text-green-600">{{ attestation.signedAt|date('d/m/Y H:i') }}</p>
</div>
{% endif %}
</div>
</div>
{# Actions #}
<div class="flex flex-wrap gap-2 mb-6">
{% if attestation.pdfUnsigned %}
<a href="{{ vich_uploader_asset(attestation, 'pdfUnsignedFile') }}" target="_blank"
class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">
Voir PDF
</a>
{% endif %}
{% if attestation.state == 'draft' %}
<form method="post" action="{{ path('app_admin_attestation_custom_regenerate_pdf', {id: attestation.id}) }}" data-confirm="Regenerer le PDF ?">
<button type="submit" class="px-4 py-2 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Regenerer PDF</button>
</form>
<form method="post" action="{{ path('app_admin_attestation_custom_sign', {id: attestation.id}) }}" data-confirm="Signer electroniquement cette attestation via DocuSeal ?">
<button type="submit" class="px-4 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Signer (DocuSeal)</button>
</form>
{% endif %}
{% if attestation.pdfSigned %}
<a href="{{ vich_uploader_asset(attestation, 'pdfSignedFile') }}" target="_blank"
class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] tracking-wider hover:bg-green-500 hover:text-white transition-all">
PDF signe
</a>
{% endif %}
{% if attestation.pdfAudit %}
<a href="{{ vich_uploader_asset(attestation, 'pdfAuditFile') }}" target="_blank"
class="px-4 py-2 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] tracking-wider hover:bg-blue-500 hover:text-white transition-all">
Audit signature
</a>
{% endif %}
<form method="post" action="{{ path('app_admin_attestation_custom_delete', {id: attestation.id}) }}" data-confirm="Supprimer definitivement cette attestation ?">
<button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Supprimer</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -12,8 +12,17 @@
{% endif %}
</div>
<div class="flex items-center gap-3">
{% if trustStatus.status == 'confiant' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs" title="{{ trustStatus.reason }}">Confiant</span>
{% elseif trustStatus.status == 'attention' %}
<span class="px-3 py-1 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-xs" title="{{ trustStatus.reason }}">Attention</span>
{% elseif trustStatus.status == 'danger' %}
<span class="px-3 py-1 bg-red-600 text-white font-bold uppercase text-xs animate-pulse" title="{{ trustStatus.reason }}">Danger</span>
{% endif %}
{% if customer.state == 'active' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span>
{% elseif customer.state == 'suspended' %}
<span class="px-3 py-1 bg-red-900 text-white font-bold uppercase text-xs animate-pulse">Suspendu</span>
{% elseif customer.state == 'pending_delete' %}
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs animate-pulse">Suppression</span>
{% else %}
@@ -41,11 +50,14 @@
'impayes': 'Impayes',
'echeancier': 'Echeancier',
'ndd': 'Noms de domaine',
'esyflex': 'EsyFlex',
'esyflex': 'E-Flex',
'sites': 'Sites Internet',
'services': 'Services',
'securite': 'Securite'
} %}
{% if is_granted('ROLE_ROOT') %}
{% set tabs = tabs|merge({'controle': 'Controle'}) %}
{% endif %}
<div class="flex flex-wrap gap-1 mb-6">
{% for key, label in tabs %}
@@ -543,6 +555,11 @@
<form method="post" action="{{ path('app_admin_facture_generate_pdf', {id: f.id}) }}" class="inline">
<button type="submit" class="px-3 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[10px]transition-all">Generer PDF</button>
</form>
{% if f.state == 'paid' and f.state != 'send' %}
<form method="post" action="{{ path('app_admin_facture_send', {id: f.id}) }}" class="inline" data-confirm="Generer et envoyer la facture {{ f.invoiceNumber }} au client ?">
<button type="submit" class="px-3 py-1 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px]transition-all">Envoyer</button>
</form>
{% endif %}
{% endif %}
</div>
{% else %}
@@ -726,7 +743,7 @@
<select id="mp-echeancier-{{ a.id }}" name="echeancierId" class="input-glass w-full px-3 py-2 text-xs font-bold">
<option value="">— Selectionner —</option>
{% for ech in echeancierList %}
<option value="{{ ech.id }}">{{ ech.description|length > 40 ? ech.description[:40] ~ '...' : ech.description }} ({{ ech.nbPaid }}/{{ ech.nbLines }} - {{ ech.totalAmountHt }} &euro;)</option>
<option value="{{ ech.id }}">{{ ech.reference }} - {{ ech.description|length > 30 ? ech.description[:30] ~ '...' : ech.description }} ({{ ech.totalAmountHt }} &euro;)</option>
{% endfor %}
</select>
</div>
@@ -887,8 +904,22 @@
{# Tab: Echeancier #}
{% elseif tab == 'echeancier' %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold uppercase">Echeanciers</h2>
<button type="button" data-modal-open="modal-echeancier" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer un echeancier</button>
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold uppercase">Echeanciers</h2>
{% if trustStatus.status == 'confiant' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Confiant</span>
{% elseif trustStatus.status == 'attention' %}
<span class="px-2 py-0.5 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-[10px]">Attention</span>
{% elseif trustStatus.status == 'danger' %}
<span class="px-2 py-0.5 bg-red-600 text-white font-bold uppercase text-[10px] animate-pulse">Danger</span>
{% endif %}
<span class="text-[10px] text-gray-400">{{ trustStatus.reason }}</span>
</div>
{% if trustStatus.status == 'danger' %}
<span class="px-4 py-2 bg-red-500/20 text-red-700 font-bold uppercase text-[10px] tracking-wider cursor-not-allowed" title="Creation bloquee : {{ trustStatus.reason }}">Creation bloquee</span>
{% else %}
<button type="button" data-modal-open="modal-echeancier" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer un echeancier</button>
{% endif %}
</div>
{# Liste des echeanciers existants #}
@@ -897,6 +928,7 @@
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Reference</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Description</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total HT</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Echeances</th>
@@ -908,6 +940,7 @@
<tbody>
{% for e in echeancierList %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold text-[10px]">{{ e.reference }}</td>
<td class="px-4 py-3 font-bold text-xs">{{ e.description|length > 50 ? e.description[:50] ~ '...' : e.description }}</td>
<td class="px-4 py-3 text-right font-bold text-xs">{{ e.totalAmountHt|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-3 text-center text-xs">{{ e.nbPaid }}/{{ e.nbLines }}</td>
@@ -963,6 +996,15 @@
<label for="ech-startDate" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Date 1ere echeance *</label>
<input type="date" id="ech-startDate" name="startDate" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div class="md:col-span-2">
<label for="ech-advertId" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Avis de paiement lie (optionnel)</label>
<select id="ech-advertId" name="advertId" class="input-glass w-full px-3 py-2 text-xs font-bold">
<option value="">— Aucun —</option>
{% for a in advertsList %}
<option value="{{ a.id }}">{{ a.orderNumber.numOrder }} ({{ a.totalTtc }} &euro;)</option>
{% endfor %}
</select>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-echeancier" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
@@ -1041,6 +1083,228 @@
</section>
{% endif %}
{# Tab: Controle (ROLE_ROOT uniquement) #}
{% elseif tab == 'controle' and is_granted('ROLE_ROOT') %}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold uppercase">Controle client</h2>
{% if trustStatus.status == 'confiant' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Confiant</span>
{% elseif trustStatus.status == 'attention' %}
<span class="px-2 py-0.5 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-[10px]">Attention</span>
{% elseif trustStatus.status == 'danger' %}
<span class="px-2 py-0.5 bg-red-600 text-white font-bold uppercase text-[10px] animate-pulse">Danger</span>
{% endif %}
</div>
</div>
{# Resume confiance #}
<div class="glass p-5 mb-6">
<h3 class="text-sm font-bold uppercase tracking-wider mb-2">Statut de confiance</h3>
<p class="text-sm text-gray-600">{{ trustStatus.reason }}</p>
</div>
{# Historique avertissements #}
<div class="glass p-5 mb-6">
<h3 class="text-sm font-bold uppercase tracking-wider mb-3">Avertissements</h3>
{% if customer.warningLevel %}
<div class="flex items-center gap-3 mb-4">
<span class="text-xs text-gray-500">Dernier avertissement :</span>
{% if customer.warningLevel == '1st' %}
<span class="px-3 py-1 bg-yellow-500/20 text-yellow-700 font-bold uppercase text-xs">1er avertissement</span>
{% elseif customer.warningLevel == '2nd' %}
<span class="px-3 py-1 bg-orange-500/20 text-orange-700 font-bold uppercase text-xs">2eme avertissement</span>
{% elseif customer.warningLevel == 'last' %}
<span class="px-3 py-1 bg-red-600 text-white font-bold uppercase text-xs animate-pulse">Dernier avertissement</span>
{% endif %}
{% if customer.warningAt %}
<span class="text-[10px] text-gray-400">envoye le {{ customer.warningAt|date('d/m/Y H:i') }}</span>
{% endif %}
</div>
{% else %}
<p class="text-xs text-gray-400 mb-4">Aucun avertissement envoye.</p>
{% endif %}
{# Motifs + Boutons d'envoi #}
{% set nextWarning = customer.warningLevel is null ? '1st' : (customer.warningLevel == '1st' ? '2nd' : 'last') %}
{% set warningLabels = {'1st': '1er avertissement', '2nd': '2eme avertissement', 'last': 'Dernier avertissement (suspension)'} %}
{% if customer.warningLevel != 'last' %}
<form method="post" action="{{ path('app_admin_clients_send_warning', {id: customer.id, level: nextWarning}) }}"
data-confirm="Envoyer le {{ warningLabels[nextWarning] }} au client {{ customer.fullName }} ?">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Motifs de l'avertissement</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" name="reasons[]" value="impayes" checked class="accent-yellow-500">
Impayes et/ou rejets de prelevement
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" name="reasons[]" value="irrespect" class="accent-yellow-500">
Manque de respect / insultes
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" name="reasons[]" value="hors_horaires" class="accent-yellow-500">
Appels hors des heures d'ouverture (refus de payer le service hors horaire)
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" name="reasons[]" value="gratuit" class="accent-yellow-500">
Exige des services gratuits
</label>
</div>
<div class="flex flex-wrap gap-2">
<button type="submit" class="px-4 py-2 {{ nextWarning == 'last' ? 'bg-red-600 text-white hover:bg-red-700' : (nextWarning == '2nd' ? 'bg-orange-500/20 text-orange-700 hover:bg-orange-500 hover:text-white' : 'bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white') }} font-bold uppercase text-[10px] tracking-wider transition-all">
Envoyer {{ warningLabels[nextWarning] }}
</button>
</div>
</form>
{% else %}
<p class="text-xs text-red-600 font-bold mb-4">Tous les avertissements ont ete envoyes.</p>
<div class="flex flex-wrap gap-3 mb-4">
{# Bouton notification (faire peur - n'effectue pas la suppression) #}
<form method="post" action="{{ path('app_admin_clients_close_account', {id: customer.id}) }}"
data-confirm="Envoyer la notification de cloture au client {{ customer.fullName }} ? (Le compte ne sera PAS supprime, c'est uniquement un courrier officiel)">
<button type="submit" class="px-5 py-3 bg-orange-600 text-white hover:bg-orange-800 font-bold uppercase text-[10px] tracking-wider transition-all">
Envoyer notification de cloture (avertissement)
</button>
</form>
{# Bouton reel de suspension #}
<form method="post" action="{{ path('app_admin_clients_suspend_account', {id: customer.id}) }}"
data-confirm="ATTENTION IRREVERSIBLE : Suspendre le compte de {{ customer.fullName }} ? Le statut passera en SUSPENDU.">
<button type="submit" class="px-5 py-3 bg-red-800 text-white hover:bg-red-950 font-bold uppercase text-[10px] tracking-wider transition-all animate-pulse">
Suspendre le compte
</button>
</form>
</div>
{% endif %}
{% if customer.warningLevel %}
<form method="post" action="{{ path('app_admin_clients_reset_warning', {id: customer.id}) }}" class="mt-3"
data-confirm="Reinitialiser les avertissements du client {{ customer.fullName }} ?">
<button type="submit" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-wider hover:bg-gray-900 hover:text-white transition-all">Reinitialiser</button>
</form>
{% endif %}
</div>
{# Progression des avertissements #}
<div class="glass p-5 mb-6">
<h3 class="text-sm font-bold uppercase tracking-wider mb-3">Progression</h3>
<div class="flex items-center gap-2">
<div class="flex-1 h-3 {{ customer.warningLevel in ['1st', '2nd', 'last'] ? 'bg-yellow-500' : 'bg-gray-200' }}"></div>
<div class="flex-1 h-3 {{ customer.warningLevel in ['2nd', 'last'] ? 'bg-orange-500' : 'bg-gray-200' }}"></div>
<div class="flex-1 h-3 {{ customer.warningLevel == 'last' ? 'bg-red-600' : 'bg-gray-200' }}"></div>
<div class="flex-1 h-3 {{ customer.state == 'pending_delete' ? 'bg-red-900' : 'bg-gray-200' }}"></div>
</div>
<div class="flex justify-between mt-1">
<span class="text-[9px] text-gray-400 font-bold uppercase">1er avert.</span>
<span class="text-[9px] text-gray-400 font-bold uppercase">2eme avert.</span>
<span class="text-[9px] text-gray-400 font-bold uppercase">Dernier avert.</span>
<span class="text-[9px] text-gray-400 font-bold uppercase">Cloture</span>
</div>
</div>
{# Tab: E-Flex #}
{% elseif tab == 'esyflex' %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold uppercase">E-Flex</h2>
<button type="button" data-modal-open="modal-eflex" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer un E-Flex</button>
</div>
{% if eflexList|length > 0 %}
<div class="glass overflow-x-auto overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Reference</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Description</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Echeances</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Methode</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Progression</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for e in eflexList %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold text-[10px]">{{ e.reference }}</td>
<td class="px-4 py-3 font-bold text-xs">{{ e.description|length > 40 ? e.description[:40] ~ '...' : e.description }}</td>
<td class="px-4 py-3 text-right font-bold text-xs">{{ e.totalAmount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-3 text-center text-xs">{{ e.nbPaid }}/{{ e.nbLines }}</td>
<td class="px-4 py-3 text-center text-xs">{{ e.paymentMethodLabel }}</td>
<td class="px-4 py-3 text-center">
<div class="w-full bg-gray-200 h-2"><div class="bg-green-500 h-2" style="width: {{ e.progress }}%"></div></div>
</td>
<td class="px-4 py-3 text-center">
{% if e.state == 'active' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span>
{% elseif e.state == 'completed' %}
<span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-[10px]">Termine</span>
{% elseif e.state == 'cancelled' %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Annule</span>
{% elseif e.state == 'draft' %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Brouillon</span>
{% elseif e.state == 'pending_setup' %}
<span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px]">En attente</span>
{% else %}
<span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">{{ e.state }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
<a href="{{ path('app_admin_eflex_show', {id: e.id}) }}" class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contrat E-Flex pour ce client.</div>
{% endif %}
{# Modal creation E-Flex #}
<div id="modal-eflex" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="glass-heavy p-6 w-full max-w-lg">
<h2 class="text-lg font-bold uppercase mb-4">Nouveau contrat E-Flex</h2>
<form method="post" action="{{ path('app_admin_eflex_create', {customerId: customer.id}) }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="md:col-span-2">
<label for="eflex-description" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Description / Service *</label>
<textarea id="eflex-description" name="description" required rows="2" class="input-glass w-full px-3 py-2 text-xs font-bold" placeholder="Ex: Financement site internet + hebergement 12 mois"></textarea>
</div>
<div>
<label for="eflex-totalAmount" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Montant total *</label>
<input type="number" id="eflex-totalAmount" name="totalAmount" step="0.01" min="1" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div>
<label for="eflex-nbEcheances" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Nombre d'echeances *</label>
<input type="number" id="eflex-nbEcheances" name="nbEcheances" min="2" max="36" value="3" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div>
<label for="eflex-startDate" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Date 1ere echeance *</label>
<input type="date" id="eflex-startDate" name="startDate" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div>
<label for="eflex-paymentMethod" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Methode de paiement</label>
<select id="eflex-paymentMethod" name="paymentMethod" class="input-glass w-full px-3 py-2 text-xs font-bold">
<option value="sepa">Prelevement SEPA</option>
<option value="cb">Carte bancaire</option>
<option value="virement">Virement bancaire</option>
</select>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-eflex" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button>
</div>
</form>
</div>
</div>
{# Tabs placeholder #}
{% else %}
<div class="glass p-12 text-center">

View File

@@ -160,6 +160,35 @@
</div>
</div>
</div>
{# Echeanciers #}
<div class="glass overflow-hidden">
<div class="px-4 py-3 glass-dark text-white">
<span class="font-bold text-sm uppercase tracking-wider">Echeanciers de paiement</span>
</div>
<div class="p-4">
<p class="text-xs text-gray-500 mb-3">Export des echeanciers et echeances : reference, client, creance, majoration, paiements recus, statut SEPA.</p>
<ul class="text-[10px] text-gray-400 space-y-1 mb-4">
<li>- Detail par echeance (date, montant, statut)</li>
<li>- Majoration 5% incluse</li>
<li>- Avis de paiement lie</li>
</ul>
<div class="flex gap-2">
<button type="button" class="compta-export-btn flex-1 btn-gold px-3 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900"
data-url="{{ path('app_admin_comptabilite_export_echeanciers') }}">
CSV / JSON
</button>
<button type="button" class="compta-pdf-btn flex-1 px-3 py-2 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] tracking-wider transition-all"
data-url="{{ path('app_admin_comptabilite_export_pdf', {type: 'echeanciers'}) }}">
PDF
</button>
<button type="button" class="compta-sign-btn px-3 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all"
data-url="{{ path('app_admin_comptabilite_export_pdf_sign', {type: 'echeanciers'}) }}"
title="PDF + Signature electronique">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
</button>
</div>
</div>
</div>
</div>
{# Rapport financier public #}

View File

@@ -7,7 +7,7 @@
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold heading-page">Echeancier</h1>
<p class="text-xs text-gray-400 mt-1">{{ customer.fullName }} - {{ echeancier.description }}</p>
<p class="text-xs text-gray-400 mt-1">{{ echeancier.reference }} - {{ customer.fullName }} - {{ echeancier.description }}</p>
</div>
<div class="flex items-center gap-3">
{% if echeancier.state == 'draft' %}
@@ -16,6 +16,8 @@
<span class="px-3 py-1 bg-blue-500/20 text-blue-700 font-bold uppercase text-xs">Envoye</span>
{% elseif echeancier.state == 'signed' %}
<span class="px-3 py-1 bg-purple-500/20 text-purple-700 font-bold uppercase text-xs">Signe</span>
{% elseif echeancier.state == 'pending_setup' %}
<span class="px-3 py-1 bg-orange-500/20 text-orange-700 font-bold uppercase text-xs">En attente SEPA</span>
{% elseif echeancier.state == 'active' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span>
{% elseif echeancier.state == 'completed' %}
@@ -64,11 +66,14 @@
<div class="glass p-5 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-2">Motif</h2>
<p class="text-sm text-gray-600">{{ echeancier.description }}</p>
{% if echeancier.advert %}
<p class="text-xs text-gray-400 mt-2">Avis lie : <a href="{{ path('app_admin_advert_show', {id: echeancier.advert.id}) }}" class="text-[#fabf04] font-bold">{{ echeancier.advert.orderNumber.numOrder }}</a></p>
{% endif %}
</div>
{# Actions #}
<div class="flex flex-wrap gap-2 mb-6">
{% if echeancier.state not in ['cancelled', 'completed'] %}
{% if echeancier.state in ['draft', 'send'] %}
{% if echeancier.pdfUnsigned %}
<form method="post" action="{{ path('app_admin_echeancier_generate_pdf', {id: echeancier.id}) }}" data-confirm="Regenerer le PDF ? Le fichier existant sera remplace.">
<button type="submit" class="px-4 py-2 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Regenerer PDF</button>
@@ -79,7 +84,7 @@
</form>
{% endif %}
{% endif %}
{% if echeancier.pdfUnsigned %}
{% if echeancier.pdfUnsigned and echeancier.state in ['draft', 'send'] %}
<a href="{{ vich_uploader_asset(echeancier, 'pdfUnsignedFile') }}" target="_blank"
class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">
Voir PDF
@@ -90,18 +95,34 @@
<button type="submit" class="px-4 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">{{ echeancier.state == 'send' ? 'Renvoyer signature' : 'Envoyer pour signature' }}</button>
</form>
{% endif %}
{% if echeancier.state == 'signed' %}
<form method="post" action="{{ path('app_admin_echeancier_activate', {id: echeancier.id}) }}" data-confirm="Activer la subscription Stripe ? Le client sera preleve automatiquement chaque mois.">
<button type="submit" class="px-4 py-2 bg-green-600 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-green-700 transition-all">Activer Stripe</button>
</form>
{% endif %}
{% if echeancier.pdfSigned %}
<a href="{{ vich_uploader_asset(echeancier, 'pdfSignedFile') }}" target="_blank"
class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] tracking-wider hover:bg-green-500 hover:text-white transition-all">
PDF signe
Voir echeancier signe
</a>
{% endif %}
{% if echeancier.state in ['draft', 'send', 'signed', 'active'] %}
{% if echeancier.pdfAudit %}
<a href="{{ vich_uploader_asset(echeancier, 'pdfAuditFile') }}" target="_blank"
class="px-4 py-2 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] tracking-wider hover:bg-blue-500 hover:text-white transition-all">
Audit de signature
</a>
{% endif %}
{% if echeancier.state in ['signed', 'pending_setup'] %}
<form method="post" action="{{ path('app_admin_echeancier_send_sepa', {id: echeancier.id}) }}" data-confirm="{{ echeancier.state == 'pending_setup' ? 'Renvoyer le lien de configuration SEPA au client ?' : 'Envoyer le lien de configuration SEPA au client ?' }}">
<button type="submit" class="px-4 py-2 bg-orange-500/20 text-orange-700 hover:bg-orange-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">{{ echeancier.state == 'pending_setup' ? 'Renvoyer lien SEPA' : 'Envoyer lien SEPA' }}</button>
</form>
{% endif %}
{% if echeancier.stripePaymentMethodId and echeancier.state in ['active', 'pending_setup'] %}
<form method="post" action="{{ path('app_admin_echeancier_reset_sepa', {id: echeancier.id}) }}" data-confirm="Reinitialiser le moyen de paiement SEPA ? Le client devra reconfigurer son IBAN.">
<button type="submit" class="px-4 py-2 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Reinitialiser SEPA</button>
</form>
{% endif %}
{% if echeancier.state not in ['draft'] %}
<form method="post" action="{{ path('app_admin_echeancier_send_attestation', {id: echeancier.id}) }}" data-confirm="Envoyer une attestation d'etat de l'echeancier au client ?">
<button type="submit" class="px-4 py-2 bg-blue-500/20 text-blue-700 hover:bg-blue-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Envoyer attestation</button>
</form>
{% endif %}
{% if echeancier.state in ['draft', 'send', 'signed', 'pending_setup', 'active'] %}
<form method="post" action="{{ path('app_admin_echeancier_cancel', {id: echeancier.id}) }}" data-confirm="Annuler cet echeancier ? La subscription Stripe sera annulee.">
<button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Annuler</button>
</form>
@@ -119,6 +140,7 @@
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Montant</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Paye le</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
@@ -142,14 +164,50 @@
<span class="text-red-500 ml-1">{{ line.failureReason }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if line.isPending and echeancier.stripePaymentMethodId and not line.stripePaymentIntentId %}
<form method="post" action="{{ path('app_admin_echeancier_force_payment', {id: echeancier.id, lineId: line.id}) }}" data-confirm="Forcer le prelevement de {{ line.amount|number_format(2, ',', ' ') }} EUR pour l'echeance {{ line.position }} ?">
<button type="submit" class="px-2 py-1 bg-orange-500/20 text-orange-700 hover:bg-orange-500 hover:text-white font-bold uppercase text-[9px] tracking-wider transition-all">Forcer</button>
</form>
{% elseif line.isFailed and echeancier.stripePaymentMethodId %}
<form method="post" action="{{ path('app_admin_echeancier_force_payment', {id: echeancier.id, lineId: line.id}) }}" data-confirm="Retenter le prelevement de {{ line.amount|number_format(2, ',', ' ') }} EUR pour l'echeance {{ line.position }} ?">
<button type="submit" class="px-2 py-1 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[9px] tracking-wider transition-all">Retenter</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if echeancier.stripeSubscriptionId %}
<p class="mt-3 text-[10px] text-gray-400 font-mono">Stripe Subscription: {{ echeancier.stripeSubscriptionId }}</p>
{% if echeancier.stripePaymentMethodId %}
<div class="glass p-4 mt-4">
<h3 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Mandat SEPA</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">IBAN</p>
<p class="font-mono font-bold mt-1">**** **** **** {{ echeancier.stripeSepaLast4 ?: '****' }}</p>
</div>
{% if echeancier.stripeSepaBankName %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Code banque</p>
<p class="font-bold mt-1">{{ echeancier.stripeSepaBankName }}</p>
</div>
{% endif %}
{% if echeancier.stripeSepaCountry %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Pays</p>
<p class="font-bold mt-1">{{ echeancier.stripeSepaCountry }}</p>
</div>
{% endif %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Statut</p>
<p class="font-bold mt-1 text-green-600">Actif</p>
</div>
</div>
<p class="text-[10px] text-gray-400 font-mono mt-2">{{ echeancier.stripePaymentMethodId }}</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,168 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}E-Flex {{ eflex.reference }} - {{ customer.fullName }}{% endblock %}
{% block admin_content %}
<div class="page-container">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold heading-page">E-Flex {{ eflex.reference }}</h1>
<p class="text-xs text-gray-400 mt-1">{{ eflex.reference }} - {{ customer.fullName }} - {{ eflex.description }}</p>
</div>
<div class="flex items-center gap-3">
{% if eflex.state == 'active' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span>
{% elseif eflex.state == 'completed' %}
<span class="px-3 py-1 bg-emerald-600 text-white font-bold uppercase text-xs">Termine</span>
{% elseif eflex.state == 'cancelled' %}
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs">Annule</span>
{% elseif eflex.state == 'draft' %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs">Brouillon</span>
{% elseif eflex.state == 'pending_setup' %}
<span class="px-3 py-1 bg-orange-500/20 text-orange-700 font-bold uppercase text-xs">En attente paiement</span>
{% else %}
<span class="px-3 py-1 bg-blue-500/20 text-blue-700 font-bold uppercase text-xs">{{ eflex.state }}</span>
{% endif %}
<a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'esyflex'}) }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
</div>
</div>
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="glass p-4 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Montant total</p>
<p class="text-xl font-bold mt-1">{{ eflex.totalAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-4 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-xl font-bold mt-1" style="color: #fabf04;">{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-4 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Progression</p>
<p class="text-xl font-bold mt-1">{{ eflex.nbPaid }}/{{ eflex.nbLines }}</p>
<div class="w-full bg-gray-200 h-2 mt-2"><div class="bg-green-500 h-2" style="width: {{ eflex.progress }}%"></div></div>
</div>
<div class="glass p-4 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Methode</p>
<p class="text-sm font-bold mt-1">{{ eflex.paymentMethodLabel }}</p>
</div>
</div>
{# Description #}
<div class="glass p-5 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-2">Description</h2>
<p class="text-sm text-gray-600">{{ eflex.description }}</p>
</div>
{# Actions #}
<div class="flex flex-wrap gap-2 mb-6">
{% if eflex.state == 'draft' %}
{% if eflex.pdfUnsigned %}
<form method="post" action="{{ path('app_admin_eflex_generate_pdf', {id: eflex.id}) }}" data-confirm="Regenerer le PDF ?">
<button type="submit" class="px-4 py-2 bg-yellow-500/20 text-yellow-700 hover:bg-yellow-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Regenerer PDF</button>
</form>
<a href="{{ vich_uploader_asset(eflex, 'pdfUnsignedFile') }}" target="_blank" class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">Voir PDF</a>
<form method="post" action="{{ path('app_admin_eflex_send_signature', {id: eflex.id}) }}" data-confirm="Envoyer le contrat E-Flex pour signature au client ?">
<button type="submit" class="px-4 py-2 bg-purple-500/20 text-purple-700 hover:bg-purple-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Envoyer pour signature</button>
</form>
{% else %}
<form method="post" action="{{ path('app_admin_eflex_generate_pdf', {id: eflex.id}) }}">
<button type="submit" class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">Generer PDF</button>
</form>
{% endif %}
{% endif %}
{% if eflex.pdfSigned %}
<a href="{{ vich_uploader_asset(eflex, 'pdfSignedFile') }}" target="_blank" class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] tracking-wider hover:bg-green-500 hover:text-white transition-all">Voir contrat signe</a>
{% endif %}
{% if eflex.pdfAudit %}
<a href="{{ vich_uploader_asset(eflex, 'pdfAuditFile') }}" target="_blank" class="px-4 py-2 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] tracking-wider hover:bg-blue-500 hover:text-white transition-all">Audit signature</a>
{% endif %}
{% if eflex.state in ['draft', 'active', 'pending_setup'] %}
<form method="post" action="{{ path('app_admin_eflex_cancel', {id: eflex.id}) }}" data-confirm="Annuler ce contrat E-Flex ?">
<button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Annuler</button>
</form>
{% endif %}
</div>
{# SEPA info #}
{% if eflex.stripePaymentMethodId %}
<div class="glass p-4 mb-6">
<h3 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Mandat SEPA</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">IBAN</p>
<p class="font-mono font-bold mt-1">**** **** **** {{ eflex.stripeSepaLast4 ?: '****' }}</p>
</div>
{% if eflex.stripeSepaCountry %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Pays</p>
<p class="font-bold mt-1">{{ eflex.stripeSepaCountry }}</p>
</div>
{% endif %}
<div>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Statut</p>
<p class="font-bold mt-1 text-green-600">Actif</p>
</div>
</div>
</div>
{% endif %}
{# Echeances #}
<h2 class="text-lg font-bold uppercase mb-4">Echeances</h2>
<div class="glass overflow-x-auto overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">N</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date prevue</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Montant</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Paye le</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for line in eflex.lines %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-bold">{{ line.position }}</td>
<td class="px-4 py-3 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-3 text-right font-bold text-xs">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-3 text-center">
{% if line.isPaid %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span>
{% elseif line.isFailed %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Echoue</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">En attente</span>
{% endif %}
</td>
<td class="px-4 py-3 text-xs text-gray-500">
{{ line.paidAt ? line.paidAt|date('d/m/Y H:i') : '—' }}
{% if line.paidMethod %}<span class="text-gray-400 ml-1">({{ line.paidMethod }})</span>{% endif %}
{% if line.failureReason %}<span class="text-red-500 ml-1">{{ line.failureReason }}</span>{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if line.isPending and eflex.stripePaymentMethodId and not line.stripePaymentIntentId %}
<form method="post" action="{{ path('app_admin_eflex_force_payment', {id: eflex.id, lineId: line.id}) }}" class="inline" data-confirm="Forcer le prelevement de {{ line.amount|number_format(2, ',', ' ') }} EUR ?">
<button type="submit" class="px-2 py-1 bg-orange-500/20 text-orange-700 hover:bg-orange-500 hover:text-white font-bold uppercase text-[9px] tracking-wider transition-all">Forcer</button>
</form>
{% endif %}
{% if (line.isPending or line.isFailed) and not line.stripePaymentIntentId %}
<form method="post" action="{{ path('app_admin_eflex_manual_payment', {id: eflex.id, lineId: line.id}) }}" class="inline" data-confirm="Marquer l'echeance {{ line.position }} comme payee manuellement ?">
<select name="method" class="text-[9px] px-1 py-0.5 border">
<option value="virement">Virement</option>
<option value="cb_externe">CB externe</option>
<option value="cheque">Cheque</option>
<option value="especes">Especes</option>
</select>
<button type="submit" class="px-2 py-1 bg-green-500/20 text-green-700 hover:bg-green-500 hover:text-white font-bold uppercase text-[9px] tracking-wider transition-all">Paye</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -96,12 +96,47 @@
<p class="text-[10px] text-gray-400 mt-1">{{ global.factures_payees }} facture(s)</p>
</div>
<div class="glass p-4" style="border-color: rgba(220,38,38,0.3);">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Impayees</p>
<p class="text-2xl font-bold mt-1 text-red-600">{{ global.montant_impaye|number_format(2, ',', ' ') }} €</p>
<p class="text-[10px] text-gray-400 mt-1">{{ global.factures_impayees }} facture(s)</p>
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total impaye (global)</p>
<p class="text-2xl font-bold mt-1 text-red-600">{{ (global.montant_impaye + echeancierStats.montantImpaye)|number_format(2, ',', ' ') }} €</p>
<p class="text-[10px] text-gray-400 mt-1">{{ global.factures_impayees }} facture(s) + {{ echeancierStats.nbEcheancesImpayees }} echeance(s)</p>
<div class="mt-2 space-y-1">
<p class="text-[10px] text-gray-400">Factures : <span class="font-bold text-red-500">{{ global.montant_impaye|number_format(2, ',', ' ') }} €</span></p>
<p class="text-[10px] text-gray-400">Echeanciers : <span class="font-bold text-orange-500">{{ echeancierStats.montantImpaye|number_format(2, ',', ' ') }} €</span> ({{ echeancierStats.nbEcheanciers }} en cours)</p>
</div>
</div>
</div>
{# Detail echeanciers en cours #}
{% if echeancierStats.echeanciers|length > 0 %}
<h2 class="text-xl font-bold uppercase mb-4">Echeanciers en cours</h2>
<div class="glass overflow-x-auto overflow-hidden mb-8">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Reference</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Client</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Echeances restantes</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Restant du</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for ech in echeancierStats.echeanciers %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold text-xs">{{ ech.reference }}</td>
<td class="px-4 py-3 text-xs">{{ ech.customer }}</td>
<td class="px-4 py-3 text-center font-bold text-xs text-orange-600">{{ ech.nbPending }}</td>
<td class="px-4 py-3 text-right font-mono font-bold text-xs text-red-600">{{ ech.restant|number_format(2, ',', ' ') }} €</td>
<td class="px-4 py-3 text-center">
<a href="{{ path('app_admin_echeancier_show', {id: ech.id}) }}" class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# Services #}
<h2 class="text-xl font-bold uppercase mb-4">Services</h2>

View File

@@ -0,0 +1,105 @@
{% extends 'base.html.twig' %}
{% block title %}Verification attestation - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Verification d'attestation</h1>
<p class="text-xs text-white/60">Association E-Cosplay</p>
</div>
</div>
</div>
<div class="p-8">
{% if valid and attestation %}
{# Attestation valide #}
<div class="glass p-4 mb-6 flex items-center gap-3" style="border-left: 4px solid #16a34a;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-sm font-bold text-green-700">Attestation authentique et verifiee</p>
<p class="text-xs text-gray-500 mt-1">Ce document a ete emis par l'Association E-Cosplay et son integrite est confirmee.</p>
</div>
</div>
<div class="glass p-5 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">{{ attestation.title }}</h2>
<p class="text-sm text-gray-600 mb-4 italic">
"Je soussigne(e), President(e) de l'Association E-Cosplay et le bureau de l'association, atteste les elements suivants :"
</p>
<div class="space-y-2 mb-4">
{% for item in attestation.items %}
<div class="flex items-start gap-3 glass p-3">
<span class="text-xs font-bold text-gray-400 mt-0.5">{{ loop.index }}.</span>
<p class="text-sm text-gray-700">{{ item }}</p>
</div>
{% endfor %}
</div>
<p class="text-sm text-gray-600 italic">
"La presente attestation est etablie pour faire valoir les droits de l'Association E-Cosplay. Les elements presentes ci-dessus sont conformes et veridiques."
</p>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="glass p-3">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Reference</p>
<p class="font-mono font-bold text-xs mt-1">{{ attestation.reference }}</p>
</div>
<div class="glass p-3">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Emise le</p>
<p class="font-bold text-xs mt-1">{{ attestation.createdAt|date('d/m/Y') }}</p>
</div>
<div class="glass p-3">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Statut</p>
{% if attestation.state == 'signed' %}
<p class="font-bold text-xs mt-1 text-green-600">Signee electroniquement</p>
{% else %}
<p class="font-bold text-xs mt-1 text-yellow-600">Brouillon</p>
{% endif %}
</div>
{% if attestation.signedAt %}
<div class="glass p-3">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Signee le</p>
<p class="font-bold text-xs mt-1">{{ attestation.signedAt|date('d/m/Y H:i') }}</p>
</div>
{% endif %}
</div>
<div class="glass p-3 mb-4">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code HMAC d'integrite</p>
<p class="font-mono text-[10px] text-gray-500 break-all">{{ attestation.hmac }}</p>
</div>
{% else %}
{# Attestation invalide #}
<div class="glass p-4 mb-6 flex items-center gap-3" style="border-left: 4px solid #dc2626;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-sm font-bold text-red-700">Attestation non valide</p>
<p class="text-xs text-gray-500 mt-1">Ce document n'a pas pu etre verifie. Il est possible qu'il ait ete falsifie ou que le lien soit incorrect.</p>
</div>
</div>
<p class="text-sm text-gray-600 mb-4">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter l'Association E-Cosplay.
</p>
{% endif %}
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Echeancier de paiement</h1>
<p class="text-xs text-white/60">Association E-Cosplay</p>
<p class="text-xs text-white/60">{{ echeancier.reference }} - Association E-Cosplay</p>
</div>
</div>
</div>

View File

@@ -10,10 +10,11 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 class="text-xl font-bold uppercase tracking-widest">Echeancier refuse</h1>
<p class="text-xs text-white/60 mt-1">{{ echeancier.reference }}</p>
</div>
<div class="p-8 text-center">
<p class="text-sm text-gray-600 mb-4">
L'echeancier de paiement a ete refuse.
L'echeancier de paiement <strong>{{ echeancier.reference }}</strong> a ete refuse.
</p>
<p class="text-xs text-gray-400">
Si vous souhaitez discuter d'autres modalites de paiement, contactez-nous a

View File

@@ -0,0 +1,38 @@
{% extends 'base.html.twig' %}
{% block title %}Paiement effectue - {{ echeancier.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-lg overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Paiement effectue</h1>
<p class="text-xs text-white/60">{{ echeancier.reference }}</p>
</div>
</div>
</div>
<div class="p-8 text-center">
<p class="text-sm text-gray-600 mb-4">
Votre echeance <strong>{{ line.label }}</strong> a ete regularisee avec succes par carte bancaire.
</p>
<div class="glass p-4 mb-4 text-left">
<p class="text-xs text-gray-500"><strong>Reference :</strong> {{ echeancier.reference }}</p>
<p class="text-xs text-gray-500"><strong>Echeance :</strong> {{ line.position }}/{{ echeancier.nbLines }}</p>
<p class="text-xs text-gray-500"><strong>Montant paye :</strong> {{ line.amount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<p class="text-xs text-gray-400">
Vous recevrez un email de confirmation lorsque le paiement sera traite.
</p>
<p class="text-xs text-gray-400 mt-4">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,184 @@
{% extends 'base.html.twig' %}
{% block title %}Configuration prelevement SEPA - {{ echeancier.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Prelevement SEPA</h1>
<p class="text-xs text-white/60">{{ echeancier.reference }} - Association E-Cosplay</p>
</div>
</div>
</div>
<div class="p-8">
<p class="text-sm text-gray-600 mb-6">
Pour finaliser la mise en place de votre echeancier, veuillez renseigner votre IBAN ci-dessous.
Les prelevements seront effectues automatiquement aux dates prevues.
</p>
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total a payer</p>
<p class="text-lg font-bold mt-1">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Echeances</p>
<p class="text-lg font-bold mt-1">{{ echeancier.nbLines }} mois</p>
</div>
</div>
{# Informations du mandat #}
<div class="glass p-4 mb-6">
<h2 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Informations du mandat SEPA</h2>
<div class="grid grid-cols-2 gap-2 text-xs">
<p class="text-gray-500"><strong>Crediteur :</strong> Association E-Cosplay</p>
<p class="text-gray-500"><strong>Reference :</strong> {{ echeancier.reference }}</p>
<p class="text-gray-500"><strong>Montant/echeance :</strong> {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
<p class="text-gray-500"><strong>Nombre d'echeances :</strong> {{ echeancier.nbLines }}</p>
<p class="text-gray-500"><strong>1ere echeance :</strong> {{ echeancier.lines|first ? echeancier.lines|first.scheduledAt|date('d/m/Y') : '—' }}</p>
<p class="text-gray-500"><strong>Derniere echeance :</strong> {{ echeancier.lines|last ? echeancier.lines|last.scheduledAt|date('d/m/Y') : '—' }}</p>
</div>
</div>
{# Formulaire IBAN #}
<form id="sepa-form">
<div class="mb-4">
<label for="account-name" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Titulaire du compte</label>
<input type="text" id="account-name" required
value="{{ customer.raisonSociale ?: customer.fullName }}"
class="input-glass w-full px-4 py-3 text-sm font-bold">
</div>
<div class="mb-4">
<label for="account-email" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email</label>
<input type="email" id="account-email" required
value="{{ customer.email }}"
class="input-glass w-full px-4 py-3 text-sm font-bold">
</div>
<div class="mb-4">
<label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">IBAN</label>
<div id="iban-element" class="input-glass w-full px-4 py-3"></div>
<div id="iban-errors" class="text-red-500 text-xs mt-1 hidden"></div>
</div>
{# Mandat SEPA #}
<div class="glass p-4 mb-6 text-xs text-gray-500 leading-relaxed">
<p class="font-bold text-[9px] uppercase tracking-wider text-gray-400 mb-2">Mandat de prelevement SEPA</p>
<p>En fournissant vos informations de paiement et en confirmant ce mandat, vous autorisez (A) Association E-Cosplay et Stripe, notre prestataire de paiement, a envoyer des instructions a votre banque pour debiter votre compte et (B) votre banque a debiter votre compte conformement a ces instructions.</p>
<p class="mt-2">Vous beneficiez d'un droit a remboursement par votre banque selon les conditions decrites dans la convention que vous avez conclue avec elle. Toute demande de remboursement doit etre presentee dans les 8 semaines suivant la date de debit de votre compte.</p>
</div>
<div id="form-error" class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs hidden"></div>
<button type="submit" id="submit-btn"
class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed">
<span id="btn-text">Autoriser le prelevement SEPA</span>
<span id="btn-loading" class="hidden">Traitement en cours...</span>
</button>
</form>
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
<script src="https://js.stripe.com/v3/" nonce="{{ csp_nonce('script') }}"></script>
<script nonce="{{ csp_nonce('script') }}">
(function() {
var stripe = Stripe('{{ stripePk }}');
var elements = stripe.elements();
var style = {
base: {
color: '#111827',
fontSize: '14px',
fontFamily: 'Arial, sans-serif',
'::placeholder': { color: '#9ca3af' }
},
invalid: { color: '#dc2626' }
};
var iban = elements.create('iban', { style: style, supportedCountries: ['SEPA'] });
iban.mount('#iban-element');
var errorEl = document.getElementById('iban-errors');
iban.on('change', function(event) {
if (event.error) {
errorEl.textContent = event.error.message;
errorEl.classList.remove('hidden');
} else {
errorEl.classList.add('hidden');
}
});
var form = document.getElementById('sepa-form');
var submitBtn = document.getElementById('submit-btn');
var btnText = document.getElementById('btn-text');
var btnLoading = document.getElementById('btn-loading');
var formError = document.getElementById('form-error');
form.addEventListener('submit', function(e) {
e.preventDefault();
submitBtn.disabled = true;
btnText.classList.add('hidden');
btnLoading.classList.remove('hidden');
formError.classList.add('hidden');
var name = document.getElementById('account-name').value;
var email = document.getElementById('account-email').value;
stripe.confirmSepaDebitSetup('{{ clientSecret }}', {
payment_method: {
sepa_debit: iban,
billing_details: { name: name, email: email }
}
}).then(function(result) {
if (result.error) {
formError.textContent = result.error.message;
formError.classList.remove('hidden');
submitBtn.disabled = false;
btnText.classList.remove('hidden');
btnLoading.classList.add('hidden');
return;
}
// Envoyer le payment_method au serveur
fetch('{{ path('app_echeancier_setup_payment_confirm', {id: echeancier.id}) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payment_method: result.setupIntent.payment_method })
}).then(function(res) {
return res.json();
}).then(function(data) {
if (data.status === 'ok') {
window.location.reload();
} else {
formError.textContent = data.error || 'Erreur lors de la configuration.';
formError.classList.remove('hidden');
submitBtn.disabled = false;
btnText.classList.remove('hidden');
btnLoading.classList.add('hidden');
}
}).catch(function() {
formError.textContent = 'Erreur de connexion. Veuillez reessayer.';
formError.classList.remove('hidden');
submitBtn.disabled = false;
btnText.classList.remove('hidden');
btnLoading.classList.add('hidden');
});
});
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends 'base.html.twig' %}
{% block title %}Mandat SEPA configure - {{ echeancier.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Mandat SEPA configure</h1>
<p class="text-xs text-white/60">{{ echeancier.reference }} - Association E-Cosplay</p>
</div>
</div>
</div>
<div class="p-8">
<div class="glass p-4 mb-6 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<div>
<p class="text-sm font-bold text-green-700">Votre mandat de prelevement SEPA est actif</p>
<p class="text-xs text-gray-500 mt-1">Les echeances seront prelevees automatiquement aux dates prevues.</p>
</div>
</div>
{# Motif #}
<div class="glass p-4 mb-6">
<h2 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Motif</h2>
<p class="text-sm font-bold">{{ echeancier.description }}</p>
</div>
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Creance</p>
<p class="text-lg font-bold mt-1">{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Majoration 5%</p>
<p class="text-lg font-bold mt-1 text-red-500">+ {{ echeancier.majoration|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total a payer</p>
<p class="text-lg font-bold mt-1">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</p>
<p class="text-[10px] text-gray-400">en {{ echeancier.nbLines }} mois</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
</div>
{# Tableau echeances avec statut #}
<div class="glass overflow-hidden mb-6">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">N</th>
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Date prevue</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Montant</th>
<th class="px-4 py-2 text-center font-bold uppercase text-[10px] tracking-widest">Statut</th>
</tr>
</thead>
<tbody>
{% for line in echeancier.lines %}
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
<td class="px-4 py-2 font-bold">{{ line.position }}</td>
<td class="px-4 py-2 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-2 text-right font-bold">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-2 text-center">
{% if line.isPaid %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span>
{% elseif line.isFailed %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Echoue</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">A prelever</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if echeancier.nbPaid > 0 %}
<div class="glass p-4 mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Progression</span>
<span class="text-sm font-bold">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}</span>
</div>
<div class="w-full bg-gray-200 h-2">
<div class="bg-green-500 h-2" style="width: {{ echeancier.progress }}%"></div>
</div>
</div>
{% endif %}
<p class="text-xs text-gray-500 mb-4">
Vous recevrez un email de confirmation a chaque prelevement effectue.
</p>
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,27 +4,88 @@
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-lg overflow-hidden">
<div class="glass-dark text-white px-8 py-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 class="text-xl font-bold uppercase tracking-widest">Echeancier signe</h1>
</div>
<div class="p-8 text-center">
<p class="text-sm text-gray-600 mb-4">
Merci <strong>{{ customer.firstName }}</strong>, votre echeancier de paiement a ete signe avec succes.
</p>
<div class="glass p-4 mb-4 text-left">
<p class="text-xs text-gray-500"><strong>Montant total :</strong> {{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} &euro;</p>
<p class="text-xs text-gray-500"><strong>Echeances :</strong> {{ echeancier.nbLines }} x {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;/mois</p>
<p class="text-xs text-gray-500"><strong>Motif :</strong> {{ echeancier.description }}</p>
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Echeancier signe</h1>
<p class="text-xs text-white/60">{{ echeancier.reference }} - Association E-Cosplay</p>
</div>
</div>
<p class="text-xs text-gray-400">
Vous allez recevoir un email pour configurer les prelevements automatiques.
</div>
<div class="p-8">
<p class="text-sm text-gray-600 mb-6">
Votre echeancier de paiement a ete signe avec succes.
</p>
<p class="text-xs text-gray-400 mt-4">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="text-[#fabf04] font-bold">contact@e-cosplay.fr</a>
{# Motif #}
<div class="glass p-4 mb-6">
<h2 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Motif</h2>
<p class="text-sm font-bold">{{ echeancier.description }}</p>
</div>
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Creance</p>
<p class="text-lg font-bold mt-1">{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Majoration 5%</p>
<p class="text-lg font-bold mt-1 text-red-500">+ {{ echeancier.majoration|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total a payer</p>
<p class="text-lg font-bold mt-1">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</p>
<p class="text-[10px] text-gray-400">en {{ echeancier.nbLines }} mois</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
</div>
{# Tableau echeances #}
<div class="glass overflow-hidden mb-6">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">N</th>
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Date prevue</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Montant</th>
</tr>
</thead>
<tbody>
{% for line in echeancier.lines %}
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
<td class="px-4 py-2 font-bold">{{ line.position }}</td>
<td class="px-4 py-2 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-2 text-right font-bold">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if echeancier.stripePaymentMethodId %}
<div class="glass p-4 mb-4 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="text-sm font-bold text-green-700">Mandat SEPA configure - Les prelevements seront effectues automatiquement.</p>
</div>
{% else %}
<p class="text-sm text-gray-600 mb-4">
Vous allez recevoir un email pour configurer les prelevements automatiques.
</p>
{% endif %}
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<div class="glass-heavy w-full max-w-md overflow-hidden">
<div class="glass-dark text-white px-8 py-6 text-center">
<h1 class="text-lg font-bold uppercase tracking-widest">Verification</h1>
<p class="text-xs text-white/60 mt-1">Un code a ete envoye a {{ customer.email }}</p>
<p class="text-xs text-white/60 mt-1">{{ echeancier.reference }} - Un code a ete envoye a {{ customer.email }}</p>
</div>
<div class="p-8">
{% if error %}

View File

@@ -0,0 +1,143 @@
{% extends 'base.html.twig' %}
{% block title %}E-Flex {{ eflex.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Contrat E-Flex</h1>
<p class="text-xs text-white/60">{{ eflex.reference }} - Association E-Cosplay</p>
</div>
</div>
</div>
<div class="p-8">
<p class="text-sm text-gray-600 mb-6">
{% if customer.raisonSociale %}Chez {{ customer.raisonSociale }}{% else %}Bonjour {{ customer.firstName }}{% endif %},
voici le detail de votre contrat de financement E-Flex.
</p>
{# Description #}
<div class="glass p-4 mb-6">
<h2 class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Service finance</h2>
<p class="text-sm font-bold">{{ eflex.description }}</p>
</div>
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Montant total</p>
<p class="text-lg font-bold mt-1">{{ eflex.totalAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Echeances</p>
<p class="text-lg font-bold mt-1">{{ eflex.nbLines }} mois</p>
</div>
</div>
{# Tableau echeances #}
<div class="glass overflow-hidden mb-6">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">N</th>
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Date prevue</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Montant</th>
<th class="px-4 py-2 text-center font-bold uppercase text-[10px] tracking-widest">Statut</th>
</tr>
</thead>
<tbody>
{% for line in eflex.lines %}
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
<td class="px-4 py-2 font-bold">{{ line.position }}</td>
<td class="px-4 py-2 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-2 text-right font-bold">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-2 text-center">
{% if line.isPaid %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span>
{% elseif line.isFailed %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Echoue</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">A payer</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Conditions #}
<div class="text-xs text-gray-500 mb-6 space-y-1">
<p class="font-bold uppercase text-[9px] tracking-wider text-gray-400 mb-2">Conditions</p>
<p>E-Flex est une solution de financement sans frais supplementaires proposee par l'Association E-Cosplay.</p>
<p>Les prelevements seront effectues automatiquement a chaque date prevue.</p>
</div>
{# Boutons signer / refuser (si pas encore signe) #}
{% if eflex.submissionId and eflex.state == 'draft' %}
<div class="flex justify-center gap-4 mb-6">
<a href="{{ path('app_eflex_sign', {id: eflex.id}) }}"
class="px-6 py-3 bg-green-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-green-700 transition-all">
Signer le contrat
</a>
</div>
{% endif %}
{# Boutons paiement (si actif ou pending_setup) #}
{% if eflex.state in ['active', 'pending_setup'] %}
<div class="flex justify-center gap-4 mb-6">
{% if eflex.paymentMethod == 'sepa' and not eflex.stripePaymentMethodId %}
<a href="{{ path('app_eflex_setup_payment', {id: eflex.id}) }}"
class="px-6 py-3 bg-green-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-green-700 transition-all">
Configurer le prelevement SEPA
</a>
{% endif %}
{% for line in eflex.lines %}
{% if line.isPending or line.isFailed %}
<a href="{{ path('app_eflex_pay', {id: eflex.id, lineId: line.id}) }}"
class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">
Payer echeance {{ line.position }} ({{ line.amount|number_format(2, ',', ' ') }} &euro;)
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# SEPA configure #}
{% if eflex.stripePaymentMethodId %}
<div class="glass p-4 mb-6 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="text-sm font-bold text-green-700">Prelevement SEPA configure - IBAN **** {{ eflex.stripeSepaLast4 ?: '****' }}</p>
</div>
{% endif %}
{% if eflex.nbPaid > 0 %}
<div class="glass p-4 mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Progression</span>
<span class="text-sm font-bold">{{ eflex.nbPaid }}/{{ eflex.nbLines }}</span>
</div>
<div class="w-full bg-gray-200 h-2">
<div class="bg-green-500 h-2" style="width: {{ eflex.progress }}%"></div>
</div>
</div>
{% endif %}
<p class="text-center text-xs text-gray-400 mt-6">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends 'base.html.twig' %}
{% block title %}Configuration prelevement SEPA - {{ eflex.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Prelevement SEPA - E-Flex</h1>
<p class="text-xs text-white/60">{{ eflex.reference }}</p>
</div>
</div>
</div>
<div class="p-8">
<p class="text-sm text-gray-600 mb-6">Renseignez votre IBAN pour activer les prelevements automatiques.</p>
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total</p>
<p class="text-lg font-bold mt-1">{{ eflex.totalAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Echeances</p>
<p class="text-lg font-bold mt-1">{{ eflex.nbLines }} mois</p>
</div>
</div>
<form id="sepa-form">
<div class="mb-4">
<label for="account-name" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Titulaire du compte</label>
<input type="text" id="account-name" required value="{{ customer.raisonSociale ?: customer.fullName }}" class="input-glass w-full px-4 py-3 text-sm font-bold">
</div>
<div class="mb-4">
<label for="account-email" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email</label>
<input type="email" id="account-email" required value="{{ customer.email }}" class="input-glass w-full px-4 py-3 text-sm font-bold">
</div>
<div class="mb-4">
<label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">IBAN</label>
<div id="iban-element" class="input-glass w-full px-4 py-3"></div>
<div id="iban-errors" class="text-red-500 text-xs mt-1 hidden"></div>
</div>
<div class="glass p-4 mb-6 text-xs text-gray-500 leading-relaxed">
<p class="font-bold text-[9px] uppercase tracking-wider text-gray-400 mb-2">Mandat de prelevement SEPA</p>
<p>En fournissant vos informations de paiement et en confirmant ce mandat, vous autorisez (A) Association E-Cosplay et Stripe, notre prestataire de paiement, a envoyer des instructions a votre banque pour debiter votre compte et (B) votre banque a debiter votre compte conformement a ces instructions.</p>
</div>
<div id="form-error" class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs hidden"></div>
<button type="submit" id="submit-btn" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed">
<span id="btn-text">Autoriser le prelevement SEPA</span>
<span id="btn-loading" class="hidden">Traitement en cours...</span>
</button>
</form>
<p class="text-center text-xs text-gray-400 mt-6">Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a></p>
</div>
</div>
</div>
<script src="https://js.stripe.com/v3/" nonce="{{ csp_nonce('script') }}"></script>
<script nonce="{{ csp_nonce('script') }}">
(function() {
var stripe = Stripe('{{ stripePk }}');
var elements = stripe.elements();
var style = { base: { color: '#111827', fontSize: '14px', fontFamily: 'Arial, sans-serif', '::placeholder': { color: '#9ca3af' } }, invalid: { color: '#dc2626' } };
var iban = elements.create('iban', { style: style, supportedCountries: ['SEPA'] });
iban.mount('#iban-element');
var errorEl = document.getElementById('iban-errors');
iban.on('change', function(event) {
if (event.error) { errorEl.textContent = event.error.message; errorEl.classList.remove('hidden'); }
else { errorEl.classList.add('hidden'); }
});
var form = document.getElementById('sepa-form');
var submitBtn = document.getElementById('submit-btn');
var btnText = document.getElementById('btn-text');
var btnLoading = document.getElementById('btn-loading');
var formError = document.getElementById('form-error');
form.addEventListener('submit', function(e) {
e.preventDefault();
submitBtn.disabled = true;
btnText.classList.add('hidden');
btnLoading.classList.remove('hidden');
formError.classList.add('hidden');
stripe.confirmSepaDebitSetup('{{ clientSecret }}', {
payment_method: { sepa_debit: iban, billing_details: { name: document.getElementById('account-name').value, email: document.getElementById('account-email').value } }
}).then(function(result) {
if (result.error) {
formError.textContent = result.error.message; formError.classList.remove('hidden');
submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden');
return;
}
fetch('{{ path('app_eflex_setup_payment_confirm', {id: eflex.id}) }}', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payment_method: result.setupIntent.payment_method })
}).then(function(res) { return res.json(); }).then(function(data) {
if (data.status === 'ok') { window.location.reload(); }
else { formError.textContent = data.error || 'Erreur.'; formError.classList.remove('hidden'); submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); }
}).catch(function() { formError.textContent = 'Erreur de connexion.'; formError.classList.remove('hidden'); submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); });
});
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% extends 'base.html.twig' %}
{% block title %}SEPA configure - {{ eflex.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">Mandat SEPA configure</h1>
<p class="text-xs text-white/60">{{ eflex.reference }}</p>
</div>
</div>
</div>
<div class="p-8">
<div class="glass p-4 mb-6 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<div>
<p class="text-sm font-bold text-green-700">Prelevement SEPA actif</p>
<p class="text-xs text-gray-500 mt-1">IBAN **** {{ eflex.stripeSepaLast4 ?: '****' }} - Les echeances seront prelevees automatiquement.</p>
</div>
</div>
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Total</p>
<p class="text-lg font-bold mt-1">{{ eflex.totalAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-lg font-bold mt-1" style="color: #fabf04;">{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-3 text-center">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Progression</p>
<p class="text-lg font-bold mt-1">{{ eflex.nbPaid }}/{{ eflex.nbLines }}</p>
</div>
</div>
{# Tableau echeances avec statut #}
<div class="glass overflow-hidden mb-6">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">N</th>
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Date</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Montant</th>
<th class="px-4 py-2 text-center font-bold uppercase text-[10px] tracking-widest">Statut</th>
</tr>
</thead>
<tbody>
{% for line in eflex.lines %}
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
<td class="px-4 py-2 font-bold">{{ line.position }}</td>
<td class="px-4 py-2 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-2 text-right font-bold">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-2 text-center">
{% if line.isPaid %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span>
{% elseif line.isFailed %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Echoue</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">A prelever</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-center text-xs text-gray-400 mt-6">Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends 'base.html.twig' %}
{% block title %}E-Flex signe - {{ eflex.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-lg overflow-hidden">
<div class="glass-dark text-white px-8 py-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h1 class="text-lg font-bold uppercase tracking-widest">E-Flex signe</h1>
<p class="text-xs text-white/60">{{ eflex.reference }}</p>
</div>
</div>
</div>
<div class="p-8 text-center">
<p class="text-sm text-gray-600 mb-4">
Votre contrat E-Flex a ete signe avec succes. Vous allez recevoir un email pour configurer vos paiements.
</p>
<div class="glass p-4 mb-4 text-left">
<p class="text-xs text-gray-500"><strong>Montant total :</strong> {{ eflex.totalAmount|number_format(2, ',', ' ') }} &euro;</p>
<p class="text-xs text-gray-500"><strong>Echeances :</strong> {{ eflex.nbLines }} x {{ eflex.monthlyAmount|number_format(2, ',', ' ') }} &euro;/mois</p>
<p class="text-xs text-gray-500"><strong>Methode :</strong> {{ eflex.paymentMethodLabel }}</p>
</div>
<p class="text-xs text-gray-400 mt-4">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" class="font-bold" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends 'base.html.twig' %}
{% block title %}Verification - E-Flex {{ eflex.reference }} - Association E-Cosplay{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
<div class="glass-heavy w-full max-w-md overflow-hidden">
<div class="glass-dark text-white px-8 py-6 text-center">
<h1 class="text-lg font-bold uppercase tracking-widest">Verification</h1>
<p class="text-xs text-white/60 mt-1">{{ eflex.reference }} - Un code a ete envoye a {{ customer.email }}</p>
</div>
<div class="p-8">
{% if error %}
<div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ error }}</div>
{% endif %}
<p class="text-sm text-gray-600 mb-4">Saisissez le code de verification a 6 chiffres recu par email.</p>
<form method="post" action="{{ path('app_eflex_verify', {id: eflex.id}) }}">
<div class="mb-4">
<label for="code" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code de verification</label>
<input type="text" id="code" name="code" maxlength="6" pattern="[0-9]{6}" required autofocus
class="input-glass w-full px-4 py-3 text-center text-2xl font-bold tracking-[0.5em]" placeholder="000000">
</div>
<button type="submit" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900">Verifier</button>
</form>
<p class="text-center text-xs text-gray-400 mt-4">Le code expire dans 15 minutes.</p>
<form method="post" action="{{ path('app_eflex_resend_code', {id: eflex.id}) }}" class="mt-3 text-center">
<button type="submit" class="text-xs font-bold uppercase tracking-wider text-gray-500 hover:text-gray-900 underline transition-all">Renvoyer le code</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% 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>
<div style="background: #7f1d1d; color: #fff; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 700; margin: 0;">
Notification de cloture definitive de votre compte
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Malgre les avertissements qui vous ont ete adresses et en l'absence de changement de votre part, le bureau de l'Association E-Cosplay, reuni a huis clos, a decide d'effectuer la procedure de cloture definitive de votre compte.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #dc2626; line-height: 22px; margin: 0 0 12px;">
Les mesures suivantes seront appliquees :
</p>
<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px; margin: 0 0 16px; padding-left: 20px;">
<li style="margin-bottom: 8px;">La <strong>totalite de vos services</strong> (sites internet, emails, noms de domaine) seront <strong>supprimes et detruits dans un delai de 24 heures</strong>, sans possibilite de recuperation.</li>
<li style="margin-bottom: 8px;">Un <strong>depot aupres d'une societe de recouvrement</strong> sera effectue pour les factures restant dues.</li>
<li style="margin-bottom: 8px;">Une <strong>mise en demeure sera deposee aupres d'un commissaire de justice</strong>.</li>
<li style="margin-bottom: 8px;">En cas de manque de respect ou d'insultes constatees, un <strong>depot aupres des forces de l'ordre</strong> sera effectue.</li>
<li style="margin-bottom: 8px;">L'ensemble de vos donnees sera supprime conformement au RGPD.</li>
</ul>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #dc2626; line-height: 22px; margin: 16px 0;">
Cette decision est definitive et irrevocable.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 20px 0 0;">
Toute contestation devra etre adressee a <a href="mailto:direction@e-cosplay.fr" style="color: #fabf04; font-weight: 700;">direction@e-cosplay.fr</a> dans un delai de 24 heures.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 10px; color: #d1d5db; margin: 16px 0 0;">
Le present document constitue une notification officielle de cloture de compte. Il fait foi en cas de litige.<br>
Toutes les decisions sont prises par le bureau de l'association a huis clos.<br>
Le PDF signe electroniquement est joint a ce courrier.
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% 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>
{% if level == '1st' %}
<div style="background: #fef3c7; border-left: 4px solid #f59e0b; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #92400e; margin: 0;">
1er avertissement
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Nous constatons des manquements sur votre compte. Nous vous invitons a regulariser votre situation dans les meilleurs delais.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
<strong>En cas de repetition, un 2eme avertissement sera decide et pourra entrainer la suspension de vos services.</strong>
</p>
{% elseif level == '2nd' %}
<div style="background: #fff7ed; border-left: 4px solid #ea580c; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #9a3412; margin: 0;">
2eme avertissement - Procedure de suspension engagee
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Malgre notre precedent avertissement, nous constatons que votre situation n'a pas ete regularisee.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #ea580c; line-height: 22px; margin: 0 0 16px; font-weight: 700;">
Ceci est votre 2eme avertissement. Nous vous informons que la procedure de suspension a ete preparee :
</p>
<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px; margin: 0 0 16px; padding-left: 20px;">
<li style="margin-bottom: 8px;">La <strong>resiliation de vos services</strong> (sites internet, emails, noms de domaine) a ete preparee</li>
<li style="margin-bottom: 8px;">La <strong>fermeture de votre compte</strong> a ete programmee</li>
<li style="margin-bottom: 8px;">Ces mesures seront <strong>effectives au prochain avertissement</strong> en l'absence de regularisation</li>
</ul>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Nous vous invitons a regulariser votre situation dans les plus brefs delais afin d'eviter la suspension definitive de vos services.
</p>
{% elseif level == 'last' %}
<div style="background: #fef2f2; border-left: 4px solid #dc2626; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #991b1b; margin: 0;">
Dernier avertissement avant suspension
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Malgre nos precedents avertissements, votre situation n'a toujours pas ete regularisee.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #dc2626; line-height: 22px; margin: 0 0 16px; font-weight: 700;">
Ceci est votre dernier avertissement. Sans regularisation sous 48 heures, nous procederons a la suspension immediate de votre compte et de l'ensemble de vos services (sites internet, emails, noms de domaine).
</p>
{% endif %}
{% if reasons is defined and reasons|length > 0 %}
{% set reasonLabels = {
'impayes': 'Impayes et/ou rejets de prelevement',
'irrespect': 'Manque de respect et/ou insultes envers notre equipe',
'hors_horaires': 'Appels repetes hors des heures d\'ouverture avec refus de payer le service hors horaire',
'gratuit': 'Exigence de services gratuits non prevus dans votre contrat'
} %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; color: #374151; margin: 16px 0 8px;">Motifs constates :</p>
<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px; margin: 0 0 16px; padding-left: 20px;">
{% for reason in reasons %}
<li style="margin-bottom: 6px;">{{ reasonLabels[reason] ?? reason }}</li>
{% endfor %}
</ul>
{% endif %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
Nous vous invitons a regulariser votre situation dans les meilleurs delais ou a contacter notre service pour trouver une solution.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 20px 0 0;">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" style="color: #fabf04; font-weight: 700;">contact@e-cosplay.fr</a>
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 10px; color: #d1d5db; margin: 16px 0 0;">
Ce message est un avertissement officiel de l'Association E-Cosplay. Il fait foi en cas de litige.<br>
Toutes les decisions relatives aux avertissements sont prises par le bureau de l'association a huis clos.<br>
Toute contestation devra etre adressee a <a href="mailto:direction@e-cosplay.fr" style="color: #d1d5db;">direction@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% 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>
<div style="background: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #166534; margin: 0;">
Situation regularisee
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Nous vous confirmons que votre situation a ete regularisee. Les avertissements precedemment emis ont ete leves.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Vos services restent actifs et votre compte est en regle. Nous vous remercions pour votre regularisation.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 20px 0 0;">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" style="color: #fabf04; font-weight: 700;">contact@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% 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;">
Veuillez trouver ci-dessous l'attestation de situation de votre echeancier <strong>{{ echeancier.reference }}</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%;">Reference</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 10px 16px; font-size: 13px;">{{ echeancier.description }}</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;">
{% if echeancier.state == 'completed' %}
<span style="color: #16a34a;">Termine</span>
{% elseif echeancier.state == 'active' %}
<span style="color: #2563eb;">Actif</span>
{% elseif echeancier.state == 'cancelled' %}
<span style="color: #dc2626;">Annule</span>
{% else %}
{{ echeancier.state }}
{% endif %}
</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Creance initiale</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px;">{{ echeancier.totalAmountHt|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;">Majoration (5%)</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; color: #dc2626;">+ {{ echeancier.majoration|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;">Total a payer</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|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;">Deja paye</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700; color: #16a34a;">{{ echeancier.totalPaid|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;">Restant du</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: {{ remaining > 0 ? '#dc2626' : '#16a34a' }};">{{ remaining|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;">Progression</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} echeances payees ({{ echeancier.progress }}%)</td>
</tr>
{% if echeancier.stripeSepaLast4 %}
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">RIB (IBAN)</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px;">**** **** **** {{ echeancier.stripeSepaLast4 }}{% if echeancier.stripeSepaCountry %} ({{ echeancier.stripeSepaCountry }}){% endif %}</td>
</tr>
{% endif %}
</table>
<h2 style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #111827; margin: 20px 0 10px;">Detail des echeances :</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="border: 1px solid #e5e7eb;">
<tr>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: left;">N</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: left;">Date</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: right;">Montant</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: center;">Statut</th>
</tr>
{% for line in echeancier.lines %}
<tr style="background: {{ loop.index is odd ? '#f9fafb' : '#fff' }};">
<td style="padding: 8px 12px; font-size: 12px;">{{ line.position }}</td>
<td style="padding: 8px 12px; font-size: 12px;">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td style="padding: 8px 12px; font-size: 12px; font-weight: 700; text-align: right;">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td style="padding: 8px 12px; font-size: 12px; text-align: center;">
{% if line.isPaid %}
<span style="color: #16a34a; font-weight: 700;">Paye{{ line.paidAt ? ' le ' ~ line.paidAt|date('d/m/Y') : '' }}</span>
{% elseif line.isFailed %}
<span style="color: #dc2626; font-weight: 700;">Echoue</span>
{% else %}
<span style="color: #d97706;">En attente</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 20px 0 0;">
Attestation emise le {{ "now"|date('d/m/Y') }} par Association E-Cosplay.<br>
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,41 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #dc2626; color: #fff; padding: 12px 20px; display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Subscription annulee</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">Echeancier {{ echeancier.reference }} annule via Stripe</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 20px; margin: 0 0 16px;">
La subscription Stripe a ete annulee (manuellement ou automatiquement). L'echeancier a ete passe en statut <strong>Annule</strong>.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Reference</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Deja paye</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; color: #16a34a;">{{ echeancier.totalPaid|number_format(2, ',', ' ') }} &euro; ({{ echeancier.nbPaid }}/{{ echeancier.nbLines }})</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Stripe ID</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 11px; color: #9ca3af;">{{ echeancier.stripeSubscriptionId }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Annule le</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ "now"|date('d/m/Y H:i') }}</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% 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 echeancier de paiement <strong>{{ echeancier.reference }}</strong> a ete annule. Les prelevements automatiques ont ete desactives.
</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%;">Reference</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.description }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|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;">Deja paye</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700; color: #16a34a;">{{ echeancier.totalPaid|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;">Progression</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} echeances payees</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;">Annule</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
Si cette annulation n'est pas de votre fait, veuillez nous contacter immediatement.
</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,38 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #dc2626; color: #fff; padding: 12px 20px; display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Echeancier annule - 2 rejets</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">{{ echeancier.reference }} - {{ customer.fullName }}</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 20px; margin: 0 0 16px;">
L'echeancier a ete automatiquement annule apres 2 rejets de prelevement SEPA.
{% if echeancier.advert %}L'avis {{ echeancier.advert.orderNumber.numOrder }} est concerne.{% endif %}
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Client</td>
<td style="padding: 12px 16px; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Deja paye</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; color: #16a34a;">{{ echeancier.totalPaid|number_format(2, ',', ' ') }} &euro; ({{ echeancier.nbPaid }}/{{ echeancier.nbLines }})</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Restant du</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700; color: #dc2626;">{{ (echeancier.totalWithMajoration - echeancier.totalPaid)|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Rejets</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700; color: #dc2626;">{{ echeancier.nbFailed }} echecs</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% 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>
<div style="background: #fef2f2; border: 1px solid #fecaca; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #dc2626; margin: 0;">
Echeancier {{ echeancier.reference }} annule suite a des rejets de prelevement
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Votre echeancier de paiement a ete annule en raison de rejets repetes de prelevement SEPA. Le solde restant reste du.
</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%;">Reference</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total echeancier</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|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;">Deja paye</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700; color: #16a34a;">{{ echeancier.totalPaid|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;">Restant du</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #dc2626;">{{ (echeancier.totalWithMajoration - echeancier.totalPaid)|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;">Echeances payees</td>
<td style="padding: 10px 16px; font-size: 13px;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}</td>
</tr>
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
Veuillez contacter notre service pour regulariser votre situation dans les plus brefs delais.
</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,40 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #16a34a; color: #fff; padding: 12px 20px; display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Echeancier termine</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">{{ echeancier.reference }} - {{ customer.fullName }}</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 20px; margin: 0 0 16px;">
Toutes les echeances ont ete payees. L'echeancier est termine.
{% if echeancier.advert %}L'avis {{ echeancier.advert.orderNumber.numOrder }} a ete passe en accepte.{% endif %}
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Reference</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-size: 13px;">{{ customer.fullName }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total paye</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700; color: #16a34a;">{{ echeancier.totalPaid|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Echeances</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}</td>
</tr>
{% if echeancier.advert %}
<tr>
<td style="padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Avis lie</td>
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.advert.orderNumber.numOrder }}</td>
</tr>
{% endif %}
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% 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>
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; padding: 16px; margin: 0 0 20px;">
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 16px; font-weight: 700; color: #16a34a; margin: 0;">
Echeancier {{ echeancier.reference }} - Paiement termine
</p>
</div>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Nous vous confirmons que l'integralite de votre echeancier de paiement a ete reglee. Voici l'attestation de fin de paiement :
</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%;">Reference</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 10px 16px; font-size: 13px;">{{ echeancier.description }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Creance initiale</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px;">{{ echeancier.totalAmountHt|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;">Majoration (5%)</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; color: #dc2626;">+ {{ echeancier.majoration|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;">Total paye</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #16a34a;">{{ echeancier.totalPaid|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;">Echeances</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #16a34a;">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} payees</td>
</tr>
</table>
<h2 style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #111827; margin: 20px 0 10px;">Detail des echeances :</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="border: 1px solid #e5e7eb;">
<tr>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: left;">N</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: left;">Date</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: right;">Montant</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #fff; background: #111827; text-align: center;">Statut</th>
</tr>
{% for line in echeancier.lines %}
<tr style="background: {{ loop.index is odd ? '#f9fafb' : '#fff' }};">
<td style="padding: 8px 12px; font-size: 12px;">{{ line.position }}</td>
<td style="padding: 8px 12px; font-size: 12px;">{{ line.paidAt ? line.paidAt|date('d/m/Y') : line.scheduledAt|date('d/m/Y') }}</td>
<td style="padding: 8px 12px; font-size: 12px; font-weight: 700; text-align: right;">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td style="padding: 8px 12px; font-size: 12px; text-align: center; color: #16a34a; font-weight: 700;">Paye</td>
</tr>
{% endfor %}
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 20px 0;">
Ce document fait office d'attestation de fin de paiement pour l'echeancier {{ echeancier.reference }}.
</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

@@ -8,7 +8,7 @@
<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.
Le prelevement de votre echeance <strong>{{ line.position }}/{{ echeancier.nbLines }}</strong> de l'echeancier <strong>{{ echeancier.reference }}</strong> a echoue.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
@@ -30,9 +30,22 @@
</tr>
</table>
{% if regularizeUrl is defined and regularizeUrl %}
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
Vous pouvez regulariser cette echeance immediatement par carte bancaire en cliquant sur le bouton ci-dessous :
</p>
<table cellpadding="0" cellspacing="0" style="margin: 24px auto;">
<tr>
<td style="background-color: #fabf04; padding: 14px 32px;">
<a href="{{ regularizeUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Regulariser par carte bancaire</a>
</td>
</tr>
</table>
{% endif %}
<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.
Si vous ne regularisez pas, veuillez verifier votre moyen de paiement. Une nouvelle tentative de prelevement SEPA sera effectuee.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">

View File

@@ -8,7 +8,7 @@
<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.
Votre echeance <strong>{{ line.position }}/{{ echeancier.nbLines }}</strong> de l'echeancier <strong>{{ echeancier.reference }}</strong> a ete prelevee avec succes.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">

View File

@@ -8,7 +8,7 @@
<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 proposons un echeancier de paiement pour faciliter le reglement de votre solde.
Nous vous proposons un echeancier de paiement <strong>{{ echeancier.reference }}</strong> pour faciliter le reglement de votre solde.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">

View File

@@ -0,0 +1,41 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #dc2626; color: #fff; padding: 12px 20px; display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Echeancier refuse</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">Le client {{ customer.fullName }} a refuse l'echeancier {{ echeancier.reference }}</h1>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Reference</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ echeancier.description }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% if reason %}
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif du refus</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px; color: #dc2626;">{{ reason }}</td>
</tr>
{% endif %}
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Refuse le</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ "now"|date('d/m/Y H:i') }}</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% 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;">
L'echeancier de paiement <strong>{{ echeancier.reference }}</strong> a ete refuse.
</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%;">Reference</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.description }}</td>
</tr>
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% if reason %}
<tr>
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif du refus</td>
<td style="padding: 10px 16px; font-size: 13px; color: #dc2626;">{{ reason }}</td>
</tr>
{% endif %}
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
Si vous souhaitez discuter d'autres modalites de paiement, n'hesitez pas a nous contacter.
</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

@@ -8,7 +8,7 @@
<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 echeancier de paiement est pret a etre signe. Veuillez cliquer sur le bouton ci-dessous pour signer electroniquement le document.
Votre echeancier de paiement <strong>{{ echeancier.reference }}</strong> est pret a etre signe. Veuillez cliquer sur le bouton ci-dessous pour signer electroniquement le document.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">

View File

@@ -0,0 +1,49 @@
{% extends 'email/base.html.twig' %}
{% block content %}
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="padding: 32px;">
<div style="background: #16a34a; color: #fff; padding: 12px 20px; display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;">Echeancier signe</div>
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 20px; font-weight: 700; color: #111827; margin: 0 0 12px;">Le client {{ customer.fullName }} a signe l'echeancier {{ echeancier.reference }}</h1>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 20px; margin: 0 0 16px;">
Le PDF signe et le certificat d'audit DocuSeal sont en piece jointe.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 40%;">Reference</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ echeancier.reference }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Client</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ customer.fullName }}{% if customer.email %}<br><span style="color: #9ca3af; font-size: 11px;">{{ customer.email }}</span>{% endif %}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Motif</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ echeancier.description }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Creance</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px;">{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Majoration (5%)</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; color: #dc2626;">+ {{ echeancier.majoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total a payer</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 14px; font-weight: 700;">{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Mensualite</td>
<td style="padding: 12px 16px; font-family: monospace; font-size: 13px; color: #fabf04;">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;/mois x {{ echeancier.nbLines }}</td>
</tr>
<tr>
<td style="padding: 12px 16px; font-family: Arial; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Signe le</td>
<td style="padding: 12px 16px; font-family: Arial; font-size: 13px;">{{ "now"|date('d/m/Y H:i') }}</td>
</tr>
</table>
</td>
</tr>
</table>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More