diff --git a/migrations/Version20260408174704.php b/migrations/Version20260408174704.php new file mode 100644 index 0000000..6c0c27c --- /dev/null +++ b/migrations/Version20260408174704.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE advert_payment ADD echeancier_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE advert_payment ADD CONSTRAINT FK_C766C45B8C858AF2 FOREIGN KEY (echeancier_id) REFERENCES echeancier (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_C766C45B8C858AF2 ON advert_payment (echeancier_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE advert_payment DROP CONSTRAINT FK_C766C45B8C858AF2'); + $this->addSql('DROP INDEX IDX_C766C45B8C858AF2'); + $this->addSql('ALTER TABLE advert_payment DROP echeancier_id'); + } +} diff --git a/src/Controller/Admin/EcheancierController.php b/src/Controller/Admin/EcheancierController.php index 8856f87..9210fe9 100644 --- a/src/Controller/Admin/EcheancierController.php +++ b/src/Controller/Admin/EcheancierController.php @@ -7,7 +7,10 @@ use App\Entity\Echeancier; use App\Entity\EcheancierLine; use App\Service\DocuSealService; use App\Service\MailerService; +use App\Service\Pdf\EcheancierPdf; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; @@ -132,6 +135,183 @@ class EcheancierController extends AbstractController return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]); } + /** + * Renvoie l'email de proposition au client. + */ + #[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])] + public function resend(int $id, MailerService $mailer, Environment $twig): 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]); + } + + $html = $twig->render('emails/echeancier_proposition.html.twig', [ + 'customer' => $customer, + 'echeancier' => $echeancier, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Rappel - Proposition d\'echeancier de paiement', + $html, + null, + null, + false, + ); + + $this->addFlash('success', 'Rappel envoye a '.$customer->getEmail().'.'); + + return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]); + } + + /** + * Genere le PDF de l'echeancier. + */ + #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] + public function generatePdf(int $id, KernelInterface $kernel): Response + { + $echeancier = $this->em->getRepository(Echeancier::class)->find($id); + if (null === $echeancier) { + throw $this->createNotFoundException(self::MSG_NOT_FOUND); + } + + $pdf = new EcheancierPdf($kernel, $echeancier); + $pdf->generate(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'echeancier_').'.pdf'; + $pdf->Output('F', $tmpPath); + + $echeancier->setPdfUnsignedFile(new UploadedFile( + $tmpPath, + 'echeancier-'.$echeancier->getId().'.pdf', + 'application/pdf', + null, + true, + )); + $echeancier->setUpdatedAt(new \DateTimeImmutable()); + $this->em->flush(); + + @unlink($tmpPath); + + $this->addFlash('success', 'PDF echeancier genere.'); + + return $this->redirectToRoute('app_admin_echeancier_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 { + $echeancier = $this->em->getRepository(Echeancier::class)->find($id); + if (null === $echeancier) { + throw $this->createNotFoundException(self::MSG_NOT_FOUND); + } + + $customer = $echeancier->getCustomer(); + if (null === $echeancier->getPdfUnsigned() || null === $customer->getEmail()) { + $this->addFlash('error', null === $echeancier->getPdfUnsigned() ? 'Le PDF doit etre genere avant l\'envoi.' : 'Email client introuvable.'); + + return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]); + } + + $pdfPath = $projectDir.'/public/uploads/echeanciers/'.$echeancier->getPdfUnsigned(); + if (!file_exists($pdfPath)) { + $this->addFlash('error', 'Fichier PDF introuvable.'); + + return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]); + } + + $signedRedirectUrl = $urlGenerator->generate('app_echeancier_signed', [ + 'id' => $echeancier->getId(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + try { + $pdfBase64 = base64_encode(file_get_contents($pdfPath)); + + $result = $docuSeal->getApi()->createSubmissionFromPdf([ + 'name' => 'Echeancier - '.$customer->getFullName(), + 'send_email' => false, + 'flatten' => true, + 'documents' => [ + [ + 'name' => 'echeancier-'.$echeancier->getId().'.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' => 'echeancier', 'echeancier_id' => $echeancier->getId()], + ], + [ + 'email' => $customer->getEmail(), + 'name' => $customer->getFullName(), + 'role' => 'First Party', + 'send_email' => false, + 'completed_redirect_url' => $signedRedirectUrl, + 'metadata' => ['doc_type' => 'echeancier', 'echeancier_id' => $echeancier->getId()], + ], + ], + ]); + + $submitterId = $result['submitters'][1]['id'] ?? ($result[1]['id'] ?? null); + if (null !== $submitterId) { + $echeancier->setSubmissionId((string) $submitterId); + $echeancier->setState(Echeancier::STATE_SEND); + $this->em->flush(); + + // Envoyer email au client avec lien de signature + $slug = $docuSeal->getSubmitterSlug($submitterId); + $signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null; + + $html = $twig->render('emails/echeancier_signature.html.twig', [ + 'customer' => $customer, + 'echeancier' => $echeancier, + 'signUrl' => $signUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Echeancier a signer - '.$customer->getFullName(), + $html, + null, + null, + false, + ); + + $this->addFlash('success', 'Echeancier 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_echeancier_show', ['id' => $id]); + } + /** * Annule un echeancier (et la subscription Stripe si active). */ diff --git a/src/Controller/EcheancierProcessController.php b/src/Controller/EcheancierProcessController.php new file mode 100644 index 0000000..aff9f05 --- /dev/null +++ b/src/Controller/EcheancierProcessController.php @@ -0,0 +1,39 @@ + '\d+'])] + public function signed(int $id): Response + { + $echeancier = $this->em->getRepository(Echeancier::class)->find($id); + if (null === $echeancier) { + throw $this->createNotFoundException('Echeancier introuvable.'); + } + + if (Echeancier::STATE_SIGNED !== $echeancier->getState()) { + $echeancier->setState(Echeancier::STATE_SIGNED); + $this->em->flush(); + } + + return $this->render('echeancier/signed.html.twig', [ + 'echeancier' => $echeancier, + 'customer' => $echeancier->getCustomer(), + ]); + } +} diff --git a/src/Service/DocuSealService.php b/src/Service/DocuSealService.php index 79f2806..f82e52c 100644 --- a/src/Service/DocuSealService.php +++ b/src/Service/DocuSealService.php @@ -349,7 +349,7 @@ class DocuSealService } } - private function getLogoBase64(): string + public function getLogoBase64(): string { $logoPath = $this->projectDir.'/public/logo.jpg'; diff --git a/src/Service/Pdf/EcheancierPdf.php b/src/Service/Pdf/EcheancierPdf.php new file mode 100644 index 0000000..9787c89 --- /dev/null +++ b/src/Service/Pdf/EcheancierPdf.php @@ -0,0 +1,276 @@ +SetTitle($this->enc('Echeancier de paiement - '.$this->echeancier->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('ECHEANCIER DE PAIEMENT'), 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($this->echeancier->getCreatedAt())), 0, 1, 'L'); + + // Client + $this->SetFont('Arial', 'B', 11); + $customer = $this->echeancier->getCustomer(); + $y = 35; + $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(65); + + $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('OBJET DE L\'ECHEANCIER'), 0, 1, 'L'); + $this->Ln(2); + + $this->SetFont('Arial', '', 10); + $this->MultiCell(0, 5, $this->enc($this->echeancier->getDescription()), 0, 'L'); + $this->Ln(3); + + $labelW = 55; + + $this->SetFont('Arial', '', 10); + $this->Cell($labelW, 6, $this->enc('Montant total HT :'), 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('Nombre d\'echeances :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 10); + $this->Cell(0, 6, (string) $this->echeancier->getNbLines().' mois', 0, 1, 'L'); + + $this->SetFont('Arial', '', 10); + $this->Cell($labelW, 6, $this->enc('Mensualite :'), 0, 0, 'L'); + $this->SetFont('Arial', 'B', 10); + $this->Cell(0, 6, number_format($this->echeancier->getMonthlyAmount(), 2, ',', ' ').' '.EURO.'/mois', 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('TABLEAU DES ECHEANCES'), 0, 1, 'L'); + $this->Ln(2); + + // En-tete tableau + $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(55, 7, $this->enc('Date de prelevement'), 1, 0, 'C', true); + $this->Cell(50, 7, $this->enc('Montant HT'), 1, 0, 'C', true); + $this->Cell(50, 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->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'); + + $this->Cell(15, 6, (string) $line->getPosition(), 'B', 0, 'C', $fill); + $this->Cell(55, 6, $this->enc($dateLabel), 'B', 0, 'L', $fill); + $this->Cell(50, 6, number_format((float) $line->getAmount(), 2, ',', ' ').' '.EURO, 'B', 0, 'R', $fill); + $this->Cell(50, 6, $this->enc('A prelever'), 'B', 1, 'C', $fill); + + $fill = !$fill; + } + + // Total + $this->SetFont('Arial', 'B', 10); + $this->SetFillColor(253, 191, 4); + $this->Cell(70, 8, $this->enc(' TOTAL'), 0, 0, 'L', true); + $this->Cell(50, 8, number_format((float) $this->echeancier->getTotalAmountHt(), 2, ',', ' ').' '.EURO, 0, 0, 'R', true); + $this->Cell(50, 8, '', 0, 1, 'C', true); + + $this->Ln(5); + } + + /** @codeCoverageIgnore */ + private function writeConditions(): void + { + $this->SetFont('Arial', 'B', 10); + $this->Cell(0, 6, $this->enc('CONDITIONS'), 0, 1, 'L'); + $this->Ln(1); + + $this->SetFont('Arial', '', 9); + $conditions = [ + 'Le prelevement sera effectue automatiquement a chaque date prevue via Stripe.', + 'En cas d\'echec de prelevement, une relance sera envoyee par email.', + 'Apres 2 echecs consecutifs, l\'echeancier sera considere en defaut.', + 'Le client peut contacter contact@e-cosplay.fr pour toute question.', + 'Majoration de 5% du montant total conformement aux CGV (article 11).', + ]; + + foreach ($conditions as $i => $condition) { + $this->Cell(5, 5, '', 0, 0); + $this->Cell(5, 5, ($i + 1).'.', 0, 0, 'R'); + $this->Cell(0, 5, $this->enc(' '.$condition), 0, 1, 'L'); + } + + $this->Ln(5); + } + + /** @codeCoverageIgnore */ + private function writeSignatures(): void + { + // S'assurer qu'on a assez de place + if ($this->GetY() + 50 > $this->GetPageHeight() - 25) { + $this->AddPage(); + } + + $this->SetDrawColor(200, 200, 200); + $this->Cell(0, 0.5, '', 'T', 1, 'L'); + $this->Ln(3); + + $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); + + // 2 colonnes de signature + $colWidth = 85; + $signY = $this->GetY(); + + // Signature E-Cosplay (gauche) - auto-signee + $this->SetFont('Arial', 'B', 9); + $this->SetXY(15, $signY); + $this->Cell($colWidth, 5, $this->enc('Pour Association E-Cosplay :'), 0, 1, 'L'); + $this->SetXY(15, $signY + 7); + $this->SetFont('Arial', '', 10); + $this->Cell($colWidth, 20, '{{Sign;type=signature;role=Company}}', 0, 0, 'L'); + + // Signature Client (droite) + $this->SetFont('Arial', 'B', 9); + $this->SetXY(110, $signY); + $customerName = $this->echeancier->getCustomer()->getRaisonSociale() ?: $this->echeancier->getCustomer()->getFullName(); + $this->Cell($colWidth, 5, $this->enc('Pour '.$customerName.' :'), 0, 1, 'L'); + $this->SetXY(110, $signY + 7); + $this->SetFont('Arial', '', 10); + $this->Cell($colWidth, 20, '{{SignClient;type=signature;role=First Party}}', 0, 0, 'L'); + + $this->SetY($signY + 35); + $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/templates/admin/echeancier/show.html.twig b/templates/admin/echeancier/show.html.twig index 3187dca..c689050 100644 --- a/templates/admin/echeancier/show.html.twig +++ b/templates/admin/echeancier/show.html.twig @@ -66,10 +66,31 @@ {# Actions #} -
+
{% if echeancier.state == 'draft' %} +
+ +
+ {% endif %} + {% if echeancier.pdfUnsigned %} + + Voir PDF + + {% endif %} + {% if echeancier.state == 'draft' and echeancier.pdfUnsigned %}
- + +
+ {% endif %} + {% if echeancier.state in ['draft', 'send'] and echeancier.pdfUnsigned %} +
+ +
+ {% endif %} + {% if echeancier.state == 'send' %} +
+
{% endif %} {% if echeancier.state == 'signed' %} @@ -77,6 +98,12 @@ {% endif %} + {% if echeancier.pdfSigned %} + + PDF signe + + {% endif %} {% if echeancier.state in ['draft', 'send', 'signed', 'active'] %}
diff --git a/templates/echeancier/signed.html.twig b/templates/echeancier/signed.html.twig new file mode 100644 index 0000000..e3a7a14 --- /dev/null +++ b/templates/echeancier/signed.html.twig @@ -0,0 +1,32 @@ +{% extends 'base.html.twig' %} + +{% block title %}Echeancier signe - Association E-Cosplay{% endblock %} + +{% 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 }}

+
+

+ Le prelevement automatique sera active par notre equipe. Vous recevrez un email de confirmation a chaque echeance. +

+

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

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

{{ greeting }},

+ +

+ Votre echeancier de paiement est pret a etre signe. Veuillez cliquer sur le bouton ci-dessous pour signer electroniquement le document. +

+ + + + + + + + + + + + + + +
Motif{{ echeancier.description }}
Montant total{{ echeancier.totalAmountHt }} €
Mensualite{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois x {{ echeancier.nbLines }}
+ + {% if signUrl %} + + + + +
+ Signer l'echeancier +
+ {% endif %} + +

+ En signant ce document, vous autorisez le prelevement automatique mensuel du montant indique via Stripe. +

+ +

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

+
+{% endblock %}