'Accueil', 'url' => '/']; private const BREADCRUMB_ACCOUNT = ['name' => 'Mon compte', 'url' => '/mon-compte']; private const EVENT_BASE_URL = '/mon-compte/evenement/'; private const EVENT_CATEGORIES_SUFFIX = '/modifier?tab=categories'; #[Route('/mon-compte', name: 'app_account')] public function index(Request $request, StripeService $stripeService, EntityManagerInterface $em, PaginatorInterface $paginator, EventIndexService $eventIndex): Response { /** @var User $user */ $user = $this->getUser(); $isOrganizer = $this->isGranted('ROLE_ORGANIZER'); $defaultTab = $isOrganizer ? 'events' : 'tickets'; $tab = $request->query->getString('tab', $defaultTab); if ($isOrganizer && $user->getStripeAccountId() && (!$user->isStripeChargesEnabled() || !$user->isStripePayoutsEnabled())) { try { // @codeCoverageIgnoreStart $account = $stripeService->getClient()->accounts->retrieve($user->getStripeAccountId()); $user->setStripeChargesEnabled((bool) $account->charges_enabled); $user->setStripePayoutsEnabled((bool) $account->payouts_enabled); $em->flush(); } catch (\Throwable) { // Stripe API unavailable, keep current status } // @codeCoverageIgnoreEnd } $payouts = []; $subAccounts = []; $events = []; if ($isOrganizer) { $payouts = $em->getRepository(Payout::class)->findBy( ['organizer' => $user], ['createdAt' => 'DESC'], ); $subAccounts = $em->getRepository(User::class)->findBy( ['parentOrganizer' => $user], ['createdAt' => 'DESC'], ); $searchQuery = $request->query->getString('q', ''); $eventsQuery = $eventIndex->searchEvents('event_'.$user->getId(), $searchQuery, ['account' => $user]); $events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10); } $financeStats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0]; if ($isOrganizer) { $orgaOrders = $em->createQueryBuilder() ->select('o') ->from(BilletBuyer::class, 'o') ->join('o.event', 'e') ->where('e.account = :user') ->setParameter('user', $user) ->getQuery() ->getResult(); $rate = $user->getCommissionRate() ?? 3; foreach ($orgaOrders as $o) { $ht = $o->getTotalHT() / 100; if (BilletBuyer::STATUS_PAID === $o->getStatus()) { $financeStats['paid'] += $ht; $financeStats['commissionEticket'] += $ht * ($rate / 100); $stripeFeeRate = (float) $this->getParameter('stripe_fee_rate'); $stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed'); $financeStats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100; } elseif (BilletBuyer::STATUS_PENDING === $o->getStatus()) { $financeStats['pending'] += $ht; } elseif (BilletBuyer::STATUS_REFUNDED === $o->getStatus()) { $financeStats['refunded'] += $ht; } elseif (BilletBuyer::STATUS_CANCELLED === $o->getStatus()) { $financeStats['cancelled'] += $ht; } } $financeStats['net'] = $financeStats['paid'] - $financeStats['commissionEticket'] - $financeStats['commissionStripe']; } $orders = $em->getRepository(BilletBuyer::class)->findBy( ['user' => $user], ['createdAt' => 'DESC'], ); $userTickets = []; foreach ($orders as $order) { $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); foreach ($tickets as $ticket) { $userTickets[] = $ticket; } } return $this->render('account/index.html.twig', [ 'tab' => $tab, 'isOrganizer' => $isOrganizer, 'payouts' => $payouts, 'subAccounts' => $subAccounts, 'events' => $events, 'orders' => $orders, 'userTickets' => $userTickets, 'financeStats' => $financeStats, 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ], ]); } #[Route('/mon-compte/parametres', name: 'app_account_settings', methods: ['POST'])] public function settings(Request $request, EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); $isOrganizer = $this->isGranted('ROLE_ORGANIZER'); if (!$isOrganizer) { $user->setFirstName(trim($request->request->getString('first_name'))); $user->setLastName(trim($request->request->getString('last_name'))); } $user->setEmail(trim($request->request->getString('email'))); $user->setPhone(trim($request->request->getString('phone'))); if (!$isOrganizer) { $user->setAddress(trim($request->request->getString('address'))); $user->setPostalCode(trim($request->request->getString('postal_code'))); $user->setCity(trim($request->request->getString('city'))); } if ($isOrganizer) { $user->setWebsite(trim($request->request->getString('website'))); $user->setFacebook(trim($request->request->getString('facebook'))); $user->setInstagram(trim($request->request->getString('instagram'))); $user->setTwitter(trim($request->request->getString('twitter'))); $user->setTiktok(trim($request->request->getString('tiktok'))); $logoFile = $request->files->get('logo'); if ($logoFile) { $user->setLogoFile($logoFile); } } $em->flush(); $this->addFlash('success', 'Parametres mis a jour.'); return $this->redirectToRoute('app_account', ['tab' => 'settings']); } /** @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/stripe-connect', name: 'app_account_stripe_connect')] public function stripeConnect(StripeService $stripeService, EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); if (!$this->isGranted('ROLE_ORGANIZER')) { return $this->redirectToRoute('app_account'); } try { if (!$user->getStripeAccountId()) { $accountId = $stripeService->createAccountConnect($user); $user->setStripeAccountId($accountId); $user->setStripeStatus('started'); $em->flush(); } $link = $stripeService->createAccountLink($user->getStripeAccountId()); return $this->redirect($link); } catch (\Throwable $e) { $this->addFlash('error', 'Erreur lors de la connexion a Stripe : '.$e->getMessage()); return $this->redirectToRoute('app_account'); } } /** @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/stripe-cancel', name: 'app_account_stripe_cancel', methods: ['POST'])] public function stripeCancel(StripeService $stripeService, EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); if ($this->isGranted('ROLE_ORGANIZER') && $user->getStripeAccountId()) { try { $stripeService->deleteAccount($user->getStripeAccountId()); } catch (\Throwable) { // Account may already be deleted on Stripe side } $user->setStripeAccountId(null); $user->setStripeChargesEnabled(false); $user->setStripePayoutsEnabled(false); $em->flush(); $this->addFlash('success', 'Compte Stripe cloture.'); } return $this->redirectToRoute('app_account'); } /** @codeCoverageIgnore Stripe redirect callback */ #[Route('/stripe/connect/return', name: 'app_stripe_connect_return')] public function stripeConnectReturn(): Response { $this->addFlash('success', 'Configuration Stripe terminee.'); return $this->redirectToRoute('app_account'); } /** @codeCoverageIgnore Stripe redirect callback */ #[Route('/stripe/connect/refresh', name: 'app_stripe_connect_refresh')] public function stripeConnectRefresh(): Response { return $this->redirectToRoute('app_account_stripe_connect'); } #[Route('/mon-compte/sous-compte/creer', name: 'app_account_create_subaccount', methods: ['POST'])] public function createSubAccount(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailerService): Response { /** @var User $user */ $user = $this->getUser(); if (!$this->isGranted('ROLE_ORGANIZER')) { return $this->redirectToRoute('app_account'); } $plainPassword = bin2hex(random_bytes(8)); $subAccount = new User(); $subAccount->setFirstName(trim($request->request->getString('first_name'))); $subAccount->setLastName(trim($request->request->getString('last_name'))); $subAccount->setEmail(trim($request->request->getString('email'))); $subAccount->setPassword($passwordHasher->hashPassword($subAccount, $plainPassword)); $subAccount->setIsVerified(true); $subAccount->setEmailVerifiedAt(new \DateTimeImmutable()); $subAccount->setParentOrganizer($user); $permissions = $request->request->all('permissions'); $subAccount->setSubAccountPermissions($permissions); $em->persist($subAccount); $em->flush(); $mailerService->sendEmail( to: $subAccount->getEmail(), subject: 'Votre sous-compte E-Ticket a ete cree', content: $this->renderView('email/subaccount_created.html.twig', [ 'firstName' => $subAccount->getFirstName(), 'organizerName' => $user->getCompanyName() ?? $user->getFirstName().' '.$user->getLastName(), 'email' => $subAccount->getEmail(), 'password' => $plainPassword, 'permissions' => $permissions, ]), withUnsubscribe: false, ); $this->addFlash('success', sprintf('Sous-compte %s %s cree.', $subAccount->getFirstName(), $subAccount->getLastName())); return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); } #[Route('/mon-compte/sous-compte/{id}', name: 'app_account_edit_subaccount_page', methods: ['GET'])] public function editSubAccountPage(User $subAccount): Response { /** @var User $user */ $user = $this->getUser(); if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } return $this->render('account/edit_subaccount.html.twig', [ 'subAccount' => $subAccount, 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => 'Sous-compte', 'url' => self::BREADCRUMB_ACCOUNT['url'].'/sous-compte/'.$subAccount->getId()], ], ]); } #[Route('/mon-compte/sous-compte/{id}/modifier', name: 'app_account_edit_subaccount', methods: ['POST'])] public function editSubAccount(User $subAccount, Request $request, EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $permissions = $request->request->all('permissions'); $subAccount->setSubAccountPermissions($permissions); $subAccount->setFirstName(trim($request->request->getString('first_name'))); $subAccount->setLastName(trim($request->request->getString('last_name'))); $subAccount->setEmail(trim($request->request->getString('email'))); $em->flush(); $this->addFlash('success', sprintf('Sous-compte %s %s mis a jour.', $subAccount->getFirstName(), $subAccount->getLastName())); return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); } #[Route('/mon-compte/sous-compte/{id}/supprimer', name: 'app_account_delete_subaccount', methods: ['POST'])] public function deleteSubAccount(User $subAccount, EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $name = sprintf('%s %s', $subAccount->getFirstName(), $subAccount->getLastName()); $em->remove($subAccount); $em->flush(); $this->addFlash('success', sprintf('Sous-compte %s supprime.', $name)); return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); } #[Route('/mon-compte/evenement/creer', name: 'app_account_create_event', methods: ['GET', 'POST'])] public function createEvent(Request $request, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($request->isMethod('POST')) { $event = new Event(); $event->setAccount($user); $this->hydrateEventFromRequest($event, $request); $em->persist($event); $em->flush(); $eventIndex->indexEvent($event); $audit->log('event_created', 'Event', $event->getId(), ['title' => $event->getTitle()]); $this->addFlash('success', 'Evenement cree avec succes.'); return $this->redirectToRoute('app_account', ['tab' => 'events']); } return $this->render('account/create_event.html.twig', [ 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => 'Creer un evenement', 'url' => '/mon-compte/evenement/creer'], ], ]); } #[Route('/mon-compte/evenement/{id}/modifier', name: 'app_account_edit_event', methods: ['GET', 'POST'])] public function editEvent(Event $event, Request $request, EntityManagerInterface $em, EventIndexService $eventIndex, PaginatorInterface $paginator, OrderIndexService $orderIndex, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } if ($request->isMethod('POST')) { $this->hydrateEventFromRequest($event, $request); $em->flush(); $eventIndex->indexEvent($event); $audit->log('event_updated', 'Event', $event->getId(), ['title' => $event->getTitle()]); $this->addFlash('success', 'Evenement modifie avec succes.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]); } $categories = $em->getRepository(Category::class)->findBy( ['event' => $event], ['position' => 'ASC'], ); $allBillets = $em->getRepository(Billet::class)->findBy(['category' => $categories], ['position' => 'ASC']); $billets = []; $billetIds = []; foreach ($allBillets as $billet) { $catId = $billet->getCategory()->getId(); $billets[$catId][] = $billet; $billetIds[] = $billet->getId(); } $soldCounts = []; if ($billetIds) { $rows = $em->createQueryBuilder() ->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt') ->from(BilletOrder::class, 'bo') ->where('bo.billet IN (:ids)') ->setParameter('ids', $billetIds) ->groupBy('bo.billet') ->getQuery() ->getArrayResult(); foreach ($rows as $row) { $soldCounts[$row['billetId']] = (int) $row['cnt']; } } $searchQuery = $request->query->getString('q', ''); $ordersQuery = '' !== $searchQuery ? $orderIndex->searchOrders($event->getId(), $searchQuery) : $em->getRepository(BilletBuyer::class)->findBy(['event' => $event], ['createdAt' => 'DESC']); $eventOrders = $paginator->paginate($ordersQuery, $request->query->getInt('page', 1), 20); $paidEventOrders = $em->createQueryBuilder() ->select('o', 'i') ->from(BilletBuyer::class, 'o') ->leftJoin('o.items', 'i') ->where('o.event = :event') ->andWhere('o.status = :status') ->setParameter('event', $event) ->setParameter('status', BilletBuyer::STATUS_PAID) ->getQuery() ->getResult(); $eventStats = $this->computeEventStats($paidEventOrders); 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]), 'event_orders' => $eventOrders, 'invitations' => $em->getRepository(BilletBuyer::class)->findBy(['event' => $event, 'isInvitation' => true], ['createdAt' => 'DESC']), 'event_total_ht' => $eventStats['totalHT'] / 100, 'event_total_sold' => $eventStats['totalSold'], 'event_total_orders' => \count($paidEventOrders), 'billet_stats' => $eventStats['billetStats'], 'search_query' => $searchQuery, 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().'/modifier'], ], ]); } #[Route('/mon-compte/evenement/{id}/categorie/ajouter', name: 'app_account_event_add_category', methods: ['POST'])] public function addCategory(Event $event, Request $request, EntityManagerInterface $em, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $name = trim($request->request->getString('name')); if ('' === $name) { $this->addFlash('error', 'Le nom de la categorie est requis.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); } $maxPosition = $em->getRepository(Category::class)->count(['event' => $event]); $category = new Category(); $category->setName($name); $category->setEvent($event); $category->setPosition($maxPosition); $startAt = $request->request->getString('start_at'); if ('' !== $startAt) { $category->setStartAt(new \DateTimeImmutable($startAt)); } $endAt = $request->request->getString('end_at'); if ('' !== $endAt) { $category->setEndAt(new \DateTimeImmutable($endAt)); } if ($category->getEndAt() < $category->getStartAt()) { $category->setEndAt($category->getStartAt()->modify('+30 days')); } $em->persist($category); $em->flush(); $audit->log('category_created', 'Category', $category->getId(), ['name' => $name, 'event' => $event->getTitle()]); $this->addFlash('success', sprintf('Categorie "%s" ajoutee.', $name)); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); } #[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/modifier', name: 'app_account_event_edit_category', methods: ['GET', 'POST'])] public function editCategory(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, AuditService $audit): 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')) { $category->setName(trim($request->request->getString('name'))); $startAt = $request->request->getString('start_at'); if ('' !== $startAt) { $category->setStartAt(new \DateTimeImmutable($startAt)); } $endAt = $request->request->getString('end_at'); if ('' !== $endAt) { $category->setEndAt(new \DateTimeImmutable($endAt)); } if ($category->getEndAt() < $category->getStartAt()) { $category->setEndAt($category->getStartAt()->modify('+30 days')); } $category->setIsHidden($request->request->getBoolean('is_hidden')); $em->flush(); $audit->log('category_updated', 'Category', $category->getId(), ['name' => $category->getName(), 'event' => $event->getTitle()]); $this->addFlash('success', 'Categorie modifiee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); } return $this->render('account/edit_category.html.twig', [ 'event' => $event, 'category' => $category, 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX], ['name' => $category->getName(), 'url' => ''], ], ]); } #[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/supprimer', name: 'app_account_event_delete_category', methods: ['POST'])] public function deleteCategory(Event $event, int $categoryId, EntityManagerInterface $em, AuditService $audit): 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()) { $catName = $category->getName(); $catId = $category->getId(); $em->remove($category); $em->flush(); $audit->log('category_deleted', 'Category', $catId, ['name' => $catName, 'event' => $event->getTitle()]); $this->addFlash('success', 'Categorie supprimee.'); } return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']); } #[Route('/mon-compte/evenement/{id}/categorie/reorder', name: 'app_account_event_reorder_categories', methods: ['POST'])] public function reorderCategories(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 => $categoryId) { $category = $em->getRepository(Category::class)->find($categoryId); if ($category && $category->getEvent()->getId() === $event->getId()) { $category->setPosition($position); } } $em->flush(); } 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, AuditService $audit): 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])); $this->hydrateBilletFromRequest($billet, $request); $em->persist($billet); $this->syncBilletToStripe($billet, $user, $stripeService); $em->flush(); $audit->log('billet_created', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]); $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, 'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()), 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX], ['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, AuditService $audit): 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')) { $this->hydrateBilletFromRequest($billet, $request); $this->syncBilletToStripe($billet, $user, $stripeService); $em->flush(); $audit->log('billet_updated', 'Billet', $billet->getId(), ['name' => $billet->getName(), 'event' => $event->getTitle()]); $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, 'allowedTypes' => self::getAllowedBilletTypes($user->getOffer()), 'breadcrumbs' => [ self::BREADCRUMB_HOME, self::BREADCRUMB_ACCOUNT, ['name' => $event->getTitle(), 'url' => self::EVENT_BASE_URL.$event->getId().self::EVENT_CATEGORIES_SUFFIX], ['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, AuditService $audit): 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(); } $billetName = $billet->getName(); $billetDbId = $billet->getId(); $this->deleteBilletFromStripe($billet, $user, $stripeService); $em->remove($billet); $em->flush(); $audit->log('billet_deleted', 'Billet', $billetDbId, ['name' => $billetName, 'event' => $event->getTitle()]); $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}/invitation', name: 'app_account_event_create_invitation', methods: ['POST'])] public function createInvitation(Event $event, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $firstName = trim($request->request->getString('first_name')); $lastName = trim($request->request->getString('last_name')); $email = trim($request->request->getString('email')); $items = $request->request->all('items'); if ('' === $firstName || '' === $lastName || '' === $email || 0 === \count($items)) { $this->addFlash('error', 'Tous les champs sont requis.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } $count = $em->getRepository(BilletBuyer::class)->count([]) + 1; $order = new BilletBuyer(); $order->setEvent($event); $order->setFirstName($firstName); $order->setLastName($lastName); $order->setEmail($email); $order->setOrderNumber(date('Y-m-d').'-'.$count); $order->setTotalHT(0); $order->setIsInvitation(true); foreach ($items as $itemData) { $billetId = (int) ($itemData['billet_id'] ?? 0); $qty = max(1, (int) ($itemData['quantity'] ?? 1)); $billet = $em->getRepository(Billet::class)->find($billetId); if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) { continue; } $item = new BilletBuyerItem(); $item->setBillet($billet); $item->setBilletName($billet->getName()); $item->setQuantity($qty); $item->setUnitPriceHT(0); $order->addItem($item); } if ($order->getItems()->isEmpty()) { $this->addFlash('error', 'Aucun billet valide selectionne.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } $em->persist($order); $em->flush(); $billetOrderService->generateOrderTickets($order); $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); foreach ($tickets as $ticket) { $ticket->setIsInvitation(true); } $em->flush(); $billetOrderService->generateAndSendTickets($order); $this->addFlash('success', 'Invitation envoyee a '.$email.'.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } #[Route('/mon-compte/evenement/{id}/invitation/{orderId}/renvoyer', name: 'app_account_event_resend_invitation', methods: ['POST'])] public function resendInvitation(Event $event, int $orderId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $order = $em->getRepository(BilletBuyer::class)->find($orderId); if (!$order || $order->getEvent()->getId() !== $event->getId()) { throw $this->createNotFoundException(); } $billetOrderService->generateAndSendTickets($order); $this->addFlash('success', 'Invitation renvoyee a '.$order->getEmail().'.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } #[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])] public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $order = $em->getRepository(BilletBuyer::class)->find($orderId); if (!$order || $order->getEvent()->getId() !== $event->getId()) { throw $this->createNotFoundException(); } $order->setStatus(BilletBuyer::STATUS_CANCELLED); $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); foreach ($tickets as $ticket) { $ticket->setState(BilletOrder::STATE_INVALID); } $em->flush(); $audit->log('order_cancelled', 'BilletBuyer', $order->getId(), [ 'orderNumber' => $order->getOrderNumber(), 'event' => $event->getTitle(), ]); $billetOrderService->notifyOrganizerCancelled($order, 'annulee'); $this->addFlash('success', 'Commande '.$order->getOrderNumber().' annulee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); } /** * @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/evenement/{id}/commande/{orderId}/rembourser', name: 'app_account_event_refund_order', methods: ['POST'])] public function refundOrder(Event $event, int $orderId, EntityManagerInterface $em, StripeService $stripeService, AuditService $audit, BilletOrderService $billetOrderService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $order = $em->getRepository(BilletBuyer::class)->find($orderId); if (!$order || $order->getEvent()->getId() !== $event->getId()) { throw $this->createNotFoundException(); } if ($order->getStripeSessionId() && $user->getStripeAccountId()) { try { $stripeService->getClient()->refunds->create([ 'payment_intent' => $order->getStripeSessionId(), ], ['stripe_account' => $user->getStripeAccountId()]); } catch (\Exception) { // Stripe failure is non-blocking } } $order->setStatus(BilletBuyer::STATUS_REFUNDED); $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); foreach ($tickets as $ticket) { $ticket->setState(BilletOrder::STATE_INVALID); } $em->flush(); $audit->log('order_refunded', 'BilletBuyer', $order->getId(), [ 'orderNumber' => $order->getOrderNumber(), 'event' => $event->getTitle(), 'totalHT' => $order->getTotalHTDecimal(), ]); $billetOrderService->notifyOrganizerCancelled($order, 'remboursee'); $this->addFlash('success', 'Commande '.$order->getOrderNumber().' remboursee.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'stats']); } #[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(); } return $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'), ]); } #[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 { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } if (!$event->isOnline() && (!$user->isStripeChargesEnabled() || !$user->isStripePayoutsEnabled())) { $this->addFlash('error', 'Configuration Stripe requise pour mettre un evenement en ligne.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]); } $event->setIsOnline(!$event->isOnline()); $em->flush(); $eventIndex->indexEvent($event); $this->addFlash('success', $event->isOnline() ? 'Evenement mis en ligne.' : 'Evenement passe hors ligne.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]); } #[Route('/mon-compte/evenement/{id}/secret', name: 'app_account_toggle_event_secret', methods: ['POST'])] public function toggleEventSecret(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $event->setIsSecret(!$event->isSecret()); $em->flush(); $eventIndex->indexEvent($event); $this->addFlash('success', $event->isSecret() ? 'Evenement marque comme secret.' : 'Evenement rendu public.'); return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId()]); } #[Route('/mon-compte/evenement/{id}/supprimer', name: 'app_account_delete_event', methods: ['POST'])] public function deleteEvent(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex, AuditService $audit): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); if ($event->getAccount()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } $eventTitle = $event->getTitle(); $eventDbId = $event->getId(); $eventIndex->removeEvent($event); $em->remove($event); $em->flush(); $audit->log('event_deleted', 'Event', $eventDbId, ['title' => $eventTitle]); $this->addFlash('success', 'Evenement supprime.'); return $this->redirectToRoute('app_account', ['tab' => 'events']); } /** @codeCoverageIgnore Test helper, not used in production */ #[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])] public function testPayout(EntityManagerInterface $em): Response { /** @var User $user */ $user = $this->getUser(); if (!$this->isGranted('ROLE_ORGANIZER') || !$user->getStripeAccountId()) { return $this->redirectToRoute('app_account'); } $payout = new Payout(); $payout->setOrganizer($user); $payout->setStripePayoutId('po_test_'.bin2hex(random_bytes(8))); $payout->setStatus('paid'); $payout->setAmount(random_int(1000, 50000)); $payout->setCurrency('eur'); $payout->setDestination('ba_test_bank'); $payout->setStripeAccountId($user->getStripeAccountId()); $payout->setArrivalDate(new \DateTimeImmutable('+2 days')); $em->persist($payout); $em->flush(); $this->addFlash('success', sprintf('Payout test cree : %s (%.2f EUR)', $payout->getStripePayoutId(), $payout->getAmountDecimal())); return $this->redirectToRoute('app_account', ['tab' => 'payouts']); } /** @codeCoverageIgnore Requires live Stripe API */ #[Route('/mon-compte/stripe-dashboard', name: 'app_account_stripe_dashboard')] public function stripeDashboard(StripeService $stripeService): Response { /** @var User $user */ $user = $this->getUser(); if (!$this->isGranted('ROLE_ORGANIZER') || !$user->getStripeAccountId()) { return $this->redirectToRoute('app_account'); } try { $link = $stripeService->createLoginLink($user->getStripeAccountId()); return $this->redirect($link); } catch (\Throwable $e) { $this->addFlash('error', 'Erreur Stripe : '.$e->getMessage()); return $this->redirectToRoute('app_account'); } } /** @codeCoverageIgnore Generates PDF with dompdf */ #[Route('/mon-compte/payout/{id}/attestation', name: 'app_account_payout_pdf')] public function payoutPdf(Payout $payout, PayoutPdfService $pdfService): Response { /** @var User $user */ $user = $this->getUser(); if ($payout->getOrganizer()->getId() !== $user->getId()) { throw $this->createAccessDeniedException(); } return new Response($pdfService->generate($payout), 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="attestation_'.$payout->getStripePayoutId().'.pdf"', ]); } /** * @codeCoverageIgnore Requires live Stripe API */ private function deleteBilletFromStripe(Billet $billet, User $user, StripeService $stripeService): void { if ($billet->getStripeProductId() && $user->getStripeAccountId()) { try { $stripeService->deleteProduct($billet->getStripeProductId(), $user->getStripeAccountId()); } catch (\Exception) { // Stripe failure is non-blocking } } } /** * @codeCoverageIgnore Requires live Stripe API */ private function syncBilletToStripe(Billet $billet, User $user, StripeService $stripeService): void { if (!$user->getStripeAccountId()) { return; } try { if ($billet->getStripeProductId()) { $stripeService->updateProduct($billet, $user->getStripeAccountId()); } else { $productId = $stripeService->createProduct($billet, $user->getStripeAccountId()); $billet->setStripeProductId($productId); } } catch (\Exception) { // Stripe failure is non-blocking } } /** * @return array{totalHT: int, totalSold: int, billetStats: array} */ #[Route('/mon-compte/export/{year}/{month}', name: 'app_account_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])] public function export(int $year, int $month, ExportService $exportService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); $stats = $exportService->getMonthlyStats($year, $month, $user); $csv = $exportService->generateCsv($stats['orders']); $filename = sprintf('export_%04d_%02d.csv', $year, $month); return new Response($csv, 200, [ 'Content-Type' => 'text/csv; charset=utf-8', 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ]); } #[Route('/mon-compte/export/{year}/{month}/pdf', name: 'app_account_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])] public function exportPdf(int $year, int $month, ExportService $exportService): Response { $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); /** @var User $user */ $user = $this->getUser(); $stats = $exportService->getMonthlyStats($year, $month, $user); $pdf = $exportService->generatePdf($stats, $year, $month, $user); $filename = sprintf('recap_%04d_%02d.pdf', $year, $month); return new Response($pdf, 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="'.$filename.'"', ]); } private function computeEventStats(array $paidOrders): array { $totalHT = 0; $totalSold = 0; $billetStats = []; foreach ($paidOrders as $order) { $totalHT += $order->getTotalHT(); foreach ($order->getItems() as $item) { $totalSold += $item->getQuantity(); $billetId = $item->getBillet()?->getId(); if (!$billetId) { continue; } if (!isset($billetStats[$billetId])) { $billetStats[$billetId] = ['name' => $item->getBilletName(), 'sold' => 0, 'revenue' => 0]; } $billetStats[$billetId]['sold'] += $item->getQuantity(); $billetStats[$billetId]['revenue'] += $item->getLineTotalHT(); } } return ['totalHT' => $totalHT, 'totalSold' => $totalSold, 'billetStats' => $billetStats]; } private function hydrateEventFromRequest(Event $event, Request $request): void { $event->setTitle(trim($request->request->getString('title'))); $event->setDescription(trim($request->request->getString('description')) ?: null); $event->setStartAt(new \DateTimeImmutable($request->request->getString('start_at'))); $event->setEndAt(new \DateTimeImmutable($request->request->getString('end_at'))); $event->setAddress(trim($request->request->getString('address'))); $event->setZipcode(trim($request->request->getString('zipcode'))); $event->setCity(trim($request->request->getString('city'))); $pictureFile = $request->files->get('event_main_picture'); if ($pictureFile) { $event->setEventMainPictureFile($pictureFile); } } /** * @return string[] */ public static function getAllowedBilletTypes(?string $offer): array { return match ($offer) { 'basic', 'sur-mesure' => ['billet', 'reservation_brocante', 'vote'], default => ['billet'], }; } private function hydrateBilletFromRequest(Billet $billet, Request $request): void { $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')); $type = $request->request->getString('type', 'billet'); /** @var User $user */ $user = $this->getUser(); $allowedTypes = self::getAllowedBilletTypes($user->getOffer()); if (!\in_array($type, $allowedTypes, true)) { $type = 'billet'; } $billet->setType($type); $billet->setDescription(trim($request->request->getString('description')) ?: null); $pictureFile = $request->files->get('picture'); if ($pictureFile) { $billet->setPictureFile($pictureFile); } } }