Add attestation system with digital signature, public verification, and detailed ticket listing

- Create Attestation entity with reference, signature hash (HMAC-SHA256), event, user, payload
- Add migration Version20260326180000 for attestation table
- Save each attestation in DB with unique signature for tamper-proof verification
- Add public route /attestation/ventes/r/{reference} for QR code verification (short URL)
- Keep fallback /attestation/ventes/{hash} route for base64-signed verification
- Public page shows "Attestation conforme" with signature proof, no detailed data
- QR code on PDF now uses short reference URL instead of full base64 hash (scannable)
- Increase QR code resolution to 300px for better readability
- Display verification URL on PDF next to QR code

Attestation PDF improvements:
- Rename "ATTESTATION DE VENTES" to "ATTESTATION"
- Add two modes: "Attestation detaillee" (with ticket list) and "Attestation simple" (certification only)
- Simple mode: certifies figures are valid, only paid billets/votes confirmed by Stripe count
- Detailed mode: adds full ticket listing with reference, order number, billet name, buyer name
- No amounts displayed in either mode
- Gold color scheme (#fabf04) for headers, borders, table headers, summary box
- Larger text in QR verification box for readability

Scanner: ROLE_ROOT buyer tickets always validate at scan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 16:13:40 +01:00
parent 822bf8915f
commit 15616167d0
10 changed files with 453 additions and 36 deletions

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326180000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create attestation table for sales attestation digital signatures';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -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();

View File

@@ -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'];

View File

@@ -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<string, mixed> $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), '+/', '-_'), '=');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Entity;
use App\Repository\AttestationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AttestationRepository::class)]
class Attestation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50, unique: true)]
private string $reference;
#[ORM\Column(length: 128, unique: true)]
private string $signatureHash;
#[ORM\ManyToOne(targetEntity: Event::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Event $event;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $generatedBy;
#[ORM\Column]
private int $totalSold;
/** @var array<string, mixed> */
#[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<string, mixed> */
public function getPayload(): array
{
return $this->payload;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Attestation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Attestation>
*/
class AttestationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Attestation::class);
}
}

View File

@@ -692,9 +692,12 @@
</div>
{% endif %}
<div class="flex gap-3">
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Generer l'attestation PDF
<div class="flex flex-wrap gap-3">
<button type="submit" name="mode" value="detail" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Attestation detaillee
</button>
<button type="submit" name="mode" value="simple" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-[#fabf04] transition-all">
Attestation simple
</button>
<button type="button" onclick="this.closest('form').querySelectorAll('input[type=checkbox]').forEach(c => c.checked = true)" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">
Tout selectionner

View File

@@ -0,0 +1,46 @@
{% extends 'base.html.twig' %}
{% block title %}Verification attestation - E-Ticket{% endblock %}
{% block body %}
<div class="page-container-md">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Verification attestation</h1>
<p class="font-bold text-gray-500 italic mb-8">Resultat de la verification par la plateforme E-Ticket.</p>
{% if isRegistered %}
<div class="card-brutal-green mb-8">
<p class="font-black text-lg mb-2">Attestation conforme</p>
<p class="text-sm text-gray-700">Ce document est authentique. Il a ete genere par la plateforme E-Ticket et <strong>aucune donnee n'a ete alteree</strong>.</p>
<div class="mt-4 pt-4 border-t border-green-300 grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Reference</p>
<p class="font-mono font-black text-sm">{{ data.ref }}</p>
</div>
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Date d'emission</p>
<p class="font-black text-sm">{{ data.generatedAt }}</p>
</div>
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Evenement</p>
<p class="font-black text-sm">{{ data.event }}</p>
</div>
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Organisateur</p>
<p class="font-black text-sm">{{ data.organizer }}</p>
</div>
</div>
<div class="mt-3 pt-3 border-t border-green-300">
<p class="text-[10px] text-gray-500"><strong>Signature numerique :</strong> <span class="font-mono">{{ attestation.signatureHash|slice(0, 32) }}...</span></p>
<p class="text-[10px] text-gray-500"><strong>Enregistree le :</strong> {{ attestation.createdAt|date('d/m/Y a H:i:s') }}</p>
</div>
</div>
{% else %}
<div class="card-brutal mb-8" style="border-color: #eab308; background: #fefce8;">
<p class="font-black text-sm mb-2" style="color: #92400e;">Signature valide mais non enregistree</p>
<p class="text-sm text-gray-700">La signature numerique de ce document est valide, mais cette attestation n'a pas ete trouvee dans notre registre.</p>
</div>
{% endif %}
<p class="text-xs text-gray-400 text-center">Verification fournie par E-Ticket (ticket.e-cosplay.fr), plateforme de billetterie geree par l'association E-Cosplay.</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends 'base.html.twig' %}
{% block title %}Attestation introuvable - E-Ticket{% endblock %}
{% block body %}
<div class="page-container-md">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Verification attestation</h1>
<div class="card-brutal-error mt-8">
<p class="font-black text-sm mb-2 text-red-800">Attestation invalide ou introuvable</p>
<p class="text-sm text-gray-700">Ce lien ne correspond a aucune attestation de ventes valide. Le document a peut-etre ete altere ou le lien est incorrect.</p>
</div>
</div>
{% endblock %}

View File

@@ -2,7 +2,7 @@
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Attestation de ventes - {{ event.title }}</title>
<title>Attestation - {{ event.title }}</title>
<style>
@page { size: A4; margin: 25mm 20mm; }
body {
@@ -15,19 +15,22 @@
.header {
background: #111827;
color: #fff;
color: #fabf04;
padding: 20px 24px;
margin: -25mm -20mm 0 -20mm;
width: calc(100% + 40mm);
border-bottom: 4px solid #fabf04;
}
.header-title {
font-size: 18px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
color: #fabf04;
}
.header-sub {
font-size: 10px;
color: #fff;
opacity: 0.7;
margin-top: 4px;
}
@@ -38,7 +41,7 @@
text-transform: uppercase;
letter-spacing: 1px;
color: #111827;
border-bottom: 2px solid #111827;
border-bottom: 3px solid #fabf04;
padding-bottom: 6px;
margin: 24px 0 12px 0;
}
@@ -70,8 +73,8 @@
margin-bottom: 16px;
}
.data-table th {
background: #111827;
color: #fff;
background: #fabf04;
color: #111827;
padding: 8px 10px;
font-size: 8px;
font-weight: bold;
@@ -99,17 +102,18 @@
border-bottom: 2px solid #111827;
}
.data-table .total-row td {
background: #f9fafb;
background: #fffbeb;
font-weight: bold;
font-size: 11px;
border-bottom: 2px solid #111827;
border-top: 2px solid #111827;
border-bottom: 3px solid #fabf04;
border-top: 3px solid #fabf04;
}
.summary-box {
border: 2px solid #111827;
border: 3px solid #fabf04;
padding: 16px 20px;
margin: 20px 0;
background: #fffbeb;
}
.summary-row {
display: flex;
@@ -161,9 +165,10 @@
left: -20mm;
right: -20mm;
background: #111827;
color: #fff;
color: #fabf04;
padding: 10px 24px;
font-size: 8px;
border-top: 3px solid #fabf04;
}
.footer-table {
width: 100%;
@@ -176,10 +181,19 @@
</head>
<body>
<div class="header">
<div class="header-title">Attestation de ventes</div>
<div class="header-title">Attestation</div>
<div class="header-sub">{{ event.title }} — Generee le {{ generatedAt|date('d/m/Y a H:i') }}</div>
</div>
<div style="margin-top: 20px; padding: 16px 20px; border: 3px solid #fabf04; background: #fffbeb; font-size: 11px; line-height: 1.6;">
{% if isSimple %}
<p style="margin: 0;">Je soussigne, <strong>Serreau Jovann</strong>, president de l'association <strong>E-Cosplay</strong>, gestionnaire de la plateforme de billetterie <strong>E-Ticket</strong> (ticket.e-cosplay.fr), atteste par la presente que les chiffres fournis par la plateforme E-Ticket a l'organisateur mentionne ci-dessous sont valides et certifies conformes, dans le cadre de l'evenement <strong>{{ event.title }}</strong>.</p>
<p style="margin: 8px 0 0 0;">Seuls les billets et votes ayant fait l'objet d'un paiement valide et confirme par Stripe sont pris en compte. Les invitations, accreditations staff et exposant ne sont pas comptabilises.</p>
{% else %}
<p style="margin: 0;">Je soussigne, <strong>Serreau Jovann</strong>, president de l'association <strong>E-Cosplay</strong>, gestionnaire de la plateforme de billetterie <strong>E-Ticket</strong> (ticket.e-cosplay.fr), atteste par la presente que les ventes ci-dessous ont ete realisees via notre plateforme pour le compte de l'organisateur mentionne, dans le cadre de l'evenement <strong>{{ event.title }}</strong>.</p>
{% endif %}
</div>
<h2>Organisateur</h2>
<table class="info-table">
<tr>
@@ -234,15 +248,14 @@
</tr>
</table>
<h2>Detail des ventes</h2>
{% if not isSimple %}
<h2>Recapitulatif par type de billet</h2>
<table class="data-table">
<thead>
<tr>
<th>Categorie</th>
<th>Billet</th>
<th>Prix unit. HT</th>
<th>Vendus</th>
<th>Total HT</th>
<th style="text-align: right;">Quantite</th>
</tr>
</thead>
<tbody>
@@ -250,43 +263,79 @@
<tr>
<td>{{ line.category }}</td>
<td style="font-weight: bold;">{{ line.name }}</td>
<td>{{ line.priceHT|number_format(2, ',', ' ') }} &euro;</td>
<td>{{ line.sold }}</td>
<td>{{ line.revenue|number_format(2, ',', ' ') }} &euro;</td>
<td style="text-align: right;">{{ line.sold }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="3" style="text-align: right;">TOTAL</td>
<td colspan="2" style="text-align: right;">TOTAL</td>
<td style="text-align: right;">{{ totalSold }}</td>
<td style="text-align: right;">{{ totalRevenue|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</tbody>
</table>
{% if ticketDetails|length > 0 %}
<h2>Liste des billets emis</h2>
<table class="data-table">
<thead>
<tr>
<th style="width: 5%;">#</th>
<th>Reference</th>
<th>Commande</th>
<th>Billet</th>
<th>Acheteur</th>
</tr>
</thead>
<tbody>
{% for ticket in ticketDetails %}
<tr>
<td>{{ loop.index }}</td>
<td style="font-family: monospace; font-size: 8px;">{{ ticket.reference }}</td>
<td style="font-family: monospace; font-size: 8px;">{{ ticket.orderNumber }}</td>
<td style="font-weight: bold;">{{ ticket.billetName }}</td>
<td>{{ ticket.buyerName }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="summary-box">
<table class="summary-table">
<tr>
<td class="label">Total billets vendus</td>
<td class="label">Total billets emis</td>
<td class="value">{{ totalSold }}</td>
</tr>
<tr>
<td class="label">Chiffre d'affaires HT</td>
<td class="value-big">{{ totalRevenue|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</table>
</div>
{% endif %}
<div class="legal">
<p><strong>Attestation :</strong> Le soussigne, {{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}{% if organizer.siret %}, SIRET {{ organizer.siret }}{% endif %}, atteste que les informations ci-dessus correspondent aux ventes realisees via la plateforme E-Ticket pour l'evenement « {{ event.title }} » a la date du {{ generatedAt|date('d/m/Y') }}.</p>
<p>Cette attestation est generee automatiquement par la plateforme E-Ticket (ticket.e-cosplay.fr) et ne constitue pas une facture. Les montants indiques sont hors taxes et hors commissions. Les invitations, accreditations staff et exposant ne sont pas comptabilisees dans ce document.</p>
<p>Document genere le {{ generatedAt|date('d/m/Y a H:i:s') }} — Ref: ATT-{{ event.id }}-{{ generatedAt|date('YmdHis') }}</p>
<div style="margin-top: 20px; border: 2px solid #e5e7eb; padding: 16px; overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<tr>
<td style="width: 170px; vertical-align: top; padding-right: 16px;">
{% if qrBase64 is defined and qrBase64 %}
<img src="{{ qrBase64 }}" alt="QR Verification" style="width: 150px; height: 150px;">
<div style="font-size: 7px; font-weight: bold; text-transform: uppercase; color: #999; text-align: center; margin-top: 4px;">Scannez pour verifier</div>
{% endif %}
</td>
<td style="vertical-align: top; overflow: hidden;">
<div style="font-size: 11px; color: #333; line-height: 1.6; overflow: hidden;">
<p style="margin: 0 0 6px; font-weight: bold;">Attestation enregistree et signee numeriquement.</p>
<p style="margin: 0 0 4px;"><strong>Reference :</strong> {{ attestationRef }}</p>
<p style="margin: 0 0 4px;"><strong>Signature :</strong> <span style="font-size: 8px; font-family: monospace;">{{ signatureHash|slice(0, 40) }}...</span></p>
<p style="margin: 0 0 4px;"><strong>Verification :</strong></p>
<p style="margin: 0 0 0; font-size: 10px; font-family: monospace; color: #4f46e5;">{{ verifyUrl }}</p>
</div>
</td>
</tr>
</table>
</div>
<div class="footer">
<table class="footer-table">
<tr>
<td>E-Ticket — Plateforme de billetterie par E-Cosplay — ticket.e-cosplay.fr</td>
<td style="text-align: right;">Page 1/1</td>
<td style="text-align: right;">{{ attestationRef }}</td>
</tr>
</table>
</div>