From 179a0703f8dcb4b0039330f81f2163ae9fe0ed26 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sat, 21 Mar 2026 12:19:46 +0100 Subject: [PATCH] Add Billet entity, BilletDesign, ticket designer, CRUD billets, commissions - Create Billet entity: name, position, priceHT, quantity (nullable=unlimited), isGeneratedBillet, hasDefinedExit, notBuyable, type (billet/reservation_brocante/vote), stripeProductId, description, picture (VichUploader), category (ManyToOne CASCADE) - Create BilletDesign entity (OneToOne Event): accentColor, invitationTitle, invitationColor - Billet CRUD: add/edit/delete with access control, Stripe product sync on connected account - Billet reorder: drag & drop with position field, refactored sortable.js for both categories and billets - Ticket designer tab (custom offer only): accent color, invitation title/color, live iframe preview - A4 ticket preview: 4 zones (HG infos+billet, HD affiche, BG association, BD sortie+invitation), fake QR code SVG - Commission calculator JS: live breakdown of E-Ticket fee, Stripe fee (1.5%+0.25EUR), net amount - Sales recap on categories tab: qty sold, total HT, total commissions, total net - DisableProfilerSubscriber: disable web profiler toolbar on preview iframe - CSP: allow self in frame-src and frame-ancestors for preview iframe - Flysystem: dedicated billets.storage for billet images - Upload accept restricted to png/jpeg/webp/gif (no HEIC) - Makefile: add force_sql_dev command - CLAUDE.md: add rule to never modify existing migrations - Consolidate all migrations into single Version20260321111125 - Tests: BilletTest (20), BilletDesignTest (6), DisableProfilerSubscriberTest (5), billet-designer.test.js (7), commission-calculator.test.js (7), AccountControllerTest billet CRUD tests (11) Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + Makefile | 3 + assets/app.js | 4 + assets/modules/billet-designer.js | 64 +++ assets/modules/commission-calculator.js | 39 ++ assets/modules/sortable.js | 26 +- config/packages/flysystem.yaml | 5 + config/packages/nelmio_security.yaml | 3 +- config/packages/vich_uploader.yaml | 5 + migrations/Version20260320214602.php | 34 -- migrations/Version20260320221953.php | 31 -- ...20201224.php => Version20260321111125.php} | 17 +- src/Controller/AccountController.php | 254 ++++++++++++ src/Entity/Billet.php | 254 ++++++++++++ src/Entity/BilletDesign.php | 101 +++++ .../DisableProfilerSubscriber.php | 36 ++ src/Repository/BilletDesignRepository.php | 18 + src/Repository/BilletRepository.php | 18 + src/Service/StripeService.php | 39 ++ .../account/_billet_commission.html.twig | 30 ++ templates/account/add_billet.html.twig | 80 ++++ templates/account/billet_preview.html.twig | 391 ++++++++++++++++++ templates/account/create_event.html.twig | 2 +- templates/account/edit_billet.html.twig | 83 ++++ templates/account/edit_event.html.twig | 176 +++++++- tests/Controller/AccountControllerTest.php | 191 +++++++++ tests/Entity/BilletDesignTest.php | 69 ++++ tests/Entity/BilletTest.php | 227 ++++++++++ .../DisableProfilerSubscriberTest.php | 85 ++++ tests/js/billet-designer.test.js | 96 +++++ tests/js/commission-calculator.test.js | 88 ++++ 31 files changed, 2377 insertions(+), 93 deletions(-) create mode 100644 assets/modules/billet-designer.js create mode 100644 assets/modules/commission-calculator.js delete mode 100644 migrations/Version20260320214602.php delete mode 100644 migrations/Version20260320221953.php rename migrations/{Version20260320201224.php => Version20260321111125.php} (71%) create mode 100644 src/Entity/Billet.php create mode 100644 src/Entity/BilletDesign.php create mode 100644 src/EventSubscriber/DisableProfilerSubscriber.php create mode 100644 src/Repository/BilletDesignRepository.php create mode 100644 src/Repository/BilletRepository.php create mode 100644 templates/account/_billet_commission.html.twig create mode 100644 templates/account/add_billet.html.twig create mode 100644 templates/account/billet_preview.html.twig create mode 100644 templates/account/edit_billet.html.twig create mode 100644 tests/Entity/BilletDesignTest.php create mode 100644 tests/Entity/BilletTest.php create mode 100644 tests/EventSubscriber/DisableProfilerSubscriberTest.php create mode 100644 tests/js/billet-designer.test.js create mode 100644 tests/js/commission-calculator.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 3bf64aa..d1530da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ Plateforme destinée aux associations pour la vente de tickets événementiels, ## Règles - **Interdiction** de supprimer des fichiers sans autorisation explicite de l'utilisateur. +- **Interdiction** de modifier une migration déjà générée. Toujours créer une nouvelle migration pour corriger. - En cas de doute, toujours poser la question avant d'agir. - Toujours pousser sur `main`, ne jamais créer de branche. - Toujours demander avant de push. diff --git a/Makefile b/Makefile index 1abb0af..ab597df 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,9 @@ migration_dev: ## Genere une migration via Docker dev migrate_dev: ## Execute les migrations via Docker dev docker compose -f docker-compose-dev.yml exec php php bin/console doctrine:migrations:migrate --no-interaction +force_sql_dev: ## Force la mise a jour du schema de la base de donnees dev + docker compose -f docker-compose-dev.yml exec php php bin/console doctrine:schema:update --force + migrate_prod: ## Execute les migrations en prod via Docker docker compose -f docker-compose-prod.yml exec php php bin/console doctrine:migrations:migrate --no-interaction --env=prod diff --git a/assets/app.js b/assets/app.js index 5b14289..a74f118 100644 --- a/assets/app.js +++ b/assets/app.js @@ -6,6 +6,8 @@ import { initCookieConsent } from "./modules/cookie-consent.js" import { initEventMap } from "./modules/event-map.js" import { initCopyUrl } from "./modules/copy-url.js" import { initSortable } from "./modules/sortable.js" +import { initBilletDesigner } from "./modules/billet-designer.js" +import { initCommissionCalculator } from "./modules/commission-calculator.js" document.addEventListener('DOMContentLoaded', () => { initMobileMenu() @@ -15,4 +17,6 @@ document.addEventListener('DOMContentLoaded', () => { initCopyUrl() initEventMap() initSortable() + initBilletDesigner() + initCommissionCalculator() }) diff --git a/assets/modules/billet-designer.js b/assets/modules/billet-designer.js new file mode 100644 index 0000000..4e901f7 --- /dev/null +++ b/assets/modules/billet-designer.js @@ -0,0 +1,64 @@ +export function initBilletDesigner() { + const designer = document.getElementById('billet-designer') + if (!designer) return + + const previewUrl = designer.dataset.previewUrl + const saveUrl = designer.dataset.saveUrl + if (!previewUrl) return + + const iframe = document.getElementById('billet-preview-frame') + const reloadBtn = document.getElementById('billet-reload-preview') + const saveBtn = document.getElementById('billet-save-design') + if (!iframe) return + + const inputs = designer.querySelectorAll('input[name]') + + function buildPreviewUrl() { + const params = new URLSearchParams() + + for (const input of inputs) { + if (input.type === 'checkbox') { + params.set(input.name, input.checked ? '1' : '0') + } else { + params.set(input.name, input.value) + } + } + + return previewUrl + '?' + params.toString() + } + + function reloadPreview() { + iframe.src = buildPreviewUrl() + } + + function saveDesign() { + if (!saveUrl) return + + const formData = new FormData() + for (const input of inputs) { + if (input.type === 'checkbox') { + formData.set(input.name, input.checked ? '1' : '0') + } else { + formData.set(input.name, input.value) + } + } + + globalThis.fetch(saveUrl, { + method: 'POST', + body: formData, + }) + } + + for (const input of inputs) { + input.addEventListener('input', reloadPreview) + input.addEventListener('change', reloadPreview) + } + + if (reloadBtn) { + reloadBtn.addEventListener('click', reloadPreview) + } + + if (saveBtn) { + saveBtn.addEventListener('click', saveDesign) + } +} diff --git a/assets/modules/commission-calculator.js b/assets/modules/commission-calculator.js new file mode 100644 index 0000000..0dac5e9 --- /dev/null +++ b/assets/modules/commission-calculator.js @@ -0,0 +1,39 @@ +export function initCommissionCalculator() { + const calculator = document.getElementById('commission-calculator') + if (!calculator) return + + const priceInput = document.getElementById('billet_price') + if (!priceInput) return + + const eticketRate = Number.parseFloat(calculator.dataset.eticketRate) || 0 + const stripeRate = Number.parseFloat(calculator.dataset.stripeRate) || 1.5 + const stripeFixed = Number.parseFloat(calculator.dataset.stripeFixed) || 0.25 + + const calcPrice = document.getElementById('calc-price') + const calcEticket = document.getElementById('calc-eticket') + const calcStripe = document.getElementById('calc-stripe') + const calcTotal = document.getElementById('calc-total') + const calcNet = document.getElementById('calc-net') + + function formatEur(value) { + return value.toFixed(2).replace('.', ',') + ' \u20AC' + } + + function update() { + const price = Number.parseFloat(priceInput.value) || 0 + + const eticketFee = price * (eticketRate / 100) + const stripeFee = price > 0 ? price * (stripeRate / 100) + stripeFixed : 0 + const totalFee = eticketFee + stripeFee + const net = price - totalFee + + calcPrice.textContent = formatEur(price) + calcEticket.textContent = '- ' + formatEur(eticketFee) + calcStripe.textContent = '- ' + formatEur(stripeFee) + calcTotal.textContent = '- ' + formatEur(totalFee) + calcNet.textContent = formatEur(Math.max(0, net)) + } + + priceInput.addEventListener('input', update) + update() +} diff --git a/assets/modules/sortable.js b/assets/modules/sortable.js index 8234860..2eb2be4 100644 --- a/assets/modules/sortable.js +++ b/assets/modules/sortable.js @@ -1,14 +1,11 @@ -export function initSortable() { - const list = document.getElementById('categories-list') - if (!list) return - +function makeSortable(list, itemSelector, idAttr) { const reorderUrl = list.dataset.reorderUrl if (!reorderUrl) return let dragEl = null list.addEventListener('dragstart', (e) => { - dragEl = e.target.closest('[data-id]') + dragEl = e.target.closest(itemSelector) if (dragEl) { dragEl.classList.add('opacity-50') e.dataTransfer.effectAllowed = 'move' @@ -24,7 +21,7 @@ export function initSortable() { list.addEventListener('dragover', (e) => { e.preventDefault() - const target = e.target.closest('[data-id]') + const target = e.target.closest(itemSelector) if (target && target !== dragEl) { const rect = target.getBoundingClientRect() const mid = rect.top + rect.height / 2 @@ -38,8 +35,8 @@ export function initSortable() { list.addEventListener('drop', (e) => { e.preventDefault() - const items = list.querySelectorAll('[data-id]') - const order = Array.from(items).map(el => Number.parseInt(el.dataset.id, 10)) + const items = list.querySelectorAll(itemSelector) + const order = Array.from(items).map(el => Number.parseInt(el.getAttribute(idAttr), 10)) globalThis.fetch(reorderUrl, { method: 'POST', @@ -48,7 +45,18 @@ export function initSortable() { }) }) - list.querySelectorAll('[data-id]').forEach(el => { + list.querySelectorAll(itemSelector).forEach(el => { el.setAttribute('draggable', 'true') }) } + +export function initSortable() { + const categoriesList = document.getElementById('categories-list') + if (categoriesList) { + makeSortable(categoriesList, '[data-id]', 'data-id') + } + + document.querySelectorAll('.billets-list').forEach(list => { + makeSortable(list, '[data-billet-id]', 'data-billet-id') + }) +} diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index 7cfa2b7..54b7614 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -5,6 +5,11 @@ flysystem: options: directory: '%kernel.project_dir%/public/uploads/events' + billets.storage: + adapter: 'local' + options: + directory: '%kernel.project_dir%/public/uploads/billets' + logos.storage: adapter: 'local' options: diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index d996a89..db400a1 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -20,8 +20,9 @@ nelmio_security: enabled: false report-uri: '%router.request_context.base_url%/my-csp-report' frame-ancestors: - - 'none' + - 'self' frame-src: + - 'self' - 'https://stripe.com' - 'https://*.stripe.com' - 'https://js.stripe.com' diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 137dfc3..9b766c8 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -10,6 +10,11 @@ vich_uploader: upload_destination: default.storage namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + billet_image: + uri_prefix: /uploads/billets + upload_destination: billets.storage + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + organizer_logo: uri_prefix: /uploads/logos upload_destination: logos.storage diff --git a/migrations/Version20260320214602.php b/migrations/Version20260320214602.php deleted file mode 100644 index 8b868e1..0000000 --- a/migrations/Version20260320214602.php +++ /dev/null @@ -1,34 +0,0 @@ -addSql('CREATE TABLE category (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))'); - $this->addSql('CREATE INDEX IDX_64C19C171F7E88B ON category (event_id)'); - $this->addSql('ALTER TABLE category ADD CONSTRAINT FK_64C19C171F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('ALTER TABLE category DROP CONSTRAINT FK_64C19C171F7E88B'); - $this->addSql('DROP TABLE category'); - } -} diff --git a/migrations/Version20260320221953.php b/migrations/Version20260320221953.php deleted file mode 100644 index bb4c293..0000000 --- a/migrations/Version20260320221953.php +++ /dev/null @@ -1,31 +0,0 @@ -addSql('ALTER TABLE category ADD is_hidden BOOLEAN DEFAULT false NOT NULL'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('ALTER TABLE category DROP is_hidden'); - } -} diff --git a/migrations/Version20260320201224.php b/migrations/Version20260321111125.php similarity index 71% rename from migrations/Version20260320201224.php rename to migrations/Version20260321111125.php index 3949445..e95fb11 100644 --- a/migrations/Version20260320201224.php +++ b/migrations/Version20260321111125.php @@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration; /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260320201224 extends AbstractMigration +final class Version20260321111125 extends AbstractMigration { public function getDescription(): string { @@ -20,6 +20,12 @@ final class Version20260320201224 extends AbstractMigration public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE billet (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, price_ht INT NOT NULL, quantity INT DEFAULT NULL, is_generated_billet BOOLEAN NOT NULL, has_defined_exit BOOLEAN NOT NULL, not_buyable BOOLEAN NOT NULL, type VARCHAR(30) NOT NULL, stripe_product_id VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, picture_name VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, category_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_1F034AF612469DE2 ON billet (category_id)'); + $this->addSql('CREATE TABLE billet_design (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, accent_color VARCHAR(7) NOT NULL, invitation_title VARCHAR(255) NOT NULL, invitation_color VARCHAR(7) NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D2CBA64371F7E88B ON billet_design (event_id)'); + $this->addSql('CREATE TABLE category (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_hidden BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_64C19C171F7E88B ON category (event_id)'); $this->addSql('CREATE TABLE email_tracking (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, message_id VARCHAR(64) NOT NULL, recipient VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, state VARCHAR(10) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, opened_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); $this->addSql('CREATE UNIQUE INDEX UNIQ_A31A7D55537A1329 ON email_tracking (message_id)'); $this->addSql('CREATE TABLE event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, description TEXT DEFAULT NULL, address VARCHAR(255) NOT NULL, zipcode VARCHAR(10) NOT NULL, city VARCHAR(255) NOT NULL, event_main_picture_name VARCHAR(255) DEFAULT NULL, is_online BOOLEAN NOT NULL, is_secret BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, account_id INT NOT NULL, PRIMARY KEY (id))'); @@ -36,6 +42,9 @@ final class Version20260320201224 extends AbstractMigration $this->addSql('CREATE INDEX IDX_8D93D649D70210F4 ON "user" (parent_organizer_id)'); $this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)'); + $this->addSql('ALTER TABLE billet ADD CONSTRAINT FK_1F034AF612469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE billet_design ADD CONSTRAINT FK_D2CBA64371F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE category ADD CONSTRAINT FK_64C19C171F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE'); $this->addSql('ALTER TABLE event ADD CONSTRAINT FK_3BAE0AA79B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE'); $this->addSql('ALTER TABLE payout ADD CONSTRAINT FK_4E2EA902876C4DDA FOREIGN KEY (organizer_id) REFERENCES "user" (id) NOT DEFERRABLE'); $this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649D70210F4 FOREIGN KEY (parent_organizer_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); @@ -44,9 +53,15 @@ final class Version20260320201224 extends AbstractMigration public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE billet DROP CONSTRAINT FK_1F034AF612469DE2'); + $this->addSql('ALTER TABLE billet_design DROP CONSTRAINT FK_D2CBA64371F7E88B'); + $this->addSql('ALTER TABLE category DROP CONSTRAINT FK_64C19C171F7E88B'); $this->addSql('ALTER TABLE event DROP CONSTRAINT FK_3BAE0AA79B6B5FBA'); $this->addSql('ALTER TABLE payout DROP CONSTRAINT FK_4E2EA902876C4DDA'); $this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649D70210F4'); + $this->addSql('DROP TABLE billet'); + $this->addSql('DROP TABLE billet_design'); + $this->addSql('DROP TABLE category'); $this->addSql('DROP TABLE email_tracking'); $this->addSql('DROP TABLE event'); $this->addSql('DROP TABLE messenger_log'); diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index bfa7695..0c522cc 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -2,6 +2,8 @@ namespace App\Controller; +use App\Entity\Billet; +use App\Entity\BilletDesign; use App\Entity\Category; use App\Entity\Event; use App\Entity\Payout; @@ -350,9 +352,24 @@ class AccountController extends AbstractController ['position' => 'ASC'], ); + $billets = []; + $soldCounts = []; + foreach ($categories as $category) { + $categoryBillets = $em->getRepository(Billet::class)->findBy(['category' => $category], ['position' => 'ASC']); + $billets[$category->getId()] = $categoryBillets; + foreach ($categoryBillets as $billet) { + // TODO: replace with real sold count from tickets entity + $soldCounts[$billet->getId()] = 0; + } + } + return $this->render('account/edit_event.html.twig', [ 'event' => $event, 'categories' => $categories, + 'billets' => $billets, + 'sold_counts' => $soldCounts, + 'commission_rate' => $user->getCommissionRate() ?? 0, + 'billet_design' => $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]), 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, @@ -508,6 +525,243 @@ class AccountController extends AbstractController return $this->json(['success' => true]); } + #[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/billet/ajouter', name: 'app_account_event_add_billet', methods: ['GET', 'POST'])] + public function addBillet(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $category = $em->getRepository(Category::class)->find($categoryId); + if (!$category || $category->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + if ($request->isMethod('POST')) { + $billet = new Billet(); + $billet->setCategory($category); + $billet->setPosition($em->getRepository(Billet::class)->count(['category' => $category])); + $billet->setName(trim($request->request->getString('name'))); + $billet->setPriceHT((int) round((float) $request->request->getString('price_ht') * 100)); + $qty = $request->request->getString('quantity'); + $billet->setQuantity('' === $qty ? null : (int) $qty); + $billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet')); + $billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit')); + $billet->setNotBuyable($request->request->getBoolean('not_buyable')); + $billet->setType($request->request->getString('type', 'billet')); + $billet->setDescription(trim($request->request->getString('description')) ?: null); + + $pictureFile = $request->files->get('picture'); + if ($pictureFile) { + $billet->setPictureFile($pictureFile); + } + + $em->persist($billet); + + if ($user->getStripeAccountId()) { + try { + $productId = $stripeService->createProduct($billet, $user->getStripeAccountId()); + $billet->setStripeProductId($productId); + } catch (\Exception) { + } + } + + $em->flush(); + + $this->addFlash('success', 'Billet ajoute avec succes.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); + } + + return $this->render('account/add_billet.html.twig', [ + 'event' => $event, + 'category' => $category, + 'breadcrumbs' => [ + self::BREADCRUMB_HOME, + self::BREADCRUMB_ACCOUNT, + ['name' => $event->getTitle(), 'url' => '/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'], + ['name' => 'Ajouter un billet'], + ], + ]); + } + + #[Route('/mon-compte/evenement/{id}/billet/{billetId}/modifier', name: 'app_account_event_edit_billet', methods: ['GET', 'POST'])] + public function editBillet(Event $event, int $billetId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $billet = $em->getRepository(Billet::class)->find($billetId); + if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + if ($request->isMethod('POST')) { + $billet->setName(trim($request->request->getString('name'))); + $billet->setPriceHT((int) round((float) $request->request->getString('price_ht') * 100)); + $qty = $request->request->getString('quantity'); + $billet->setQuantity('' === $qty ? null : (int) $qty); + $billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet')); + $billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit')); + $billet->setNotBuyable($request->request->getBoolean('not_buyable')); + $billet->setType($request->request->getString('type', 'billet')); + $billet->setDescription(trim($request->request->getString('description')) ?: null); + + $pictureFile = $request->files->get('picture'); + if ($pictureFile) { + $billet->setPictureFile($pictureFile); + } + + if ($user->getStripeAccountId()) { + try { + if ($billet->getStripeProductId()) { + $stripeService->updateProduct($billet, $user->getStripeAccountId()); + } else { + $productId = $stripeService->createProduct($billet, $user->getStripeAccountId()); + $billet->setStripeProductId($productId); + } + } catch (\Exception) { + } + } + + $em->flush(); + + $this->addFlash('success', 'Billet modifie avec succes.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); + } + + return $this->render('account/edit_billet.html.twig', [ + 'event' => $event, + 'billet' => $billet, + 'breadcrumbs' => [ + self::BREADCRUMB_HOME, + self::BREADCRUMB_ACCOUNT, + ['name' => $event->getTitle(), 'url' => '/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'], + ['name' => 'Modifier un billet'], + ], + ]); + } + + #[Route('/mon-compte/evenement/{id}/billet/{billetId}/supprimer', name: 'app_account_event_delete_billet', methods: ['POST'])] + public function deleteBillet(Event $event, int $billetId, EntityManagerInterface $em, StripeService $stripeService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $billet = $em->getRepository(Billet::class)->find($billetId); + if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + if ($billet->getStripeProductId() && $user->getStripeAccountId()) { + try { + $stripeService->deleteProduct($billet->getStripeProductId(), $user->getStripeAccountId()); + } catch (\Exception) { + } + } + + $em->remove($billet); + $em->flush(); + + $this->addFlash('success', 'Billet supprime avec succes.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); + } + + #[Route('/mon-compte/evenement/{id}/billet/reorder', name: 'app_account_event_reorder_billets', methods: ['POST'])] + public function reorderBillets(Event $event, Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $order = json_decode($request->getContent(), true); + if (\is_array($order)) { + foreach ($order as $position => $billetId) { + $billet = $em->getRepository(Billet::class)->find($billetId); + if ($billet && $billet->getCategory()->getEvent()->getId() === $event->getId()) { + $billet->setPosition($position); + } + } + $em->flush(); + } + + return $this->json(['success' => true]); + } + + #[Route('/mon-compte/evenement/{id}/billet-preview', name: 'app_account_event_billet_preview', methods: ['GET'])] + public function billetPreview(Event $event, Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $response = $this->render('account/billet_preview.html.twig', [ + 'event' => $event, + 'user' => $user, + 'bg_color' => '#ffffff', + 'text_color' => '#111111', + 'accent_color' => $request->query->getString('accent_color', '#4f46e5'), + 'show_logo' => true, + 'show_invitation' => true, + 'invitation_title' => $request->query->getString('invitation_title', 'Invitation'), + 'invitation_color' => $request->query->getString('invitation_color', '#d4a017'), + ]); + + return $response; + } + + #[Route('/mon-compte/evenement/{id}/billet-design', name: 'app_account_event_save_billet_design', methods: ['POST'])] + public function saveBilletDesign(Event $event, Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $design = $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]); + if (!$design) { + $design = new BilletDesign(); + $design->setEvent($event); + $em->persist($design); + } + + $design->setAccentColor($request->request->getString('accent_color', '#4f46e5')); + $design->setInvitationTitle($request->request->getString('invitation_title', 'Invitation')); + $design->setInvitationColor($request->request->getString('invitation_color', '#d4a017')); + $design->setUpdatedAt(new \DateTimeImmutable()); + + $em->flush(); + + return $this->json(['success' => true]); + } + #[Route('/mon-compte/evenement/{id}/en-ligne', name: 'app_account_toggle_event_online', methods: ['POST'])] public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response { diff --git a/src/Entity/Billet.php b/src/Entity/Billet.php new file mode 100644 index 0000000..92cdb86 --- /dev/null +++ b/src/Entity/Billet.php @@ -0,0 +1,254 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCategory(): ?Category + { + return $this->category; + } + + public function setCategory(?Category $category): static + { + $this->category = $category; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + public function getPriceHT(): int + { + return $this->priceHT; + } + + public function setPriceHT(int $priceHT): static + { + $this->priceHT = $priceHT; + + return $this; + } + + public function getPriceHTDecimal(): float + { + return $this->priceHT / 100; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): static + { + $this->quantity = $quantity; + + return $this; + } + + public function isUnlimited(): bool + { + return null === $this->quantity; + } + + public function isGeneratedBillet(): bool + { + return $this->isGeneratedBillet; + } + + public function setIsGeneratedBillet(bool $isGeneratedBillet): static + { + $this->isGeneratedBillet = $isGeneratedBillet; + + return $this; + } + + public function hasDefinedExit(): bool + { + return $this->hasDefinedExit; + } + + public function setHasDefinedExit(bool $hasDefinedExit): static + { + $this->hasDefinedExit = $hasDefinedExit; + + return $this; + } + + public function isNotBuyable(): bool + { + return $this->notBuyable; + } + + public function setNotBuyable(bool $notBuyable): static + { + $this->notBuyable = $notBuyable; + + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getStripeProductId(): ?string + { + return $this->stripeProductId; + } + + public function setStripeProductId(?string $stripeProductId): static + { + $this->stripeProductId = $stripeProductId; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getPictureFile(): ?File + { + return $this->pictureFile; + } + + public function setPictureFile(?File $pictureFile): static + { + $this->pictureFile = $pictureFile; + + if ($pictureFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + + return $this; + } + + public function getPictureName(): ?string + { + return $this->pictureName; + } + + public function setPictureName(?string $pictureName): static + { + $this->pictureName = $pictureName; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Entity/BilletDesign.php b/src/Entity/BilletDesign.php new file mode 100644 index 0000000..1663a02 --- /dev/null +++ b/src/Entity/BilletDesign.php @@ -0,0 +1,101 @@ +updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEvent(): ?Event + { + return $this->event; + } + + public function setEvent(?Event $event): static + { + $this->event = $event; + + return $this; + } + + public function getAccentColor(): string + { + return $this->accentColor; + } + + public function setAccentColor(string $accentColor): static + { + $this->accentColor = $accentColor; + + return $this; + } + + public function getInvitationTitle(): string + { + return $this->invitationTitle; + } + + public function setInvitationTitle(string $invitationTitle): static + { + $this->invitationTitle = $invitationTitle; + + return $this; + } + + public function getInvitationColor(): string + { + return $this->invitationColor; + } + + public function setInvitationColor(string $invitationColor): static + { + $this->invitationColor = $invitationColor; + + return $this; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } +} diff --git a/src/EventSubscriber/DisableProfilerSubscriber.php b/src/EventSubscriber/DisableProfilerSubscriber.php new file mode 100644 index 0000000..35304b9 --- /dev/null +++ b/src/EventSubscriber/DisableProfilerSubscriber.php @@ -0,0 +1,36 @@ + 'onKernelResponse', + ]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $route = $event->getRequest()->attributes->getString('_route'); + + if ('app_account_event_billet_preview' === $route && $this->profiler) { + $this->profiler->disable(); + } + } +} diff --git a/src/Repository/BilletDesignRepository.php b/src/Repository/BilletDesignRepository.php new file mode 100644 index 0000000..57bdf26 --- /dev/null +++ b/src/Repository/BilletDesignRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletDesignRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilletDesign::class); + } +} diff --git a/src/Repository/BilletRepository.php b/src/Repository/BilletRepository.php new file mode 100644 index 0000000..f28b073 --- /dev/null +++ b/src/Repository/BilletRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Billet::class); + } +} diff --git a/src/Service/StripeService.php b/src/Service/StripeService.php index 8e8dc9e..ee92282 100644 --- a/src/Service/StripeService.php +++ b/src/Service/StripeService.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\Entity\Billet; use App\Entity\User; use Stripe\Event; use Stripe\Exception\SignatureVerificationException; @@ -96,6 +97,44 @@ class StripeService return $link->url; } + /** + * @codeCoverageIgnore Requires live Stripe API + */ + public function createProduct(Billet $billet, string $connectedAccountId): string + { + $product = $this->stripe->products->create([ + 'name' => $billet->getName(), + 'description' => $billet->getDescription(), + 'default_price_data' => [ + 'currency' => 'eur', + 'unit_amount' => $billet->getPriceHT(), + ], + ], ['stripe_account' => $connectedAccountId]); + + return $product->id; + } + + /** + * @codeCoverageIgnore Requires live Stripe API + */ + public function updateProduct(Billet $billet, string $connectedAccountId): void + { + $this->stripe->products->update($billet->getStripeProductId(), [ + 'name' => $billet->getName(), + 'description' => $billet->getDescription(), + ], ['stripe_account' => $connectedAccountId]); + } + + /** + * @codeCoverageIgnore Requires live Stripe API + */ + public function deleteProduct(string $productId, string $connectedAccountId): void + { + $this->stripe->products->update($productId, [ + 'active' => false, + ], ['stripe_account' => $connectedAccountId]); + } + /** * @codeCoverageIgnore Simple getter */ diff --git a/templates/account/_billet_commission.html.twig b/templates/account/_billet_commission.html.twig new file mode 100644 index 0000000..a4cfbf5 --- /dev/null +++ b/templates/account/_billet_commission.html.twig @@ -0,0 +1,30 @@ +
+

Detail des commissions

+ +
+ Prix de vente HT + 0,00 € +
+ +
+ Commission E-Ticket ({{ app.user.commissionRate ?? 0 }}%) + - 0,00 € +
+ +
+ Commission Stripe (1.5% + 0,25 €) + - 0,00 € +
+ +
+ +
+ Total commissions + - 0,00 € +
+ +
+ Montant percu + 0,00 € +
+
diff --git a/templates/account/add_billet.html.twig b/templates/account/add_billet.html.twig new file mode 100644 index 0000000..a6bc466 --- /dev/null +++ b/templates/account/add_billet.html.twig @@ -0,0 +1,80 @@ +{% extends 'base.html.twig' %} + +{% block title %}Ajouter un billet - {{ category.name }} - E-Ticket{% endblock %} + +{% block body %} +
+ + + Retour aux categories + + +

Ajouter un billet

+

{{ event.title }} — {{ category.name }}

+ + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% include 'account/_billet_commission.html.twig' %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/templates/account/billet_preview.html.twig b/templates/account/billet_preview.html.twig new file mode 100644 index 0000000..09f8812 --- /dev/null +++ b/templates/account/billet_preview.html.twig @@ -0,0 +1,391 @@ + + + + + + + +
+ +
+
{{ event.title }}
+
Billet d'entree
+ +
+
Date
+
{{ event.startAt|date('d/m/Y') }}
+
+
+
Horaires
+
{{ event.startAt|date('H:i') }} — {{ event.endAt|date('H:i') }}
+
+
+
Lieu
+
{{ event.address }}
+
+
+
Ville
+
{{ event.zipcode }} {{ event.city }}
+
+ +
+ +
Nom du billet
+
00,00 € HT
+ +
+
+
Categorie
+
Categorie
+
+
+
Date d'achat
+
{{ "now"|date('d/m/Y H:i') }}
+
+
+
Acheteur
+
Prenom Nom
+
+
+
E-mail
+
email@exemple.fr
+
+
+ +
+
Sortie definitive
+ {% if show_invitation %} +
{{ invitation_title }}
+ {% endif %} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Reference
+
ETICKET-XXXX-XXXX-XXXX
+
+
+
+ + +
+ {% if event.eventMainPictureName %} + {{ event.title }} + {% else %} +
Affiche
evenement
+ {% endif %} +
+ + +
+ {% if show_logo and user.logoName %} + + {% elseif show_logo %} +
Logo
+ {% endif %} + +
+
{{ user.companyName ?? (user.firstName ~ ' ' ~ user.lastName) }}
+ {% if user.address %} +
{{ user.address }}{% if user.postalCode %}, {{ user.postalCode }}{% endif %}{% if user.city %} {{ user.city }}{% endif %}
+ {% endif %} + {% if user.phone or user.website %} +
+ {% if user.phone %}{{ user.phone }}{% endif %} + {% if user.phone and user.website %} — {% endif %} + {% if user.website %}{{ user.website }}{% endif %} +
+ {% endif %} +
+ +
E-Ticket
by E-Cosplay
+
+
+ + diff --git a/templates/account/create_event.html.twig b/templates/account/create_event.html.twig index 340eb8f..b51b815 100644 --- a/templates/account/create_event.html.twig +++ b/templates/account/create_event.html.twig @@ -58,7 +58,7 @@
- +
diff --git a/templates/account/edit_billet.html.twig b/templates/account/edit_billet.html.twig new file mode 100644 index 0000000..ee9d789 --- /dev/null +++ b/templates/account/edit_billet.html.twig @@ -0,0 +1,83 @@ +{% extends 'base.html.twig' %} + +{% block title %}Modifier {{ billet.name }} - E-Ticket{% endblock %} + +{% block body %} +
+ + + Retour aux categories + + +

Modifier le billet

+

{{ event.title }} — {{ billet.category.name }}

+ + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% include 'account/_billet_commission.html.twig' %} + +
+ + +
+ +
+ + + {% if billet.pictureName %} +

Image actuelle : {{ billet.pictureName }}

+ {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/templates/account/edit_event.html.twig b/templates/account/edit_event.html.twig index bc4313a..e85da3e 100644 --- a/templates/account/edit_event.html.twig +++ b/templates/account/edit_event.html.twig @@ -79,7 +79,10 @@ {% set current_tab = app.request.query.get('tab', 'info') %}
Informations - Categories / Billets + Categories + {% if is_granted('ROLE_ROOT') or app.user.offer == 'custom' %} + Billets + {% endif %} Statistiques Parametres
@@ -129,7 +132,7 @@
- +
@@ -185,30 +188,171 @@ {% if categories|length > 0 %}
{% for category in categories %} -
- - {{ category.name }} - {{ category.startAt|date('d/m/Y H:i') }} — {{ category.endAt|date('d/m/Y H:i') }} - {% if category.hidden %} - Masquee - {% elseif category.active %} - Active - {% else %} - Inactive +
+
+ + {{ category.name }} + {{ category.startAt|date('d/m/Y H:i') }} — {{ category.endAt|date('d/m/Y H:i') }} + {% if category.hidden %} + Masquee + {% elseif category.active %} + Active + {% else %} + Inactive + {% endif %} + +
+ +
+
+ + {% set category_billets = billets[category.id] ?? [] %} + {% if category_billets|length > 0 %} +
+ {% for billet in category_billets %} + {% set sold = sold_counts[billet.id] ?? 0 %} +
+ + {% if billet.pictureName %} + {{ billet.name }} + {% endif %} + {{ billet.name }} + {{ billet.priceHTDecimal|number_format(2, ',', ' ') }} € HT + {{ billet.unlimited ? 'Illimite' : billet.quantity ~ ' places' }} + {{ sold }} vendu{{ sold > 1 ? 's' : '' }} + {% if billet.generatedBillet %} + Billet + {% else %} + Sans billet + {% endif %} + {% if billet.definedExit %} + Sortie def. + {% endif %} + {% if billet.notBuyable %} + Non achetable + {% endif %} + {% if billet.type != 'billet' %} + {{ billet.type == 'reservation_brocante' ? 'Brocante' : 'Vote' }} + {% endif %} + +
+ +
+
+ {% endfor %} +
{% endif %} - -
- -
+ +
{% endfor %}
+ + {% set total_sold = 0 %} + {% set total_ht = 0 %} + {% set total_commission = 0 %} + {% set total_net = 0 %} + {% for cat_billets in billets %} + {% for billet in cat_billets %} + {% set sold = sold_counts[billet.id] ?? 0 %} + {% set line_ht = billet.priceHTDecimal * sold %} + {% set eticket_fee = line_ht * (commission_rate / 100) %} + {% set stripe_fee = sold > 0 ? (line_ht * 0.015) + (0.25 * sold) : 0 %} + {% set line_commission = eticket_fee + stripe_fee %} + {% set total_sold = total_sold + sold %} + {% set total_ht = total_ht + line_ht %} + {% set total_commission = total_commission + line_commission %} + {% set total_net = total_net + (line_ht - line_commission) %} + {% endfor %} + {% endfor %} + +
+
+

Recapitulatif ventes

+
+
+
+
+
Qt vendue
+
{{ total_sold }}
+
+
+
Total HT
+
{{ total_ht|number_format(2, ',', ' ') }} €
+
+
+
Total commissions
+
{{ total_commission|number_format(2, ',', ' ') }} €
+
+
+
Total percu
+
{{ total_net|number_format(2, ',', ' ') }} €
+
+
+
+
+ {% else %}

Aucune categorie. Ajoutez-en une pour commencer a vendre des billets.

{% endif %}
+ {% elseif current_tab == 'billets' and (is_granted('ROLE_ROOT') or app.user.offer == 'custom') %} + + {% set bd = billet_design %} +
+
+
+
+

Personnalisation

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

Apercu du billet — A4

+ +
+
+ +
+
+
+
+ {% elseif current_tab == 'stats' %}
diff --git a/tests/Controller/AccountControllerTest.php b/tests/Controller/AccountControllerTest.php index c506282..923ffef 100644 --- a/tests/Controller/AccountControllerTest.php +++ b/tests/Controller/AccountControllerTest.php @@ -1101,6 +1101,197 @@ class AccountControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } + public function testAddBilletPage(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + $category = $this->createCategory($em, $event); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter'); + + self::assertResponseIsSuccessful(); + } + + public function testAddBilletSubmit(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + $category = $this->createCategory($em, $event); + + $client->loginUser($user); + $client->request('POST', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter', [ + 'name' => 'Entree VIP', + 'price_ht' => '1500', + 'is_generated_billet' => '1', + 'description' => 'Acces backstage', + ]); + + self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'); + + $billet = $em->getRepository(\App\Entity\Billet::class)->findOneBy(['name' => 'Entree VIP']); + self::assertNotNull($billet); + self::assertSame(1500, $billet->getPriceHT()); + self::assertTrue($billet->isGeneratedBillet()); + self::assertSame('Acces backstage', $billet->getDescription()); + } + + public function testAddBilletDeniedForOtherUser(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $owner = $this->createUser(['ROLE_ORGANIZER'], true); + $other = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $owner); + $category = $this->createCategory($em, $event); + + $client->loginUser($other); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter'); + + self::assertResponseStatusCodeSame(403); + } + + public function testAddBilletCategoryNotFound(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/999999/billet/ajouter'); + + self::assertResponseStatusCodeSame(404); + } + + public function testEditBilletPage(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + $category = $this->createCategory($em, $event); + $billet = $this->createBillet($em, $category); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier'); + + self::assertResponseIsSuccessful(); + } + + public function testEditBilletSubmit(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + $category = $this->createCategory($em, $event); + $billet = $this->createBillet($em, $category); + + $client->loginUser($user); + $client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier', [ + 'name' => 'Entree Premium', + 'price_ht' => '2500', + 'is_generated_billet' => '1', + 'description' => 'Acces VIP', + ]); + + self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'); + + $em->refresh($billet); + self::assertSame('Entree Premium', $billet->getName()); + self::assertSame(2500, $billet->getPriceHT()); + } + + public function testEditBilletDeniedForOtherUser(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $owner = $this->createUser(['ROLE_ORGANIZER'], true); + $other = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $owner); + $category = $this->createCategory($em, $event); + $billet = $this->createBillet($em, $category); + + $client->loginUser($other); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier'); + + self::assertResponseStatusCodeSame(403); + } + + public function testEditBilletNotFound(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/999999/modifier'); + + self::assertResponseStatusCodeSame(404); + } + + public function testDeleteBillet(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $user); + $category = $this->createCategory($em, $event); + $billet = $this->createBillet($em, $category); + $billetId = $billet->getId(); + + $client->loginUser($user); + $client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billetId.'/supprimer'); + + self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'); + + self::assertNull($em->getRepository(\App\Entity\Billet::class)->find($billetId)); + } + + public function testDeleteBilletDeniedForOtherUser(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $owner = $this->createUser(['ROLE_ORGANIZER'], true); + $other = $this->createUser(['ROLE_ORGANIZER'], true); + + $event = $this->createEvent($em, $owner); + $category = $this->createCategory($em, $event); + $billet = $this->createBillet($em, $category); + + $client->loginUser($other); + $client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/supprimer'); + + self::assertResponseStatusCodeSame(403); + } + + private function createBillet(EntityManagerInterface $em, \App\Entity\Category $category, string $name = 'Test Billet', int $priceHT = 1000): \App\Entity\Billet + { + $billet = new \App\Entity\Billet(); + $billet->setName($name); + $billet->setCategory($category); + $billet->setPriceHT($priceHT); + $em->persist($billet); + $em->flush(); + + return $billet; + } + private function createEvent(EntityManagerInterface $em, User $user): \App\Entity\Event { $event = new \App\Entity\Event(); diff --git a/tests/Entity/BilletDesignTest.php b/tests/Entity/BilletDesignTest.php new file mode 100644 index 0000000..d5d8c81 --- /dev/null +++ b/tests/Entity/BilletDesignTest.php @@ -0,0 +1,69 @@ +getId()); + self::assertNull($design->getEvent()); + self::assertSame('#4f46e5', $design->getAccentColor()); + self::assertSame('Invitation', $design->getInvitationTitle()); + self::assertSame('#d4a017', $design->getInvitationColor()); + self::assertInstanceOf(\DateTimeImmutable::class, $design->getUpdatedAt()); + } + + public function testSetAndGetEvent(): void + { + $design = new BilletDesign(); + $event = new Event(); + $result = $design->setEvent($event); + + self::assertSame($event, $design->getEvent()); + self::assertSame($design, $result); + } + + public function testSetAndGetAccentColor(): void + { + $design = new BilletDesign(); + $result = $design->setAccentColor('#ff0000'); + + self::assertSame('#ff0000', $design->getAccentColor()); + self::assertSame($design, $result); + } + + public function testSetAndGetInvitationTitle(): void + { + $design = new BilletDesign(); + $result = $design->setInvitationTitle('VIP Pass'); + + self::assertSame('VIP Pass', $design->getInvitationTitle()); + self::assertSame($design, $result); + } + + public function testSetAndGetInvitationColor(): void + { + $design = new BilletDesign(); + $result = $design->setInvitationColor('#00ff00'); + + self::assertSame('#00ff00', $design->getInvitationColor()); + self::assertSame($design, $result); + } + + public function testSetAndGetUpdatedAt(): void + { + $design = new BilletDesign(); + $date = new \DateTimeImmutable('2026-01-01'); + $result = $design->setUpdatedAt($date); + + self::assertSame($date, $design->getUpdatedAt()); + self::assertSame($design, $result); + } +} diff --git a/tests/Entity/BilletTest.php b/tests/Entity/BilletTest.php new file mode 100644 index 0000000..b079b87 --- /dev/null +++ b/tests/Entity/BilletTest.php @@ -0,0 +1,227 @@ +getId()); + self::assertNull($billet->getName()); + self::assertNull($billet->getCategory()); + self::assertNull($billet->getDescription()); + self::assertNull($billet->getPictureName()); + self::assertNull($billet->getPictureFile()); + self::assertNull($billet->getUpdatedAt()); + self::assertSame(0, $billet->getPosition()); + self::assertSame(0, $billet->getPriceHT()); + self::assertNull($billet->getQuantity()); + self::assertTrue($billet->isUnlimited()); + self::assertTrue($billet->isGeneratedBillet()); + self::assertFalse($billet->hasDefinedExit()); + self::assertFalse($billet->isNotBuyable()); + self::assertSame('billet', $billet->getType()); + self::assertNull($billet->getStripeProductId()); + self::assertInstanceOf(\DateTimeImmutable::class, $billet->getCreatedAt()); + } + + public function testSetAndGetCategory(): void + { + $billet = new Billet(); + $category = new Category(); + $result = $billet->setCategory($category); + + self::assertSame($category, $billet->getCategory()); + self::assertSame($billet, $result); + } + + public function testSetAndGetName(): void + { + $billet = new Billet(); + $result = $billet->setName('Entree VIP'); + + self::assertSame('Entree VIP', $billet->getName()); + self::assertSame($billet, $result); + } + + public function testSetAndGetPriceHT(): void + { + $billet = new Billet(); + $result = $billet->setPriceHT(1500); + + self::assertSame(1500, $billet->getPriceHT()); + self::assertSame($billet, $result); + } + + public function testGetPriceHTDecimal(): void + { + $billet = new Billet(); + $billet->setPriceHT(1500); + + self::assertSame(15.0, $billet->getPriceHTDecimal()); + } + + public function testGetPriceHTDecimalZero(): void + { + $billet = new Billet(); + + self::assertSame(0.0, $billet->getPriceHTDecimal()); + } + + public function testSetAndGetIsGeneratedBillet(): void + { + $billet = new Billet(); + $result = $billet->setIsGeneratedBillet(false); + + self::assertFalse($billet->isGeneratedBillet()); + self::assertSame($billet, $result); + + $billet->setIsGeneratedBillet(true); + self::assertTrue($billet->isGeneratedBillet()); + } + + public function testSetAndGetHasDefinedExit(): void + { + $billet = new Billet(); + $result = $billet->setHasDefinedExit(true); + + self::assertTrue($billet->hasDefinedExit()); + self::assertSame($billet, $result); + + $billet->setHasDefinedExit(false); + self::assertFalse($billet->hasDefinedExit()); + } + + public function testSetAndGetDescription(): void + { + $billet = new Billet(); + $result = $billet->setDescription('Acces backstage inclus'); + + self::assertSame('Acces backstage inclus', $billet->getDescription()); + self::assertSame($billet, $result); + } + + public function testSetDescriptionNull(): void + { + $billet = new Billet(); + $billet->setDescription('Test'); + $billet->setDescription(null); + + self::assertNull($billet->getDescription()); + } + + public function testSetAndGetPictureName(): void + { + $billet = new Billet(); + $result = $billet->setPictureName('billet-vip.jpg'); + + self::assertSame('billet-vip.jpg', $billet->getPictureName()); + self::assertSame($billet, $result); + } + + public function testSetPictureFileUpdatesTimestamp(): void + { + $billet = new Billet(); + self::assertNull($billet->getUpdatedAt()); + + $file = $this->createMock(File::class); + $result = $billet->setPictureFile($file); + + self::assertSame($file, $billet->getPictureFile()); + self::assertInstanceOf(\DateTimeImmutable::class, $billet->getUpdatedAt()); + self::assertSame($billet, $result); + } + + public function testSetAndGetPosition(): void + { + $billet = new Billet(); + $result = $billet->setPosition(3); + + self::assertSame(3, $billet->getPosition()); + self::assertSame($billet, $result); + } + + public function testSetAndGetQuantity(): void + { + $billet = new Billet(); + $result = $billet->setQuantity(100); + + self::assertSame(100, $billet->getQuantity()); + self::assertFalse($billet->isUnlimited()); + self::assertSame($billet, $result); + + $billet->setQuantity(null); + self::assertNull($billet->getQuantity()); + self::assertTrue($billet->isUnlimited()); + } + + public function testSetAndGetNotBuyable(): void + { + $billet = new Billet(); + $result = $billet->setNotBuyable(true); + + self::assertTrue($billet->isNotBuyable()); + self::assertSame($billet, $result); + + $billet->setNotBuyable(false); + self::assertFalse($billet->isNotBuyable()); + } + + public function testSetAndGetType(): void + { + $billet = new Billet(); + $result = $billet->setType('reservation_brocante'); + + self::assertSame('reservation_brocante', $billet->getType()); + self::assertSame($billet, $result); + + $billet->setType('vote'); + self::assertSame('vote', $billet->getType()); + } + + public function testSetAndGetStripeProductId(): void + { + $billet = new Billet(); + $result = $billet->setStripeProductId('prod_abc123'); + + self::assertSame('prod_abc123', $billet->getStripeProductId()); + self::assertSame($billet, $result); + + $billet->setStripeProductId(null); + self::assertNull($billet->getStripeProductId()); + } + + public function testSetPictureFileNullDoesNotUpdateTimestamp(): void + { + $billet = new Billet(); + $billet->setPictureFile(null); + + self::assertNull($billet->getUpdatedAt()); + self::assertNull($billet->getPictureFile()); + } + + public function testSetCategoryNull(): void + { + $billet = new Billet(); + $billet->setCategory(null); + + self::assertNull($billet->getCategory()); + } + + public function testGetCreatedAt(): void + { + $before = new \DateTimeImmutable(); + $billet = new Billet(); + $after = new \DateTimeImmutable(); + + self::assertGreaterThanOrEqual($before, $billet->getCreatedAt()); + self::assertLessThanOrEqual($after, $billet->getCreatedAt()); + } +} diff --git a/tests/EventSubscriber/DisableProfilerSubscriberTest.php b/tests/EventSubscriber/DisableProfilerSubscriberTest.php new file mode 100644 index 0000000..abde6dc --- /dev/null +++ b/tests/EventSubscriber/DisableProfilerSubscriberTest.php @@ -0,0 +1,85 @@ +createMock(Profiler::class); + $profiler->expects(self::once())->method('disable'); + + $subscriber = new DisableProfilerSubscriber($profiler); + + $request = new Request(); + $request->attributes->set('_route', 'app_account_event_billet_preview'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()); + + $subscriber->onKernelResponse($event); + } + + public function testDoesNotDisableProfilerOnOtherRoutes(): void + { + $profiler = $this->createMock(Profiler::class); + $profiler->expects(self::never())->method('disable'); + + $subscriber = new DisableProfilerSubscriber($profiler); + + $request = new Request(); + $request->attributes->set('_route', 'app_home'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()); + + $subscriber->onKernelResponse($event); + } + + public function testHandlesNullProfiler(): void + { + $subscriber = new DisableProfilerSubscriber(null); + + $request = new Request(); + $request->attributes->set('_route', 'app_account_event_billet_preview'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()); + + // No exception thrown + $subscriber->onKernelResponse($event); + self::assertTrue(true); + } + + public function testIgnoresSubRequests(): void + { + $profiler = $this->createMock(Profiler::class); + $profiler->expects(self::never())->method('disable'); + + $subscriber = new DisableProfilerSubscriber($profiler); + + $request = new Request(); + $request->attributes->set('_route', 'app_account_event_billet_preview'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new ResponseEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, new Response()); + + $subscriber->onKernelResponse($event); + } +} diff --git a/tests/js/billet-designer.test.js b/tests/js/billet-designer.test.js new file mode 100644 index 0000000..93563c8 --- /dev/null +++ b/tests/js/billet-designer.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { initBilletDesigner } from '../../assets/modules/billet-designer.js' + +describe('initBilletDesigner', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('does nothing without designer element', () => { + expect(() => initBilletDesigner()).not.toThrow() + }) + + it('does nothing without preview url', () => { + document.body.innerHTML = '
' + expect(() => initBilletDesigner()).not.toThrow() + }) + + it('does nothing without iframe', () => { + document.body.innerHTML = '
' + expect(() => initBilletDesigner()).not.toThrow() + }) + + it('reloads iframe on color input change', () => { + document.body.innerHTML = ` +
+ + +
+ ` + + initBilletDesigner() + + const input = document.querySelector('input[name="bg_color"]') + input.value = '#ff0000' + input.dispatchEvent(new Event('input', { bubbles: true })) + + const iframe = document.getElementById('billet-preview-frame') + expect(iframe.src).toContain('bg_color=%23ff0000') + }) + + it('reloads iframe on checkbox change', () => { + document.body.innerHTML = ` +
+ + +
+ ` + + initBilletDesigner() + + const checkbox = document.querySelector('input[name="show_logo"]') + checkbox.checked = false + checkbox.dispatchEvent(new Event('change', { bubbles: true })) + + const iframe = document.getElementById('billet-preview-frame') + expect(iframe.src).toContain('show_logo=0') + }) + + it('includes all inputs in preview url', () => { + document.body.innerHTML = ` +
+ + + + +
+ ` + + initBilletDesigner() + + const input = document.querySelector('input[name="bg_color"]') + input.dispatchEvent(new Event('input', { bubbles: true })) + + const iframe = document.getElementById('billet-preview-frame') + expect(iframe.src).toContain('bg_color=%23ffffff') + expect(iframe.src).toContain('text_color=%23111111') + expect(iframe.src).toContain('show_logo=1') + }) + + it('reloads on reload button click', () => { + document.body.innerHTML = ` +
+ + + +
+ ` + + initBilletDesigner() + + document.getElementById('billet-reload-preview').click() + + const iframe = document.getElementById('billet-preview-frame') + expect(iframe.src).toContain('bg_color=%23aabbcc') + }) +}) diff --git a/tests/js/commission-calculator.test.js b/tests/js/commission-calculator.test.js new file mode 100644 index 0000000..c5c7bb4 --- /dev/null +++ b/tests/js/commission-calculator.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { initCommissionCalculator } from '../../assets/modules/commission-calculator.js' + +describe('initCommissionCalculator', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('does nothing without calculator element', () => { + expect(() => initCommissionCalculator()).not.toThrow() + }) + + it('does nothing without price input', () => { + document.body.innerHTML = '
' + expect(() => initCommissionCalculator()).not.toThrow() + }) + + function setupCalculator(eticketRate = '5', price = '') { + document.body.innerHTML = ` + +
+ + + + + +
+ ` + initCommissionCalculator() + } + + it('shows zero values when price is empty', () => { + setupCalculator('5', '') + + expect(document.getElementById('calc-price').textContent).toBe('0,00 \u20AC') + expect(document.getElementById('calc-net').textContent).toBe('0,00 \u20AC') + }) + + it('calculates commissions for 10 EUR with 5% eticket rate', () => { + setupCalculator('5', '10') + + // E-Ticket: 10 * 5% = 0.50 + // Stripe: 10 * 1.5% + 0.25 = 0.40 + // Total: 0.90 + // Net: 9.10 + expect(document.getElementById('calc-price').textContent).toBe('10,00 \u20AC') + expect(document.getElementById('calc-eticket').textContent).toBe('- 0,50 \u20AC') + expect(document.getElementById('calc-stripe').textContent).toBe('- 0,40 \u20AC') + expect(document.getElementById('calc-total').textContent).toBe('- 0,90 \u20AC') + expect(document.getElementById('calc-net').textContent).toBe('9,10 \u20AC') + }) + + it('calculates with 0% eticket rate', () => { + setupCalculator('0', '20') + + // E-Ticket: 0 + // Stripe: 20 * 1.5% + 0.25 = 0.55 + // Net: 19.45 + expect(document.getElementById('calc-eticket').textContent).toBe('- 0,00 \u20AC') + expect(document.getElementById('calc-stripe').textContent).toBe('- 0,55 \u20AC') + expect(document.getElementById('calc-net').textContent).toBe('19,45 \u20AC') + }) + + it('updates on input event', () => { + setupCalculator('5', '0') + + const input = document.getElementById('billet_price') + input.value = '15' + input.dispatchEvent(new Event('input', { bubbles: true })) + + // E-Ticket: 15 * 5% = 0.75 + // Stripe: 15 * 1.5% + 0.25 = 0.475 → 0.48 + // Total: 1.225 → 1.23 (but floating point...) + expect(document.getElementById('calc-price').textContent).toBe('15,00 \u20AC') + expect(document.getElementById('calc-eticket').textContent).toBe('- 0,75 \u20AC') + + const net = document.getElementById('calc-net').textContent + expect(net).toContain('\u20AC') + }) + + it('net is never negative', () => { + setupCalculator('99', '0.01') + + // With 99% commission the net would be very low or negative after stripe + const net = document.getElementById('calc-net').textContent + expect(net).toBe('0,00 \u20AC') + }) +})