diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index 75bd877..4749a77 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -10,3 +10,13 @@ knpu_oauth2_client: provider_options: authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%' realm: '%env(OAUTH_KEYCLOAK_REALM)%' + keycloak_api: + type: generic + provider_class: Stevenmaguire\OAuth2\Client\Provider\Keycloak + client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%' + client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%' + redirect_route: app_api_auth_sso_validate + redirect_params: {} + provider_options: + authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%' + realm: '%env(OAUTH_KEYCLOAK_REALM)%' diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index c77e5bd..d0be287 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -442,7 +442,9 @@ class AccountController extends AbstractController $rows = $em->createQueryBuilder() ->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt') ->from(BilletOrder::class, 'bo') + ->join('bo.billetBuyer', 'bb') ->where('bo.billet IN (:ids)') + ->andWhere('bb.isInvitation = false OR bb.isInvitation IS NULL') ->setParameter('ids', $billetIds) ->groupBy('bo.billet') ->getQuery() @@ -459,12 +461,29 @@ class AccountController extends AbstractController : $em->getRepository(BilletBuyer::class)->findBy(['event' => $event], ['createdAt' => 'DESC']); $eventOrders = $paginator->paginate($ordersQuery, $request->query->getInt('page', 1), 20); + $ticketsSearchQuery = $request->query->getString('tq', ''); + $ticketsQb = $em->createQueryBuilder() + ->select('t', 'bb') + ->from(BilletOrder::class, 't') + ->join('t.billetBuyer', 'bb') + ->where('bb.event = :ticketEvent') + ->setParameter('ticketEvent', $event) + ->orderBy('t.createdAt', 'DESC'); + + if ('' !== $ticketsSearchQuery) { + $ticketsQb->andWhere('t.reference LIKE :tq OR t.securityKey LIKE :tq OR t.billetName LIKE :tq OR bb.firstName LIKE :tq OR bb.lastName LIKE :tq OR bb.email LIKE :tq') + ->setParameter('tq', '%'.$ticketsSearchQuery.'%'); + } + + $eventTickets = $paginator->paginate($ticketsQb->getQuery(), $request->query->getInt('tp', 1), 20); + $paidEventOrders = $em->createQueryBuilder() ->select('o', 'i') ->from(BilletBuyer::class, 'o') ->leftJoin('o.items', 'i') ->where('o.event = :event') ->andWhere('o.status = :status') + ->andWhere('o.isInvitation = false OR o.isInvitation IS NULL') ->setParameter('event', $event) ->setParameter('status', BilletBuyer::STATUS_PAID) ->getQuery() @@ -478,8 +497,12 @@ class AccountController extends AbstractController 'billets' => $billets, 'sold_counts' => $soldCounts, 'commission_rate' => $user->getCommissionRate() ?? 0, + 'stripe_fee_rate' => (float) $this->getParameter('stripe_fee_rate'), + 'stripe_fee_fixed' => (int) $this->getParameter('stripe_fee_fixed'), 'billet_design' => $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]), 'event_orders' => $eventOrders, + 'event_tickets' => $eventTickets, + 'tickets_search_query' => $ticketsSearchQuery, 'invitations' => $em->getRepository(BilletBuyer::class)->findBy(['event' => $event, 'isInvitation' => true], ['createdAt' => 'DESC']), 'event_total_ht' => $eventStats['totalHT'] / 100, 'event_total_sold' => $eventStats['totalSold'], @@ -809,6 +832,91 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } + #[Route('/mon-compte/evenement/{id}/accreditation', name: 'app_account_event_create_accreditation', methods: ['POST'])] + public function createAccreditation(Event $event, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart + return $redirect; + } // @codeCoverageIgnoreEnd + + $this->requireEventOwnership($event); + + $accreditationType = $request->request->getString('accreditation_type', 'staff'); + if (!\in_array($accreditationType, ['staff', 'exposant'], true)) { + $accreditationType = 'staff'; + } + + $firstName = trim($request->request->getString('first_name')); + $lastName = trim($request->request->getString('last_name')); + $email = trim($request->request->getString('email')); + $redirectResponse = $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); + + if ('' === $firstName || '' === $lastName || '' === $email) { + $this->addFlash('error', 'Tous les champs sont requis.'); + + return $redirectResponse; + } + + $categories = $em->getRepository(Category::class)->findBy(['event' => $event], ['position' => 'ASC']); + if (empty($categories)) { + $this->addFlash('error', 'Creez au moins une categorie avant de generer une accreditation.'); + + return $redirectResponse; + } + + $category = $categories[0]; + $label = 'staff' === $accreditationType ? 'Staff' : 'Exposant'; + + $billet = new Billet(); + $billet->setCategory($category); + $billet->setName($label.' - '.$firstName.' '.$lastName); + $billet->setType($accreditationType); + $billet->setPriceHT(0); + $billet->setQuantity(1); + $billet->setIsGeneratedBillet(true); + $billet->setNotBuyable(true); + $billet->setPosition(9999); + $em->persist($billet); + $em->flush(); + + $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); + + $item = new BilletBuyerItem(); + $item->setBillet($billet); + $item->setBilletName($billet->getName()); + $item->setQuantity(1); + $item->setUnitPriceHT(0); + $order->addItem($item); + + $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); + + $label = 'staff' === $accreditationType ? 'Staff' : 'Exposant'; + $this->addFlash('success', 'Accreditation '.$label.' envoyee a '.$order->getEmail().'.'); + + return $redirectResponse; + } + #[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 { @@ -831,6 +939,164 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']); } + #[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/telecharger', name: 'app_account_event_download_ticket', requirements: ['ticketId' => '\d+'], methods: ['GET'])] + public function downloadTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + $this->requireEventOwnership($event); + + $ticket = $em->getRepository(BilletOrder::class)->find($ticketId); + if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + $pdf = $billetOrderService->generatePdf($ticket); + + return new Response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$ticket->getReference().'.pdf"', + ]); + } + + #[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/renvoyer', name: 'app_account_event_resend_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])] + public function resendTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + $this->requireEventOwnership($event); + + $ticket = $em->getRepository(BilletOrder::class)->find($ticketId); + if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + $billetOrderService->generateAndSendTickets($ticket->getBilletBuyer()); + + $this->addFlash('success', 'Billet renvoye a '.$ticket->getBilletBuyer()->getEmail().'.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']); + } + + #[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/annuler', name: 'app_account_event_cancel_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])] + public function cancelTicket(Event $event, int $ticketId, EntityManagerInterface $em, AuditService $audit): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + $this->requireEventOwnership($event); + + $ticket = $em->getRepository(BilletOrder::class)->find($ticketId); + if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) { + throw $this->createNotFoundException(); + } + + $ticket->setState(BilletOrder::STATE_INVALID); + $em->flush(); + + $audit->log('ticket_cancelled', 'BilletOrder', $ticket->getId(), [ + 'reference' => $ticket->getReference(), + 'event' => $event->getTitle(), + ]); + + $this->addFlash('success', 'Billet '.$ticket->getReference().' annule.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']); + } + + #[Route('/mon-compte/evenement/{id}/attestation', name: 'app_account_event_attestation', methods: ['POST'])] + public function eventAttestation(Event $event, Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + $user = $this->requireEventOwnership($event); + + $categoryIds = array_map('intval', $request->request->all('categories')); + $billetIds = array_map('intval', $request->request->all('billets')); + + $categories = []; + if ($categoryIds) { + $categories = $em->getRepository(Category::class)->findBy(['id' => $categoryIds, 'event' => $event]); + } + + $billets = []; + if ($billetIds) { + $billets = $em->getRepository(Billet::class)->findBy(['id' => $billetIds]); + $billets = array_filter($billets, fn (Billet $b) => $b->getCategory()->getEvent()->getId() === $event->getId()); + } + + if (empty($categories) && empty($billets)) { + $this->addFlash('error', 'Selectionnez au moins une categorie ou un billet.'); + + return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'attestation']); + } + + $allBilletIds = []; + foreach ($billets as $b) { + $allBilletIds[] = $b->getId(); + } + foreach ($categories as $cat) { + $catBillets = $em->getRepository(Billet::class)->findBy(['category' => $cat]); + foreach ($catBillets as $b) { + if (!\in_array($b->getId(), $allBilletIds, true)) { + $allBilletIds[] = $b->getId(); + $billets[] = $b; + } + } + } + + $soldCounts = []; + $revenueCounts = []; + if ($allBilletIds) { + $rows = $em->createQueryBuilder() + ->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt, SUM(bo.unitPriceHT) AS revenue') + ->from(BilletOrder::class, 'bo') + ->join('bo.billetBuyer', 'bb') + ->where('bo.billet IN (:ids)') + ->andWhere('bb.isInvitation = false OR bb.isInvitation IS NULL') + ->setParameter('ids', $allBilletIds) + ->groupBy('bo.billet') + ->getQuery() + ->getArrayResult(); + foreach ($rows as $row) { + $soldCounts[(int) $row['billetId']] = (int) $row['cnt']; + $revenueCounts[(int) $row['billetId']] = (int) $row['revenue']; + } + } + + $billetLines = []; + $totalSold = 0; + $totalRevenue = 0; + foreach ($billets as $b) { + $sold = $soldCounts[$b->getId()] ?? 0; + $revenue = $revenueCounts[$b->getId()] ?? 0; + $billetLines[] = [ + 'category' => $b->getCategory()->getName(), + 'name' => $b->getName(), + 'priceHT' => $b->getPriceHTDecimal(), + 'sold' => $sold, + 'revenue' => $revenue / 100, + ]; + $totalSold += $sold; + $totalRevenue += $revenue; + } + + $html = $this->renderView('pdf/attestation_ventes.html.twig', [ + 'event' => $event, + 'organizer' => $user, + 'billetLines' => $billetLines, + 'totalSold' => $totalSold, + 'totalRevenue' => $totalRevenue / 100, + 'generatedAt' => new \DateTimeImmutable(), + 'selectedCategories' => array_map(fn ($c) => $c->getName(), $categories), + ]); + + $dompdf = new \Dompdf\Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + return new Response($dompdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="attestation_'.$event->getSlug().'_'.date('Y-m-d').'.pdf"', + ]); + } + #[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 { @@ -1377,6 +1643,7 @@ class AccountController extends AbstractController ->from(BilletBuyer::class, 'o') ->join('o.event', 'e') ->where('e.account = :user') + ->andWhere('o.isInvitation = false OR o.isInvitation IS NULL') ->setParameter('user', $user) ->getQuery() ->getResult(); diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 057b166..845154c 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -6,11 +6,13 @@ use App\Entity\AnalyticsEvent; use App\Entity\AnalyticsUniqId; use App\Entity\AuditLog; use App\Entity\BilletBuyer; +use App\Entity\BilletOrder; use App\Entity\Event; use App\Entity\OrganizerInvitation; use App\Entity\User; use App\Service\AuditService; use App\Service\EventIndexService; +use App\Service\BilletOrderService; use App\Service\ExportService; use App\Service\MailerService; use App\Service\MeilisearchService; @@ -48,11 +50,19 @@ class AdminController extends AbstractController ->select('SUM(o.totalHT)') ->from(BilletBuyer::class, 'o') ->where(self::DQL_STATUS_PAID) + ->andWhere('o.isInvitation = false OR o.isInvitation IS NULL') ->setParameter('paid', BilletBuyer::STATUS_PAID) ->getQuery() ->getSingleScalarResult() ?? 0); - $nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]); + $nbOrders = $em->createQueryBuilder() + ->select('COUNT(o.id)') + ->from(BilletBuyer::class, 'o') + ->where(self::DQL_STATUS_PAID) + ->andWhere('o.isInvitation = false OR o.isInvitation IS NULL') + ->setParameter('paid', BilletBuyer::STATUS_PAID) + ->getQuery() + ->getSingleScalarResult(); $nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]); $commissionEticket = 0; @@ -63,6 +73,7 @@ class AdminController extends AbstractController ->join('o.event', 'e') ->join('e.account', 'a') ->where(self::DQL_STATUS_PAID) + ->andWhere('o.isInvitation = false OR o.isInvitation IS NULL') ->setParameter('paid', BilletBuyer::STATUS_PAID) ->getQuery() ->getResult(); @@ -569,6 +580,47 @@ class AdminController extends AbstractController ]); } + #[Route('/commandes/{id}/billets', name: 'app_admin_order_tickets', requirements: ['id' => '\d+'], methods: ['GET'])] + public function orderTickets(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response + { + $order = $em->getRepository(BilletBuyer::class)->find($id); + if (!$order) { + throw $this->createNotFoundException(); + } + + $tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]); + if (!$tickets) { + throw $this->createNotFoundException('Aucun billet pour cette commande.'); + } + + if (1 === \count($tickets)) { + $pdf = $billetOrderService->generatePdf($tickets[0]); + + return new Response($pdf, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$tickets[0]->getReference().'.pdf"', + ]); + } + + $zip = new \ZipArchive(); + $tmpFile = tempnam(sys_get_temp_dir(), 'tickets_'); + $zip->open($tmpFile, \ZipArchive::OVERWRITE); + + foreach ($tickets as $ticket) { + $pdf = $billetOrderService->generatePdf($ticket); + $zip->addFromString($ticket->getReference().'.pdf', $pdf); + } + + $zip->close(); + $content = file_get_contents($tmpFile); + unlink($tmpFile); + + return new Response($content, 200, [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="billets_'.$order->getOrderNumber().'.zip"', + ]); + } + #[Route('/evenements', name: 'app_admin_events')] public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response { diff --git a/src/Controller/Api/ApiAuthController.php b/src/Controller/Api/ApiAuthController.php index 9686f13..ebed036 100644 --- a/src/Controller/Api/ApiAuthController.php +++ b/src/Controller/Api/ApiAuthController.php @@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; #[Route('/api/auth')] @@ -42,7 +43,9 @@ class ApiAuthController extends AbstractController $user = $em->getRepository(User::class)->findOneBy(['email' => $email]); - if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + $hasAccess = $user && (\in_array('ROLE_ORGANIZER', $user->getRoles(), true) || \in_array('ROLE_ROOT', $user->getRoles(), true)); + + if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !$hasAccess) { return $this->json(['success' => false, 'data' => null, 'error' => !$user || !$passwordHasher->isPasswordValid($user, $password) ? 'Identifiants invalides.' : 'Acces reserve aux organisateurs.'], 401); } @@ -105,8 +108,14 @@ class ApiAuthController extends AbstractController /** @codeCoverageIgnore Requires live Keycloak */ #[Route('/login/sso', name: 'app_api_auth_sso', methods: ['GET'])] - public function sso(ClientRegistry $clientRegistry): RedirectResponse + public function sso(Request $request, ClientRegistry $clientRegistry): RedirectResponse { + $from = $request->query->get('from', ''); + $session = $request->getSession(); + if ('scanner' === $from) { + $session->set('sso_from', 'scanner'); + } + return $clientRegistry->getClient('keycloak')->redirect( ['openid', 'email', 'profile'], ['redirect_uri' => $this->generateUrl('app_api_auth_sso_validate', [], UrlGeneratorInterface::ABSOLUTE_URL)], @@ -118,14 +127,23 @@ class ApiAuthController extends AbstractController */ #[Route('/login/sso/validate', name: 'app_api_auth_sso_validate', methods: ['GET'])] public function ssoValidate( + Request $request, ClientRegistry $clientRegistry, EntityManagerInterface $em, - ): JsonResponse { + ): JsonResponse|RedirectResponse { + $session = $request->getSession(); + $fromScanner = 'scanner' === $session->get('sso_from', ''); + $session->remove('sso_from'); + try { - $client = $clientRegistry->getClient('keycloak'); + $client = $clientRegistry->getClient('keycloak_api'); $accessToken = $client->getAccessToken(); $keycloakUser = $client->fetchUserFromToken($accessToken); } catch (\Throwable) { + if ($fromScanner) { + return new RedirectResponse('/scanner/#sso_error=auth_failed'); + } + return $this->json(['success' => false, 'data' => null, 'error' => 'Authentification SSO echouee.'], 401); } @@ -136,10 +154,25 @@ class ApiAuthController extends AbstractController $user = $em->getRepository(User::class)->findOneBy(['keycloakId' => $keycloakId]) ?? $em->getRepository(User::class)->findOneBy(['email' => $email]); - if (!$user || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + $hasAccess = $user && (\in_array('ROLE_ORGANIZER', $user->getRoles(), true) || \in_array('ROLE_ROOT', $user->getRoles(), true)); + + if (!$user || !$hasAccess) { + if ($fromScanner) { + $error = !$user ? 'no_account' : 'no_access'; + + return new RedirectResponse('/scanner/#sso_error='.$error); + } + return $this->json(['success' => false, 'data' => null, 'error' => !$user ? 'Aucun compte associe a ce SSO.' : 'Acces reserve aux organisateurs.'], 403); } + if ($fromScanner) { + $token = $this->generateJwt($user); + $expiresAt = (new \DateTimeImmutable())->modify('+'.self::JWT_TTL.' seconds')->format(\DateTimeInterface::ATOM); + + return new RedirectResponse('/scanner/#sso_token='.urlencode($token).'&sso_email='.urlencode($user->getEmail()).'&sso_expires='.urlencode($expiresAt)); + } + return $this->tokenResponse($user, true); } diff --git a/src/Controller/Api/ApiLiveController.php b/src/Controller/Api/ApiLiveController.php index 377a08b..324b3d4 100644 --- a/src/Controller/Api/ApiLiveController.php +++ b/src/Controller/Api/ApiLiveController.php @@ -3,9 +3,12 @@ namespace App\Controller\Api; use App\Entity\Billet; +use App\Entity\BilletBuyerItem; use App\Entity\BilletOrder; use App\Entity\Category; use App\Entity\Event; +use App\Entity\User; +use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -30,6 +33,11 @@ class ApiLiveController extends AbstractController ) { } + private function isRoot(User $user): bool + { + return \in_array('ROLE_ROOT', $user->getRoles(), true); + } + #[Route('/events', name: 'app_api_live_events', methods: ['GET'])] public function events(Request $request, EntityManagerInterface $em): JsonResponse { @@ -38,7 +46,8 @@ class ApiLiveController extends AbstractController return $user; } - $events = $em->getRepository(Event::class)->findBy(['account' => $user], ['startAt' => 'DESC']); + $criteria = $this->isRoot($user) ? [] : ['account' => $user]; + $events = $em->getRepository(Event::class)->findBy($criteria, ['startAt' => 'DESC']); $data = array_map(fn (Event $e) => [ 'id' => $e->getId(), @@ -65,7 +74,7 @@ class ApiLiveController extends AbstractController } $event = $em->getRepository(Event::class)->find($id); - if (!$event || $event->getAccount()->getId() !== $user->getId()) { + if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) { return $this->error(self::ERR_EVENT, 404); } @@ -93,7 +102,7 @@ class ApiLiveController extends AbstractController } $event = $em->getRepository(Event::class)->find($id); - if (!$event || $event->getAccount()->getId() !== $user->getId()) { + if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) { return $this->error(self::ERR_EVENT, 404); } @@ -121,7 +130,7 @@ class ApiLiveController extends AbstractController } $category = $em->getRepository(Category::class)->find($id); - if (!$category || $category->getEvent()->getAccount()->getId() !== $user->getId()) { + if (!$category || (!$this->isRoot($user) && $category->getEvent()->getAccount()->getId() !== $user->getId())) { return $this->error(self::ERR_CATEGORY, 404); } @@ -167,7 +176,7 @@ class ApiLiveController extends AbstractController } $billet = $em->getRepository(Billet::class)->find($id); - if (!$billet || $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId()) { + if (!$billet || (!$this->isRoot($user) && $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId())) { return $this->error(self::ERR_BILLET, 404); } @@ -205,37 +214,132 @@ class ApiLiveController extends AbstractController return $user; } - $reference = (json_decode($request->getContent(), true) ?? [])['reference'] ?? ''; - $ticket = '' !== $reference ? $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]) : null; + $data = json_decode($request->getContent(), true) ?? []; + $reference = $data['reference'] ?? ''; + $securityKey = $data['securityKey'] ?? ''; - if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() !== $user->getId()) { - return $this->error(!$ticket && '' === $reference ? 'Reference requise.' : self::ERR_BILLET, '' === $reference ? 400 : 404); + if ('' === $reference && '' === $securityKey) { + return $this->error('Reference ou cle de securite requise.', 400); } - return $this->success($this->processScan($ticket, $em)); + $ticket = null; + if ('' !== $reference) { + $ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]); + } elseif ('' !== $securityKey) { + $ticket = $em->getRepository(BilletOrder::class)->findOneBy(['securityKey' => strtoupper($securityKey)]); + } + + if (!$ticket || (!$this->isRoot($user) && $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() !== $user->getId())) { + return $this->error(self::ERR_BILLET, 404); + } + + $isOwner = $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() === $user->getId(); + $canForce = $this->isRoot($user) || ($isOwner && \in_array('ROLE_ORGANIZER', $user->getRoles(), true)); + + return $this->success($this->processScan($ticket, $em, $canForce)); + } + + #[Route('/scan/force', name: 'app_api_live_scan_force', methods: ['POST'])] + public function scanForce(Request $request, EntityManagerInterface $em, MailerService $mailerService): JsonResponse + { + $user = $this->authenticateRequest($request, $em, $this->appSecret); + if ($user instanceof JsonResponse) { + return $user; + } + + if (!$this->isRoot($user) && !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + return $this->error('Acces reserve aux organisateurs.', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $reference = $data['reference'] ?? ''; + + if ('' === $reference) { + return $this->error('Reference requise.', 400); + } + + $ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]); + + if (!$ticket) { + return $this->error(self::ERR_BILLET, 404); + } + + $event = $ticket->getBilletBuyer()->getEvent(); + $isOwner = $event->getAccount()->getId() === $user->getId(); + + if (!$this->isRoot($user) && !$isOwner) { + return $this->error('Acces reserve aux organisateurs.', 403); + } + + $previousState = $ticket->getState(); + $ticket->setState(BilletOrder::STATE_VALID); + $ticket->setFirstScannedAt(new \DateTimeImmutable()); + $em->flush(); + + $organizer = $event->getAccount(); + $html = $this->renderView('email/scan_force_notification.html.twig', [ + 'event_title' => $event->getTitle(), + 'billet_name' => $ticket->getBilletName(), + 'reference' => $ticket->getReference(), + 'buyer_name' => $ticket->getBilletBuyer()->getFirstName().' '.$ticket->getBilletBuyer()->getLastName(), + 'previous_state' => $previousState, + 'forced_by_name' => $user->getFirstName().' '.$user->getLastName(), + 'forced_by_email' => $user->getEmail(), + ]); + $mailerService->sendEmail( + $organizer->getEmail(), + 'Validation forcee d\'un billet - '.$event->getTitle(), + $html, + ); + + return $this->success($this->buildScanResponse('accepted', 'forced', $ticket)); } /** * @return array */ - private function processScan(BilletOrder $ticket, EntityManagerInterface $em): array + private function processScan(BilletOrder $ticket, EntityManagerInterface $em, bool $canForce = false): array { - $reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired']; + $billetType = $ticket->getBillet()?->getType() ?? 'billet'; + $isAlwaysValid = \in_array($billetType, ['staff', 'exposant'], true); - if (isset($reasonMap[$ticket->getState()])) { - return $this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket); + if (!$isAlwaysValid) { + $reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired']; + + if (isset($reasonMap[$ticket->getState()])) { + $response = $this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket); + $response['canForce'] = $canForce; + + return $response; + } + + $scannedToday = null !== $ticket->getFirstScannedAt() + && $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d'); + + if ($scannedToday && ($ticket->getBillet()?->hasDefinedExit() ?? false)) { + $response = $this->buildScanResponse('refused', 'exit_definitive', $ticket); + $response['canForce'] = $canForce; + + return $response; + } } - if (null !== $ticket->getFirstScannedAt() && ($ticket->getBillet()?->hasDefinedExit() ?? false)) { - return $this->buildScanResponse('refused', 'exit_definitive', $ticket); - } + $alreadyScanned = null !== $ticket->getFirstScannedAt(); - if (null === $ticket->getFirstScannedAt()) { + if (!$alreadyScanned) { $ticket->setFirstScannedAt(new \DateTimeImmutable()); $em->flush(); } - return $this->buildScanResponse('accepted', null, $ticket); + if ($isAlwaysValid) { + return $this->buildScanResponse('accepted', null, $ticket); + } + + $scannedToday = $alreadyScanned + && $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d'); + $reason = ($alreadyScanned && $scannedToday) ? 'already_scanned' : null; + + return $this->buildScanResponse('accepted', $reason, $ticket); } /** @@ -243,17 +347,33 @@ class ApiLiveController extends AbstractController */ private function buildScanResponse(string $state, ?string $reason, BilletOrder $ticket): array { + $order = $ticket->getBilletBuyer(); + + $items = array_map(fn (BilletBuyerItem $i) => [ + 'billetName' => $i->getBilletName(), + 'quantity' => $i->getQuantity(), + 'unitPriceHT' => $i->getUnitPriceHTDecimal(), + ], $order->getItems()->toArray()); + return [ 'state' => $state, 'reason' => $reason, 'reference' => $ticket->getReference(), 'billetName' => $ticket->getBilletName(), - 'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(), - 'buyerLastName' => $ticket->getBilletBuyer()->getLastName(), + 'buyerFirstName' => $order->getFirstName(), + 'buyerLastName' => $order->getLastName(), + 'buyerEmail' => $order->getEmail(), 'isInvitation' => (bool) $ticket->isInvitation(), + 'billetType' => $ticket->getBillet()?->getType() ?? 'billet', 'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM), 'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false, - 'details' => 'accepted' === $state ? [] : null, + 'order' => [ + 'orderNumber' => $order->getOrderNumber(), + 'status' => $order->getStatus(), + 'totalHT' => $order->getTotalHTDecimal(), + 'paidAt' => $order->getPaidAt()?->format(\DateTimeInterface::ATOM), + 'items' => $items, + ], ]; } } diff --git a/src/Controller/ScannerController.php b/src/Controller/ScannerController.php index f6819c9..c9129fb 100644 --- a/src/Controller/ScannerController.php +++ b/src/Controller/ScannerController.php @@ -8,7 +8,7 @@ use Symfony\Component\Routing\Attribute\Route; class ScannerController extends AbstractController { - #[Route('/scanner', name: 'app_scanner', methods: ['GET'])] + #[Route('/scanner/', name: 'app_scanner', methods: ['GET'])] public function index(): Response { return $this->render('scanner/index.html.twig'); @@ -21,8 +21,8 @@ class ScannerController extends AbstractController 'name' => 'E-Ticket Scanner', 'short_name' => 'Scanner', 'description' => 'Application de scan de billets pour organisateurs', - 'start_url' => '/scanner', - 'scope' => '/scanner', + 'start_url' => '/scanner/', + 'scope' => '/scanner/', 'display' => 'standalone', 'orientation' => 'portrait', 'theme_color' => '#111827', diff --git a/src/Service/BilletOrderService.php b/src/Service/BilletOrderService.php index 3d8f6c4..58325a7 100644 --- a/src/Service/BilletOrderService.php +++ b/src/Service/BilletOrderService.php @@ -47,7 +47,7 @@ class BilletOrderService $billet->setQuantity(max(0, $newQty)); } - if ('billet' !== $billet->getType() || !$billet->isGeneratedBillet()) { + if (!\in_array($billet->getType(), ['billet', 'staff', 'exposant'], true) || !$billet->isGeneratedBillet()) { continue; } diff --git a/templates/account/edit_event.html.twig b/templates/account/edit_event.html.twig index 2ec0e05..de1912a 100644 --- a/templates/account/edit_event.html.twig +++ b/templates/account/edit_event.html.twig @@ -108,6 +108,8 @@ Billets {% endif %} Invitations + Tickets + Attestation Statistiques @@ -278,28 +280,29 @@ {% set total_sold = 0 %} {% set total_ht = 0 %} - {% set total_commission = 0 %} - {% set total_net = 0 %} + {% set total_commission_eticket = 0 %} + {% set total_commission_stripe = 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 stripe_fee = sold > 0 ? (line_ht * stripe_fee_rate) + ((stripe_fee_fixed / 100) * sold) : 0 %} {% 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) %} + {% set total_commission_eticket = total_commission_eticket + eticket_fee %} + {% set total_commission_stripe = total_commission_stripe + stripe_fee %} {% endfor %} {% endfor %} + {% set total_commission = total_commission_eticket + total_commission_stripe %} + {% set total_net = total_ht - total_commission %}
-

Recapitulatif ventes

+

Recapitulatif ventes (hors invitations)

-
+
Qt vendue
{{ total_sold }}
@@ -317,6 +320,20 @@
{{ total_net|number_format(2, ',', ' ') }} €
+
+
+
Commission E-Ticket ({{ commission_rate }}%)
+
-{{ total_commission_eticket|number_format(2, ',', ' ') }} €
+
+
+
Commission Stripe ({{ (stripe_fee_rate * 100)|number_format(1) }}% + {{ (stripe_fee_fixed / 100)|number_format(2, ',', ' ') }}€/tx)
+
-{{ total_commission_stripe|number_format(2, ',', ' ') }} €
+
+
+
Total commissions
+
-{{ total_commission|number_format(2, ',', ' ') }} €
+
+
@@ -453,6 +470,45 @@
+
+
+

Accreditation Staff / Exposant

+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+
+ {% if invitations|length > 0 %}
@@ -484,6 +540,173 @@
{% endif %} + {% elseif current_tab == 'tickets' %} + +
+

Rechercher un ticket

+
+ +
+ + +
+ + {% if tickets_search_query %} + Effacer + {% endif %} +
+
+ +
+
+

Tickets vendus ({{ event_tickets.getTotalItemCount }})

+
+
+ + + + + + + + + + + + + + {% for ticket in event_tickets %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ReferenceCleBilletAcheteurStatutScanneActions
{{ ticket.reference }}{{ ticket.securityKey }}{{ ticket.billetName }} +

{{ ticket.billetBuyer.firstName }} {{ ticket.billetBuyer.lastName }}

+

{{ ticket.billetBuyer.email }}

+
+ {% if ticket.state == 'valid' %} + Valide + {% elseif ticket.state == 'invalid' %} + Annule + {% elseif ticket.state == 'expired' %} + Expire + {% endif %} + {% if ticket.invitation %} + Invitation + {% endif %} + + {% if ticket.firstScannedAt %} + {{ ticket.firstScannedAt|date('d/m/Y H:i') }} + {% else %} + - + {% endif %} + + PDF +
+ +
+ {% if ticket.state == 'valid' %} +
+ +
+ {% endif %} +
Aucun ticket vendu.
+
+
+ + {% if event_tickets.getTotalItemCount > 20 %} +
+ {% for page in 1..event_tickets.getPageCount %} + {% if page == event_tickets.getCurrentPageNumber %} + {{ page }} + {% else %} + {{ page }} + {% endif %} + {% endfor %} +
+ {% endif %} + + {% elseif current_tab == 'attestation' %} + +
+
+

Generer une attestation de ventes

+
+
+

Selectionnez les categories et/ou billets a inclure dans l'attestation. Le document PDF certifiera le nombre de billets vendus et le chiffre d'affaires HT (hors invitations).

+ +
+ {% if categories|length > 0 %} +
+

Categories

+
+ {% for category in categories %} + + {% endfor %} +
+
+ {% endif %} + + {% set has_billets = false %} + {% for cat_billets in billets %} + {% if cat_billets|length > 0 %} + {% set has_billets = true %} + {% endif %} + {% endfor %} + + {% if has_billets %} +
+

Billets individuels

+

Si vous selectionnez une categorie ci-dessus, tous ses billets seront inclus. Utilisez cette section pour ajouter des billets specifiques en complement.

+
+ {% for category in categories %} + {% set cat_billets = billets[category.id] ?? [] %} + {% for billet in cat_billets %} + + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ + + +
+
+
+
+ {% elseif current_tab == 'stats' %}
@@ -501,8 +724,25 @@
Total percu
- {% set total_commission_event = event_total_ht * (commission_rate / 100) %} -
{{ (event_total_ht - total_commission_event)|number_format(2, ',', ' ') }} €
+ {% set commission_eticket = event_total_ht * (commission_rate / 100) %} + {% set commission_stripe = event_total_orders > 0 ? (event_total_ht * stripe_fee_rate + event_total_orders * (stripe_fee_fixed / 100)) : 0 %} + {% set total_net = event_total_ht - commission_eticket - commission_stripe %} +
{{ total_net|number_format(2, ',', ' ') }} €
+
+
+ +
+
+
Commission E-Ticket ({{ commission_rate }}%)
+
-{{ commission_eticket|number_format(2, ',', ' ') }} €
+
+
+
Commission Stripe ({{ (stripe_fee_rate * 100)|number_format(1) }}% + {{ (stripe_fee_fixed / 100)|number_format(2, ',', ' ') }}€/tx)
+
-{{ commission_stripe|number_format(2, ',', ' ') }} €
+
+
+
Total commissions
+
-{{ (commission_eticket + commission_stripe)|number_format(2, ',', ' ') }} €
diff --git a/templates/admin/orders.html.twig b/templates/admin/orders.html.twig index fe63e10..c9191f4 100644 --- a/templates/admin/orders.html.twig +++ b/templates/admin/orders.html.twig @@ -62,6 +62,7 @@ Total HT Date Statut + Actions @@ -99,10 +100,15 @@ Invitation {% endif %} + + {% if order.status == 'paid' %} + Billets + {% endif %} + {% else %} - Aucune commande. + Aucune commande. {% endfor %} diff --git a/templates/email/scan_force_notification.html.twig b/templates/email/scan_force_notification.html.twig new file mode 100644 index 0000000..e5ea0da --- /dev/null +++ b/templates/email/scan_force_notification.html.twig @@ -0,0 +1,49 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Validation forcee d'un billet{% endblock %} + +{% block content %} +

Validation forcee

+

Un billet a ete force lors du scan sur votre evenement. Voici les details :

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Details du billet +
Evenement{{ event_title }}
Billet{{ billet_name }}
Reference{{ reference }}
Acheteur{{ buyer_name }}
Ancien statut + {{ previous_state }} +
+ + + + + +
+

Force par

+

{{ forced_by_name }} ({{ forced_by_email }})

+
+ +

Si cette action n'etait pas prevue, nous vous recommandons de verifier la situation.

+{% endblock %} diff --git a/templates/pdf/attestation_ventes.html.twig b/templates/pdf/attestation_ventes.html.twig new file mode 100644 index 0000000..33f45f7 --- /dev/null +++ b/templates/pdf/attestation_ventes.html.twig @@ -0,0 +1,294 @@ + + + + + Attestation de ventes - {{ event.title }} + + + +
+
Attestation de ventes
+
{{ event.title }} — Generee le {{ generatedAt|date('d/m/Y a H:i') }}
+
+ +

Organisateur

+ + + + + + + {% if organizer.siret %} + + {% endif %} + {% if organizer.address %} + + {% endif %} + +
+
Raison sociale
+
{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}
+
+
Email
+
{{ organizer.email }}
+
+
SIRET
+
{{ organizer.siret }}
+
+
Adresse
+
{{ organizer.address }}{% if organizer.postalCode %}, {{ organizer.postalCode }}{% endif %}{% if organizer.city %} {{ organizer.city }}{% endif %}
+
+ +

Evenement

+ + + + + + + + + +
+
Nom
+
{{ event.title }}
+
+
Date
+
{{ event.startAt|date('d/m/Y H:i') }} — {{ event.endAt|date('d/m/Y H:i') }}
+
+
Lieu
+
{{ event.address }}, {{ event.zipcode }} {{ event.city }}
+
+ {% if selectedCategories|length > 0 %} +
Categories selectionnees
+
{{ selectedCategories|join(', ') }}
+ {% endif %} +
+ +

Detail des ventes

+ + + + + + + + + + + + {% for line in billetLines %} + + + + + + + + {% endfor %} + + + + + + +
CategorieBilletPrix unit. HTVendusTotal HT
{{ line.category }}{{ line.name }}{{ line.priceHT|number_format(2, ',', ' ') }} €{{ line.sold }}{{ line.revenue|number_format(2, ',', ' ') }} €
TOTAL{{ totalSold }}{{ totalRevenue|number_format(2, ',', ' ') }} €
+ +
+ + + + + + + + + +
Total billets vendus{{ totalSold }}
Chiffre d'affaires HT{{ totalRevenue|number_format(2, ',', ' ') }} €
+
+ + + + + + diff --git a/templates/pdf/billet.html.twig b/templates/pdf/billet.html.twig index 0eea995..fa06907 100644 --- a/templates/pdf/billet.html.twig +++ b/templates/pdf/billet.html.twig @@ -5,7 +5,10 @@ {{ ticket.reference }} - {{ event.title }}