From 6656d561110587b4c00df1b657a010e7469c7ba4 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 22 Jan 2026 20:15:21 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(reservation/contrat):=20?= =?UTF-8?q?Ajoute=20la=20gestion=20compl=C3=A8te=20des=20contrats=20```?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + assets/reserve.js | 16 + config/packages/security.yaml | 18 +- migrations/Version20260122160120.php | 40 +++ migrations/Version20260122161702.php | 35 +++ migrations/Version20260122183540.php | 36 +++ migrations/Version20260122184310.php | 38 +++ migrations/Version20260122185430.php | 33 ++ src/Controller/ContratController.php | 246 ++++++++++++++- src/Controller/SignatureController.php | 71 ++++- src/Controller/Webhooks.php | 90 ++++++ src/Entity/Contrats.php | 37 +++ src/Entity/ContratsPayments.php | 61 ++++ src/Entity/Customer.php | 281 ++++++++++-------- src/Entity/ProductReserve.php | 15 + src/Event/Signature/ContratSubscriber.php | 2 +- src/Security/AuthenticationEntryPoint.php | 65 ++-- src/Security/CustomerAuthenticator.php | 69 +++++ src/Security/LoginFormAuthenticator.php | 57 ++-- src/Service/Signature/Client.php | 41 +++ src/Service/Stripe/Client.php | 193 +++++++++++- src/Twig/StripeExtension.php | 54 +++- .../mails/customer/verification_code.twig | 48 +++ templates/mails/sign/signed_contrat.twig | 42 +++ .../sign/signed_contrat_notification.twig | 42 +++ templates/reservation/contrat/cancel.twig | 46 +++ templates/reservation/contrat/check.twig | 45 +++ .../reservation/contrat/finish_activate.twig | 44 +++ .../reservation/contrat/finish_config.twig | 68 +++++ .../reservation/contrat/finish_error.twig | 45 +++ templates/reservation/contrat/nofound.twig | 51 ++++ templates/reservation/contrat/success.twig | 59 ++++ templates/reservation/contrat/view.twig | 272 +++++++++++++++++ templates/revervation/base.twig | 9 +- templates/revervation/home.twig | 1 + templates/sign/contrat_sign_success.twig | 64 ++++ 36 files changed, 2127 insertions(+), 209 deletions(-) create mode 100644 migrations/Version20260122160120.php create mode 100644 migrations/Version20260122161702.php create mode 100644 migrations/Version20260122183540.php create mode 100644 migrations/Version20260122184310.php create mode 100644 migrations/Version20260122185430.php create mode 100644 src/Controller/Webhooks.php create mode 100644 src/Security/CustomerAuthenticator.php create mode 100644 templates/mails/customer/verification_code.twig create mode 100644 templates/mails/sign/signed_contrat.twig create mode 100644 templates/mails/sign/signed_contrat_notification.twig create mode 100644 templates/reservation/contrat/cancel.twig create mode 100644 templates/reservation/contrat/check.twig create mode 100644 templates/reservation/contrat/finish_activate.twig create mode 100644 templates/reservation/contrat/finish_config.twig create mode 100644 templates/reservation/contrat/finish_error.twig create mode 100644 templates/reservation/contrat/nofound.twig create mode 100644 templates/reservation/contrat/success.twig create mode 100644 templates/reservation/contrat/view.twig create mode 100644 templates/sign/contrat_sign_success.twig diff --git a/.gitignore b/.gitignore index fa5197b..43ad359 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,7 @@ backup/*.sql /public/images/**/*.jpeg /public/images/**/*.webp /public/images/*/*.png +/public/images/*/*.pdf /public/pdf/**/*.pdf /public/seo/*.xml + diff --git a/assets/reserve.js b/assets/reserve.js index 5598291..0ac1c80 100644 --- a/assets/reserve.js +++ b/assets/reserve.js @@ -11,6 +11,20 @@ Sentry.init({ tracesSampleRate: 1.0, }); +const initAutoRedirect = () => { + const container = document.getElementById('payment-check-container'); + if (container && container.dataset.autoRedirect) { + const url = container.dataset.autoRedirect; + + // On attend 5 secondes avant de rediriger via Turbo + setTimeout(() => { + // On vérifie que l'utilisateur est toujours sur la page de check + if (document.getElementById('payment-check-container')) { + Turbo.visit(url); + } + }, 10000); + } +} // --- LOGIQUE DU LOADER TURBO --- const initLoader = () => { let loaderEl = document.getElementById('turbo-loader'); @@ -104,6 +118,7 @@ document.addEventListener('DOMContentLoaded', () => { initLoader(); initMobileMenu(); initCatalogueSearch(); + initAutoRedirect(); customElements.define('utm-event',UtmEvent) customElements.define('utm-account',UtmAccount) @@ -112,6 +127,7 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('turbo:load', () => { initMobileMenu(); initCatalogueSearch(); + initAutoRedirect(); }); // Nettoyage avant cache pour éviter les bugs au retour arrière diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 3677a0c..cc2edd9 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,7 +4,13 @@ security: entity: class: App\Entity\Account property: email - + reserve_account: + entity: + class: App\Entity\Customer + property: email + all_users: + chain: + providers: [app_account_provider, reserve_account] firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -12,7 +18,7 @@ security: main: lazy: true - provider: app_account_provider + provider: all_users user_checker: App\Security\UserChecker # --- AJOUT DE LA CONFIGURATION 2FA --- @@ -31,6 +37,7 @@ security: entry_point: App\Security\AuthenticationEntryPoint custom_authenticator: + - App\Security\CustomerAuthenticator - App\Security\LoginFormAuthenticator - App\Security\KeycloakAuthenticator @@ -40,15 +47,16 @@ security: password_hashers: App\Entity\Account: 'auto' + App\Entity\Customer: 'auto' role_hierarchy: - ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN] - ROLE_CLIENT_MAIN: [ROLE_ADMIN] + ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN,ROLE_CUSTOMER] + ROLE_CLIENT_MAIN: [ROLE_ADMIN,ROLE_CUSTOMER] access_control: # Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN" - { path: ^/2fa, roles: PUBLIC_ACCESS } - + - { path: ^/gestion-contrat, roles: [ROLE_CUSTOMER] } - { path: ^/crm, roles: [ROLE_ADMIN] } - { path: ^/, roles: PUBLIC_ACCESS } diff --git a/migrations/Version20260122160120.php b/migrations/Version20260122160120.php new file mode 100644 index 0000000..cf10564 --- /dev/null +++ b/migrations/Version20260122160120.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE customer ADD roles JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE customer ADD password VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE customer ADD is_account_configured BOOLEAN DEFAULT false NOT NULL'); + $this->addSql('ALTER TABLE customer ALTER email TYPE VARCHAR(180)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_81398E09E7927C74 ON customer (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_81398E09E7927C74'); + $this->addSql('ALTER TABLE customer DROP roles'); + $this->addSql('ALTER TABLE customer DROP password'); + $this->addSql('ALTER TABLE customer DROP is_account_configured'); + $this->addSql('ALTER TABLE customer ALTER email TYPE VARCHAR(255)'); + } +} diff --git a/migrations/Version20260122161702.php b/migrations/Version20260122161702.php new file mode 100644 index 0000000..3b620b2 --- /dev/null +++ b/migrations/Version20260122161702.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE customer ADD verification_code VARCHAR(6) DEFAULT NULL'); + $this->addSql('ALTER TABLE customer ADD verification_code_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN customer.verification_code_expires_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE customer DROP verification_code'); + $this->addSql('ALTER TABLE customer DROP verification_code_expires_at'); + } +} diff --git a/migrations/Version20260122183540.php b/migrations/Version20260122183540.php new file mode 100644 index 0000000..dca6503 --- /dev/null +++ b/migrations/Version20260122183540.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE product_reserve ADD contrat_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1921823061F FOREIGN KEY (contrat_id) REFERENCES contrats (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_CE39F1921823061F ON product_reserve (contrat_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1921823061F'); + $this->addSql('DROP INDEX IDX_CE39F1921823061F'); + $this->addSql('ALTER TABLE product_reserve DROP contrat_id'); + } +} diff --git a/migrations/Version20260122184310.php b/migrations/Version20260122184310.php new file mode 100644 index 0000000..e39e8aa --- /dev/null +++ b/migrations/Version20260122184310.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE contrats_payments ADD payment_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE contrats_payments ADD amount DOUBLE PRECISION DEFAULT NULL'); + $this->addSql('ALTER TABLE contrats_payments ADD validate_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN contrats_payments.payment_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN contrats_payments.validate_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE contrats_payments DROP payment_at'); + $this->addSql('ALTER TABLE contrats_payments DROP amount'); + $this->addSql('ALTER TABLE contrats_payments DROP validate_at'); + } +} diff --git a/migrations/Version20260122185430.php b/migrations/Version20260122185430.php new file mode 100644 index 0000000..ce55f1e --- /dev/null +++ b/migrations/Version20260122185430.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE contrats_payments ADD card TEXT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN contrats_payments.card IS \'(DC2Type:array)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE contrats_payments DROP card'); + } +} diff --git a/src/Controller/ContratController.php b/src/Controller/ContratController.php index cdea22f..9716902 100644 --- a/src/Controller/ContratController.php +++ b/src/Controller/ContratController.php @@ -4,16 +4,24 @@ namespace App\Controller; use App\Entity\Account; use App\Entity\AccountResetPasswordRequest; +use App\Entity\Contrats; +use App\Entity\ContratsPayments; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; use App\Logger\AppLogger; +use App\Repository\ContratsRepository; +use App\Repository\CustomerRepository; +use App\Service\Mailer\Mailer; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; use App\Service\ResetPassword\Event\ResetPasswordEvent; +use App\Service\Signature\Client; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -25,9 +33,241 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class ContratController extends AbstractController { - #[Route('/gestion-contrat/{num}', name: 'gestion_contrat')] - public function gestionContrat() - { + #[Route('/contrat/payment/cancel/{id}', name: 'gestion_contrat_cancel')] + public function gestionContratCancel( + Contrats $contrat, + Request $request + ): Response { + // On peut éventuellement ajouter un message flash pour informer l'utilisateur + $this->addFlash('info', 'Le paiement a été annulé. Votre réservation n\'est pas encore validée.'); + return $this->render('reservation/contrat/cancel.twig', [ + 'contrat' => $contrat + ]); + } + #[Route('/contrat/payment/success/{id}', name: 'gestion_contrat_success')] + public function gestionContratSuccess( + Contrats $contrat, + Request $request, + EntityManagerInterface $entityManager, + ): Response { + $type = $request->query->get('type', 'accompte'); + + if ($type === "accompte") { + $pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([ + 'type' => 'accompte', + 'contrat' => $contrat + ]); + + // Si le paiement est déjà marqué comme complété par le Webhook + if ($pl && $pl->getState() === "complete") { + return $this->render('reservation/contrat/success.twig', [ + 'contrat' => $contrat, + 'type' => $type + ]); + } + } + + return $this->render('reservation/contrat/check.twig', [ + 'contrat' => $contrat, + 'type' => $type + ]); + } + + #[Route('/reservation/gestion-contrat/{num}', name: 'gestion_contrat_view')] + public function gestionContratView(string $num,\App\Service\Stripe\Client $stripeClient,Client $client,Mailer $mailer,EntityManagerInterface $entityManager, Request $request, ContratsRepository $contratsRepository): Response + { + $contrat = $contratsRepository->findOneBy(['numReservation' => $num]); + + if (null === $contrat) { + return $this->render('reservation/contrat/nofound.twig'); + } + + $customer = $contrat->getCustomer(); + + if (!$customer->isAccountConfigured()) { + $code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $customer->setVerificationCode($code); + $customer->setVerificationCodeExpiresAt(new \DateTimeImmutable('+15 minutes')); + $entityManager->flush(); + + // Envoi du mail (utilise ton service Mailer existant) + $mailer->send( + $customer->getEmail(), + $customer->getSurname().' '.$customer->getName(), + "[Ludikevent] Votre code de vérification", + "mails/customer/verification_code.twig", + ['code' => $code, 'customer' => $customer] + ); + + $request->getSession()->set('config_customer_id', $customer->getId()); + $request->getSession()->set('num_reservation', $num); + + return $this->redirectToRoute('gestion_contrat_finish'); + } + +// Calcul de la durée + $dateStart = $contrat->getDateAt(); + $dateEnd = $contrat->getEndAt(); + $totalDays = $dateStart->diff($dateEnd)->days + 1; + + $totalHT = 0; + $totalCaution = 0; + +// Calcul des lignes (Location dégressive) + foreach ($contrat->getContratsLines() as $line) { + $priceLine = $line->getPrice1DayHt(); + if ($totalDays > 1) { + $priceLine += ($line->getPriceSupDayHt() * ($totalDays - 1)); + } + $totalHT += $priceLine; + $totalCaution += $line->getCaution(); + } + +// Ajout des options (Forfait) + foreach ($contrat->getContratsOptions() as $option) { + $totalHT += $option->getPrice(); + } + +// --- NOUVEAUX CALCULS --- + $arrhes = $totalHT * 0.25; // 25% + $solde = $totalHT; + + + if($request->query->has('act') && $request->query->get('act') === 'accomptePay') { + $pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([ + 'type' => 'accompte', + 'contrat' => $contrat + ]); + + if(!$pl instanceof ContratsPayments) { + // SCÉNARIO 1 : PREMIÈRE CRÉATION + $result = $stripeClient->createPaymentAccompte($arrhes, $contrat); + + $pl = new ContratsPayments(); + $pl->setContrat($contrat); + $pl->setType('accompte'); + $pl->setAmount($arrhes); + $pl->setPaymentAt(new \DateTimeImmutable('now')); + $pl->setState("created"); + $pl->setPaymentId($result['id']); // On stocke l'ID de session Stripe + + $entityManager->persist($pl); + $entityManager->flush(); + + return new RedirectResponse($result['url']); + } else { + // SCÉNARIO 2 : RÉCUPÉRATION OU RE-GÉNÉRATION (si expiré) + $result = $stripeClient->linkPaymentAccompte($arrhes, $contrat, $pl); + return new RedirectResponse($result['url']); + } + } + + if($request->query->has('act') && $request->query->get('act') === 'cautionPay') { + $pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([ + 'type' => 'caution', + 'contrat' => $contrat + ]); + + if(!$pl instanceof ContratsPayments) { + // SCÉNARIO 1 : PREMIÈRE CRÉATION + $result = $stripeClient->createPaymentCaution($totalCaution, $contrat); + + $pl = new ContratsPayments(); + $pl->setContrat($contrat); + $pl->setType('caution'); + $pl->setAmount($totalCaution); + $pl->setPaymentAt(new \DateTimeImmutable('now')); + $pl->setState("created"); + $pl->setPaymentId($result['id']); // On stocke l'ID de session Stripe + + $entityManager->persist($pl); + $entityManager->flush(); + + return new RedirectResponse($result['url']); + } else { + // SCÉNARIO 2 : RÉCUPÉRATION OU RE-GÉNÉRATION (si expiré) + $result = $stripeClient->linkPaymentCaution($arrhes, $contrat, $pl); + return new RedirectResponse($result['url']); + } + } + + /** @var ContratsPayments $paymentList */ + $paymentList =[]; + $paymentCaution =[]; + foreach ($contrat->getContratsPayments() as $contratsPayment) { + if($contratsPayment->getType() != "caution") { + if($contratsPayment->getType() == "accompte" && $contratsPayment->getState() == "complete") { + $solde = $solde - $contratsPayment->getAmount(); + } else { + $solde = $solde - $contratsPayment->getAmount(); + } + $paymentList[] = $contratsPayment; + } else { + $paymentCaution[] = ""; + } + } + + return $this->render('reservation/contrat/view.twig', [ + 'contrat' => $contrat, + 'days' => $totalDays, + 'totalHT' => $totalHT, + 'totalCaution' => $totalCaution, + 'arrhes' => $arrhes, + 'paymentList' => $paymentList, + 'solde' => $solde, + 'signUrl' => (!$contrat->isSigned())?$client->getLinkSign($contrat->getSignID()):null, + 'signEvents' => ($contrat->getSignID() !="")?$client->eventSign($contrat):[], + 'signedNumber' => ($contrat->isSigned())?$client->signedData($contrat): null, + ]); + } + + #[Route('/reservation/gestion-contrat/configuration', name: 'gestion_contrat_finish', priority: 5)] + public function gestionContratConfig( + Request $request, + EntityManagerInterface $em, + UserPasswordHasherInterface $hasher, + CustomerRepository $customerRepository, + Security $security // Injection du service Security + ): Response { + $session = $request->getSession(); + $customer = $session->get('config_customer_id') ? $customerRepository->find($session->get('config_customer_id')) : null; + + if (!$customer || $customer->isAccountConfigured()) return $this->redirectToRoute('reservation'); + + if ($request->isMethod('POST')) { + $inputCode = $request->request->get('verification_code'); + $password = $request->request->get('password'); + + // 1. Vérification du code + if ($inputCode !== $customer->getVerificationCode() || new \DateTimeImmutable() > $customer->getVerificationCodeExpiresAt()) { + $this->addFlash('danger', 'Code invalide ou expiré.'); + return $this->render('reservation/contrat/finish_error.twig', ['customer' => $customer, 'error' => 'Code incorrect']); + } + + // 2. Configuration du compte + $customer->setPassword($hasher->hashPassword($customer, $password)); + $customer->setIsAccountConfigured(true); + $customer->setRoles(['ROLE_USER', 'ROLE_CUSTOMER']); + $customer->setVerificationCode(null); + $customer->setVerificationCodeExpiresAt(null); + + $em->flush(); + + $security->login($customer, 'form_login', 'main'); + + return $this->render('reservation/contrat/finish_activate.twig', [ + 'customer' => $customer, + 'link' => $this->generateUrl('gestion_contrat_view', ['num' => $session->get('num_reservation')]) + ]); + } + + return $this->render('reservation/contrat/finish_config.twig', ['customer' => $customer]); + } + + #[Route('/reservation/gestion-contrat',name: 'gestion_contrat')] + public function gestionContrat(string $num,Request $request,ContratsRepository $contratsRepository): Response + { + //espacce } } diff --git a/src/Controller/SignatureController.php b/src/Controller/SignatureController.php index dcdeef7..c9f0603 100644 --- a/src/Controller/SignatureController.php +++ b/src/Controller/SignatureController.php @@ -9,6 +9,7 @@ use App\Entity\ProductReserve; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; use App\Logger\AppLogger; +use App\Repository\ContratsRepository; use App\Repository\DevisRepository; use App\Service\Mailer\Mailer; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; @@ -36,12 +37,80 @@ class SignatureController extends AbstractController public function appSignComplete( Client $client, DevisRepository $devisRepository, + ContratsRepository $contratsRepository, EntityManagerInterface $entityManager, Request $request, Mailer $mailer, ): Response { if ($request->get('type') === "contrat") { - dd(); + $contrats = $contratsRepository->find($request->get('id')); + if (!$contrats) { + throw $this->createNotFoundException("Contrat introuvable."); + } + + // On évite de retraiter un devis déjà marqué comme signé + if ($contrats->isSigned()) { + return $this->render('sign/contrat_sign_success.twig', ['contrat' => $contrats]); + } + $submiter = $client->getSubmiter($contrats->getSignID()); + $submission = $client->getSubmition($submiter['submission_id']); + if ($submission['status'] === "completed") { + $contrats->setIsSigned(true); + + $auditUrl = $submission['audit_log_url']; + $signedDocUrl = $submission['documents'][0]['url']; + + try { + // 1. Gestion du PDF SIGNÉ + $tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf'; + $signedContent = file_get_contents($signedDocUrl); + file_put_contents($tmpSigned, $signedContent); + + // On utilise UploadedFile pour simuler un upload propre pour VichUploader + $contrats->setDevisSignFile(new UploadedFile($tmpSigned, "sign-" . $contrats->getNumReservation() . ".pdf", "application/pdf", null, true)); + + // 2. Gestion de l'AUDIT LOG + $tmpAudit = sys_get_temp_dir() . '/audit_' . uniqid() . '.pdf'; + $auditContent = file_get_contents($auditUrl); + file_put_contents($tmpAudit, $auditContent); + + $contrats->setDevisAuditFile(new UploadedFile($tmpAudit, "audit-" . $contrats->getNumReservation() . ".pdf", "application/pdf", null, true)); + + // 3. Préparation des pièces jointes pour le mail (Le PDF signé est le plus important) + $attachments = [ + new DataPart($signedContent, "Contrat -" . $contrats->getNumReservation() . "-Signe.pdf", "application/pdf"), + new DataPart($auditContent, "Certificat-Signature-" . $contrats->getNumReservation() . ".pdf", "application/pdf"), + ]; + + $entityManager->persist($contrats); + $entityManager->flush(); + + // 5. Envoi du mail de confirmation avec le récapitulatif + $mailer->send( + $contrats->getCustomer()->getEmail(), + $contrats->getCustomer()->getName() . " " . $contrats->getCustomer()->getSurname(), + "[Ludikevent] Confirmation de signature - Contrat " . $contrats->getNumReservation(), + "mails/sign/signed_contrat.twig", + [ + 'contrats' => $contrats // Correction ici : passage de l'objet, pas d'un string + ], + $attachments + ); + $mailer->send( + "contact@ludikevent.fr", + "Ludikevent", + "[Intranet Ludikevent] Confirmation signature d'un client pour - Contrat " . $contrats->getNumReservation(), + "mails/sign/signed_contrat_notification.twig", + [ + 'contrats' => $contrats // Correction ici : passage de l'objet, pas d'un string + ], + $attachments + ); + } catch (\Exception $e) { + return new Response("Erreur lors de la récupération ou de l'envoi des documents : " . $e->getMessage(), 500); + } + } + return $this->render('sign/contrat_sign_success.twig', ['contrat' => $contrats]); } if ($request->get('type') === "devis") { $devis = $devisRepository->find($request->get('id')); diff --git a/src/Controller/Webhooks.php b/src/Controller/Webhooks.php new file mode 100644 index 0000000..3bc9c9b --- /dev/null +++ b/src/Controller/Webhooks.php @@ -0,0 +1,90 @@ + false], methods: ['POST'])] + public function payment(ProductRepository $productRepository,Request $request, Client $client, EntityManagerInterface $entityManager): Response + { + // 1. Vérification de la signature via ton service + if ($client->checkWebhooks($request, 'payment')) { + + // 2. Récupération des données JSON de Stripe + $payload = json_decode($request->getContent(), true); + + if (!$payload) { + return new Response('Invalid JSON', 400); + } + + if ($payload['type'] === 'checkout.session.completed') { + + $session = $payload['data']['object']; + $paymentId = $session['id'] ?? null; + + if ($paymentId) { + $pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy(['paymentId'=>$paymentId]); + + + if ($pl instanceof ContratsPayments) { + + if($pl->getState() != "complete") { + $pl->setState("complete"); + $pl->setValidateAt(new \DateTimeImmutable('now')); + $pl->setCard($client->paymentMethod($session['payment_intent'])); + $entityManager->persist($pl); + + + if (!$pl->getContrat()->getDevis() instanceof Devis) { + foreach ($pl->getContrat()->getContratsLines() as $line) { + $pr = $productRepository->findOneBy(['name' => $line->getName()]); + if ($pr instanceof Product) { + $pres = new ProductReserve(); + $pres->setProduct($pres->getProduct()); + $pres->setStartAt($pl->getContrat()->getDateAt()); + $pres->setEndAt($pl->getContrat()->getEndAt()); + $pres->setContrat($pl->getContrat()); + $entityManager->persist($pres); + } + } + } else { + foreach ($pl->getContrat()->getDevis()->getProductReserve() as $productReserve) { + $productReserve->setContrat($pl->getContrat()); + } + $entityManager->persist($pl); + } + $entityManager->flush(); + } + } + } + } + + // Toujours renvoyer une 200 à Stripe pour confirmer la réception + return new Response('Event Handled', 200); + } + + // Si la signature est invalide + return new Response('Invalid Signature', 400); + } +} diff --git a/src/Entity/Contrats.php b/src/Entity/Contrats.php index 93decb1..e003633 100644 --- a/src/Entity/Contrats.php +++ b/src/Entity/Contrats.php @@ -129,11 +129,18 @@ class Contrats #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $updateAt = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'contrat')] + private Collection $productReserves; + public function __construct() { $this->contratsPayments = new ArrayCollection(); $this->contratsLines = new ArrayCollection(); $this->contratsOptions = new ArrayCollection(); + $this->productReserves = new ArrayCollection(); } public function getId(): ?int @@ -751,6 +758,36 @@ class Contrats return $this->adressEvent; } + /** + * @return Collection + */ + public function getProductReserves(): Collection + { + return $this->productReserves; + } + + public function addProductReserf(ProductReserve $productReserf): static + { + if (!$this->productReserves->contains($productReserf)) { + $this->productReserves->add($productReserf); + $productReserf->setContrat($this); + } + + return $this; + } + + public function removeProductReserf(ProductReserve $productReserf): static + { + if ($this->productReserves->removeElement($productReserf)) { + // set the owning side to null (unless already changed) + if ($productReserf->getContrat() === $this) { + $productReserf->setContrat(null); + } + } + + return $this; + } + } diff --git a/src/Entity/ContratsPayments.php b/src/Entity/ContratsPayments.php index 1dd33a5..641ef27 100644 --- a/src/Entity/ContratsPayments.php +++ b/src/Entity/ContratsPayments.php @@ -3,6 +3,7 @@ namespace App\Entity; use App\Repository\ContratsPaymentsRepository; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ContratsPaymentsRepository::class)] @@ -25,6 +26,18 @@ class ContratsPayments #[ORM\Column(length: 255)] private ?string $state = null; + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $paymentAt = null; + + #[ORM\Column(nullable: true)] + private ?float $amount = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $validateAt = null; + + #[ORM\Column(type: Types::ARRAY,nullable: true)] + private array $card = []; + public function getId(): ?int { return $this->id; @@ -77,4 +90,52 @@ class ContratsPayments return $this; } + + public function getPaymentAt(): ?\DateTimeImmutable + { + return $this->paymentAt; + } + + public function setPaymentAt(\DateTimeImmutable $paymentAt): static + { + $this->paymentAt = $paymentAt; + + return $this; + } + + public function getAmount(): ?float + { + return $this->amount; + } + + public function setAmount(?float $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function getValidateAt(): ?\DateTimeImmutable + { + return $this->validateAt; + } + + public function setValidateAt(\DateTimeImmutable $validateAt): static + { + $this->validateAt = $validateAt; + + return $this; + } + + public function getCard(): array + { + return $this->card; + } + + public function setCard(array $card): static + { + $this->card = $card; + + return $this; + } } diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index fc556de..8f437b8 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -6,15 +6,42 @@ use App\Repository\CustomerRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: CustomerRepository::class)] -class Customer +class Customer implements UserInterface, PasswordAuthenticatedUserInterface { + public const ROLE_CUSTOMER = 'ROLE_CUSTOMER'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; + // --- CHAMPS SÉCURITÉ & COMPTE --- + + #[ORM\Column(length: 180, unique: true)] + private ?string $email = null; + + #[ORM\Column(nullable: true)] + private array $roles = []; + + #[ORM\Column(nullable: true)] + private ?string $password = null; + + #[ORM\Column(options: ["default" => false])] + private bool $isAccountConfigured = false; + + + #[ORM\Column(length: 6, nullable: true)] + private ?string $verificationCode = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $verificationCodeExpiresAt = null; + + // --- CHAMPS IDENTITÉ --- + #[ORM\Column(length: 255)] private ?string $civ = null; @@ -27,9 +54,6 @@ class Customer #[ORM\Column(length: 255)] private ?string $phone = null; - #[ORM\Column(length: 255)] - private ?string $email = null; - #[ORM\Column(length: 255)] private ?string $type = null; @@ -39,6 +63,8 @@ class Customer #[ORM\Column(length: 255, nullable: true)] private ?string $customerId = null; + // --- RELATIONS --- + /** * @var Collection */ @@ -69,58 +95,62 @@ class Customer $this->devis = new ArrayCollection(); $this->productReserves = new ArrayCollection(); $this->contrats = new ArrayCollection(); + + // Configuration par défaut + $this->roles = [self::ROLE_CUSTOMER]; + $this->isAccountConfigured = false; } + // --- MÉTHODES INTERFACES (SECURITY) --- + + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = self::ROLE_CUSTOMER; + return array_unique($roles); + } + + public function setRoles(array $roles): static + { + $this->roles = $roles; + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(?string $password): static + { + $this->password = $password; + return $this; + } + + public function eraseCredentials(): void + { + } + + // --- GETTERS & SETTERS PROPRES --- + public function getId(): ?int { return $this->id; } - public function getCiv(): ?string + public function isAccountConfigured(): bool { - return $this->civ; + return $this->isAccountConfigured; } - public function setCiv(string $civ): static + public function setIsAccountConfigured(bool $isAccountConfigured): static { - $this->civ = $civ; - - return $this; - } - - public function getName(): ?string - { - return $this->name; - } - - public function setName(string $name): static - { - $this->name = $name; - - return $this; - } - - public function getSurname(): ?string - { - return $this->surname; - } - - public function setSurname(string $surname): static - { - $this->surname = $surname; - - return $this; - } - - public function getPhone(): ?string - { - return $this->phone; - } - - public function setPhone(string $phone): static - { - $this->phone = $phone; - + $this->isAccountConfigured = $isAccountConfigured; return $this; } @@ -132,7 +162,50 @@ class Customer public function setEmail(string $email): static { $this->email = $email; + return $this; + } + public function getCiv(): ?string + { + return $this->civ; + } + + public function setCiv(string $civ): static + { + $this->civ = $civ; + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + return $this; + } + + public function getSurname(): ?string + { + return $this->surname; + } + + public function setSurname(string $surname): static + { + $this->surname = $surname; + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(string $phone): static + { + $this->phone = $phone; return $this; } @@ -144,7 +217,6 @@ class Customer public function setType(string $type): static { $this->type = $type; - return $this; } @@ -156,7 +228,6 @@ class Customer public function setSiret(?string $siret): static { $this->siret = $siret; - return $this; } @@ -168,43 +239,27 @@ class Customer public function setCustomerId(?string $customerId): static { $this->customerId = $customerId; - return $this; } - /** - * @return Collection - */ + // --- LOGIQUE DES COLLECTIONS --- + + /** @return Collection */ public function getCustomerAddresses(): Collection { return $this->customerAddresses; } - public function addCustomerAddress(CustomerAddress $customerAddress): static + public function addCustomerAddress(CustomerAddress $address): static { - if (!$this->customerAddresses->contains($customerAddress)) { - $this->customerAddresses->add($customerAddress); - $customerAddress->setCustomer($this); + if (!$this->customerAddresses->contains($address)) { + $this->customerAddresses->add($address); + $address->setCustomer($this); } - return $this; } - public function removeCustomerAddress(CustomerAddress $customerAddress): static - { - if ($this->customerAddresses->removeElement($customerAddress)) { - // set the owning side to null (unless already changed) - if ($customerAddress->getCustomer() === $this) { - $customerAddress->setCustomer(null); - } - } - - return $this; - } - - /** - * @return Collection - */ + /** @return Collection */ public function getDevis(): Collection { return $this->devis; @@ -216,55 +271,10 @@ class Customer $this->devis->add($devi); $devi->setCustomer($this); } - return $this; } - public function removeDevi(Devis $devi): static - { - if ($this->devis->removeElement($devi)) { - // set the owning side to null (unless already changed) - if ($devi->getCustomer() === $this) { - $devi->setCustomer(null); - } - } - - return $this; - } - - /** - * @return Collection - */ - public function getProductReserves(): Collection - { - return $this->productReserves; - } - - public function addProductReserf(ProductReserve $productReserf): static - { - if (!$this->productReserves->contains($productReserf)) { - $this->productReserves->add($productReserf); - $productReserf->setCustomer($this); - } - - return $this; - } - - public function removeProductReserf(ProductReserve $productReserf): static - { - if ($this->productReserves->removeElement($productReserf)) { - // set the owning side to null (unless already changed) - if ($productReserf->getCustomer() === $this) { - $productReserf->setCustomer(null); - } - } - - return $this; - } - - /** - * @return Collection - */ + /** @return Collection */ public function getContrats(): Collection { return $this->contrats; @@ -276,19 +286,38 @@ class Customer $this->contrats->add($contrat); $contrat->setCustomer($this); } - return $this; } - public function removeContrat(Contrats $contrat): static + /** + * @return string|null + */ + public function getVerificationCode(): ?string { - if ($this->contrats->removeElement($contrat)) { - // set the owning side to null (unless already changed) - if ($contrat->getCustomer() === $this) { - $contrat->setCustomer(null); - } - } + return $this->verificationCode; + } - return $this; + /** + * @return \DateTimeImmutable|null + */ + public function getVerificationCodeExpiresAt(): ?\DateTimeImmutable + { + return $this->verificationCodeExpiresAt; + } + + /** + * @param \DateTimeImmutable|null $verificationCodeExpiresAt + */ + public function setVerificationCodeExpiresAt(?\DateTimeImmutable $verificationCodeExpiresAt): void + { + $this->verificationCodeExpiresAt = $verificationCodeExpiresAt; + } + + /** + * @param string|null $verificationCode + */ + public function setVerificationCode(?string $verificationCode): void + { + $this->verificationCode = $verificationCode; } } diff --git a/src/Entity/ProductReserve.php b/src/Entity/ProductReserve.php index 066cbab..37d10a4 100644 --- a/src/Entity/ProductReserve.php +++ b/src/Entity/ProductReserve.php @@ -28,6 +28,9 @@ class ProductReserve #[ORM\OneToOne(inversedBy: 'productReserve', cascade: ['persist', 'remove'])] private ?Devis $devis = null; + #[ORM\ManyToOne(inversedBy: 'productReserves')] + private ?Contrats $contrat = null; + public function getId(): ?int { return $this->id; @@ -92,4 +95,16 @@ class ProductReserve return $this; } + + public function getContrat(): ?Contrats + { + return $this->contrat; + } + + public function setContrat(?Contrats $contrat): static + { + $this->contrat = $contrat; + + return $this; + } } diff --git a/src/Event/Signature/ContratSubscriber.php b/src/Event/Signature/ContratSubscriber.php index a64fbc7..d2f2e81 100644 --- a/src/Event/Signature/ContratSubscriber.php +++ b/src/Event/Signature/ContratSubscriber.php @@ -48,7 +48,7 @@ class ContratSubscriber "mails/sign/contrat.twig", [ 'contrat' => $contrat, - 'contratLink' => $_ENV['CONTRAT_BASEURL'] . $this->urlGenerator->generate('gestion_contrat', ['num' => $contrat->getNumReservation()]) + 'contratLink' => $_ENV['CONTRAT_BASEURL'] . $this->urlGenerator->generate('gestion_contrat_view', ['num' => $contrat->getNumReservation()]) ], $attachments ); diff --git a/src/Security/AuthenticationEntryPoint.php b/src/Security/AuthenticationEntryPoint.php index bace209..4198b8d 100644 --- a/src/Security/AuthenticationEntryPoint.php +++ b/src/Security/AuthenticationEntryPoint.php @@ -2,55 +2,58 @@ namespace App\Security; +use App\Entity\Account; +use App\Entity\Customer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken; -use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; class AuthenticationEntryPoint implements AuthenticationEntryPointInterface { - /** - * @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface|mixed - */ - public $urlGenerator; - /** - * @var AccessDeniedHandler|mixed - */ - public $accessDeniedHandler; public function __construct( - UrlGeneratorInterface $urlGenerator, - AccessDeniedHandler $accessDeniedHandler - ) { - $this->urlGenerator = $urlGenerator; - $this->accessDeniedHandler = $accessDeniedHandler; - } + private readonly UrlGeneratorInterface $urlGenerator, + private readonly TokenStorageInterface $tokenStorage + ) {} public function start(Request $request, AuthenticationException $authException = null): Response { - $previous = $authException !== null ? $authException->getPrevious() : null; - - // Parque le composant security est un peu bête et ne renvoie pas un AccessDenied pour les utilisateur connecté avec un cookie - // On redirige le traitement de cette situation vers le AccessDeniedHandler - if ($authException instanceof InsufficientAuthenticationException && - $previous instanceof AccessDeniedException && - $authException->getToken() instanceof RememberMeToken - ) { - return $this->accessDeniedHandler->handle($request, $previous); - } + $token = $this->tokenStorage->getToken(); + $user = $token ? $token->getUser() : null; + $path = $request->getPathInfo(); + // 1. Gestion des appels API / AJAX if (in_array('application/json', $request->getAcceptableContentTypes())) { - return new JsonResponse( - ['title' => "Vous n'avez pas les permissions suffisantes pour effectuer cette action"], - Response::HTTP_FORBIDDEN - ); + return new JsonResponse(['error' => 'Accès restreint'], Response::HTTP_FORBIDDEN); } + // 2. LOGIQUE POUR LE CRM (/crm) + // Seuls les objets Account (Admins) sont autorisés + if (str_starts_with($path, '/crm')) { + if ($user instanceof Customer) { + // Un client tente d'entrer dans le CRM -> Redirection Accueil + return new RedirectResponse($this->urlGenerator->generate('app_home')); + } + // Si pas connecté du tout -> Redirection Login Admin (app_home dans ton cas) + return new RedirectResponse($this->urlGenerator->generate('app_home')); + } + + // 3. LOGIQUE POUR LA RÉSERVATION (/reservation) + // On autorise Account ET Customer + if (str_starts_with($path, '/reservation')) { + // Si l'utilisateur est un Account ou un Customer, il a le droit d'être ici + // Le contrôle d'accès (AccessControl) fera le reste. + // Si personne n'est connecté, on envoie vers le login client + if (!$user) { + return new RedirectResponse($this->urlGenerator->generate('reservation_login')); + } + } + + // Par défaut, retour à l'accueil return new RedirectResponse($this->urlGenerator->generate('app_home')); } } diff --git a/src/Security/CustomerAuthenticator.php b/src/Security/CustomerAuthenticator.php new file mode 100644 index 0000000..3801961 --- /dev/null +++ b/src/Security/CustomerAuthenticator.php @@ -0,0 +1,69 @@ +getHost(); + $allowedHosts = ['reservation.ludikevent.fr', 'esyweb.local']; + + // Supporte la route login si le host correspond + return $request->attributes->get('_route') === 'reservation_login' + && $request->isMethod('POST') + && in_array($host, $allowedHosts); + } + + public function authenticate(Request $request): Passport + { + $email = $request->request->get('email', ''); + $password = $request->request->get('password', ''); + $csrfToken = $request->request->get('_csrf_token'); + + return new Passport( + new UserBadge($email), + new PasswordCredentials($password), + [ + new CsrfTokenBadge('authenticate_customer', $csrfToken), + ] + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + // Redirection après succès vers l'espace client + return new RedirectResponse($this->urlGenerator->generate('reservation')); + } + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate('reservation_login'); + } +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index 2e09273..001a36d 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -4,7 +4,7 @@ namespace App\Security; use App\Entity\Account; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Bundle\SecurityBundle\Security; // L'objet moderne pour les opérations de sécurité +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,7 +13,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; -use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; @@ -24,49 +23,42 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator { use TargetPathTrait; - public const LOGIN_ROUTE = 'app_login'; + public const LOGIN_ROUTE = 'app_home'; - // Les propriétés typées sont la norme en Symfony 7 / PHP 8.2+ - private EntityManagerInterface $entityManager; - private UrlGeneratorInterface $urlGenerator; - private Security $security; - - public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, Security $security) - { - $this->entityManager = $entityManager; - $this->urlGenerator = $urlGenerator; - $this->security = $security; - } + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly Security $security + ) {} public function supports(Request $request): bool { - return ($request->attributes->get('_route') === self::LOGIN_ROUTE) && $request->isMethod('POST'); + $host = $request->getHost(); + $allowedAdminHosts = ['intranet.ludikevent.fr', 'esyweb.local']; + // On n'autorise la connexion que sur les domaines admin + return ($request->attributes->get('_route') === self::LOGIN_ROUTE) + && $request->isMethod('POST') + && in_array($host, $allowedAdminHosts); } - /** - * @param Request $request La requête HTTP entrante - * @return Passport Le passeport contenant l'utilisateur et les informations d'authentification - */ public function authenticate(Request $request): Passport { - - $email = (string) $request->request->get('_username', ''); $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email); return new Passport( - // 1. UserBadge: Charge l'utilisateur par l'email new UserBadge($email, function(string $userIdentifier): Account { + // On force la recherche EXCLUSIVEMENT dans l'entité Account $user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $userIdentifier]); if (!$user) { - throw new CustomUserMessageAuthenticationException('Email ou mot de passe invalide.'); + // Message générique pour ne pas confirmer l'existence d'un email + throw new CustomUserMessageAuthenticationException('Identifiants invalides.'); } return $user; }), - // 2. Credentials: Vérifie le mot de passe new PasswordCredentials($request->request->get('_password', '')), [ new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')), @@ -74,21 +66,16 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator ); } - /** - * @param Request $request La requête actuelle - * @param TokenInterface $token Le jeton d'authentification - * @param string $firewallName Le nom du pare-feu utilisé ('main' dans votre cas) - * @return Response|null - */ public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { - return new RedirectResponse($this->urlGenerator->generate('app_home')); + // Si l'admin essayait d'accéder à une page précise (/crm/stats par ex) avant d'être connecté + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate('app_crm')); } - /** - * @param Request $request - * @return string L'URL de la page de connexion - */ protected function getLoginUrl(Request $request): string { return $this->urlGenerator->generate(self::LOGIN_ROUTE); diff --git a/src/Service/Signature/Client.php b/src/Service/Signature/Client.php index c318cf3..24523d6 100644 --- a/src/Service/Signature/Client.php +++ b/src/Service/Signature/Client.php @@ -227,4 +227,45 @@ class Client return $this->getLinkSign($devis->getSignID()); } + + public function eventSign(object $contrat): array + { + $events = []; + if ($contrat instanceof Contrats) { + $signId = $contrat->getSignID(); + if (!$signId) return []; // Sécurité si pas d'ID de signature + + $submiter = $this->getSubmiter($signId); + + // Vérifier si submission_events existe pour éviter une erreur undefined index + if (!isset($submiter['submission_events'])) return []; + + foreach ($submiter['submission_events'] as $event) { + $label = match($event['event_type']) { + 'view_form' => "Contrat consulté", + 'start_form' => "Début de procédure", + 'complete_form' => "Contrat signé", + 'decline_form' => "Signature refusée", + default => null + }; + + if ($label) { + $events[] = [ + 'event_type' => $label, + 'event_timestamp' => new \DateTimeImmutable($event['event_timestamp']) + ]; + } + } + } + return $events; + } + + public function signedData(Contrats $contrat) : string + { + $signId = $contrat->getSignID(); + if (!$signId) return []; // Sécurité si pas d'ID de signature + + $submiter = $this->getSubmiter($signId); + return $submiter['uuid']; // numéro de signature; + } } diff --git a/src/Service/Stripe/Client.php b/src/Service/Stripe/Client.php index b53df8d..4466d76 100644 --- a/src/Service/Stripe/Client.php +++ b/src/Service/Stripe/Client.php @@ -2,6 +2,8 @@ namespace App\Service\Stripe; +use App\Entity\Contrats; +use App\Entity\ContratsPayments; use App\Entity\Customer; use App\Entity\StripeConfig; use App\Entity\Product; @@ -242,7 +244,7 @@ class Client ], 'payment' => [ 'url' => $baseUrl . '/webhooks/payment-intent', - 'events' => ['payment_intent.succeeded', 'payment_intent.canceled'] + 'events' => ['payment_intent.succeeded', 'payment_intent.canceled','checkout.session.completed'] ] ]; @@ -309,4 +311,193 @@ class Client { return $this->check()['state']; } + + /** + * Récupère le lien existant ou en génère un nouveau si expiré/inexistant + */ + public function linkPaymentAccompte(float $arrhes, Contrats $contrat, ContratsPayments $contratsPayments): array + { + $stripeSessionId = $contratsPayments->getPaymentId(); + + // 1. TENTATIVE DE RÉCUPÉRATION DU LIEN EXISTANT + if ($stripeSessionId) { + try { + $session = $this->client->checkout->sessions->retrieve($stripeSessionId); + + // Si la session est active et non payée, on retourne l'URL existante + if ($session->status === 'open' && $session->payment_status === 'unpaid') { + return [ + 'state' => true, + 'url' => $session->url, + 'id' => $session->id + ]; + } + } catch (\Exception $e) { + // Si l'ID n'est pas trouvé chez Stripe, on continue pour en créer un nouveau + } + } + + // 2. CRÉATION D'UN NOUVEAU LIEN (Si inexistant, expiré ou invalide) + $newSession = $this->createPaymentAccompte($arrhes, $contrat); + + if ($newSession['state']) { + // On met à jour l'entité ContratsPayments avec le nouvel ID de session + $contratsPayments->setPaymentId($newSession['id']); + $this->em->persist($contratsPayments); + $this->em->flush(); + } + + return $newSession; + } + + /** + * Ta fonction de création originale (utilisée en fallback) + */ + public function createPaymentAccompte(float $arrhes, Contrats $contrats): array + { + try { + $session = $this->client->checkout->sessions->create([ + 'customer' => $contrats->getCustomer()->getCustomerId(), + 'line_items' => [[ + 'price_data' => [ + 'currency' => 'eur', + 'product_data' => [ + 'name' => sprintf('Acompte Réservation #%s', $contrats->getNumReservation()), + 'description' => 'Règlement de 25% pour validation du contrat Ludikevent', + ], + 'unit_amount' => (int)round($arrhes * 100), + ], + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'success_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/success/' . $contrats->getId() . '?type=accompte', + 'cancel_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/cancel/' . $contrats->getId() . '?type=accompte', + 'metadata' => [ + 'contrat_id' => $contrats->getId(), + 'type' => 'accompte' + ] + ]); + + return [ + 'state' => true, + 'url' => $session->url, + 'id' => $session->id + ]; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + /** + * Vérifie l'authenticité d'un Webhook Stripe + * @return bool true si la signature est valide, false sinon + */ + public function checkWebhooks(\Symfony\Component\HttpFoundation\Request $request, string $configName): bool + { + // 1. Récupération de la config (Secret de signature) + $config = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $configName]); + + if (!$config || !$config->getSecret()) { + return false; + } + + $payload = $request->getContent(); + $sigHeader = $request->headers->get('Stripe-Signature'); + + + try { + // 2. Vérification de la signature via la librairie native Stripe + // Si la signature est invalide, une exception est levée + \Stripe\Webhook::constructEvent( + $payload, + $sigHeader, + $config->getSecret() + ); + + return true; + } catch (\Exception $e) { + // Signature invalide, timestamp trop ancien, ou payload altéré + return false; + } + } + + public function paymentMethod(mixed $paymentId) + { + $data = $this->client->paymentIntents->retrieve($paymentId); + $paymentMethod = $this->client->paymentMethods->retrieve($data->payment_method); + return $paymentMethod->toArray(); + } + + public function createPaymentCaution(float|int|null $totalCaution, Contrats $contrat): array + { + // On convertit le montant en centimes pour Stripe + $amountInCents = (int)($totalCaution * 100); + + $session = $this->client->checkout->sessions->create([ + 'payment_method_types' => ['card'], // La pré-auth nécessite généralement une carte + 'line_items' => [[ + 'price_data' => [ + 'currency' => 'eur', + 'product_data' => [ + 'name' => 'Dépôt de garantie (Caution) - Réservation #' . $contrat->getNumReservation(), + 'description' => 'Cette somme sera bloquée temporairement mais non débitée immédiatement.', + ], + 'unit_amount' => $amountInCents, + ], + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'payment_intent_data' => [ + // IMPORTANT : 'manual' permet de bloquer les fonds sans les encaisser + 'capture_method' => 'manual', + 'metadata' => [ + 'contrat_id' => $contrat->getId(), + 'type' => 'caution' + ], + ], + 'success_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/success/' . $contrat->getId() . '?type=caution', + 'cancel_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/cancel/' . $contrat->getId() . '?type=caution' + ]); + + return [ + 'state' => true, + 'url' => $session->url, + 'id' => $session->id + ]; + } + + public function linkPaymentCaution(float $arrhes,Contrats $contrat, ContratsPayments $pl) + { + $stripeSessionId = $pl->getPaymentId(); + + // 1. TENTATIVE DE RÉCUPÉRATION DU LIEN EXISTANT + if ($stripeSessionId) { + try { + $session = $this->client->checkout->sessions->retrieve($stripeSessionId); + + // Si la session est active et non payée, on retourne l'URL existante + if ($session->status === 'open' && $session->payment_status === 'unpaid') { + return [ + 'state' => true, + 'url' => $session->url, + 'id' => $session->id + ]; + } + } catch (\Exception $e) { + // Si l'ID n'est pas trouvé chez Stripe, on continue pour en créer un nouveau + } + } + + // 2. CRÉATION D'UN NOUVEAU LIEN (Si inexistant, expiré ou invalide) + $newSession = $this->createPaymentAccompte($arrhes, $contrat); + + if ($newSession['state']) { + // On met à jour l'entité ContratsPayments avec le nouvel ID de session + $pl->setPaymentId($newSession['id']); + $this->em->persist($pl); + $this->em->flush(); + } + + return $newSession; + } } diff --git a/src/Twig/StripeExtension.php b/src/Twig/StripeExtension.php index 6378474..a54d1c7 100644 --- a/src/Twig/StripeExtension.php +++ b/src/Twig/StripeExtension.php @@ -2,22 +2,26 @@ namespace App\Twig; +use App\Entity\Contrats; +use App\Entity\ContratsPayments; use App\Entity\Devis; use App\Service\Stripe\Client; +use Doctrine\ORM\EntityManagerInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; use Twig\TwigFunction; class StripeExtension extends AbstractExtension { - public function __construct(private readonly Client $client) + public function __construct(private readonly Client $client,private readonly EntityManagerInterface $em) { } public function getFilters() { return [ - new TwigFilter('totalQuoto',[$this,'totalQuoto']) + new TwigFilter('totalQuoto',[$this,'totalQuoto']), + new TwigFilter('totalContrat',[$this,'totalContrat']) ]; } @@ -52,13 +56,59 @@ class StripeExtension extends AbstractExtension return (float) $totalHT; } + public function totalContrat(Contrats $devis): float + { + $totalHT = 0; + + // Calcul de la durée (différence entre dateDebut et dateFin) + $dateDebut = $devis->getDateAt(); + $dateFin = $devis->getEndAt(); + + // Calcul du nombre de jours (minimum 1 jour) + $nbDays = 1; + if ($dateDebut && $dateFin) { + $diff = $dateDebut->diff($dateFin); + $nbDays = $diff->days + 1; // +1 pour inclure le jour de départ + } + + // 1. Calcul des lignes de produits (Location) + foreach ($devis->getContratsLines() as $line) { + $price1Day = $line->getPrice1DayHt() ?? 0; + $priceSupHT = $line->getPriceSupDayHt() ?? 0; + + // Calcul : J1 + (Jours restants * Prix Sup) + $lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT); + $totalHT += $lineTotalHT; + } + + // 2. Calcul des options additionnelles + foreach ($devis->getContratsOptions() as $devisOption) { + $totalHT += $devisOption->getPrice() ?? 0; + } + + return (float) $totalHT; + } + public function getFunctions() { return [ new TwigFunction('syncStripe', [$this, 'syncStripe']), + new TwigFunction('contratPaymentPay', [$this, 'contratPaymentPay']), ]; } + public function contratPaymentPay(Contrats $contrat,string $type): bool + { + if($type == "accompte") { + $pl = $this->em->getRepository(ContratsPayments::class)->findOneBy(['type'=>$type,'contrat'=>$contrat]); + if($pl instanceof ContratsPayments) { + return $pl->getState() == "complete"; + } + } + + return false; + + } public function syncStripe(): array { return $this->client->check(); diff --git a/templates/mails/customer/verification_code.twig b/templates/mails/customer/verification_code.twig new file mode 100644 index 0000000..8068859 --- /dev/null +++ b/templates/mails/customer/verification_code.twig @@ -0,0 +1,48 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Ludikevent • Sécurité + + + + Vérification du compte + + + + + + Bonjour {{ datas.customer.surname }} {{ datas.customer.name }}, + + + + Pour activer votre espace client, veuillez saisir le code de sécurité suivant : + + + + + + Votre code secret + + + {{ datas.code }} + + + + + + Ce code est strictement confidentiel et expirera dans 15 minutes. +
+ Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. +
+ + + + + Besoin d'assistance ? Contactez-nous au 06 14 17 24 47. + +
+
+{% endblock %} diff --git a/templates/mails/sign/signed_contrat.twig b/templates/mails/sign/signed_contrat.twig new file mode 100644 index 0000000..ceb575e --- /dev/null +++ b/templates/mails/sign/signed_contrat.twig @@ -0,0 +1,42 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Signature confirmée ! + + + Votre contrat #{{ datas.contrats.numReservation }} est désormais signé. + + + + + + + + ⚠️ ACTION REQUISE : PAIEMENT DE L'ACOMPTE + + + Pour valider définitivement votre réservation, vous disposez de 3 jours pour effectuer le paiement de l'acompte. +

+ Passé ce délai, votre réservation sera automatiquement annulée et les dates seront libérées. +
+
+
+ + + + + Bonjour {{ datas.contrats.customer.name }},

+ Nous avons bien reçu votre signature électronique. Vous trouverez en pièces jointes de cet e-mail : +
+ +
    +
  • Votre contrat signé au format PDF
  • +
  • Le certificat d'audit de la signature
  • +
+
+
+
+{% endblock %} diff --git a/templates/mails/sign/signed_contrat_notification.twig b/templates/mails/sign/signed_contrat_notification.twig new file mode 100644 index 0000000..81c8811 --- /dev/null +++ b/templates/mails/sign/signed_contrat_notification.twig @@ -0,0 +1,42 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Bonne nouvelle ! Le client {{ datas.contrats.customer.name }} {{ datas.contrats.customer.surname }} vient de signer numériquement son contrat. + + + + + + + + + N° Contrat : + {{ datas.contrats.numReservation }} + + + Client : + {{ datas.contrats.customer.numReservation }} + + + Montant HT : + {{ (datas.contrats|totalContrat)|number_format(2, ',', ' ') }} € + + + Date de signature : + {{ "now"|date("d/m/Y à H:i") }} + + + + + + + + + Rappel : Le client a reçu l'instruction de régler l'acompte sous 3 jours. + + + +{% endblock %} diff --git a/templates/reservation/contrat/cancel.twig b/templates/reservation/contrat/cancel.twig new file mode 100644 index 0000000..90ea9e6 --- /dev/null +++ b/templates/reservation/contrat/cancel.twig @@ -0,0 +1,46 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Paiement annulé - #{{ contrat.numReservation }}{% endblock %} + +{% block body %} +
+
+
+ + {# Décoration subtile #} +
+ + {# Icône Annulation #} +
+ + + +
+ +

+ Paiement interrompu +

+ +

+ Le processus de règlement de l'acompte pour la réservation #{{ contrat.numReservation }} a été annulé. + Aucun montant n'a été débité de votre compte. +

+ +
+ {# Bouton Retour à la gestion #} + + + + + Retourner au contrat + + +

+ Besoin d'aide ? Contactez-nous au 06 14 17 24 47 +

+
+
+
+
+{% endblock %} diff --git a/templates/reservation/contrat/check.twig b/templates/reservation/contrat/check.twig new file mode 100644 index 0000000..82a6c89 --- /dev/null +++ b/templates/reservation/contrat/check.twig @@ -0,0 +1,45 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Validation du paiement - Ludikevent{% endblock %} + +{% block body %} + {# L'attribut data-auto-redirect contient l'URL de destination #} +
+ +
+
+ +
+
+
+
+ + + +
+
+ +

+ Paiement en cours +

+ +

+ Nous vérifions la confirmation de votre banque auprès de Stripe.
+ Merci de patienter quelques instants... +

+ +
+

+ Réservation #{{ contrat.numReservation }} +

+
+
+ +

+ Redirection automatique dans quelques secondes +

+
+
+{% endblock %} diff --git a/templates/reservation/contrat/finish_activate.twig b/templates/reservation/contrat/finish_activate.twig new file mode 100644 index 0000000..464799e --- /dev/null +++ b/templates/reservation/contrat/finish_activate.twig @@ -0,0 +1,44 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Compte activé - Ludikevent{% endblock %} + +{% block body %} +
+
+ +
+
+ + + +
+ +

+ Compte
Activé ! +

+ +

+ Félicitations {{ customer.surname }} ! Votre espace client est désormais configuré. Vous pouvez maintenant accéder à votre contrat pour le consulter et le signer. +

+ +
+
+
+ +
+ Rappel de vos accès +
+

Identifiant : {{ customer.email }}

+
+ + + Accéder à mon contrat + + + + +
+ +
+
+{% endblock %} diff --git a/templates/reservation/contrat/finish_config.twig b/templates/reservation/contrat/finish_config.twig new file mode 100644 index 0000000..ad6c3d2 --- /dev/null +++ b/templates/reservation/contrat/finish_config.twig @@ -0,0 +1,68 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Configuration de votre compte - Ludikevent{% endblock %} + +{% block body %} +
+
+ +
+ + Étape de sécurité + + +

+ Activez votre
Espace Client +

+

+ Ravi de vous revoir, {{ customer.surname }}.
+ Un code vient de vous être envoyé par e-mail. +

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

+ Minimum 8 caractères conseillés. +

+
+ +
+ +
+ +
+

+ En activant votre compte, vous pourrez signer vos contrats et consulter vos factures en ligne. +

+
+
+ +
+
+{% endblock %} diff --git a/templates/reservation/contrat/finish_error.twig b/templates/reservation/contrat/finish_error.twig new file mode 100644 index 0000000..5738f9f --- /dev/null +++ b/templates/reservation/contrat/finish_error.twig @@ -0,0 +1,45 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Erreur de vérification - Ludikevent{% endblock %} + +{% block body %} +
+
+ +
+
+ + + +
+ +

+ Code
Invalide +

+ +
+

+ {{ error|default('Le code de sécurité est incorrect ou a expiré (15 min).') }} +

+
+ +

+ Pour des raisons de sécurité, l'accès à votre contrat nécessite une validation par mail valide. Veuillez vérifier votre saisie ou demander un nouveau code. +

+ +
+ {# Retour à la page de saisie #} + + Réessayer la saisie + +
+ +
+

Support Ludikevent

+

06 14 17 24 47

+
+
+ +
+
+{% endblock %} diff --git a/templates/reservation/contrat/nofound.twig b/templates/reservation/contrat/nofound.twig new file mode 100644 index 0000000..ae7f518 --- /dev/null +++ b/templates/reservation/contrat/nofound.twig @@ -0,0 +1,51 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Contrat introuvable - Ludikevent{% endblock %} + +{% block body %} +
+
+ + {# --- ILLUSTRATION --- #} +
+
+ + + +
+
+ ? +
+
+ + {# --- TEXTE D'ERREUR --- #} +

+ Oups ! Contrat introuvable +

+ +

+ Désolé, nous ne parvenons pas à trouver le contrat correspondant à ce numéro. + Il a peut-être été expiré, supprimé ou l'adresse saisie est incorrecte. +

+ + {# --- BOUTON DE RETOUR --- #} +
+ + + + + Accueil + + +
+

+ Besoin d'assistance ? +

+

06 14 17 24 47

+
+
+ +
+
+{% endblock %} diff --git a/templates/reservation/contrat/success.twig b/templates/reservation/contrat/success.twig new file mode 100644 index 0000000..f551c71 --- /dev/null +++ b/templates/reservation/contrat/success.twig @@ -0,0 +1,59 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Paiement Validé - Ludikevent{% endblock %} + +{% block body %} +
+
+
+ + {# Effet de fond vert subtil #} +
+ + {# Icône Succès Animée #} +
+
+
+ + + +
+
+ +

+ Paiement Accepté ! +

+ +

+ Votre acompte a été validé avec succès. Votre réservation #{{ contrat.numReservation }} est désormais confirmée et le matériel vous est réservé. +

+ +
+
+

Référence

+

#{{ contrat.numReservation }}

+
+
+

Statut

+

Confirmé

+
+
+ +
+ {# BOUTON RETOUR GESTION DU CONTRAT #} + + Gérer ma réservation + + + + +
+
+ +

+ Merci de votre confiance — Ludikevent +

+
+
+{% endblock %} diff --git a/templates/reservation/contrat/view.twig b/templates/reservation/contrat/view.twig new file mode 100644 index 0000000..7c6c36e --- /dev/null +++ b/templates/reservation/contrat/view.twig @@ -0,0 +1,272 @@ +{% extends 'revervation/base.twig' %} + +{% block title %}Récapitulatif de réservation #{{ contrat.numReservation }}{% endblock %} + +{% block body %} +
+
+ + {# HEADER : NAVIGATION, TITRE & DATES #} +
+
+ + + +
+
+

Détails Réservation

+

Référence : #{{ contrat.numReservation }}

+
+ +
+
+

Du

+

{{ contrat.dateAt|date('d/m/Y') }}

+
+
+
+ {{ days }} J +
+
+

Au

+

{{ contrat.endAt|date('d/m/Y') }}

+
+
+
+
+ +
+
+ +
+
+

Ville de l'événement

+

{{ contrat.townEvent }} ({{ contrat.zipCodeEvent }})

+
+
+
+ +
+ {# COLONNE GAUCHE #} +
+ + {# STATUT SIGNATURE #} + {% if not contrat.signed %} +
+
+ +
+
+

Action requise : Signature

+

Veuillez signer le contrat pour activer les options de paiement.

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

Contrat validé

+

Signature enregistrée avec succès.

+
+
+
+ {% endif %} + + {# TABLEAU PRESTATIONS #} +
+
+

Détail des prestations

+ {{ days }} Jours +
+
+ + + {% for line in contrat.contratsLines %} + {% set priceLine = line.price1DayHt + (line.priceSupDayHt * (days - 1)) %} + + + + + {% endfor %} + +
+

{{ line.name }}

+

Caution : {{ line.caution|number_format(0, ',', ' ') }}€

+
{{ priceLine|number_format(2, ',', ' ') }}€
+
+
+ + {% if not contrat.signed %} + + {% endif %} +
+ + {# COLONNE DROITE : FINANCES #} +
+
+

Total Prestations HT

+

{{ totalHT|number_format(2, ',', ' ') }}€

+
+ + {# --- SECTION ACOMPTE --- #} + {% if not contratPaymentPay(contrat, 'accompte') %} +
+
+
+ +
+

Acompte à régler (25%)

+
+
+

{{ arrhes|number_format(2, ',', ' ') }}€

+ + {% if contrat.signed %} + + Payer l'acompte + + + {% else %} +

Veuillez signer le contrat
pour débloquer le paiement

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

Acompte encaissé

+
+
+ {% for payment in paymentList %} +
+
+ Paiement validé + {{ payment.validateAt|date('d/m/Y à H:i') }} +
+
+

ID Transaction

+

{{ payment.paymentId }}

+
+
+
+

Moyen utilisé

+ +
+ + {# Affiche le nom propre (ex: Carte Bancaire, Klarna...) #} + {{ payment.card.method_label|default('Paiement Stripe') }} + + + {% if payment.card.type == "card" %} + + {# Affiche la marque et les 4 chiffres #} + ({{ payment.card.card.brand|default('') }} **** {{ payment.card.card.last4|default('') }}) + + + {# Badge optionnel pour le type de débit #} + {% if payment.card.card.funding == "debit" %} + DEBIT + {% endif %} + {% if payment.card.card.funding == "credit" %} + CREDIT + {% endif %} + {% endif %} +
+
+

{{ payment.amount|number_format(2, ',', ' ') }}€

+
+
+ {% endfor %} +
+
+ {% endif %} + + {# --- SECTION CAUTION --- #} + {% if contratPaymentPay(contrat, 'accompte') %} + {% if not contratPaymentPay(contrat, 'caution') %} +
+
+
+ +
+

Caution à déposer

+
+
+

{{ totalCaution|number_format(2, ',', ' ') }}€

+ + {% set canPayCaution = (date('now') >= contrat.dateAt.modify('-7 days')) %} + {% if canPayCaution %} + + Déposer la caution + + + {% else %} +
+

+ Lien actif le {{ contrat.dateAt.modify('-7 days')|date('d/m/Y') }} +

+

(7 jours avant le début)

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

Caution sécurisée

+
+
+ {% for payment in paymentCaution %} +
+
+ Empreinte OK + {{ payment.validateAt|date('d/m/Y à H:i') }} +
+
+

ID Garantie

+

{{ payment.paymentId }}

+
+
+
+

Source

+ + {{ payment.card.method_label|default('Empreinte CB') }} + +
+

{{ payment.amount|number_format(2, ',', ' ') }}€

+
+
+ {% endfor %} +
+
+ {% endif %} + {% endif %} + + {# SOLDE #} +
+

Solde restant

+

{{ solde|number_format(2, ',', ' ') }}€

+
+

À régler le jour de la prestation

+
+
+
+
+
+
+{% endblock %} diff --git a/templates/revervation/base.twig b/templates/revervation/base.twig index 03f3da3..ca3ea99 100644 --- a/templates/revervation/base.twig +++ b/templates/revervation/base.twig @@ -93,10 +93,11 @@ -{% if is_granted('ROLE_USER') %} - - - +{% if is_granted('ROLE_ADMIN') %} + +{% endif %} +{% if is_granted('ROLE_CUSTOMER') and 'gestion-contrat' not in app.request.pathInfo %} + {% endif %} {# --- NAVIGATION --- #}