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:
@@ -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
|
||||
|
||||
31
migrations/Version20260408192621.php
Normal file
31
migrations/Version20260408192621.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260408193306.php
Normal file
31
migrations/Version20260408193306.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260408194313.php
Normal file
35
migrations/Version20260408194313.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260408194549.php
Normal file
35
migrations/Version20260408194549.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
migrations/Version20260408201451.php
Normal file
33
migrations/Version20260408201451.php
Normal 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');
|
||||
}
|
||||
}
|
||||
40
migrations/Version20260408204544.php
Normal file
40
migrations/Version20260408204544.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260409053555.php
Normal file
31
migrations/Version20260409053555.php
Normal 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');
|
||||
}
|
||||
}
|
||||
BIN
public/facture/Facture Mobile.pdf
Normal file
BIN
public/facture/Facture Mobile.pdf
Normal file
Binary file not shown.
BIN
public/facture/Facture Mobile.pdf:Zone.Identifier
Normal file
BIN
public/facture/Facture Mobile.pdf:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/Facture_Free_202604_12693704_1459149540.pdf
Normal file
BIN
public/facture/Facture_Free_202604_12693704_1459149540.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/facture/Part 11.stl
Normal file
BIN
public/facture/Part 11.stl
Normal file
Binary file not shown.
BIN
public/facture/Part 11.stl:Zone.Identifier
Normal file
BIN
public/facture/Part 11.stl:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/Stripe - Fraix stripe sut transaction.pdf
Normal file
BIN
public/facture/Stripe - Fraix stripe sut transaction.pdf
Normal file
Binary file not shown.
BIN
public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf
Normal file
BIN
public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/facture/amazon/Invoice_EUINFR26_290299.pdf
Normal file
BIN
public/facture/amazon/Invoice_EUINFR26_290299.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/facture/avis-94312151700016-20260403154501.pdf
Normal file
BIN
public/facture/avis-94312151700016-20260403154501.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf
Normal file
BIN
public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/facture/google/GCFRD0012061879 (1).pdf
Normal file
BIN
public/facture/google/GCFRD0012061879 (1).pdf
Normal file
Binary file not shown.
BIN
public/facture/google/GCFRD0012061879 (1).pdf:Zone.Identifier
Normal file
BIN
public/facture/google/GCFRD0012061879 (1).pdf:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75155222 (3).pdf
Normal file
BIN
public/facture/ovh/Facture_FR75155222 (3).pdf
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75155222 (3).pdf:Zone.Identifier
Normal file
BIN
public/facture/ovh/Facture_FR75155222 (3).pdf:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75584205.pdf
Normal file
BIN
public/facture/ovh/Facture_FR75584205.pdf
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75584205.pdf:Zone.Identifier
Normal file
BIN
public/facture/ovh/Facture_FR75584205.pdf:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75598678.pdf
Normal file
BIN
public/facture/ovh/Facture_FR75598678.pdf
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75598678.pdf:Zone.Identifier
Normal file
BIN
public/facture/ovh/Facture_FR75598678.pdf:Zone.Identifier
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75673484.pdf
Normal file
BIN
public/facture/ovh/Facture_FR75673484.pdf
Normal file
Binary file not shown.
BIN
public/facture/ovh/Facture_FR75673484.pdf:Zone.Identifier
Normal file
BIN
public/facture/ovh/Facture_FR75673484.pdf:Zone.Identifier
Normal file
Binary file not shown.
156
src/Command/EcheancierProcessPaymentsCommand.php
Normal file
156
src/Command/EcheancierProcessPaymentsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
208
src/Controller/Admin/AttestationCustomController.php
Normal file
208
src/Controller/Admin/AttestationCustomController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
350
src/Controller/Admin/EFlexController.php
Normal file
350
src/Controller/Admin/EFlexController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
25
src/Controller/AttestationCustomVerifyController.php
Normal file
25
src/Controller/AttestationCustomVerifyController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
348
src/Controller/EFlexProcessController.php
Normal file
348
src/Controller/EFlexProcessController.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
251
src/Entity/AttestationCustom.php
Normal file
251
src/Entity/AttestationCustom.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
413
src/Entity/EFlex.php
Normal 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
180
src/Entity/EFlexLine.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
238
src/Service/Pdf/AttestationCustomPdf.php
Normal file
238
src/Service/Pdf/AttestationCustomPdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
225
src/Service/Pdf/ClientClosurePdf.php
Normal file
225
src/Service/Pdf/ClientClosurePdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
276
src/Service/Pdf/ClientWarningPdf.php
Normal file
276
src/Service/Pdf/ClientWarningPdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
178
src/Service/Pdf/ClientWarningResetPdf.php
Normal file
178
src/Service/Pdf/ClientWarningResetPdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
249
src/Service/Pdf/EFlexPdf.php
Normal file
249
src/Service/Pdf/EFlexPdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
369
src/Service/Pdf/EcheancierAttestationPdf.php
Normal file
369
src/Service/Pdf/EcheancierAttestationPdf.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
121
templates/admin/attestation_custom/index.html.twig
Normal file
121
templates/admin/attestation_custom/index.html.twig
Normal 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 %}
|
||||
107
templates/admin/attestation_custom/show.html.twig
Normal file
107
templates/admin/attestation_custom/show.html.twig
Normal 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 %}
|
||||
@@ -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 }} €)</option>
|
||||
<option value="{{ ech.id }}">{{ ech.reference }} - {{ ech.description|length > 30 ? ech.description[:30] ~ '...' : ech.description }} ({{ ech.totalAmountHt }} €)</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, ',', ' ') }} €</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 }} €)</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, ',', ' ') }} €</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">
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
168
templates/admin/eflex/show.html.twig
Normal file
168
templates/admin/eflex/show.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
@@ -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>
|
||||
|
||||
105
templates/attestation_custom/verify.html.twig
Normal file
105
templates/attestation_custom/verify.html.twig
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
38
templates/echeancier/regularize_success.html.twig
Normal file
38
templates/echeancier/regularize_success.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
184
templates/echeancier/setup_payment.html.twig
Normal file
184
templates/echeancier/setup_payment.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
112
templates/echeancier/setup_payment_done.html.twig
Normal file
112
templates/echeancier/setup_payment_done.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
@@ -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, ',', ' ') }} €</p>
|
||||
<p class="text-xs text-gray-500"><strong>Echeances :</strong> {{ echeancier.nbLines }} x {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
143
templates/eflex/process.html.twig
Normal file
143
templates/eflex/process.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €)
|
||||
</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 %}
|
||||
116
templates/eflex/setup_payment.html.twig
Normal file
116
templates/eflex/setup_payment.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
82
templates/eflex/setup_payment_done.html.twig
Normal file
82
templates/eflex/setup_payment_done.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
34
templates/eflex/signed.html.twig
Normal file
34
templates/eflex/signed.html.twig
Normal 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, ',', ' ') }} €</p>
|
||||
<p class="text-xs text-gray-500"><strong>Echeances :</strong> {{ eflex.nbLines }} x {{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €/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 %}
|
||||
32
templates/eflex/verify.html.twig
Normal file
32
templates/eflex/verify.html.twig
Normal 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 %}
|
||||
48
templates/emails/client_closure.html.twig
Normal file
48
templates/emails/client_closure.html.twig
Normal 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 %}
|
||||
98
templates/emails/client_warning.html.twig
Normal file
98
templates/emails/client_warning.html.twig
Normal 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 %}
|
||||
30
templates/emails/client_warning_reset.html.twig
Normal file
30
templates/emails/client_warning_reset.html.twig
Normal 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 %}
|
||||
103
templates/emails/echeancier_attestation.html.twig
Normal file
103
templates/emails/echeancier_attestation.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
41
templates/emails/echeancier_cancelled_admin.html.twig
Normal file
41
templates/emails/echeancier_cancelled_admin.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} € ({{ 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 %}
|
||||
51
templates/emails/echeancier_cancelled_client.html.twig
Normal file
51
templates/emails/echeancier_cancelled_client.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
@@ -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, ',', ' ') }} €</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, ',', ' ') }} € ({{ 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, ',', ' ') }} €</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 %}
|
||||
@@ -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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
40
templates/emails/echeancier_completed_admin.html.twig
Normal file
40
templates/emails/echeancier_completed_admin.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
76
templates/emails/echeancier_completed_client.html.twig
Normal file
76
templates/emails/echeancier_completed_client.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;">
|
||||
|
||||
41
templates/emails/echeancier_refused_admin.html.twig
Normal file
41
templates/emails/echeancier_refused_admin.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
45
templates/emails/echeancier_refused_client.html.twig
Normal file
45
templates/emails/echeancier_refused_client.html.twig
Normal 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, ',', ' ') }} €</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 %}
|
||||
@@ -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;">
|
||||
|
||||
49
templates/emails/echeancier_signed_admin.html.twig
Normal file
49
templates/emails/echeancier_signed_admin.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €/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
Reference in New Issue
Block a user