diff --git a/migrations/Version20260326180000.php b/migrations/Version20260326180000.php new file mode 100644 index 0000000..ad9d7ff --- /dev/null +++ b/migrations/Version20260326180000.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE attestation (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_id INT NOT NULL, generated_by_id INT NOT NULL, reference VARCHAR(50) NOT NULL, signature_hash VARCHAR(128) NOT NULL, total_sold INT NOT NULL, payload JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_326EC63FAEA34913 ON attestation (reference)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_326EC63F3E4E45E5 ON attestation (signature_hash)'); + $this->addSql('CREATE INDEX IDX_326EC63F71F7E88B ON attestation (event_id)'); + $this->addSql('CREATE INDEX IDX_326EC63FB0A59F70 ON attestation (generated_by_id)'); + $this->addSql('COMMENT ON COLUMN attestation.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE attestation ADD CONSTRAINT FK_326EC63F71F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE attestation ADD CONSTRAINT FK_326EC63FB0A59F70 FOREIGN KEY (generated_by_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE attestation'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index d0be287..3a48309 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Entity\Attestation; use App\Entity\Billet; use App\Entity\BilletBuyer; use App\Entity\BilletBuyerItem; @@ -1076,14 +1077,86 @@ class AccountController extends AbstractController $totalRevenue += $revenue; } + $ticketDetails = []; + if ($allBilletIds) { + $tickets = $em->createQueryBuilder() + ->select('t', 'bb') + ->from(BilletOrder::class, 't') + ->join('t.billetBuyer', 'bb') + ->where('t.billet IN (:ids)') + ->andWhere('bb.isInvitation = false OR bb.isInvitation IS NULL') + ->setParameter('ids', $allBilletIds) + ->orderBy('t.createdAt', 'ASC') + ->getQuery() + ->getResult(); + + foreach ($tickets as $t) { + $ticketDetails[] = [ + 'reference' => $t->getReference(), + 'securityKey' => $t->getSecurityKey(), + 'billetName' => $t->getBilletName(), + 'orderNumber' => $t->getBilletBuyer()->getOrderNumber(), + 'buyerName' => $t->getBilletBuyer()->getFirstName().' '.$t->getBilletBuyer()->getLastName(), + ]; + } + } + + $mode = $request->request->getString('mode', 'detail'); + $isSimple = 'simple' === $mode; + + $generatedAt = new \DateTimeImmutable(); + $selectedCategoryNames = array_map(fn ($c) => $c->getName(), $categories); + + $attestationData = [ + 'ref' => 'ATT-'.$event->getId().'-'.$generatedAt->format('YmdHis'), + 'event' => $event->getTitle(), + 'eventDate' => $event->getStartAt()?->format('d/m/Y H:i').' - '.$event->getEndAt()?->format('d/m/Y H:i'), + 'eventLocation' => $event->getAddress().', '.$event->getZipcode().' '.$event->getCity(), + 'organizer' => $user->getCompanyName() ?? $user->getFirstName().' '.$user->getLastName(), + 'siret' => $user->getSiret(), + 'categories' => $selectedCategoryNames, + 'billets' => array_map(fn ($l) => ['cat' => $l['category'], 'name' => $l['name'], 'sold' => $l['sold']], $billetLines), + 'totalSold' => $totalSold, + 'generatedAt' => $generatedAt->format('d/m/Y H:i:s'), + ]; + + $signatureHash = hash_hmac('sha256', json_encode($attestationData, \JSON_UNESCAPED_UNICODE), $this->getParameter('kernel.secret')); + + $attestationEntity = new Attestation( + $attestationData['ref'], + $signatureHash, + $event, + $user, + $totalSold, + $attestationData, + ); + $em->persist($attestationEntity); + $em->flush(); + + $verifyUrl = $this->generateUrl('app_attestation_ventes_ref', ['reference' => $attestationData['ref']], UrlGeneratorInterface::ABSOLUTE_URL); + + $qrCode = (new \Endroid\QrCode\Builder\Builder( + writer: new \Endroid\QrCode\Writer\PngWriter(), + data: $verifyUrl, + encoding: new \Endroid\QrCode\Encoding\Encoding('UTF-8'), + size: 300, + margin: 5, + ))->build(); + $qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString()); + $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), + 'generatedAt' => $generatedAt, + 'selectedCategories' => $selectedCategoryNames, + 'verifyUrl' => $verifyUrl, + 'qrBase64' => $qrBase64, + 'attestationRef' => $attestationData['ref'], + 'signatureHash' => $signatureHash, + 'isSimple' => $isSimple, + 'ticketDetails' => $ticketDetails, ]); $dompdf = new \Dompdf\Dompdf(); diff --git a/src/Controller/Api/ApiLiveController.php b/src/Controller/Api/ApiLiveController.php index 324b3d4..d786083 100644 --- a/src/Controller/Api/ApiLiveController.php +++ b/src/Controller/Api/ApiLiveController.php @@ -303,6 +303,14 @@ class ApiLiveController extends AbstractController $billetType = $ticket->getBillet()?->getType() ?? 'billet'; $isAlwaysValid = \in_array($billetType, ['staff', 'exposant'], true); + if (!$isAlwaysValid) { + $buyerEmail = $ticket->getBilletBuyer()->getEmail(); + $buyerUser = $em->getRepository(User::class)->findOneBy(['email' => $buyerEmail]); + if ($buyerUser && \in_array('ROLE_ROOT', $buyerUser->getRoles(), true)) { + $isAlwaysValid = true; + } + } + if (!$isAlwaysValid) { $reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired']; diff --git a/src/Controller/AttestationController.php b/src/Controller/AttestationController.php index 8ff5748..0a7b346 100644 --- a/src/Controller/AttestationController.php +++ b/src/Controller/AttestationController.php @@ -2,14 +2,21 @@ namespace App\Controller; +use App\Entity\Attestation; use App\Entity\Payout; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class AttestationController extends AbstractController { + public function __construct( + #[Autowire('%kernel.secret%')] private string $appSecret, + ) { + } + #[Route('/attestation/check/{stripePayoutId}', name: 'app_attestation_check')] public function check(string $stripePayoutId, EntityManagerInterface $em): Response { @@ -31,4 +38,79 @@ class AttestationController extends AbstractController 'breadcrumbs' => $breadcrumbs, ]); } + + #[Route('/attestation/ventes/r/{reference}', name: 'app_attestation_ventes_ref')] + public function ventesRef(string $reference, EntityManagerInterface $em): Response + { + $breadcrumbs = [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Verification attestation de ventes', 'url' => null], + ]; + + $attestation = $em->getRepository(Attestation::class)->findOneBy(['reference' => $reference]); + + if (!$attestation) { + return $this->render('attestation/not_found_ventes.html.twig', ['breadcrumbs' => $breadcrumbs]); + } + + return $this->render('attestation/check_ventes.html.twig', [ + 'data' => $attestation->getPayload(), + 'breadcrumbs' => $breadcrumbs, + 'isRegistered' => true, + 'attestation' => $attestation, + ]); + } + + #[Route('/attestation/ventes/{hash}', name: 'app_attestation_ventes')] + public function ventes(string $hash, EntityManagerInterface $em): Response + { + $breadcrumbs = [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Verification attestation de ventes', 'url' => null], + ]; + + $decoded = base64_decode(strtr($hash, '-_', '+/'), true); + if (!$decoded) { + return $this->render('attestation/not_found_ventes.html.twig', ['breadcrumbs' => $breadcrumbs]); + } + + $parts = explode('|', $decoded, 2); + if (2 !== \count($parts)) { + return $this->render('attestation/not_found_ventes.html.twig', ['breadcrumbs' => $breadcrumbs]); + } + + [$signature, $jsonPayload] = $parts; + $expectedSignature = hash_hmac('sha256', $jsonPayload, $this->appSecret); + + if (!hash_equals($expectedSignature, $signature)) { + return $this->render('attestation/not_found_ventes.html.twig', ['breadcrumbs' => $breadcrumbs]); + } + + $data = json_decode($jsonPayload, true); + if (!$data) { + return $this->render('attestation/not_found_ventes.html.twig', ['breadcrumbs' => $breadcrumbs]); + } + + $signatureHash = hash_hmac('sha256', $jsonPayload, $this->appSecret); + $attestation = $em->getRepository(Attestation::class)->findOneBy(['signatureHash' => $signatureHash]); + $isRegistered = null !== $attestation; + + return $this->render('attestation/check_ventes.html.twig', [ + 'data' => $data, + 'breadcrumbs' => $breadcrumbs, + 'isRegistered' => $isRegistered, + 'attestation' => $attestation, + ]); + } + + /** + * @param array $data + */ + public static function generateHash(array $data, string $appSecret): string + { + $json = json_encode($data, \JSON_UNESCAPED_UNICODE); + $signature = hash_hmac('sha256', $json, $appSecret); + + return rtrim(strtr(base64_encode($signature.'|'.$json), '+/', '-_'), '='); + } } diff --git a/src/Entity/Attestation.php b/src/Entity/Attestation.php new file mode 100644 index 0000000..d8c8f64 --- /dev/null +++ b/src/Entity/Attestation.php @@ -0,0 +1,91 @@ + */ + #[ORM\Column(type: 'json')] + private array $payload = []; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + public function __construct(string $reference, string $signatureHash, Event $event, User $generatedBy, int $totalSold, array $payload) + { + $this->reference = $reference; + $this->signatureHash = $signatureHash; + $this->event = $event; + $this->generatedBy = $generatedBy; + $this->totalSold = $totalSold; + $this->payload = $payload; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getReference(): string + { + return $this->reference; + } + + public function getSignatureHash(): string + { + return $this->signatureHash; + } + + public function getEvent(): Event + { + return $this->event; + } + + public function getGeneratedBy(): User + { + return $this->generatedBy; + } + + public function getTotalSold(): int + { + return $this->totalSold; + } + + /** @return array */ + public function getPayload(): array + { + return $this->payload; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Repository/AttestationRepository.php b/src/Repository/AttestationRepository.php new file mode 100644 index 0000000..924bb68 --- /dev/null +++ b/src/Repository/AttestationRepository.php @@ -0,0 +1,18 @@ + + */ +class AttestationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Attestation::class); + } +} diff --git a/templates/account/edit_event.html.twig b/templates/account/edit_event.html.twig index de1912a..26f330c 100644 --- a/templates/account/edit_event.html.twig +++ b/templates/account/edit_event.html.twig @@ -692,9 +692,12 @@ {% endif %} -
- +