diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index b757462..b9bc551 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -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 diff --git a/migrations/Version20260408192621.php b/migrations/Version20260408192621.php new file mode 100644 index 0000000..ce296dd --- /dev/null +++ b/migrations/Version20260408192621.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20260408193306.php b/migrations/Version20260408193306.php new file mode 100644 index 0000000..19d530b --- /dev/null +++ b/migrations/Version20260408193306.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20260408194313.php b/migrations/Version20260408194313.php new file mode 100644 index 0000000..832c27c --- /dev/null +++ b/migrations/Version20260408194313.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/migrations/Version20260408194549.php b/migrations/Version20260408194549.php new file mode 100644 index 0000000..50e400c --- /dev/null +++ b/migrations/Version20260408194549.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/migrations/Version20260408201451.php b/migrations/Version20260408201451.php new file mode 100644 index 0000000..954fe13 --- /dev/null +++ b/migrations/Version20260408201451.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/migrations/Version20260408204544.php b/migrations/Version20260408204544.php new file mode 100644 index 0000000..47bd5ef --- /dev/null +++ b/migrations/Version20260408204544.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/migrations/Version20260409053555.php b/migrations/Version20260409053555.php new file mode 100644 index 0000000..779a77d --- /dev/null +++ b/migrations/Version20260409053555.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/public/facture/Facture Mobile.pdf b/public/facture/Facture Mobile.pdf new file mode 100644 index 0000000..a004f39 Binary files /dev/null and b/public/facture/Facture Mobile.pdf differ diff --git a/public/facture/Facture Mobile.pdf:Zone.Identifier b/public/facture/Facture Mobile.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/Facture Mobile.pdf:Zone.Identifier differ diff --git a/public/facture/Facture_Free_202604_12693704_1459149540.pdf b/public/facture/Facture_Free_202604_12693704_1459149540.pdf new file mode 100644 index 0000000..2a99227 Binary files /dev/null and b/public/facture/Facture_Free_202604_12693704_1459149540.pdf differ diff --git a/public/facture/Facture_Free_202604_12693704_1459149540.pdf:Zone.Identifier b/public/facture/Facture_Free_202604_12693704_1459149540.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/Facture_Free_202604_12693704_1459149540.pdf:Zone.Identifier differ diff --git a/public/facture/Part 11.stl b/public/facture/Part 11.stl new file mode 100644 index 0000000..bd720cd Binary files /dev/null and b/public/facture/Part 11.stl differ diff --git a/public/facture/Part 11.stl:Zone.Identifier b/public/facture/Part 11.stl:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/Part 11.stl:Zone.Identifier differ diff --git a/public/facture/Stripe - Fraix stripe sut transaction.pdf b/public/facture/Stripe - Fraix stripe sut transaction.pdf new file mode 100644 index 0000000..51abe60 Binary files /dev/null and b/public/facture/Stripe - Fraix stripe sut transaction.pdf differ diff --git a/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf b/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf new file mode 100644 index 0000000..3d99863 Binary files /dev/null and b/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf differ diff --git a/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf:Zone.Identifier b/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/ag_normal_ecosplay_29-11-2025 (1).pdf:Zone.Identifier differ diff --git a/public/facture/amazon/Invoice_EUINFR26_290299.pdf b/public/facture/amazon/Invoice_EUINFR26_290299.pdf new file mode 100644 index 0000000..315aac1 Binary files /dev/null and b/public/facture/amazon/Invoice_EUINFR26_290299.pdf differ diff --git a/public/facture/amazon/Invoice_EUINFR26_290299.pdf:Zone.Identifier b/public/facture/amazon/Invoice_EUINFR26_290299.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/amazon/Invoice_EUINFR26_290299.pdf:Zone.Identifier differ diff --git a/public/facture/avis-94312151700016-20260403154501.pdf b/public/facture/avis-94312151700016-20260403154501.pdf new file mode 100644 index 0000000..bd0d15c Binary files /dev/null and b/public/facture/avis-94312151700016-20260403154501.pdf differ diff --git a/public/facture/avis-94312151700016-20260403154501.pdf:Zone.Identifier b/public/facture/avis-94312151700016-20260403154501.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/avis-94312151700016-20260403154501.pdf:Zone.Identifier differ diff --git a/public/facture/demande buvette LAURENA 13 ET 14 SEPTEMBRE 2025.pdf b/public/facture/demande buvette LAURENA 13 ET 14 SEPTEMBRE 2025.pdf new file mode 100644 index 0000000..f2baff6 Binary files /dev/null and b/public/facture/demande buvette LAURENA 13 ET 14 SEPTEMBRE 2025.pdf differ diff --git a/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf b/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf new file mode 100644 index 0000000..c119c9f Binary files /dev/null and b/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf differ diff --git a/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf:Zone.Identifier b/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/docuseal/Invoice-NUXTJ7CH-0006.pdf:Zone.Identifier differ diff --git a/public/facture/google/GCFRD0012061879 (1).pdf b/public/facture/google/GCFRD0012061879 (1).pdf new file mode 100644 index 0000000..ff2db9e Binary files /dev/null and b/public/facture/google/GCFRD0012061879 (1).pdf differ diff --git a/public/facture/google/GCFRD0012061879 (1).pdf:Zone.Identifier b/public/facture/google/GCFRD0012061879 (1).pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/google/GCFRD0012061879 (1).pdf:Zone.Identifier differ diff --git a/public/facture/ovh/Facture_FR75155222 (3).pdf b/public/facture/ovh/Facture_FR75155222 (3).pdf new file mode 100644 index 0000000..147c08e Binary files /dev/null and b/public/facture/ovh/Facture_FR75155222 (3).pdf differ diff --git a/public/facture/ovh/Facture_FR75155222 (3).pdf:Zone.Identifier b/public/facture/ovh/Facture_FR75155222 (3).pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/ovh/Facture_FR75155222 (3).pdf:Zone.Identifier differ diff --git a/public/facture/ovh/Facture_FR75584205.pdf b/public/facture/ovh/Facture_FR75584205.pdf new file mode 100644 index 0000000..d1f820a Binary files /dev/null and b/public/facture/ovh/Facture_FR75584205.pdf differ diff --git a/public/facture/ovh/Facture_FR75584205.pdf:Zone.Identifier b/public/facture/ovh/Facture_FR75584205.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/ovh/Facture_FR75584205.pdf:Zone.Identifier differ diff --git a/public/facture/ovh/Facture_FR75598678.pdf b/public/facture/ovh/Facture_FR75598678.pdf new file mode 100644 index 0000000..fa56f9c Binary files /dev/null and b/public/facture/ovh/Facture_FR75598678.pdf differ diff --git a/public/facture/ovh/Facture_FR75598678.pdf:Zone.Identifier b/public/facture/ovh/Facture_FR75598678.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/ovh/Facture_FR75598678.pdf:Zone.Identifier differ diff --git a/public/facture/ovh/Facture_FR75673484.pdf b/public/facture/ovh/Facture_FR75673484.pdf new file mode 100644 index 0000000..6f2cd71 Binary files /dev/null and b/public/facture/ovh/Facture_FR75673484.pdf differ diff --git a/public/facture/ovh/Facture_FR75673484.pdf:Zone.Identifier b/public/facture/ovh/Facture_FR75673484.pdf:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/facture/ovh/Facture_FR75673484.pdf:Zone.Identifier differ diff --git a/src/Command/EcheancierProcessPaymentsCommand.php b/src/Command/EcheancierProcessPaymentsCommand.php new file mode 100644 index 0000000..b2b5ef1 --- /dev/null +++ b/src/Command/EcheancierProcessPaymentsCommand.php @@ -0,0 +1,156 @@ +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; + } +} diff --git a/src/Controller/Admin/AttestationCustomController.php b/src/Controller/Admin/AttestationCustomController.php new file mode 100644 index 0000000..9c4198f --- /dev/null +++ b/src/Controller/Admin/AttestationCustomController.php @@ -0,0 +1,208 @@ +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); + } +} diff --git a/src/Controller/Admin/ClientsController.php b/src/Controller/Admin/ClientsController.php index b9e0ba6..35db509 100644 --- a/src/Controller/Admin/ClientsController.php +++ b/src/Controller/Admin/ClientsController.php @@ -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 $adverts + * @param list $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']); + } } diff --git a/src/Controller/Admin/ComptabiliteController.php b/src/Controller/Admin/ComptabiliteController.php index 89a231c..8fca540 100644 --- a/src/Controller/Admin/ComptabiliteController.php +++ b/src/Controller/Admin/ComptabiliteController.php @@ -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', ]; } } diff --git a/src/Controller/Admin/EFlexController.php b/src/Controller/Admin/EFlexController.php new file mode 100644 index 0000000..a7ad474 --- /dev/null +++ b/src/Controller/Admin/EFlexController.php @@ -0,0 +1,350 @@ + '\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); + } +} diff --git a/src/Controller/Admin/EcheancierController.php b/src/Controller/Admin/EcheancierController.php index 136f1df..411bcfe 100644 --- a/src/Controller/Admin/EcheancierController.php +++ b/src/Controller/Admin/EcheancierController.php @@ -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()); } diff --git a/src/Controller/Admin/FactureController.php b/src/Controller/Admin/FactureController.php index 108b8ad..e157155 100644 --- a/src/Controller/Admin/FactureController.php +++ b/src/Controller/Admin/FactureController.php @@ -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(); diff --git a/src/Controller/Admin/StatsController.php b/src/Controller/Admin/StatsController.php index 7b080e6..a789ec1 100644 --- a/src/Controller/Admin/StatsController.php +++ b/src/Controller/Admin/StatsController.php @@ -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} + */ + 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) { diff --git a/src/Controller/AttestationCustomVerifyController.php b/src/Controller/AttestationCustomVerifyController.php new file mode 100644 index 0000000..9d7390f --- /dev/null +++ b/src/Controller/AttestationCustomVerifyController.php @@ -0,0 +1,25 @@ + '\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, + ]); + } +} diff --git a/src/Controller/EFlexProcessController.php b/src/Controller/EFlexProcessController.php new file mode 100644 index 0000000..2ed24e4 --- /dev/null +++ b/src/Controller/EFlexProcessController.php @@ -0,0 +1,348 @@ + '\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 + } +} diff --git a/src/Controller/EcheancierProcessController.php b/src/Controller/EcheancierProcessController.php index 046d7c1..8beff0a 100644 --- a/src/Controller/EcheancierProcessController.php +++ b/src/Controller/EcheancierProcessController.php @@ -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(); diff --git a/src/Controller/WebhookDocuSealController.php b/src/Controller/WebhookDocuSealController.php index 12b21d6..37c6299 100644 --- a/src/Controller/WebhookDocuSealController.php +++ b/src/Controller/WebhookDocuSealController.php @@ -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 $data + * @param array $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 $data + * @param array $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 $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 $data + * @param array $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 $data + * @param array $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 $data + * @param array $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 $data + * @param array $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 $data + * @param array $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 $data + * @param array $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 $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. */ diff --git a/src/Controller/WebhookStripeController.php b/src/Controller/WebhookStripeController.php index e969c3a..9f0b1bb 100644 --- a/src/Controller/WebhookStripeController.php +++ b/src/Controller/WebhookStripeController.php @@ -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. * diff --git a/src/Entity/AttestationCustom.php b/src/Entity/AttestationCustom.php new file mode 100644 index 0000000..f2631d6 --- /dev/null +++ b/src/Entity/AttestationCustom.php @@ -0,0 +1,251 @@ + */ + #[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 $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 */ + public function getItems(): array + { + return $this->items; + } + + /** @param list $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'); + } +} diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 3eefacf..e914ec5 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -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; + } } diff --git a/src/Entity/EFlex.php b/src/Entity/EFlex.php new file mode 100644 index 0000000..b2b42da --- /dev/null +++ b/src/Entity/EFlex.php @@ -0,0 +1,413 @@ + '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 */ + #[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 */ + 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, + }; + } +} diff --git a/src/Entity/EFlexLine.php b/src/Entity/EFlexLine.php new file mode 100644 index 0000000..2667feb --- /dev/null +++ b/src/Entity/EFlexLine.php @@ -0,0 +1,180 @@ + '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; + } +} diff --git a/src/Entity/Echeancier.php b/src/Entity/Echeancier.php index 5ccc1a1..8cfcc86 100644 --- a/src/Entity/Echeancier.php +++ b/src/Entity/Echeancier.php @@ -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); + } } diff --git a/src/Entity/EcheancierLine.php b/src/Entity/EcheancierLine.php index 1b20501..8c3289f 100644 --- a/src/Entity/EcheancierLine.php +++ b/src/Entity/EcheancierLine.php @@ -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; diff --git a/src/Service/ComptaExportService.php b/src/Service/ComptaExportService.php index 104169d..8b60493 100644 --- a/src/Service/ComptaExportService.php +++ b/src/Service/ComptaExportService.php @@ -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> + */ + 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; + } } diff --git a/src/Service/DocuSealService.php b/src/Service/DocuSealService.php index f82e52c..ccbe45e 100644 --- a/src/Service/DocuSealService.php +++ b/src/Service/DocuSealService.php @@ -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 * diff --git a/src/Service/Pdf/AttestationCustomPdf.php b/src/Service/Pdf/AttestationCustomPdf.php new file mode 100644 index 0000000..3c87e73 --- /dev/null +++ b/src/Service/Pdf/AttestationCustomPdf.php @@ -0,0 +1,238 @@ +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'); + } +} diff --git a/src/Service/Pdf/ClientClosurePdf.php b/src/Service/Pdf/ClientClosurePdf.php new file mode 100644 index 0000000..17da4df --- /dev/null +++ b/src/Service/Pdf/ClientClosurePdf.php @@ -0,0 +1,225 @@ +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'); + } +} diff --git a/src/Service/Pdf/ClientWarningPdf.php b/src/Service/Pdf/ClientWarningPdf.php new file mode 100644 index 0000000..2b611f3 --- /dev/null +++ b/src/Service/Pdf/ClientWarningPdf.php @@ -0,0 +1,276 @@ + '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 $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'); + } +} diff --git a/src/Service/Pdf/ClientWarningResetPdf.php b/src/Service/Pdf/ClientWarningResetPdf.php new file mode 100644 index 0000000..3a1f2bc --- /dev/null +++ b/src/Service/Pdf/ClientWarningResetPdf.php @@ -0,0 +1,178 @@ +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'); + } +} diff --git a/src/Service/Pdf/EFlexPdf.php b/src/Service/Pdf/EFlexPdf.php new file mode 100644 index 0000000..b91303d --- /dev/null +++ b/src/Service/Pdf/EFlexPdf.php @@ -0,0 +1,249 @@ +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'); + } +} diff --git a/src/Service/Pdf/EcheancierAttestationPdf.php b/src/Service/Pdf/EcheancierAttestationPdf.php new file mode 100644 index 0000000..67901da --- /dev/null +++ b/src/Service/Pdf/EcheancierAttestationPdf.php @@ -0,0 +1,369 @@ +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'); + } +} diff --git a/src/Service/Pdf/EcheancierPdf.php b/src/Service/Pdf/EcheancierPdf.php index 7b87347..7020719 100644 --- a/src/Service/Pdf/EcheancierPdf.php +++ b/src/Service/Pdf/EcheancierPdf.php @@ -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 diff --git a/templates/admin/_layout.html.twig b/templates/admin/_layout.html.twig index ac3a41d..6cad9bc 100644 --- a/templates/admin/_layout.html.twig +++ b/templates/admin/_layout.html.twig @@ -97,6 +97,10 @@ Tarification + + + Attestations + {% endif %} diff --git a/templates/admin/attestation_custom/index.html.twig b/templates/admin/attestation_custom/index.html.twig new file mode 100644 index 0000000..9b7c3ad --- /dev/null +++ b/templates/admin/attestation_custom/index.html.twig @@ -0,0 +1,121 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Attestations - Association E-Cosplay{% endblock %} + +{% block admin_content %} +
+
+

Attestations

+ +
+ + {% for type, messages in app.flashes %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endfor %} + + {% if attestations|length > 0 %} +
+ + + + + + + + + + + + + {% for att in attestations %} + + + + + + + + + {% endfor %} + +
ReferenceTitreElementsStatutDateActions
{{ att.reference }}{{ att.title|length > 50 ? att.title[:50] ~ '...' : att.title }}{{ att.items|length }} + {% if att.state == 'signed' %} + Signee + {% else %} + Brouillon + {% endif %} + {{ att.createdAt|date('d/m/Y H:i') }} + Voir +
+
+ {% else %} +
Aucune attestation.
+ {% endif %} + + {# Modal creation #} + +
+ + +{% endblock %} diff --git a/templates/admin/attestation_custom/show.html.twig b/templates/admin/attestation_custom/show.html.twig new file mode 100644 index 0000000..9bbc044 --- /dev/null +++ b/templates/admin/attestation_custom/show.html.twig @@ -0,0 +1,107 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Attestation {{ attestation.reference }} - Association E-Cosplay{% endblock %} + +{% block admin_content %} +
+
+
+

{{ attestation.reference }}

+

{{ attestation.title }}

+
+
+ {% if attestation.state == 'signed' %} + Signee + {% else %} + Brouillon + {% endif %} + Retour +
+
+ + {% for type, messages in app.flashes %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endfor %} + + {# Contenu de l'attestation #} +
+

{{ attestation.title }}

+ +

+ "Je soussigne(e), President(e) de l'Association E-Cosplay et le bureau de l'association, atteste les elements suivants :" +

+ +
+ {% for item in attestation.items %} +
+ {{ loop.index }}. +

{{ item }}

+
+ {% endfor %} +
+ +

+ "La presente attestation est etablie pour faire valoir les droits de l'Association E-Cosplay. Les elements presentes ci-dessus sont conformes et veridiques." +

+
+ + {# Infos techniques #} +
+
+
+

Reference

+

{{ attestation.reference }}

+
+
+

HMAC

+

{{ attestation.hmac[:16] }}...

+
+
+

Creee le

+

{{ attestation.createdAt|date('d/m/Y H:i') }}

+
+ {% if attestation.signedAt %} +
+

Signee le

+

{{ attestation.signedAt|date('d/m/Y H:i') }}

+
+ {% endif %} +
+
+ + {# Actions #} +
+ {% if attestation.pdfUnsigned %} + + Voir PDF + + {% endif %} + {% if attestation.state == 'draft' %} +
+ +
+
+ +
+ {% endif %} + {% if attestation.pdfSigned %} + + PDF signe + + {% endif %} + {% if attestation.pdfAudit %} + + Audit signature + + {% endif %} +
+ +
+
+
+{% endblock %} diff --git a/templates/admin/clients/show.html.twig b/templates/admin/clients/show.html.twig index c94a562..f829a14 100644 --- a/templates/admin/clients/show.html.twig +++ b/templates/admin/clients/show.html.twig @@ -12,8 +12,17 @@ {% endif %}
+ {% if trustStatus.status == 'confiant' %} + Confiant + {% elseif trustStatus.status == 'attention' %} + Attention + {% elseif trustStatus.status == 'danger' %} + Danger + {% endif %} {% if customer.state == 'active' %} Actif + {% elseif customer.state == 'suspended' %} + Suspendu {% elseif customer.state == 'pending_delete' %} Suppression {% 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 %}
{% for key, label in tabs %} @@ -543,6 +555,11 @@
+ {% if f.state == 'paid' and f.state != 'send' %} +
+ +
+ {% endif %} {% endif %}
{% else %} @@ -726,7 +743,7 @@
@@ -887,8 +904,22 @@ {# Tab: Echeancier #} {% elseif tab == 'echeancier' %}
-

Echeanciers

- +
+

Echeanciers

+ {% if trustStatus.status == 'confiant' %} + Confiant + {% elseif trustStatus.status == 'attention' %} + Attention + {% elseif trustStatus.status == 'danger' %} + Danger + {% endif %} + {{ trustStatus.reason }} +
+ {% if trustStatus.status == 'danger' %} + Creation bloquee + {% else %} + + {% endif %}
{# Liste des echeanciers existants #} @@ -897,6 +928,7 @@ + @@ -908,6 +940,7 @@ {% for e in echeancierList %} + @@ -963,6 +996,15 @@ +
+ + +
@@ -1041,6 +1083,228 @@ {% endif %} + {# Tab: Controle (ROLE_ROOT uniquement) #} + {% elseif tab == 'controle' and is_granted('ROLE_ROOT') %} +
+
+

Controle client

+ {% if trustStatus.status == 'confiant' %} + Confiant + {% elseif trustStatus.status == 'attention' %} + Attention + {% elseif trustStatus.status == 'danger' %} + Danger + {% endif %} +
+
+ + {# Resume confiance #} +
+

Statut de confiance

+

{{ trustStatus.reason }}

+
+ + {# Historique avertissements #} +
+

Avertissements

+ {% if customer.warningLevel %} +
+ Dernier avertissement : + {% if customer.warningLevel == '1st' %} + 1er avertissement + {% elseif customer.warningLevel == '2nd' %} + 2eme avertissement + {% elseif customer.warningLevel == 'last' %} + Dernier avertissement + {% endif %} + {% if customer.warningAt %} + envoye le {{ customer.warningAt|date('d/m/Y H:i') }} + {% endif %} +
+ {% else %} +

Aucun avertissement envoye.

+ {% 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' %} +
+ +

Motifs de l'avertissement

+
+ + + + +
+ +
+ +
+ + {% else %} +

Tous les avertissements ont ete envoyes.

+ +
+ {# Bouton notification (faire peur - n'effectue pas la suppression) #} +
+ + + + {# Bouton reel de suspension #} +
+ + +
+ {% endif %} + + {% if customer.warningLevel %} +
+ + + {% endif %} +
+ + {# Progression des avertissements #} +
+

Progression

+
+
+
+
+
+
+
+ 1er avert. + 2eme avert. + Dernier avert. + Cloture +
+
+ + {# Tab: E-Flex #} + {% elseif tab == 'esyflex' %} +
+

E-Flex

+ +
+ + {% if eflexList|length > 0 %} +
+
Reference Description Total HT Echeances
{{ e.reference }} {{ e.description|length > 50 ? e.description[:50] ~ '...' : e.description }} {{ e.totalAmountHt|number_format(2, ',', ' ') }} € {{ e.nbPaid }}/{{ e.nbLines }}
+ + + + + + + + + + + + + + {% for e in eflexList %} + + + + + + + + + + + {% endfor %} + +
ReferenceDescriptionTotalEcheancesMethodeProgressionStatutActions
{{ e.reference }}{{ e.description|length > 40 ? e.description[:40] ~ '...' : e.description }}{{ e.totalAmount|number_format(2, ',', ' ') }} €{{ e.nbPaid }}/{{ e.nbLines }}{{ e.paymentMethodLabel }} +
+
+ {% if e.state == 'active' %} + Actif + {% elseif e.state == 'completed' %} + Termine + {% elseif e.state == 'cancelled' %} + Annule + {% elseif e.state == 'draft' %} + Brouillon + {% elseif e.state == 'pending_setup' %} + En attente + {% else %} + {{ e.state }} + {% endif %} + + Voir +
+ + {% else %} +
Aucun contrat E-Flex pour ce client.
+ {% endif %} + + {# Modal creation E-Flex #} + + {# Tabs placeholder #} {% else %}
diff --git a/templates/admin/comptabilite/index.html.twig b/templates/admin/comptabilite/index.html.twig index 2d7184b..83f4345 100644 --- a/templates/admin/comptabilite/index.html.twig +++ b/templates/admin/comptabilite/index.html.twig @@ -160,6 +160,35 @@
+ {# Echeanciers #} +
+
+ Echeanciers de paiement +
+
+

Export des echeanciers et echeances : reference, client, creance, majoration, paiements recus, statut SEPA.

+
    +
  • - Detail par echeance (date, montant, statut)
  • +
  • - Majoration 5% incluse
  • +
  • - Avis de paiement lie
  • +
+
+ + + +
+
+
{# Rapport financier public #} diff --git a/templates/admin/echeancier/show.html.twig b/templates/admin/echeancier/show.html.twig index 118d4db..97ab209 100644 --- a/templates/admin/echeancier/show.html.twig +++ b/templates/admin/echeancier/show.html.twig @@ -7,7 +7,7 @@

Echeancier

-

{{ customer.fullName }} - {{ echeancier.description }}

+

{{ echeancier.reference }} - {{ customer.fullName }} - {{ echeancier.description }}

{% if echeancier.state == 'draft' %} @@ -16,6 +16,8 @@ Envoye {% elseif echeancier.state == 'signed' %} Signe + {% elseif echeancier.state == 'pending_setup' %} + En attente SEPA {% elseif echeancier.state == 'active' %} Actif {% elseif echeancier.state == 'completed' %} @@ -64,11 +66,14 @@

Motif

{{ echeancier.description }}

+ {% if echeancier.advert %} +

Avis lie : {{ echeancier.advert.orderNumber.numOrder }}

+ {% endif %}
{# Actions #}
- {% if echeancier.state not in ['cancelled', 'completed'] %} + {% if echeancier.state in ['draft', 'send'] %} {% if echeancier.pdfUnsigned %}
@@ -79,7 +84,7 @@
{% endif %} {% endif %} - {% if echeancier.pdfUnsigned %} + {% if echeancier.pdfUnsigned and echeancier.state in ['draft', 'send'] %} Voir PDF @@ -90,18 +95,34 @@ {% endif %} - {% if echeancier.state == 'signed' %} -
- -
- {% endif %} {% if echeancier.pdfSigned %}
- PDF signe + Voir echeancier signe {% endif %} - {% if echeancier.state in ['draft', 'send', 'signed', 'active'] %} + {% if echeancier.pdfAudit %} + + Audit de signature + + {% endif %} + {% if echeancier.state in ['signed', 'pending_setup'] %} +
+ +
+ {% endif %} + {% if echeancier.stripePaymentMethodId and echeancier.state in ['active', 'pending_setup'] %} +
+ +
+ {% endif %} + {% if echeancier.state not in ['draft'] %} +
+ +
+ {% endif %} + {% if echeancier.state in ['draft', 'send', 'signed', 'pending_setup', 'active'] %}
@@ -119,6 +140,7 @@ Montant Statut Paye le + Actions @@ -142,14 +164,50 @@ {{ line.failureReason }} {% endif %} + + {% if line.isPending and echeancier.stripePaymentMethodId and not line.stripePaymentIntentId %} +
+ +
+ {% elseif line.isFailed and echeancier.stripePaymentMethodId %} +
+ +
+ {% endif %} + {% endfor %}
- {% if echeancier.stripeSubscriptionId %} -

Stripe Subscription: {{ echeancier.stripeSubscriptionId }}

+ {% if echeancier.stripePaymentMethodId %} +
+

Mandat SEPA

+
+
+

IBAN

+

**** **** **** {{ echeancier.stripeSepaLast4 ?: '****' }}

+
+ {% if echeancier.stripeSepaBankName %} +
+

Code banque

+

{{ echeancier.stripeSepaBankName }}

+
+ {% endif %} + {% if echeancier.stripeSepaCountry %} +
+

Pays

+

{{ echeancier.stripeSepaCountry }}

+
+ {% endif %} +
+

Statut

+

Actif

+
+
+

{{ echeancier.stripePaymentMethodId }}

+
{% endif %}
{% endblock %} diff --git a/templates/admin/eflex/show.html.twig b/templates/admin/eflex/show.html.twig new file mode 100644 index 0000000..16ff576 --- /dev/null +++ b/templates/admin/eflex/show.html.twig @@ -0,0 +1,168 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}E-Flex {{ eflex.reference }} - {{ customer.fullName }}{% endblock %} + +{% block admin_content %} +
+
+
+

E-Flex {{ eflex.reference }}

+

{{ eflex.reference }} - {{ customer.fullName }} - {{ eflex.description }}

+
+
+ {% if eflex.state == 'active' %} + Actif + {% elseif eflex.state == 'completed' %} + Termine + {% elseif eflex.state == 'cancelled' %} + Annule + {% elseif eflex.state == 'draft' %} + Brouillon + {% elseif eflex.state == 'pending_setup' %} + En attente paiement + {% else %} + {{ eflex.state }} + {% endif %} + Retour +
+
+ + {# Resume #} +
+
+

Montant total

+

{{ eflex.totalAmount|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Progression

+

{{ eflex.nbPaid }}/{{ eflex.nbLines }}

+
+
+
+

Methode

+

{{ eflex.paymentMethodLabel }}

+
+
+ + {# Description #} +
+

Description

+

{{ eflex.description }}

+
+ + {# Actions #} +
+ {% if eflex.state == 'draft' %} + {% if eflex.pdfUnsigned %} +
+ +
+ Voir PDF +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% endif %} + {% if eflex.pdfSigned %} + Voir contrat signe + {% endif %} + {% if eflex.pdfAudit %} + Audit signature + {% endif %} + {% if eflex.state in ['draft', 'active', 'pending_setup'] %} +
+ +
+ {% endif %} +
+ + {# SEPA info #} + {% if eflex.stripePaymentMethodId %} +
+

Mandat SEPA

+
+
+

IBAN

+

**** **** **** {{ eflex.stripeSepaLast4 ?: '****' }}

+
+ {% if eflex.stripeSepaCountry %} +
+

Pays

+

{{ eflex.stripeSepaCountry }}

+
+ {% endif %} +
+

Statut

+

Actif

+
+
+
+ {% endif %} + + {# Echeances #} +

Echeances

+
+ + + + + + + + + + + + + {% for line in eflex.lines %} + + + + + + + + + {% endfor %} + +
NDate prevueMontantStatutPaye leActions
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye + {% elseif line.isFailed %} + Echoue + {% else %} + En attente + {% endif %} + + {{ line.paidAt ? line.paidAt|date('d/m/Y H:i') : '—' }} + {% if line.paidMethod %}({{ line.paidMethod }}){% endif %} + {% if line.failureReason %}{{ line.failureReason }}{% endif %} + + {% if line.isPending and eflex.stripePaymentMethodId and not line.stripePaymentIntentId %} +
+ +
+ {% endif %} + {% if (line.isPending or line.isFailed) and not line.stripePaymentIntentId %} +
+ + +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/admin/stats/index.html.twig b/templates/admin/stats/index.html.twig index 699f46a..72cfa1c 100644 --- a/templates/admin/stats/index.html.twig +++ b/templates/admin/stats/index.html.twig @@ -96,12 +96,47 @@

{{ global.factures_payees }} facture(s)

-

Impayees

-

{{ global.montant_impaye|number_format(2, ',', ' ') }} €

-

{{ global.factures_impayees }} facture(s)

+

Total impaye (global)

+

{{ (global.montant_impaye + echeancierStats.montantImpaye)|number_format(2, ',', ' ') }} €

+

{{ global.factures_impayees }} facture(s) + {{ echeancierStats.nbEcheancesImpayees }} echeance(s)

+
+

Factures : {{ global.montant_impaye|number_format(2, ',', ' ') }} €

+

Echeanciers : {{ echeancierStats.montantImpaye|number_format(2, ',', ' ') }} € ({{ echeancierStats.nbEcheanciers }} en cours)

+
+ {# Detail echeanciers en cours #} + {% if echeancierStats.echeanciers|length > 0 %} +

Echeanciers en cours

+
+ + + + + + + + + + + + {% for ech in echeancierStats.echeanciers %} + + + + + + + + {% endfor %} + +
ReferenceClientEcheances restantesRestant duActions
{{ ech.reference }}{{ ech.customer }}{{ ech.nbPending }}{{ ech.restant|number_format(2, ',', ' ') }} € + Voir +
+
+ {% endif %} + {# Services #}

Services

diff --git a/templates/attestation_custom/verify.html.twig b/templates/attestation_custom/verify.html.twig new file mode 100644 index 0000000..94b4813 --- /dev/null +++ b/templates/attestation_custom/verify.html.twig @@ -0,0 +1,105 @@ +{% extends 'base.html.twig' %} + +{% block title %}Verification attestation - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ E-Cosplay +
+

Verification d'attestation

+

Association E-Cosplay

+
+
+
+ +
+ {% if valid and attestation %} + {# Attestation valide #} +
+ + + +
+

Attestation authentique et verifiee

+

Ce document a ete emis par l'Association E-Cosplay et son integrite est confirmee.

+
+
+ +
+

{{ attestation.title }}

+ +

+ "Je soussigne(e), President(e) de l'Association E-Cosplay et le bureau de l'association, atteste les elements suivants :" +

+ +
+ {% for item in attestation.items %} +
+ {{ loop.index }}. +

{{ item }}

+
+ {% endfor %} +
+ +

+ "La presente attestation est etablie pour faire valoir les droits de l'Association E-Cosplay. Les elements presentes ci-dessus sont conformes et veridiques." +

+
+ +
+
+

Reference

+

{{ attestation.reference }}

+
+
+

Emise le

+

{{ attestation.createdAt|date('d/m/Y') }}

+
+
+

Statut

+ {% if attestation.state == 'signed' %} +

Signee electroniquement

+ {% else %} +

Brouillon

+ {% endif %} +
+ {% if attestation.signedAt %} +
+

Signee le

+

{{ attestation.signedAt|date('d/m/Y H:i') }}

+
+ {% endif %} +
+ +
+

Code HMAC d'integrite

+

{{ attestation.hmac }}

+
+ + {% else %} + {# Attestation invalide #} +
+ + + +
+

Attestation non valide

+

Ce document n'a pas pu etre verifie. Il est possible qu'il ait ete falsifie ou que le lien soit incorrect.

+
+
+ +

+ Si vous pensez qu'il s'agit d'une erreur, veuillez contacter l'Association E-Cosplay. +

+ {% endif %} + +

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

+
+
+
+{% endblock %} diff --git a/templates/echeancier/process.html.twig b/templates/echeancier/process.html.twig index 99838c9..b21a4cb 100644 --- a/templates/echeancier/process.html.twig +++ b/templates/echeancier/process.html.twig @@ -10,7 +10,7 @@ E-Cosplay

Echeancier de paiement

-

Association E-Cosplay

+

{{ echeancier.reference }} - Association E-Cosplay

diff --git a/templates/echeancier/refused.html.twig b/templates/echeancier/refused.html.twig index ecc11d8..de8e820 100644 --- a/templates/echeancier/refused.html.twig +++ b/templates/echeancier/refused.html.twig @@ -10,10 +10,11 @@

Echeancier refuse

+

{{ echeancier.reference }}

- L'echeancier de paiement a ete refuse. + L'echeancier de paiement {{ echeancier.reference }} a ete refuse.

Si vous souhaitez discuter d'autres modalites de paiement, contactez-nous a diff --git a/templates/echeancier/regularize_success.html.twig b/templates/echeancier/regularize_success.html.twig new file mode 100644 index 0000000..f44cb3e --- /dev/null +++ b/templates/echeancier/regularize_success.html.twig @@ -0,0 +1,38 @@ +{% extends 'base.html.twig' %} + +{% block title %}Paiement effectue - {{ echeancier.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +

+
+
+
+ + + +
+

Paiement effectue

+

{{ echeancier.reference }}

+
+
+
+ +
+

+ Votre echeance {{ line.label }} a ete regularisee avec succes par carte bancaire. +

+
+

Reference : {{ echeancier.reference }}

+

Echeance : {{ line.position }}/{{ echeancier.nbLines }}

+

Montant paye : {{ line.amount|number_format(2, ',', ' ') }} €

+
+

+ Vous recevrez un email de confirmation lorsque le paiement sera traite. +

+

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

+
+
+
+{% endblock %} diff --git a/templates/echeancier/setup_payment.html.twig b/templates/echeancier/setup_payment.html.twig new file mode 100644 index 0000000..82c21fb --- /dev/null +++ b/templates/echeancier/setup_payment.html.twig @@ -0,0 +1,184 @@ +{% extends 'base.html.twig' %} + +{% block title %}Configuration prelevement SEPA - {{ echeancier.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ E-Cosplay +
+

Prelevement SEPA

+

{{ echeancier.reference }} - Association E-Cosplay

+
+
+
+ +
+

+ Pour finaliser la mise en place de votre echeancier, veuillez renseigner votre IBAN ci-dessous. + Les prelevements seront effectues automatiquement aux dates prevues. +

+ + {# Resume #} +
+
+

Total a payer

+

{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Echeances

+

{{ echeancier.nbLines }} mois

+
+
+ + {# Informations du mandat #} +
+

Informations du mandat SEPA

+
+

Crediteur : Association E-Cosplay

+

Reference : {{ echeancier.reference }}

+

Montant/echeance : {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €

+

Nombre d'echeances : {{ echeancier.nbLines }}

+

1ere echeance : {{ echeancier.lines|first ? echeancier.lines|first.scheduledAt|date('d/m/Y') : '—' }}

+

Derniere echeance : {{ echeancier.lines|last ? echeancier.lines|last.scheduledAt|date('d/m/Y') : '—' }}

+
+
+ + {# Formulaire IBAN #} +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {# Mandat SEPA #} +
+

Mandat de prelevement SEPA

+

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.

+

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.

+
+ + + + +
+ +

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

+
+
+
+ + + +{% endblock %} diff --git a/templates/echeancier/setup_payment_done.html.twig b/templates/echeancier/setup_payment_done.html.twig new file mode 100644 index 0000000..6b9ecd5 --- /dev/null +++ b/templates/echeancier/setup_payment_done.html.twig @@ -0,0 +1,112 @@ +{% extends 'base.html.twig' %} + +{% block title %}Mandat SEPA configure - {{ echeancier.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ + + +
+

Mandat SEPA configure

+

{{ echeancier.reference }} - Association E-Cosplay

+
+
+
+ +
+
+ + + +
+

Votre mandat de prelevement SEPA est actif

+

Les echeances seront prelevees automatiquement aux dates prevues.

+
+
+ + {# Motif #} +
+

Motif

+

{{ echeancier.description }}

+
+ + {# Resume #} +
+
+

Creance

+

{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €

+
+
+

Majoration 5%

+

+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €

+
+
+

Total a payer

+

{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €

+

en {{ echeancier.nbLines }} mois

+
+
+

Mensualite

+

{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+ + {# Tableau echeances avec statut #} +
+ + + + + + + + + + + {% for line in echeancier.lines %} + + + + + + + {% endfor %} + +
NDate prevueMontantStatut
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye + {% elseif line.isFailed %} + Echoue + {% else %} + A prelever + {% endif %} +
+
+ + {% if echeancier.nbPaid > 0 %} +
+
+ Progression + {{ echeancier.nbPaid }}/{{ echeancier.nbLines }} +
+
+
+
+
+ {% endif %} + +

+ Vous recevrez un email de confirmation a chaque prelevement effectue. +

+ +

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

+
+
+
+{% endblock %} diff --git a/templates/echeancier/signed.html.twig b/templates/echeancier/signed.html.twig index 24ab384..9df7dc0 100644 --- a/templates/echeancier/signed.html.twig +++ b/templates/echeancier/signed.html.twig @@ -4,27 +4,88 @@ {% block body %}
-
-
- - - -

Echeancier signe

-
-
-

- Merci {{ customer.firstName }}, votre echeancier de paiement a ete signe avec succes. -

-
-

Montant total : {{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €

-

Echeances : {{ echeancier.nbLines }} x {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois

-

Motif : {{ echeancier.description }}

+
+
+
+ + + +
+

Echeancier signe

+

{{ echeancier.reference }} - Association E-Cosplay

+
-

- Vous allez recevoir un email pour configurer les prelevements automatiques. +

+ +
+

+ Votre echeancier de paiement a ete signe avec succes.

-

- Pour toute question : contact@e-cosplay.fr + + {# Motif #} +

+

Motif

+

{{ echeancier.description }}

+
+ + {# Resume #} +
+
+

Creance

+

{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €

+
+
+

Majoration 5%

+

+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €

+
+
+

Total a payer

+

{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €

+

en {{ echeancier.nbLines }} mois

+
+
+

Mensualite

+

{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+ + {# Tableau echeances #} +
+ + + + + + + + + + {% for line in echeancier.lines %} + + + + + + {% endfor %} + +
NDate prevueMontant
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} €
+
+ + {% if echeancier.stripePaymentMethodId %} +
+ + + +

Mandat SEPA configure - Les prelevements seront effectues automatiquement.

+
+ {% else %} +

+ Vous allez recevoir un email pour configurer les prelevements automatiques. +

+ {% endif %} + +

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

diff --git a/templates/echeancier/verify.html.twig b/templates/echeancier/verify.html.twig index eaae832..1e9797d 100644 --- a/templates/echeancier/verify.html.twig +++ b/templates/echeancier/verify.html.twig @@ -7,7 +7,7 @@

Verification

-

Un code a ete envoye a {{ customer.email }}

+

{{ echeancier.reference }} - Un code a ete envoye a {{ customer.email }}

{% if error %} diff --git a/templates/eflex/process.html.twig b/templates/eflex/process.html.twig new file mode 100644 index 0000000..ad0eafb --- /dev/null +++ b/templates/eflex/process.html.twig @@ -0,0 +1,143 @@ +{% extends 'base.html.twig' %} + +{% block title %}E-Flex {{ eflex.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ E-Cosplay +
+

Contrat E-Flex

+

{{ eflex.reference }} - Association E-Cosplay

+
+
+
+ +
+

+ {% if customer.raisonSociale %}Chez {{ customer.raisonSociale }}{% else %}Bonjour {{ customer.firstName }}{% endif %}, + voici le detail de votre contrat de financement E-Flex. +

+ + {# Description #} +
+

Service finance

+

{{ eflex.description }}

+
+ + {# Resume #} +
+
+

Montant total

+

{{ eflex.totalAmount|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Echeances

+

{{ eflex.nbLines }} mois

+
+
+ + {# Tableau echeances #} +
+ + + + + + + + + + + {% for line in eflex.lines %} + + + + + + + {% endfor %} + +
NDate prevueMontantStatut
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye + {% elseif line.isFailed %} + Echoue + {% else %} + A payer + {% endif %} +
+
+ + {# Conditions #} +
+

Conditions

+

E-Flex est une solution de financement sans frais supplementaires proposee par l'Association E-Cosplay.

+

Les prelevements seront effectues automatiquement a chaque date prevue.

+
+ + {# Boutons signer / refuser (si pas encore signe) #} + {% if eflex.submissionId and eflex.state == 'draft' %} + + {% endif %} + + {# Boutons paiement (si actif ou pending_setup) #} + {% if eflex.state in ['active', 'pending_setup'] %} +
+ {% if eflex.paymentMethod == 'sepa' and not eflex.stripePaymentMethodId %} + + Configurer le prelevement SEPA + + {% endif %} + {% for line in eflex.lines %} + {% if line.isPending or line.isFailed %} + + Payer echeance {{ line.position }} ({{ line.amount|number_format(2, ',', ' ') }} €) + + {% endif %} + {% endfor %} +
+ {% endif %} + + {# SEPA configure #} + {% if eflex.stripePaymentMethodId %} +
+ + + +

Prelevement SEPA configure - IBAN **** {{ eflex.stripeSepaLast4 ?: '****' }}

+
+ {% endif %} + + {% if eflex.nbPaid > 0 %} +
+
+ Progression + {{ eflex.nbPaid }}/{{ eflex.nbLines }} +
+
+
+
+
+ {% endif %} + +

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

+
+
+
+{% endblock %} diff --git a/templates/eflex/setup_payment.html.twig b/templates/eflex/setup_payment.html.twig new file mode 100644 index 0000000..e82e836 --- /dev/null +++ b/templates/eflex/setup_payment.html.twig @@ -0,0 +1,116 @@ +{% extends 'base.html.twig' %} + +{% block title %}Configuration prelevement SEPA - {{ eflex.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ E-Cosplay +
+

Prelevement SEPA - E-Flex

+

{{ eflex.reference }}

+
+
+
+ +
+

Renseignez votre IBAN pour activer les prelevements automatiques.

+ +
+
+

Total

+

{{ eflex.totalAmount|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Echeances

+

{{ eflex.nbLines }} mois

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+

Mandat de prelevement SEPA

+

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.

+
+ + + + +
+ +

Pour toute question : contact@e-cosplay.fr

+
+
+
+ + + +{% endblock %} diff --git a/templates/eflex/setup_payment_done.html.twig b/templates/eflex/setup_payment_done.html.twig new file mode 100644 index 0000000..cd13e04 --- /dev/null +++ b/templates/eflex/setup_payment_done.html.twig @@ -0,0 +1,82 @@ +{% extends 'base.html.twig' %} + +{% block title %}SEPA configure - {{ eflex.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ + + +
+

Mandat SEPA configure

+

{{ eflex.reference }}

+
+
+
+ +
+
+ + + +
+

Prelevement SEPA actif

+

IBAN **** {{ eflex.stripeSepaLast4 ?: '****' }} - Les echeances seront prelevees automatiquement.

+
+
+ +
+
+

Total

+

{{ eflex.totalAmount|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Progression

+

{{ eflex.nbPaid }}/{{ eflex.nbLines }}

+
+
+ + {# Tableau echeances avec statut #} +
+ + + + + + + + + + + {% for line in eflex.lines %} + + + + + + + {% endfor %} + +
NDateMontantStatut
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye + {% elseif line.isFailed %} + Echoue + {% else %} + A prelever + {% endif %} +
+
+ +

Pour toute question : contact@e-cosplay.fr

+
+
+
+{% endblock %} diff --git a/templates/eflex/signed.html.twig b/templates/eflex/signed.html.twig new file mode 100644 index 0000000..ab9e331 --- /dev/null +++ b/templates/eflex/signed.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}E-Flex signe - {{ eflex.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+
+ + + +
+

E-Flex signe

+

{{ eflex.reference }}

+
+
+
+
+

+ Votre contrat E-Flex a ete signe avec succes. Vous allez recevoir un email pour configurer vos paiements. +

+
+

Montant total : {{ eflex.totalAmount|number_format(2, ',', ' ') }} €

+

Echeances : {{ eflex.nbLines }} x {{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €/mois

+

Methode : {{ eflex.paymentMethodLabel }}

+
+

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

+
+
+
+{% endblock %} diff --git a/templates/eflex/verify.html.twig b/templates/eflex/verify.html.twig new file mode 100644 index 0000000..37be563 --- /dev/null +++ b/templates/eflex/verify.html.twig @@ -0,0 +1,32 @@ +{% extends 'base.html.twig' %} + +{% block title %}Verification - E-Flex {{ eflex.reference }} - Association E-Cosplay{% endblock %} + +{% block body %} +
+
+
+

Verification

+

{{ eflex.reference }} - Un code a ete envoye a {{ customer.email }}

+
+
+ {% if error %} +
{{ error }}
+ {% endif %} +

Saisissez le code de verification a 6 chiffres recu par email.

+
+
+ + +
+ +
+

Le code expire dans 15 minutes.

+
+ +
+
+
+
+{% endblock %} diff --git a/templates/emails/client_closure.html.twig b/templates/emails/client_closure.html.twig new file mode 100644 index 0000000..7a75597 --- /dev/null +++ b/templates/emails/client_closure.html.twig @@ -0,0 +1,48 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +
+

+ Notification de cloture definitive de votre compte +

+
+ +

+ 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. +

+ +

+ Les mesures suivantes seront appliquees : +

+ +
    +
  • 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.
  • +
+ +

+ Cette decision est definitive et irrevocable. +

+ +

+ Toute contestation devra etre adressee a direction@e-cosplay.fr dans un delai de 24 heures. +

+ +

+ Le present document constitue une notification officielle de cloture de compte. Il fait foi en cas de litige.
+ Toutes les decisions sont prises par le bureau de l'association a huis clos.
+ Le PDF signe electroniquement est joint a ce courrier. +

+
+{% endblock %} diff --git a/templates/emails/client_warning.html.twig b/templates/emails/client_warning.html.twig new file mode 100644 index 0000000..b521eb5 --- /dev/null +++ b/templates/emails/client_warning.html.twig @@ -0,0 +1,98 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ + {% if level == '1st' %} +
+

+ 1er avertissement +

+
+ +

+ Nous constatons des manquements sur votre compte. Nous vous invitons a regulariser votre situation dans les meilleurs delais. +

+ +

+ En cas de repetition, un 2eme avertissement sera decide et pourra entrainer la suspension de vos services. +

+ + {% elseif level == '2nd' %} +
+

+ 2eme avertissement - Procedure de suspension engagee +

+
+ +

+ Malgre notre precedent avertissement, nous constatons que votre situation n'a pas ete regularisee. +

+ +

+ Ceci est votre 2eme avertissement. Nous vous informons que la procedure de suspension a ete preparee : +

+ +
    +
  • La resiliation de vos services (sites internet, emails, noms de domaine) a ete preparee
  • +
  • La fermeture de votre compte a ete programmee
  • +
  • Ces mesures seront effectives au prochain avertissement en l'absence de regularisation
  • +
+ +

+ Nous vous invitons a regulariser votre situation dans les plus brefs delais afin d'eviter la suspension definitive de vos services. +

+ + {% elseif level == 'last' %} +
+

+ Dernier avertissement avant suspension +

+
+ +

+ 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 (sites internet, emails, noms de domaine). +

+ + {% 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' + } %} +

Motifs constates :

+
    + {% for reason in reasons %} +
  • {{ reasonLabels[reason] ?? reason }}
  • + {% endfor %} +
+ {% endif %} + +

+ Nous vous invitons a regulariser votre situation dans les meilleurs delais ou a contacter notre service pour trouver une solution. +

+ +

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

+ +

+ Ce message est 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 +

+
+{% endblock %} diff --git a/templates/emails/client_warning_reset.html.twig b/templates/emails/client_warning_reset.html.twig new file mode 100644 index 0000000..d40f4c8 --- /dev/null +++ b/templates/emails/client_warning_reset.html.twig @@ -0,0 +1,30 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +
+

+ Situation regularisee +

+
+ +

+ Nous vous confirmons que votre situation a ete regularisee. Les avertissements precedemment emis ont ete leves. +

+ +

+ Vos services restent actifs et votre compte est en regle. Nous vous remercions pour votre regularisation. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_attestation.html.twig b/templates/emails/echeancier_attestation.html.twig new file mode 100644 index 0000000..29eaf98 --- /dev/null +++ b/templates/emails/echeancier_attestation.html.twig @@ -0,0 +1,103 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Veuillez trouver ci-dessous l'attestation de situation de votre echeancier {{ echeancier.reference }}. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if echeancier.stripeSepaLast4 %} + + + + + {% endif %} +
Reference{{ echeancier.reference }}
Motif{{ echeancier.description }}
Statut + {% if echeancier.state == 'completed' %} + Termine + {% elseif echeancier.state == 'active' %} + Actif + {% elseif echeancier.state == 'cancelled' %} + Annule + {% else %} + {{ echeancier.state }} + {% endif %} +
Creance initiale{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €
Majoration (5%)+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €
Total a payer{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Deja paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €
Restant du{{ remaining|number_format(2, ',', ' ') }} €
Progression{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} echeances payees ({{ echeancier.progress }}%)
RIB (IBAN)**** **** **** {{ echeancier.stripeSepaLast4 }}{% if echeancier.stripeSepaCountry %} ({{ echeancier.stripeSepaCountry }}){% endif %}
+ +

Detail des echeances :

+ + + + + + + + + {% for line in echeancier.lines %} + + + + + + + {% endfor %} +
NDateMontantStatut
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye{{ line.paidAt ? ' le ' ~ line.paidAt|date('d/m/Y') : '' }} + {% elseif line.isFailed %} + Echoue + {% else %} + En attente + {% endif %} +
+ +

+ Attestation emise le {{ "now"|date('d/m/Y') }} par Association E-Cosplay.
+ Pour toute question : contact@e-cosplay.fr +

+
+{% endblock %} diff --git a/templates/emails/echeancier_cancelled_admin.html.twig b/templates/emails/echeancier_cancelled_admin.html.twig new file mode 100644 index 0000000..6b865c6 --- /dev/null +++ b/templates/emails/echeancier_cancelled_admin.html.twig @@ -0,0 +1,41 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+
Subscription annulee
+

Echeancier {{ echeancier.reference }} annule via Stripe

+

+ La subscription Stripe a ete annulee (manuellement ou automatiquement). L'echeancier a ete passe en statut Annule. +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Client{{ customer.fullName }}{% if customer.email %}
{{ customer.email }}{% endif %}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Deja paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} € ({{ echeancier.nbPaid }}/{{ echeancier.nbLines }})
Stripe ID{{ echeancier.stripeSubscriptionId }}
Annule le{{ "now"|date('d/m/Y H:i') }}
+
+{% endblock %} diff --git a/templates/emails/echeancier_cancelled_client.html.twig b/templates/emails/echeancier_cancelled_client.html.twig new file mode 100644 index 0000000..866fde5 --- /dev/null +++ b/templates/emails/echeancier_cancelled_client.html.twig @@ -0,0 +1,51 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Votre echeancier de paiement {{ echeancier.reference }} a ete annule. Les prelevements automatiques ont ete desactives. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Motif{{ echeancier.description }}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Deja paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €
Progression{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} echeances payees
StatutAnnule
+ +

+ Si cette annulation n'est pas de votre fait, veuillez nous contacter immediatement. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_cancelled_rejects_admin.html.twig b/templates/emails/echeancier_cancelled_rejects_admin.html.twig new file mode 100644 index 0000000..5af59af --- /dev/null +++ b/templates/emails/echeancier_cancelled_rejects_admin.html.twig @@ -0,0 +1,38 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+
Echeancier annule - 2 rejets
+

{{ echeancier.reference }} - {{ customer.fullName }}

+

+ L'echeancier a ete automatiquement annule apres 2 rejets de prelevement SEPA. + {% if echeancier.advert %}L'avis {{ echeancier.advert.orderNumber.numOrder }} est concerne.{% endif %} +

+ + + + + + + + + + + + + + + + + + + + + +
Client{{ customer.fullName }}{% if customer.email %}
{{ customer.email }}{% endif %}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Deja paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} € ({{ echeancier.nbPaid }}/{{ echeancier.nbLines }})
Restant du{{ (echeancier.totalWithMajoration - echeancier.totalPaid)|number_format(2, ',', ' ') }} €
Rejets{{ echeancier.nbFailed }} echecs
+
+{% endblock %} diff --git a/templates/emails/echeancier_cancelled_rejects_client.html.twig b/templates/emails/echeancier_cancelled_rejects_client.html.twig new file mode 100644 index 0000000..98e1554 --- /dev/null +++ b/templates/emails/echeancier_cancelled_rejects_client.html.twig @@ -0,0 +1,53 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +
+

+ Echeancier {{ echeancier.reference }} annule suite a des rejets de prelevement +

+
+ +

+ Votre echeancier de paiement a ete annule en raison de rejets repetes de prelevement SEPA. Le solde restant reste du. +

+ + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Total echeancier{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Deja paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €
Restant du{{ (echeancier.totalWithMajoration - echeancier.totalPaid)|number_format(2, ',', ' ') }} €
Echeances payees{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}
+ +

+ Veuillez contacter notre service pour regulariser votre situation dans les plus brefs delais. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_completed_admin.html.twig b/templates/emails/echeancier_completed_admin.html.twig new file mode 100644 index 0000000..a5a5d40 --- /dev/null +++ b/templates/emails/echeancier_completed_admin.html.twig @@ -0,0 +1,40 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+
Echeancier termine
+

{{ echeancier.reference }} - {{ customer.fullName }}

+

+ 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 %} +

+ + + + + + + + + + + + + + + + + + {% if echeancier.advert %} + + + + + {% endif %} +
Reference{{ echeancier.reference }}
Client{{ customer.fullName }}
Total paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €
Echeances{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}
Avis lie{{ echeancier.advert.orderNumber.numOrder }}
+
+{% endblock %} diff --git a/templates/emails/echeancier_completed_client.html.twig b/templates/emails/echeancier_completed_client.html.twig new file mode 100644 index 0000000..11f0c63 --- /dev/null +++ b/templates/emails/echeancier_completed_client.html.twig @@ -0,0 +1,76 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +
+

+ Echeancier {{ echeancier.reference }} - Paiement termine +

+
+ +

+ Nous vous confirmons que l'integralite de votre echeancier de paiement a ete reglee. Voici l'attestation de fin de paiement : +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Motif{{ echeancier.description }}
Creance initiale{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €
Majoration (5%)+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €
Total paye{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €
Echeances{{ echeancier.nbPaid }}/{{ echeancier.nbLines }} payees
+ +

Detail des echeances :

+ + + + + + + + + {% for line in echeancier.lines %} + + + + + + + {% endfor %} +
NDateMontantStatut
{{ line.position }}{{ line.paidAt ? line.paidAt|date('d/m/Y') : line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} €Paye
+ +

+ Ce document fait office d'attestation de fin de paiement pour l'echeancier {{ echeancier.reference }}. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_echeance_echec.html.twig b/templates/emails/echeancier_echeance_echec.html.twig index be3db52..657772b 100644 --- a/templates/emails/echeancier_echeance_echec.html.twig +++ b/templates/emails/echeancier_echeance_echec.html.twig @@ -8,7 +8,7 @@

{{ greeting }},

- Le prelevement de votre echeance {{ line.position }}/{{ echeancier.nbLines }} a echoue. + Le prelevement de votre echeance {{ line.position }}/{{ echeancier.nbLines }} de l'echeancier {{ echeancier.reference }} a echoue.

@@ -30,9 +30,22 @@
+ {% if regularizeUrl is defined and regularizeUrl %} +

+ Vous pouvez regulariser cette echeance immediatement par carte bancaire en cliquant sur le bouton ci-dessous : +

+ + + + + +
+ Regulariser par carte bancaire +
+ {% endif %} +

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

diff --git a/templates/emails/echeancier_echeance_payee.html.twig b/templates/emails/echeancier_echeance_payee.html.twig index db90a27..7a802a0 100644 --- a/templates/emails/echeancier_echeance_payee.html.twig +++ b/templates/emails/echeancier_echeance_payee.html.twig @@ -8,7 +8,7 @@

{{ greeting }},

- Votre echeance {{ line.position }}/{{ echeancier.nbLines }} a ete prelevee avec succes. + Votre echeance {{ line.position }}/{{ echeancier.nbLines }} de l'echeancier {{ echeancier.reference }} a ete prelevee avec succes.

diff --git a/templates/emails/echeancier_proposition.html.twig b/templates/emails/echeancier_proposition.html.twig index 19e54f3..b887866 100644 --- a/templates/emails/echeancier_proposition.html.twig +++ b/templates/emails/echeancier_proposition.html.twig @@ -8,7 +8,7 @@

{{ greeting }},

- Nous vous proposons un echeancier de paiement pour faciliter le reglement de votre solde. + Nous vous proposons un echeancier de paiement {{ echeancier.reference }} pour faciliter le reglement de votre solde.

diff --git a/templates/emails/echeancier_refused_admin.html.twig b/templates/emails/echeancier_refused_admin.html.twig new file mode 100644 index 0000000..c56ee66 --- /dev/null +++ b/templates/emails/echeancier_refused_admin.html.twig @@ -0,0 +1,41 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} +
+ + + +
+
Echeancier refuse
+

Le client {{ customer.fullName }} a refuse l'echeancier {{ echeancier.reference }}

+ + + + + + + + + + + + + + + + + + + {% if reason %} + + + + + {% endif %} + + + + +
Reference{{ echeancier.reference }}
Client{{ customer.fullName }}{% if customer.email %}
{{ customer.email }}{% endif %}
Motif{{ echeancier.description }}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Motif du refus{{ reason }}
Refuse le{{ "now"|date('d/m/Y H:i') }}
+
+{% endblock %} diff --git a/templates/emails/echeancier_refused_client.html.twig b/templates/emails/echeancier_refused_client.html.twig new file mode 100644 index 0000000..fa3098c --- /dev/null +++ b/templates/emails/echeancier_refused_client.html.twig @@ -0,0 +1,45 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ L'echeancier de paiement {{ echeancier.reference }} a ete refuse. +

+ + + + + + + + + + + + + + + {% if reason %} + + + + + {% endif %} +
Reference{{ echeancier.reference }}
Motif{{ echeancier.description }}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Motif du refus{{ reason }}
+ +

+ Si vous souhaitez discuter d'autres modalites de paiement, n'hesitez pas a nous contacter. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_signature.html.twig b/templates/emails/echeancier_signature.html.twig index fcdeae4..4a259ea 100644 --- a/templates/emails/echeancier_signature.html.twig +++ b/templates/emails/echeancier_signature.html.twig @@ -8,7 +8,7 @@

{{ greeting }},

- 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 {{ echeancier.reference }} est pret a etre signe. Veuillez cliquer sur le bouton ci-dessous pour signer electroniquement le document.

diff --git a/templates/emails/echeancier_signed_admin.html.twig b/templates/emails/echeancier_signed_admin.html.twig new file mode 100644 index 0000000..50ee51e --- /dev/null +++ b/templates/emails/echeancier_signed_admin.html.twig @@ -0,0 +1,49 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} +
+ + + +
+
Echeancier signe
+

Le client {{ customer.fullName }} a signe l'echeancier {{ echeancier.reference }}

+

+ Le PDF signe et le certificat d'audit DocuSeal sont en piece jointe. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Client{{ customer.fullName }}{% if customer.email %}
{{ customer.email }}{% endif %}
Motif{{ echeancier.description }}
Creance{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €
Majoration (5%)+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €
Total a payer{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Mensualite{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ echeancier.nbLines }}
Signe le{{ "now"|date('d/m/Y H:i') }}
+
+{% endblock %} diff --git a/templates/emails/echeancier_signed_client.html.twig b/templates/emails/echeancier_signed_client.html.twig new file mode 100644 index 0000000..0016111 --- /dev/null +++ b/templates/emails/echeancier_signed_client.html.twig @@ -0,0 +1,61 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Votre echeancier de paiement {{ echeancier.reference }} a ete signe avec succes. Vous trouverez en piece jointe le document signe ainsi que le certificat d'audit. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Motif{{ echeancier.description }}
Creance{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €
Majoration (5%)+ {{ echeancier.majoration|number_format(2, ',', ' ') }} €
Total a payer{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
Mensualite{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ echeancier.nbLines }}
+ +

+ Vous allez recevoir un email pour configurer votre prelevement SEPA. Il vous suffira de renseigner votre IBAN pour que les echeances soient prelevees automatiquement aux dates prevues. +

+ +
+

Mandat de prelevement SEPA

+

+ Crediteur : Association E-Cosplay
+ Reference echeancier : {{ echeancier.reference }}
+ Montant : {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ echeancier.nbLines }} echeances
+ Premiere echeance : {{ echeancier.lines|first ? echeancier.lines|first.scheduledAt|date('d/m/Y') : '—' }} +

+
+ +

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

+
+{% endblock %} diff --git a/templates/emails/echeancier_stripe_setup.html.twig b/templates/emails/echeancier_stripe_setup.html.twig new file mode 100644 index 0000000..82aeedb --- /dev/null +++ b/templates/emails/echeancier_stripe_setup.html.twig @@ -0,0 +1,60 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Votre echeancier {{ echeancier.reference }} a ete signe. Pour finaliser la mise en place des prelevements automatiques, veuillez configurer votre IBAN en cliquant sur le bouton ci-dessous. +

+ + + + + + + + + + + + + + +
Reference{{ echeancier.reference }}
Mensualite{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ echeancier.nbLines }}
Total{{ echeancier.totalWithMajoration|number_format(2, ',', ' ') }} €
+ + {% if setupUrl is defined and setupUrl %} + + + + +
+ Configurer mon prelevement SEPA +
+ {% endif %} + +
+

Informations du mandat SEPA

+

+ Crediteur : Association E-Cosplay
+ Reference : {{ echeancier.reference }}
+ Montant par echeance : {{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €
+ Nombre d'echeances : {{ echeancier.nbLines }}
+ Premiere echeance : {{ echeancier.lines|first ? echeancier.lines|first.scheduledAt|date('d/m/Y') : '—' }} +

+
+ +

+ Il vous suffit de renseigner votre IBAN. Chaque echeance sera ensuite prelevee automatiquement a la date prevue. +

+ +

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

+
+{% endblock %} diff --git a/templates/emails/eflex_echeance_echec.html.twig b/templates/emails/eflex_echeance_echec.html.twig new file mode 100644 index 0000000..f5ead22 --- /dev/null +++ b/templates/emails/eflex_echeance_echec.html.twig @@ -0,0 +1,25 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+

+ Le prelevement de votre echeance {{ line.position }}/{{ eflex.nbLines }} du contrat {{ eflex.reference }} a echoue. +

+ + + + +
Echeance{{ line.label }}
Montant{{ line.amount }} €
Raison{{ errorMessage }}
+ {% if payUrl is defined and payUrl %} +

Vous pouvez regulariser par carte bancaire :

+
Payer par carte bancaire
+ {% endif %} +

Pour toute question : contact@e-cosplay.fr

+
+{% endblock %} diff --git a/templates/emails/eflex_echeance_payee.html.twig b/templates/emails/eflex_echeance_payee.html.twig new file mode 100644 index 0000000..b9a1a23 --- /dev/null +++ b/templates/emails/eflex_echeance_payee.html.twig @@ -0,0 +1,21 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+

+ Votre echeance {{ line.position }}/{{ eflex.nbLines }} du contrat {{ eflex.reference }} a ete payee. +

+ + + + +
Echeance{{ line.label }}
Montant{{ line.amount }} €
Progression{{ eflex.nbPaid }}/{{ eflex.nbLines }} echeances payees ({{ eflex.progress }}%)
+

Pour toute question : contact@e-cosplay.fr

+
+{% endblock %} diff --git a/templates/emails/eflex_signature.html.twig b/templates/emails/eflex_signature.html.twig new file mode 100644 index 0000000..547ee7e --- /dev/null +++ b/templates/emails/eflex_signature.html.twig @@ -0,0 +1,26 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+

+ Votre contrat de financement E-Flex {{ eflex.reference }} est pret a etre signe. +

+ + + + + +
Reference{{ eflex.reference }}
Montant total{{ eflex.totalAmount|number_format(2, ',', ' ') }} €
Mensualite{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ eflex.nbLines }}
Methode{{ eflex.paymentMethodLabel }}
+ {% if processUrl is defined and processUrl %} +
Voir le contrat E-Flex
+ {% endif %} +

E-Flex est une solution de financement sans frais supplementaires proposee par l'Association E-Cosplay.

+

Pour toute question : contact@e-cosplay.fr

+
+{% endblock %} diff --git a/templates/emails/eflex_signed_admin.html.twig b/templates/emails/eflex_signed_admin.html.twig new file mode 100644 index 0000000..1a5da14 --- /dev/null +++ b/templates/emails/eflex_signed_admin.html.twig @@ -0,0 +1,18 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+
E-Flex signe
+

{{ eflex.reference }} - {{ customer.fullName }}

+ + + + + +
Client{{ customer.fullName }}{% if customer.email %}
{{ customer.email }}{% endif %}
Total{{ eflex.totalAmount|number_format(2, ',', ' ') }} €
Mensualite{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ eflex.nbLines }}
Methode{{ eflex.paymentMethodLabel }}
+
+{% endblock %} diff --git a/templates/emails/eflex_signed_client.html.twig b/templates/emails/eflex_signed_client.html.twig new file mode 100644 index 0000000..ff63a48 --- /dev/null +++ b/templates/emails/eflex_signed_client.html.twig @@ -0,0 +1,37 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+

+ Votre contrat E-Flex {{ eflex.reference }} a ete signe avec succes. Le contrat signe et le certificat d'audit sont en piece jointe. +

+ + + + +
Reference{{ eflex.reference }}
Montant total{{ eflex.totalAmount|number_format(2, ',', ' ') }} €
Mensualite{{ eflex.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ eflex.nbLines }}
+ +

Coordonnees bancaires pour virement :

+ + + + + +
TitulaireAssociation E-Cosplay
IBANFR76 XXXX XXXX XXXX XXXX XXXX XXX
BICXXXXXXXX
Reference{{ eflex.reference }}
+ + {% if processUrl is defined and processUrl %} +

+ Pour configurer le prelevement automatique ou payer par carte bancaire, cliquez ci-dessous : +

+
Configurer mes paiements
+ {% endif %} + +

Pour toute question : contact@e-cosplay.fr

+
+{% endblock %} diff --git a/templates/emails/eflex_verify_code.html.twig b/templates/emails/eflex_verify_code.html.twig new file mode 100644 index 0000000..9bd9d33 --- /dev/null +++ b/templates/emails/eflex_verify_code.html.twig @@ -0,0 +1,14 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+

Code de verification E-Flex

+

Voici votre code de verification :

+
{{ code }}
+

Ce code expire dans 15 minutes.

+
+{% endblock %}