```
✨ feat(Devis.php): Ajoute liaison Options <-> Devis et méthode get/set. ✨ 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. ```
This commit is contained in:
4
.env
4
.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=
|
||||
|
||||
@@ -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'
|
||||
|
||||
38
migrations/Version20260122074529.php
Normal file
38
migrations/Version20260122074529.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260122074529 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE 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');
|
||||
}
|
||||
}
|
||||
32
migrations/Version20260122075732.php
Normal file
32
migrations/Version20260122075732.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260122075732 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE 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');
|
||||
}
|
||||
}
|
||||
37
migrations/Version20260122081226.php
Normal file
37
migrations/Version20260122081226.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260122081226 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE 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');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
if (empty($customers)) {
|
||||
$io->success('Tous les clients sont déjà synchronisés.');
|
||||
} else {
|
||||
$io->progressStart(count($customers));
|
||||
$customers = $this->entityManager->getRepository(Customer::class)->findAll();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
175
src/Entity/Options.php
Normal file
175
src/Entity/Options.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\OptionsRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
|
||||
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
|
||||
|
||||
#[ORM\Entity(repositoryClass: OptionsRepository::class)]
|
||||
#[Uploadable]
|
||||
class Options
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?float $priceHt = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Devis>
|
||||
*/
|
||||
#[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<int, Devis>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
45
src/Form/OptionsType.php
Normal file
45
src/Form/OptionsType.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Options;
|
||||
use App\Entity\Product;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TelType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class OptionsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
src/Repository/OptionsRepository.php
Normal file
43
src/Repository/OptionsRepository.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Options;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Options>
|
||||
*/
|
||||
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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => false,
|
||||
'message' => 'Erreur API Stripe lors de la suppression : ' . $e->getMessage()
|
||||
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' => 'Erreur système : ' . $e->getMessage()
|
||||
];
|
||||
return ['state' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure, met à jour et sauvegarde les secrets des Webhooks
|
||||
*/
|
||||
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 = $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'];
|
||||
}
|
||||
}
|
||||
|
||||
103
templates/dashboard/options/add.twig
Normal file
103
templates/dashboard/options/add.twig
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends 'dashboard/base.twig' %}
|
||||
|
||||
{% block title %}Fiche Option{% endblock %}
|
||||
{% block title_header %}Gestion des <span class="text-purple-500">Options</span>{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{# --- BLOC VISUEL (Gauche) --- #}
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
|
||||
<span class="w-8 h-8 bg-purple-600/20 text-purple-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">00</span>
|
||||
Image du Produit
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center gap-8">
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-48 h-48 rounded-[2rem] overflow-hidden border-2 border-white/10 bg-slate-900/50 flex items-center justify-center">
|
||||
{# L'image avec un ID spécifique pour le JS #}
|
||||
<img id="product-image-preview"
|
||||
src="{{ options.imageName ? vich_uploader_asset(options, 'imageFile') | imagine_filter('webp') : '#' }}"
|
||||
class="w-full h-full object-cover {{ options.imageName ? '' : 'hidden' }}">
|
||||
|
||||
{# L'icône de remplacement #}
|
||||
<svg id="product-image-placeholder"
|
||||
class="w-12 h-12 text-slate-700 {{ options.imageName ? 'hidden' : '' }}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
{{ 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'
|
||||
}
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- BLOC CONFIGURATION (Droite) --- #}
|
||||
<div class="lg:col-span-1 space-y-8">
|
||||
<div class="backdrop-blur-xl bg-slate-900/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
|
||||
<h3 class="text-sm font-black text-purple-500 uppercase tracking-widest mb-8 flex items-center">
|
||||
<span class="w-6 h-6 bg-purple-600/20 rounded-lg flex items-center justify-center mr-3 text-[10px]">01</span>
|
||||
Détails de l'Option
|
||||
</h3>
|
||||
|
||||
<div class="space-y-8">
|
||||
{# Nom de l'option #}
|
||||
<div>
|
||||
{{ 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...'}}) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
{{ form_label(form.priceHt, 'Prix Ht', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }}
|
||||
<div class="relative">
|
||||
{{ 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'}}) }}
|
||||
<span class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-600 font-bold">€HT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# FOOTER ACTIONS #}
|
||||
<div class="mt-12 mb-20 flex items-center justify-between backdrop-blur-xl bg-slate-900/40 p-6 rounded-[2.5rem] border border-white/5 shadow-xl">
|
||||
<a href="{{ path('app_crm_product') }}" class="px-8 py-4 text-[10px] font-black text-slate-400 hover:text-white uppercase tracking-widest transition-colors flex items-center group">
|
||||
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Annuler
|
||||
</a>
|
||||
|
||||
<button type="submit" class="relative overflow-hidden group px-12 py-5 bg-purple-600 hover:bg-purple-500 text-white text-[10px] font-black uppercase tracking-[0.3em] rounded-2xl transition-all shadow-lg shadow-purple-600/30 flex items-center hover:scale-[1.02] active:scale-95">
|
||||
<span class="relative z-10 flex items-center">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Enregistrer l'option
|
||||
</span>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -126,6 +126,113 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="mt-5 flex items-end justify-between mb-10 pb-8 border-b border-slate-200 dark:border-slate-800/50">
|
||||
<div>
|
||||
<h1 class="text-4xl font-extrabold text-slate-900 dark:text-white">
|
||||
Gestion des <span class="text-blue-500">Options</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="{{ path('app_crm_product_options_add') }}" class="flex items-center space-x-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-xl transition-all shadow-lg shadow-blue-600/20 group">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span>Nouvelle options</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] overflow-hidden shadow-2xl animate-in fade-in duration-700">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-white/5 bg-black/20">
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Visuel</th>
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Désignation</th>
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Stripe</th>
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Tarif</th>
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
{% for option in options %}
|
||||
<tr class="group hover:bg-white/[0.02] transition-colors">
|
||||
{# VISUEL & REF #}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="h-12 w-12 rounded-xl overflow-hidden border border-white/10 bg-slate-900 flex-shrink-0">
|
||||
{% if option.imageName %}
|
||||
<img src="{{ vich_uploader_asset(option, 'imageFile') | imagine_filter('webp') }}" class="h-full w-full object-cover">
|
||||
{% else %}
|
||||
<div class="h-full w-full flex items-center justify-center bg-slate-800 text-slate-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm font-bold text-white group-hover:text-blue-400 transition-colors capitalize">
|
||||
{{ option.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if option.stripeId != "" %}
|
||||
<div class="flex items-center text-[8px] font-black text-emerald-400 uppercase tracking-widest">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
|
||||
Synchronisé
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center text-[8px] font-black text-rose-500 uppercase tracking-widest opacity-60">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-rose-500 mr-2 animate-pulse"></span>
|
||||
En attente
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{# PRIX #}
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class="text-sm font-black text-emerald-400">
|
||||
{{ option.priceHt|number_format(2, ',', ' ') }}€
|
||||
</span>
|
||||
</td>
|
||||
{# ACTIONS #}
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="{{ path('app_crm_product_options_edit', {id: option.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
</a>
|
||||
|
||||
<a href="{{ path('app_crm_product_option_delete', {id: option.id}) }}?_token={{ csrf_token('delete' ~ option.id) }}"
|
||||
data-turbo-method="post"
|
||||
data-turbo-confirm="Confirmer la suppression définitive de '{{ option.name }}' ?"
|
||||
class="p-2 bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white rounded-xl transition-all border border-rose-500/20 shadow-lg shadow-rose-500/5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="py-24 text-center">
|
||||
<p class="text-slate-500 italic uppercase tracking-[0.2em] text-[10px] font-black">Aucun options dans le catalogue</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# PAGINATION #}
|
||||
{% if options.getTotalItemCount is defined and options.getTotalItemCount > options.getItemNumberPerPage %}
|
||||
<div class="mt-8 flex justify-center custom-pagination">
|
||||
{{ knp_pagination_render(options) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.custom-pagination nav ul { @apply flex space-x-2; }
|
||||
.custom-pagination nav ul li span,
|
||||
|
||||
Reference in New Issue
Block a user