From 5ab4b06d7d74ba8f569a7742decbed479767bf0a Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 22 Jan 2026 09:19:04 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(Devis.php):=20Ajoute=20l?= =?UTF-8?q?iaison=20Options=20<->=20Devis=20et=20m=C3=A9thode=20get/set.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(options/add.twig): Ajoute template création/édition des options. ♻️ refactor(.env): Met à jour les URLs ngrok pour la synchro Stripe. 🐛 fix(StripeCommand.php): Corrige et améliore la synchro Stripe. ✨ feat(products.twig): Ajoute gestion et affichage des options. ✨ feat(Client.php): Ajoute gestion des options (CRUD) pour Stripe. ✨ feat(vich_uploader.yaml): Ajoute configuration pour upload images options. ✨ feat(ProductController.php): Gère les options (CRUD) dans le contrôleur. ✨ feat(OptionsType.php): Ajoute formulaire pour la gestion des options. ``` --- .env | 4 +- config/packages/vich_uploader.yaml | 4 + migrations/Version20260122074529.php | 38 ++ migrations/Version20260122075732.php | 32 ++ migrations/Version20260122081226.php | 37 ++ src/Command/StripeCommand.php | 63 ++- .../Dashboard/ProductController.php | 123 ++++- src/Entity/Devis.php | 15 + src/Entity/Options.php | 175 +++++++ src/Form/OptionsType.php | 45 ++ src/Repository/OptionsRepository.php | 43 ++ src/Service/Stripe/Client.php | 492 ++++++++---------- templates/dashboard/options/add.twig | 103 ++++ templates/dashboard/products.twig | 107 ++++ 14 files changed, 988 insertions(+), 293 deletions(-) create mode 100644 migrations/Version20260122074529.php create mode 100644 migrations/Version20260122075732.php create mode 100644 migrations/Version20260122081226.php create mode 100644 src/Entity/Options.php create mode 100644 src/Form/OptionsType.php create mode 100644 src/Repository/OptionsRepository.php create mode 100644 templates/dashboard/options/add.twig diff --git a/.env b/.env index e064be6..ea85ad6 100644 --- a/.env +++ b/.env @@ -83,8 +83,8 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR STRIPE_WEBHOOKS_SECRET= -SIGN_URL=https://e2221f1f9e85.ngrok-free.app -STRIPE_BASEURL=https://e2221f1f9e85.ngrok-free.app +SIGN_URL=https://785fe10a414b.ngrok-free.app +STRIPE_BASEURL=https://785fe10a414b.ngrok-free.app MINIO_S3_URL= MINIO_S3_CLIENT_ID= diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index f9706fb..1a67cfa 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -5,6 +5,10 @@ vich_uploader: uri_prefix: /images/image_product upload_destination: '%kernel.project_dir%/public/images/image_product' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + image_options: + uri_prefix: /images/image_options + upload_destination: '%kernel.project_dir%/public/images/image_options' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_file: uri_prefix: /pdf/devis_file upload_destination: '%kernel.project_dir%/public/pdf/devis_file' diff --git a/migrations/Version20260122074529.php b/migrations/Version20260122074529.php new file mode 100644 index 0000000..3269e3d --- /dev/null +++ b/migrations/Version20260122074529.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE options (id SERIAL NOT NULL, name VARCHAR(255) NOT NULL, price_ht DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('ALTER TABLE devis ADD options_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE devis ADD CONSTRAINT FK_8B27C52B3ADB05F1 FOREIGN KEY (options_id) REFERENCES options (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_8B27C52B3ADB05F1 ON devis (options_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 devis DROP CONSTRAINT FK_8B27C52B3ADB05F1'); + $this->addSql('DROP TABLE options'); + $this->addSql('DROP INDEX IDX_8B27C52B3ADB05F1'); + $this->addSql('ALTER TABLE devis DROP options_id'); + } +} diff --git a/migrations/Version20260122075732.php b/migrations/Version20260122075732.php new file mode 100644 index 0000000..bc654d1 --- /dev/null +++ b/migrations/Version20260122075732.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE options ADD stripe_id VARCHAR(255) NOT NULL'); + } + + 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 options DROP stripe_id'); + } +} diff --git a/migrations/Version20260122081226.php b/migrations/Version20260122081226.php new file mode 100644 index 0000000..7ec93bf --- /dev/null +++ b/migrations/Version20260122081226.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE options ADD image_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE options ADD image_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE options ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN options.updated_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 options DROP image_name'); + $this->addSql('ALTER TABLE options DROP image_size'); + $this->addSql('ALTER TABLE options DROP updated_at'); + } +} diff --git a/src/Command/StripeCommand.php b/src/Command/StripeCommand.php index ee586ef..6d07159 100644 --- a/src/Command/StripeCommand.php +++ b/src/Command/StripeCommand.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Command; use App\Entity\Customer; +use App\Entity\Options; +use App\Entity\Product; use App\Service\Stripe\Client; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -33,14 +35,10 @@ class StripeCommand extends Command // 1. Synchronisation des clients manquants $io->section('Synchronisation des clients'); - $customers = $this->entityManager->getRepository(Customer::class)->findBy(['customerId' => null]); + $customers = $this->entityManager->getRepository(Customer::class)->findAll(); - if (empty($customers)) { - $io->success('Tous les clients sont déjà synchronisés.'); - } else { - $io->progressStart(count($customers)); - - foreach ($customers as $customer) { + foreach ($customers as $customer) { + if($customer->getCustomerId() == null) { $result = $this->client->createCustomer($customer); if ($result['state']) { @@ -48,15 +46,56 @@ class StripeCommand extends Command } else { $io->error(sprintf('Échec pour %s : %s', $customer->getEmail(), $result['message'])); } + } else { + $result = $this->client->updateCustomer($customer); - $io->progressAdvance(); + if ($result['state']) { + $this->entityManager->persist($customer); + } else { + $io->error(sprintf('Échec pour %s : %s', $customer->getEmail(), $result['message'])); + } } - - $this->entityManager->flush(); - $io->progressFinish(); - $io->success('Synchronisation des clients terminée.'); } + $io->section('Synchronisation des product'); + $products = $this->entityManager->getRepository(Product::class)->findAll(); + foreach ($products as $product) { + if($product->getProductId() == null) { + $result = $this->client->createProduct($product); + if ($result['state']) { + $this->entityManager->persist($product); + } else { + $io->error(sprintf('Échec pour %s : %s', $product->getName(), $result['message'])); + } + } else { + $result = $this->client->updateProduct($product); + if ($result['state']) { + $this->entityManager->persist($product); + } else { + $io->error(sprintf('Échec pour %s : %s', $product->getName(), $result['message'])); + } + } + } + + $io->section('Synchronisation des options'); + $options = $this->entityManager->getRepository(Options::class)->findAll(); + foreach ($options as $option) { + if($option->getStripeId() == "") { + $result = $this->client->createOptions($option); + if ($result['state']) { + $this->entityManager->persist($option); + } else { + $io->error(sprintf('Échec pour %s : %s', $option->getName(), $result['message'])); + } + } else { + $result = $this->client->updateOptions($option); + if ($result['state']) { + $this->entityManager->persist($option); + } else { + $io->error(sprintf('Échec pour %s : %s', $option->getName(), $result['message'])); + } + } + } // 2. Configuration des Webhooks $io->section('Configuration des Webhooks'); $this->client->webhooks(); diff --git a/src/Controller/Dashboard/ProductController.php b/src/Controller/Dashboard/ProductController.php index 2b03400..f82bb2d 100644 --- a/src/Controller/Dashboard/ProductController.php +++ b/src/Controller/Dashboard/ProductController.php @@ -3,16 +3,19 @@ namespace App\Controller\Dashboard; use App\Entity\Account; +use App\Entity\Options; use App\Entity\Product; use App\Event\Object\EventAdminCreate; use App\Event\Object\EventAdminDeleted; use App\Form\AccountPasswordType; use App\Form\AccountType; +use App\Form\OptionsType; use App\Form\ProductType; use App\Logger\AppLogger; use App\Repository\AccountLoginRegisterRepository; use App\Repository\AccountRepository; use App\Repository\AuditLogRepository; +use App\Repository\OptionsRepository; use App\Repository\ProductRepository; use App\Service\Mailer\Mailer; use App\Service\Stripe\Client; @@ -51,14 +54,59 @@ class ProductController extends AbstractController return $this->json($products); } #[Route(path: '/crm/products', name: 'app_crm_product', options: ['sitemap' => false], methods: ['GET'])] - public function products(ProductRepository $productRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response + public function products(OptionsRepository $optionsRepository, ProductRepository $productRepository, AppLogger $appLogger, PaginatorInterface $paginator, Request $request): Response { $appLogger->record('VIEW','Consultation liste des produits'); return $this->render('dashboard/products.twig', [ - 'products' => $paginator->paginate($productRepository->findBy([],['ref'=>'asc']), $request->get('page', 1), 10), + // Utilisation de 'product_page' pour les produits + 'products' => $paginator->paginate( + $productRepository->findBy([], ['ref' => 'asc']), + $request->query->getInt('product_page', 1), + 10, + ['pageParameterName' => 'product_page'] // <--- Important + ), + // Utilisation de 'option_page' pour les options + 'options' => $paginator->paginate( + $optionsRepository->findBy([], ['id' => 'asc']), + $request->query->getInt('option_page', 1), + 10, + ['pageParameterName' => 'option_page'] // <--- Important + ), ]); } + + #[Route(path: '/crm/products/options/add', name: 'app_crm_product_options_add', options: ['sitemap' => false], methods: ['GET', 'POST'])] + public function optionsAdd( + EntityManagerInterface $entityManager, + AppLogger $appLogger, + Client $client, + Request $request, + MessageBusInterface $messageBus, + ): Response { + $appLogger->record('VIEW', 'Consultation page création d\'une options'); + $options = new Options(); + $form = $this->createForm(OptionsType::class, $options); + $form->handleRequest($request); + if($form->isSubmitted() && $form->isValid()) { + $productName = $options->getName(); + $appLogger->record('CREATE', sprintf('Création du options : %s', $productName)); + $options->setStripeId(""); + $options->setUpdatedAt(new \DateTimeImmutable()); + $client->createOptions($options); + $entityManager->persist($options); + $entityManager->flush(); + $messageBus->dispatch(new DumpSitemapMessage()); + $this->addFlash('success', sprintf('L\'options "%s" a été ajouté au catalogue avec succès.', $productName)); + + return $this->redirectToRoute('app_crm_product'); + } + return $this->render('dashboard/options/add.twig', [ + 'form' => $form->createView(), + 'product' => $options // Optionnel, utile pour l'aperçu d'image si défini + ]); + } + #[Route(path: '/crm/products/add', name: 'app_crm_product_add', options: ['sitemap' => false], methods: ['GET', 'POST'])] public function productAdd( EntityManagerInterface $entityManager, @@ -139,6 +187,47 @@ class ProductController extends AbstractController 'is_edit' => true ]); } + + #[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])] + public function productOptions( + Options $product, + EntityManagerInterface $entityManager, + AppLogger $appLogger, + Request $request, + Client $stripeService + ): Response { + $appLogger->record('VIEW', 'Consultation modification options : ' . $product->getName()); + + $form = $this->createForm(OptionsType::class, $product); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + // 1. Mise à jour Stripe si le produit possède un ID Stripe + if ($product->getStripeId()) { + $stripeResult = $stripeService->updateOptions($product); + + if (!$stripeResult['state']) { + $this->addFlash('warning', 'Erreur synchro Stripe : ' . $stripeResult['message']); + } + } + + // 2. Sauvegarde en base locale + $entityManager->flush(); + + $appLogger->record('UPDATE', 'Mise à jour de l\'options : ' . $product->getName()); + $this->addFlash('success', 'Le options a été mis à jour avec succès.'); + + return $this->redirectToRoute('app_crm_product'); + } + + return $this->render('dashboard/options/add.twig', [ + 'form' => $form->createView(), + 'options' => $product, + 'is_edit' => true + ]); + } + #[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['POST'])] public function productDelete( Product $product, @@ -169,4 +258,34 @@ class ProductController extends AbstractController // 4. Redirection vers le catalogue return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing } + + #[Route(path: '/crm/products/options/delete/{id}', name: 'app_crm_product_option_delete', options: ['sitemap' => false], methods: ['POST'])] + public function productOptionsDelete( + Options $product, + EntityManagerInterface $entityManager, + Request $request, + AppLogger $appLogger, + Client $client + ): Response { + // 1. Vérification du jeton CSRF (sécurité contre les suppressions via URL forcée) + if ($this->isCsrfTokenValid('delete' . $product->getId(), $request->query->get('_token'))) { + + $productName = $product->getName(); + + // 2. Log de l'action avant suppression + $appLogger->record('DELETE', sprintf('Suppression du produit : %s', $productName)); + + $client->deleteOptions($product); + // 3. Suppression en base de données + $entityManager->remove($product); + $entityManager->flush(); + + $this->addFlash('success', sprintf('L\'options "%s" a été supprimé avec succès.', $productName)); + } else { + $this->addFlash('error', 'Jeton de sécurité invalide. Impossible de supprimer le produit.'); + } + + // 4. Redirection vers le catalogue + return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing + } } diff --git a/src/Entity/Devis.php b/src/Entity/Devis.php index 1078fbf..c1dfe09 100644 --- a/src/Entity/Devis.php +++ b/src/Entity/Devis.php @@ -83,6 +83,9 @@ class Devis #[ORM\OneToOne(mappedBy: 'devis', cascade: ['persist', 'remove'])] private ?Contrats $contrats = null; + #[ORM\ManyToOne(inversedBy: 'devis')] + private ?Options $options = null; + public function __construct() { $this->devisLines = new ArrayCollection(); @@ -475,4 +478,16 @@ class Devis return $this; } + public function getOptions(): ?Options + { + return $this->options; + } + + public function setOptions(?Options $options): static + { + $this->options = $options; + + return $this; + } + } diff --git a/src/Entity/Options.php b/src/Entity/Options.php new file mode 100644 index 0000000..5aa56f7 --- /dev/null +++ b/src/Entity/Options.php @@ -0,0 +1,175 @@ + + */ + #[ORM\OneToMany(targetEntity: Devis::class, mappedBy: 'options')] + private Collection $devis; + + #[ORM\Column(length: 255)] + private ?string $stripeId = null; + + #[UploadableField(mapping: 'image_options', fileNameProperty: 'imageName', size: 'imageSize')] + private ?File $imageFile = null; + #[ORM\Column(nullable: true)] + private ?string $imageName = null; + + #[ORM\Column(nullable: true)] + private ?int $imageSize = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + + public function __construct() + { + $this->devis = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPriceHt(): ?float + { + return $this->priceHt; + } + + public function setPriceHt(float $priceHt): static + { + $this->priceHt = $priceHt; + + return $this; + } + + /** + * @return Collection + */ + public function getDevis(): Collection + { + return $this->devis; + } + + public function addDevi(Devis $devi): static + { + if (!$this->devis->contains($devi)) { + $this->devis->add($devi); + $devi->setOptions($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->getOptions() === $this) { + $devi->setOptions(null); + } + } + + return $this; + } + + public function getStripeId(): ?string + { + return $this->stripeId; + } + + public function setStripeId(string $stripeId): static + { + $this->stripeId = $stripeId; + + return $this; + } + + public function setImageFile(?File $imageFile = null): void + { + $this->imageFile = $imageFile; + + if (null !== $imageFile) { + // It is required that at least one field changes if you are using doctrine + // otherwise the event listeners won't be called and the file is lost + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getImageFile(): ?File + { + return $this->imageFile; + } + + public function setImageName(?string $imageName): void + { + $this->imageName = $imageName; + } + + public function getImageName(): ?string + { + return $this->imageName; + } + + public function setImageSize(?int $imageSize): void + { + $this->imageSize = $imageSize; + } + + public function getImageSize(): ?int + { + return $this->imageSize; + } + + /** + * @param \DateTimeImmutable|null $updatedAt + */ + public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } + + /** + * @return \DateTimeImmutable|null + */ + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Form/OptionsType.php b/src/Form/OptionsType.php new file mode 100644 index 0000000..ea8794a --- /dev/null +++ b/src/Form/OptionsType.php @@ -0,0 +1,45 @@ +add('name',TextType::class,[ + 'label' => 'Nom de l\'option', + 'required' => true, + ]) + ->add('priceHt',NumberType::class,[ + 'label' => 'Prix HT', + 'required' => true, + ]) + ->add('imageFile',FileType::class,[ + 'label' => 'Image de l\'options', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Options::class, + ]); + } +} diff --git a/src/Repository/OptionsRepository.php b/src/Repository/OptionsRepository.php new file mode 100644 index 0000000..a7ae369 --- /dev/null +++ b/src/Repository/OptionsRepository.php @@ -0,0 +1,43 @@ + + */ +class OptionsRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Options::class); + } + + // /** + // * @return Options[] Returns an array of Options objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('o') + // ->andWhere('o.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('o.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Options + // { + // return $this->createQueryBuilder('o') + // ->andWhere('o.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Service/Stripe/Client.php b/src/Service/Stripe/Client.php index 880d755..b53df8d 100644 --- a/src/Service/Stripe/Client.php +++ b/src/Service/Stripe/Client.php @@ -4,23 +4,47 @@ namespace App\Service\Stripe; use App\Entity\Customer; use App\Entity\StripeConfig; +use App\Entity\Product; +use App\Entity\Options; use Doctrine\ORM\EntityManagerInterface; use Stripe\Exception\ApiConnectionException; use Stripe\Exception\ApiErrorException; use Stripe\Exception\AuthenticationException; use Stripe\StripeClient; +use Vich\UploaderBundle\Templating\Helper\UploaderHelper; class Client { private StripeClient $client; + private mixed $stripeBaseUrl; public function __construct( - private EntityManagerInterface $em + private EntityManagerInterface $em, + private UploaderHelper $uploaderHelper, ) { $stripeSk = $_ENV['STRIPE_SK'] ?? ''; + $this->stripeBaseUrl = $_ENV['STRIPE_BASEURL'] ?? ''; $this->client = new StripeClient($stripeSk); } + /** + * Génère l'URL absolue de l'image pour Stripe + */ + private function getImageUrl($entity): ?array + { + $path = $this->uploaderHelper->asset($entity, 'imageFile'); + if (!$path) { + return null; + } + + // Construction de l'URL absolue (Stripe nécessite un accès public à l'image) + $fullUrl = str_starts_with($path, 'http') + ? $path + : rtrim($this->stripeBaseUrl, '/') . $path; + + return [$fullUrl]; + } + /** * Vérifie la connexion avec l'API Stripe */ @@ -28,22 +52,18 @@ class Client { try { $this->client->accounts->all(['limit' => 1]); - return [ - 'state' => true, - 'message' => 'Connexion établie avec Stripe' - ]; - } catch (AuthenticationException $e) { - return ['state' => false, 'message' => 'Clé API Stripe invalide ou expirée.']; - } catch (ApiConnectionException $e) { - return ['state' => false, 'message' => 'Problème de connexion réseau avec Stripe.']; + return ['state' => true, 'message' => 'Connexion établie avec Stripe']; + } catch (AuthenticationException) { + return ['state' => false, 'message' => 'Clé API Stripe invalide.']; + } catch (ApiConnectionException) { + return ['state' => false, 'message' => 'Problème de connexion réseau.']; } catch (\Exception $e) { return ['state' => false, 'message' => 'Erreur : ' . $e->getMessage()]; } } - /** - * Crée un client sur Stripe et met à jour l'entité locale - */ + // --- GESTION DES CLIENTS --- + public function createCustomer(Customer $customer): array { try { @@ -59,64 +79,162 @@ class Client ]); $customer->setCustomerId($stripeCustomer->id); - - return [ - 'state' => true, - 'message' => 'Client synchronisé avec succès.', - 'id' => $stripeCustomer->id - ]; + return ['state' => true, 'id' => $stripeCustomer->id]; } catch (ApiErrorException $e) { - return ['state' => false, 'message' => 'Erreur Stripe : ' . $e->getMessage()]; + return ['state' => false, 'message' => $e->getMessage()]; } } - /** - * Supprime un client sur Stripe - */ - public function deleteCustomer(?string $stripeCustomerId): array + public function updateCustomer(Customer $customer): array { - if (!$stripeCustomerId) { - return [ - 'state' => false, - 'message' => 'Aucun ID Stripe fourni pour la suppression.' - ]; - } + if (!$customer->getCustomerId()) return ['state' => false, 'message' => 'Pas d\'ID Stripe']; try { - $this->client->customers->delete($stripeCustomerId, []); - - return [ - 'state' => true, - 'message' => 'Client supprimé avec succès sur Stripe.' - ]; + $this->client->customers->update($customer->getCustomerId(), [ + 'name' => sprintf('%s %s', $customer->getSurname(), $customer->getName()), + 'email' => $customer->getEmail(), + 'phone' => $customer->getPhone(), + 'metadata' => [ + 'internal_id' => $customer->getId(), + 'updated_at' => (new \DateTime())->format('Y-m-d H:i:s') + ], + ]); + return ['state' => true, 'message' => 'Client mis à jour']; } catch (ApiErrorException $e) { - // Si le client n'existe plus sur Stripe, on considère cela comme un succès pour le CRM - if ($e->getStripeCode() === 'resource_missing') { - return [ - 'state' => true, - 'message' => 'Client déjà absent sur Stripe.' - ]; - } - - return [ - 'state' => false, - 'message' => 'Erreur API Stripe lors de la suppression : ' . $e->getMessage() - ]; - } catch (\Exception $e) { - return [ - 'state' => false, - 'message' => 'Erreur système : ' . $e->getMessage() - ]; + return ['state' => false, 'message' => $e->getMessage()]; } } - /** - * Configure, met à jour et sauvegarde les secrets des Webhooks - */ + public function deleteCustomer(?string $stripeCustomerId): array + { + if (!$stripeCustomerId) return ['state' => false]; + try { + $this->client->customers->delete($stripeCustomerId, []); + return ['state' => true]; + } catch (ApiErrorException $e) { + if ($e->getStripeCode() === 'resource_missing') return ['state' => true]; + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + // --- GESTION DES PRODUITS --- + + public function createProduct(Product $product): array + { + try { + $metadata = ['internal_id' => $product->getId(), 'reference' => $product->getRef()]; + $params = [ + 'name' => $product->getName(), + 'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()), + 'metadata' => $metadata, + 'active' => true, + ]; + + if ($images = $this->getImageUrl($product)) { $params['images'] = $images; } + + $stripeProduct = $this->client->products->create($params); + + $this->client->prices->create([ + 'unit_amount' => (int)($product->getPriceDay() * 100), + 'currency' => 'eur', + 'product' => $stripeProduct->id, + ]); + + $product->setProductId($stripeProduct->id); + return ['state' => true, 'id' => $stripeProduct->id]; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + public function updateProduct(Product $product): array + { + try { + $params = [ + 'name' => $product->getName(), + 'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()), + ]; + + if ($images = $this->getImageUrl($product)) { $params['images'] = $images; } + + $this->client->products->update($product->getProductId(), $params); + + $this->client->prices->create([ + 'unit_amount' => (int)($product->getPriceDay() * 100), + 'currency' => 'eur', + 'product' => $product->getProductId(), + ]); + + return ['state' => true, 'message' => 'Produit mis à jour']; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + public function deleteProduct(Product $product): array + { + if (!$product->getProductId()) return ['state' => true]; + try { + $this->client->products->update($product->getProductId(), ['active' => false]); + return ['state' => true]; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + // --- GESTION DES OPTIONS --- + + public function createOptions(Options $options): array + { + try { + $params = [ + 'name' => $options->getName(), + 'metadata' => ['internal_id' => $options->getId()], + 'active' => true, + ]; + + if ($images = $this->getImageUrl($options)) { $params['images'] = $images; } + + $stripeProduct = $this->client->products->create($params); + + $this->client->prices->create([ + 'unit_amount' => (int)($options->getPriceHt() * 100), + 'currency' => 'eur', + 'product' => $stripeProduct->id, + ]); + + $options->setStripeId($stripeProduct->id); + return ['state' => true, 'id' => $stripeProduct->id]; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + public function updateOptions(Options $options): array + { + try { + $params = ['name' => $options->getName()]; + if ($images = $this->getImageUrl($options)) { $params['images'] = $images; } + + $this->client->products->update($options->getStripeId(), $params); + + $this->client->prices->create([ + 'unit_amount' => (int)($options->getPriceHt() * 100), + 'currency' => 'eur', + 'product' => $options->getStripeId(), + ]); + + return ['state' => true, 'message' => 'Option mise à jour']; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + // --- WEBHOOKS & UTILS --- + public function webhooks(): array { - $baseUrl = $_ENV['STRIPE_BASEURL'] ?? 'https://votre-domaine.fr'; - + $baseUrl = rtrim($this->stripeBaseUrl, '/'); $configs = [ 'refund' => [ 'url' => $baseUrl . '/webhooks/refund', @@ -124,66 +242,61 @@ class Client ], 'payment' => [ 'url' => $baseUrl . '/webhooks/payment-intent', - 'events' => [ - 'payment_intent.created', - 'payment_intent.canceled', - 'payment_intent.succeeded', - 'payment_intent.amount_capturable_updated' - ] + 'events' => ['payment_intent.succeeded', 'payment_intent.canceled'] ] ]; - $report = []; - try { - $existingEndpoints = $this->client->webhookEndpoints->all(['limit' => 100]); - + $endpoints = $this->client->webhookEndpoints->all(); foreach ($configs as $name => $config) { $stripeEndpoint = null; - - foreach ($existingEndpoints->data as $endpoint) { - if ($endpoint->url === $config['url']) { - $stripeEndpoint = $endpoint; - break; - } + foreach ($endpoints->data as $ep) { + if ($ep->url === $config['url']) { $stripeEndpoint = $ep; break; } } - $dbConfig = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $name]); - if (!$dbConfig) { - $dbConfig = new StripeConfig(); - $dbConfig->setName($name); - } + $dbConfig = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $name]) ?? new StripeConfig(); + $dbConfig->setName($name); if ($stripeEndpoint) { - $this->client->webhookEndpoints->update($stripeEndpoint->id, [ + $this->client->webhookEndpoints->update($stripeEndpoint->id, ['enabled_events' => $config['events']]); + $dbConfig->setWebhookId($stripeEndpoint->id); + } else { + $new = $this->client->webhookEndpoints->create([ + 'url' => $config['url'], 'enabled_events' => $config['events'] ]); - - $dbConfig->setWebhookId($stripeEndpoint->id); - $report[$name] = ['status' => 'updated', 'url' => $config['url']]; - } else { - $newEndpoint = $this->client->webhookEndpoints->create([ - 'url' => $config['url'], - 'enabled_events' => $config['events'], - 'description' => 'Ludikevent Webhook - ' . $name - ]); - - $dbConfig->setWebhookId($newEndpoint->id); - $dbConfig->setSecret($newEndpoint->secret); - - $report[$name] = ['status' => 'created', 'url' => $config['url']]; + $dbConfig->setWebhookId($new->id); + $dbConfig->setSecret($new->secret); } - $this->em->persist($dbConfig); } - $this->em->flush(); - return ['state' => true, 'data' => $report]; - - } catch (ApiErrorException $e) { - return ['state' => false, 'message' => 'Erreur API Stripe : ' . $e->getMessage()]; + return ['state' => true]; } catch (\Exception $e) { - return ['state' => false, 'message' => 'Erreur système : ' . $e->getMessage()]; + return ['state' => false, 'message' => $e->getMessage()]; + } + } + + + public function deleteOptions(Options $options): array + { + if (!$options->getStripeId()) { + return ['state' => true]; + } + + try { + $this->client->products->update($options->getStripeId(), [ + 'active' => false, + 'metadata' => [ + 'deleted_at' => (new \DateTime())->format('Y-m-d H:i:s') + ] + ]); + return [ + 'state' => true, + 'message' => 'Option désactivée sur Stripe.' + ]; + } catch (\Exception $e) { + return ['state' => false, 'message' => $e->getMessage()]; } } @@ -192,183 +305,8 @@ class Client return $this->client; } - /** - * Met à jour un client existant sur Stripe - * @return array ['state' => bool, 'message' => string] - */ - public function updateCustomer(Customer $customer): array + public function status(): bool { - // On vérifie d'abord si le client possède bien un ID Stripe - if (!$customer->getCustomerId()) { - return [ - 'state' => false, - 'message' => 'Ce client n\'est pas encore lié à un compte Stripe.' - ]; - } - - try { - // Préparation des données de mise à jour - $this->client->customers->update($customer->getCustomerId(), [ - 'name' => sprintf('%s %s', $customer->getSurname(), $customer->getName()), - 'email' => $customer->getEmail(), - 'phone' => $customer->getPhone(), - 'metadata' => [ - 'internal_id' => $customer->getId(), - 'type' => $customer->getType(), - 'siret' => $customer->getSiret() ?? 'N/A', - 'updated_at' => (new \DateTime())->format('Y-m-d H:i:s') - ], - ]); - - return [ - 'state' => true, - 'message' => 'Client mis à jour sur Stripe avec succès.' - ]; - - } catch (ApiErrorException $e) { - return [ - 'state' => false, - 'message' => 'Erreur API Stripe : ' . $e->getMessage() - ]; - } catch (\Exception $e) { - return [ - 'state' => false, - 'message' => 'Erreur système lors de la mise à jour : ' . $e->getMessage() - ]; - } - } - - /** - * Désactive un produit sur Stripe - * @return array ['state' => bool, 'message' => string] - */ - public function deleteProduct(\App\Entity\Product $product): array - { - // Si le produit n'a pas d'ID Stripe, rien à faire côté API - if (!$product->getProductId()) { - return [ - 'state' => true, - 'message' => 'Produit local uniquement, aucune action Stripe requise.' - ]; - } - - try { - // Chez Stripe, on ne "supprime" pas un produit qui peut être lié à des archives, - // on le désactive pour qu'il ne soit plus utilisable. - $this->client->products->update($product->getProductId(), [ - 'active' => false, - 'metadata' => [ - 'deleted_at' => (new \DateTime())->format('Y-m-d H:i:s'), - 'internal_id' => $product->getId() - ] - ]); - - return [ - 'state' => true, - 'message' => 'Le produit a été désactivé avec succès sur Stripe.' - ]; - - } catch (\Stripe\Exception\ApiErrorException $e) { - return [ - 'state' => false, - 'message' => 'Erreur Stripe : ' . $e->getMessage() - ]; - } catch (\Exception $e) { - return [ - 'state' => false, - 'message' => 'Erreur système : ' . $e->getMessage() - ]; - } - } - - /** - * Crée un produit et son prix par défaut sur Stripe - * @return array ['state' => bool, 'id' => string|null, 'message' => string] - */ - public function createProduct(\App\Entity\Product $product): array - { - try { - // 1. Préparation des métadonnées (utile pour retrouver le produit plus tard) - $metadata = [ - 'internal_id' => $product->getId(), - 'reference' => $product->getRef(), - 'category' => $product->getCategory() - ]; - - // 2. Création du Produit sur Stripe - $stripeProduct = $this->client->products->create([ - 'name' => $product->getName(), - 'description' => sprintf('Référence : %s | Catégorie : %s', $product->getRef(), $product->getCategory()), - 'metadata' => $metadata, - 'active' => true, - ]); - - // 3. Création du Prix associé (Stripe sépare l'objet Produit de l'objet Prix) - // Note : Stripe attend des montants en centimes (ex: 10.00€ -> 1000) - $stripePrice = $this->client->prices->create([ - 'unit_amount' => (int)($product->getPriceDay() * 100), - 'currency' => 'eur', - 'product' => $stripeProduct->id, - 'metadata' => $metadata, - ]); - - // 4. On met à jour l'entité avec l'ID Stripe reçu - $product->setProductId($stripeProduct->id); - - return [ - 'state' => true, - 'id' => $stripeProduct->id, - 'message' => 'Produit et prix synchronisés sur Stripe.' - ]; - - } catch (\Stripe\Exception\ApiErrorException $e) { - return [ - 'state' => false, - 'message' => 'Erreur Stripe : ' . $e->getMessage() - ]; - } catch (\Exception $e) { - return [ - 'state' => false, - 'message' => 'Erreur système : ' . $e->getMessage() - ]; - } - } - - /** - * Met à jour le produit et son prix sur Stripe - */ - public function updateProduct(\App\Entity\Product $product): array - { - try { - // 1. Mise à jour des infos de base du produit - $this->client->products->update($product->getProductId(), [ - 'name' => $product->getName(), - 'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()), - ]); - - // 2. Gestion du prix (Stripe recommande de créer un nouveau prix plutôt que d'éditer) - // On récupère le prix actuel pour voir s'il a changé - $currentPriceCents = (int)($product->getPriceDay() * 100); - - // Optionnel : Tu peux vérifier ici si le prix a réellement changé avant de recréer - $this->client->prices->create([ - 'unit_amount' => $currentPriceCents, - 'currency' => 'eur', - 'product' => $product->getProductId(), - ]); - - // Note: Stripe utilisera toujours le dernier prix créé par défaut pour les nouvelles sessions - - return ['state' => true, 'message' => 'Synchro Stripe OK']; - - } catch (\Exception $e) { - return ['state' => false, 'message' => $e->getMessage()]; - } - } - - public function status() - { - $result = $this->check(); - return $result['state']; + return $this->check()['state']; } } diff --git a/templates/dashboard/options/add.twig b/templates/dashboard/options/add.twig new file mode 100644 index 0000000..9b3f8ee --- /dev/null +++ b/templates/dashboard/options/add.twig @@ -0,0 +1,103 @@ +{% extends 'dashboard/base.twig' %} + +{% block title %}Fiche Option{% endblock %} +{% block title_header %}Gestion des Options{% endblock %} + +{% block body %} +
+ {{ form_start(form) }} + +
+ + {# --- BLOC VISUEL (Gauche) --- #} +
+
+

+ 00 + Image du Produit +

+ +
+
+
+ {# L'image avec un ID spécifique pour le JS #} + + + {# L'icône de remplacement #} + + + +
+
+ +
+ {{ form_label(form.imageFile, 'Sélectionner un fichier', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest mb-2 block'}}) }} + + {# On enlève le "onchange" inline, on cible via l'ID généré par Symfony ou un ID fixe #} + {{ form_widget(form.imageFile, { + 'id': 'product_image_input', + 'attr': { + 'class': 'block w-full text-xs text-slate-400 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-[10px] file:font-black file:uppercase file:tracking-widest file:bg-blue-600/10 file:text-blue-500 hover:file:bg-blue-600/20 transition-all cursor-pointer' + } + }) }} +
+
+
+
+ + {# --- BLOC CONFIGURATION (Droite) --- #} +
+
+

+ 01 + Détails de l'Option +

+ +
+ {# Nom de l'option #} +
+ {{ form_label(form.name, 'Nom de l\'option', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-3 block'}}) }} + {{ form_widget(form.name, {'attr': {'class': 'w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-4 px-6 text-lg font-bold', 'placeholder': 'ex: Assurance Sérénité, Nettoyage...'}}) }} +
+ +
+
+ {{ form_label(form.priceHt, 'Prix Ht', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }} +
+ {{ form_widget(form.priceHt, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-emerald-400 font-black text-xl focus:ring-emerald-500/20 focus:border-emerald-500 transition-all py-4 px-5'}}) }} + €HT +
+
+
+
+
+
+
+ + {# FOOTER ACTIONS #} +
+ + + + + Annuler + + + +
+ + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/dashboard/products.twig b/templates/dashboard/products.twig index 34bef12..8c108b2 100644 --- a/templates/dashboard/products.twig +++ b/templates/dashboard/products.twig @@ -126,6 +126,113 @@ {% endif %} + +
+
+

+ Gestion des Options +

+
+ +
+ +
+
+ + + + + + + + + + + + {% for option in options %} + + {# VISUEL & REF #} + + + + {# PRIX #} + + {# ACTIONS #} + + + {% else %} + + + + {% endfor %} + +
VisuelDésignationStripeTarifActions
+
+
+ {% if option.imageName %} + + {% else %} +
+ +
+ {% endif %} +
+
+
+ + {{ option.name }} + + + {% if option.stripeId != "" %} +
+ + Synchronisé +
+ {% else %} +
+ + En attente +
+ {% endif %} +
+ + {{ option.priceHt|number_format(2, ',', ' ') }}€ + + +
+ + + + + + + +
+
+

Aucun options dans le catalogue

+
+
+
+ + {# PAGINATION #} + {% if options.getTotalItemCount is defined and options.getTotalItemCount > options.getItemNumberPerPage %} +
+ {{ knp_pagination_render(options) }} +
+ {% endif %} +