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:
33
migrations/Version20260326180000.php
Normal file
33
migrations/Version20260326180000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
91
src/Entity/Attestation.php
Normal file
91
src/Entity/Attestation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Repository/AttestationRepository.php
Normal file
18
src/Repository/AttestationRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
46
templates/attestation/check_ventes.html.twig
Normal file
46
templates/attestation/check_ventes.html.twig
Normal 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 %}
|
||||
14
templates/attestation/not_found_ventes.html.twig
Normal file
14
templates/attestation/not_found_ventes.html.twig
Normal 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 %}
|
||||
@@ -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, ',', ' ') }} €</td>
|
||||
<td>{{ line.sold }}</td>
|
||||
<td>{{ line.revenue|number_format(2, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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>
|
||||
|
||||
Reference in New Issue
Block a user