Add invoice PDF generation, download, and email attachment

- InvoiceService: generates A4 invoice PDF with dompdf (organizer info,
  buyer info, event details, items table, totals, payment details)
- Route /ma-commande/{orderNumber}/{token}/facture to download invoice
- Invoice attached to confirmation email alongside ticket PDFs
- /mon-compte factures tab: list paid orders with download button
- /mon-compte achats tab: add facture download button
- /ma-commande public page: add facture download button
- Confirmation page: add facture download button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 17:08:26 +01:00
parent c6fd76da28
commit dfeed0ab5e
9 changed files with 285 additions and 6 deletions

View File

@@ -38,6 +38,7 @@
<file>src/Service/StripeService.php</file> <file>src/Service/StripeService.php</file>
<file>src/Service/PayoutPdfService.php</file> <file>src/Service/PayoutPdfService.php</file>
<file>src/Service/BilletOrderService.php</file> <file>src/Service/BilletOrderService.php</file>
<file>src/Service/InvoiceService.php</file>
</exclude> </exclude>
<deprecationTrigger> <deprecationTrigger>

View File

@@ -1,7 +1,7 @@
sonar.projectKey=e-ticket sonar.projectKey=e-ticket
sonar.projectName=E-Ticket sonar.projectName=E-Ticket
sonar.sources=src,assets,templates,docker sonar.sources=src,assets,templates,docker
sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,assets/modules/stripe-payment.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/**,assets/modules/editor.js,assets/modules/event-map.js,assets/modules/billet-designer.js,assets/modules/stripe-payment.js,src/Controller/StripeWebhookController.php,src/Service/StripeService.php,src/Service/PayoutPdfService.php,src/Service/BilletOrderService.php,src/Service/InvoiceService.php
sonar.php.version=8.4 sonar.php.version=8.4
sonar.sourceEncoding=UTF-8 sonar.sourceEncoding=UTF-8
sonar.php.coverage.reportPaths=coverage.xml sonar.php.coverage.reportPaths=coverage.xml

View File

@@ -9,6 +9,7 @@ use App\Entity\BilletOrder;
use App\Entity\Event; use App\Entity\Event;
use App\Entity\User; use App\Entity\User;
use App\Service\BilletOrderService; use App\Service\BilletOrderService;
use App\Service\InvoiceService;
use App\Service\StripeService; use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -257,6 +258,22 @@ class OrderController extends AbstractController
]); ]);
} }
#[Route('/ma-commande/{orderNumber}/{token}/facture', name: 'app_order_invoice', methods: ['GET'])]
public function downloadInvoice(string $orderNumber, string $token, EntityManagerInterface $em, InvoiceService $invoiceService): Response
{
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['orderNumber' => $orderNumber, 'accessToken' => $token]);
if (!$order || BilletBuyer::STATUS_PAID !== $order->getStatus()) {
throw $this->createNotFoundException();
}
$pdf = $invoiceService->generatePdf($order);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="facture_'.$order->getOrderNumber().'.pdf"',
]);
}
/** /**
* @param list<array<string, mixed>> $cart * @param list<array<string, mixed>> $cart
*/ */

View File

@@ -23,6 +23,7 @@ class BilletOrderService
private EntityManagerInterface $em, private EntityManagerInterface $em,
private Environment $twig, private Environment $twig,
private MailerService $mailer, private MailerService $mailer,
private InvoiceService $invoiceService,
private UrlGeneratorInterface $urlGenerator, private UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] private string $projectDir, #[Autowire('%kernel.project_dir%')] private string $projectDir,
#[Autowire('%kernel.secret%')] private string $appSecret, #[Autowire('%kernel.secret%')] private string $appSecret,
@@ -150,6 +151,12 @@ class BilletOrderService
]; ];
} }
$invoicePath = $this->invoiceService->generateToFile($order);
$attachments[] = [
'path' => $invoicePath,
'name' => 'facture_'.$order->getOrderNumber().'.pdf',
];
$orderUrl = $this->urlGenerator->generate('app_order_public', [ $orderUrl = $this->urlGenerator->generate('app_order_public', [
'orderNumber' => $order->getOrderNumber(), 'orderNumber' => $order->getOrderNumber(),
'token' => $order->getAccessToken(), 'token' => $order->getAccessToken(),

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Service;
use App\Entity\BilletBuyer;
use Dompdf\Dompdf;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Environment;
/**
* @codeCoverageIgnore PDF generation with dompdf
*/
class InvoiceService
{
public function __construct(
private Environment $twig,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {
}
public function generatePdf(BilletBuyer $order): string
{
$organizer = $order->getEvent()->getAccount();
$logoBase64 = '';
$logoPath = $this->projectDir.'/public/logo.png';
if (file_exists($logoPath)) {
$logoBase64 = 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath));
}
$html = $this->twig->render('pdf/invoice.html.twig', [
'order' => $order,
'organizer' => $organizer,
'logoBase64' => $logoBase64,
]);
$dompdf = new Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
return $dompdf->output();
}
public function generateToFile(BilletBuyer $order): string
{
$dir = $this->projectDir.'/var/invoices';
if (!is_dir($dir)) {
mkdir($dir, 0o755, true);
}
$filename = $dir.'/facture_'.$order->getOrderNumber().'.pdf';
file_put_contents($filename, $this->generatePdf($order));
return $filename;
}
}

View File

@@ -191,9 +191,16 @@
<span>{{ order.paymentMethod }}{% if order.cardBrand and order.cardLast4 %}{{ order.cardBrand|upper }} **** {{ order.cardLast4 }}{% endif %}</span> <span>{{ order.paymentMethod }}{% if order.cardBrand and order.cardLast4 %}{{ order.cardBrand|upper }} **** {{ order.cardLast4 }}{% endif %}</span>
{% endif %} {% endif %}
</div> </div>
<a href="{{ path('app_order_public', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all"> <div class="flex gap-2">
Voir la commande {% if order.status == 'paid' %}
</a> <a href="{{ path('app_order_invoice', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all" target="_blank">
Facture
</a>
{% endif %}
<a href="{{ path('app_order_public', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all">
Commande
</a>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -205,14 +212,32 @@
{% endif %} {% endif %}
</div> </div>
{% elseif tab == 'invoices' %} {% elseif tab == 'invoices' %}
<div class="card-brutal"> <div class="card-brutal overflow-hidden">
<div class="section-header"> <div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes factures</h2> <h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes factures</h2>
</div> </div>
{% set paid_orders = orders|filter(o => o.status == 'paid') %}
{% if paid_orders|length > 0 %}
<div class="p-6">
{% for order in paid_orders %}
<div class="flex flex-wrap items-center gap-4 py-3 {{ not loop.last ? 'border-b border-gray-200' : '' }}">
<div class="flex-1 min-w-0">
<p class="font-black uppercase text-sm">Facture {{ order.orderNumber }}</p>
<p class="text-xs font-bold text-gray-500">{{ order.event.title }}{{ order.paidAt ? order.paidAt|date('d/m/Y') : order.createdAt|date('d/m/Y') }}</p>
</div>
<span class="font-black text-sm text-indigo-600">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</span>
<a href="{{ path('app_order_invoice', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all" target="_blank">
Telecharger
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-12 text-center"> <div class="p-12 text-center">
<p class="text-gray-400 font-bold text-sm">Aucune facture pour le moment.</p> <p class="text-gray-400 font-bold text-sm">Aucune facture pour le moment.</p>
</div> </div>
{% endif %}
</div> </div>
{% elseif tab == 'events' %} {% elseif tab == 'events' %}

View File

@@ -47,6 +47,13 @@
{% endif %} {% endif %}
</p> </p>
{% endif %} {% endif %}
{% if order.status == 'paid' %}
<div class="mt-3">
<a href="{{ path('app_order_invoice', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="px-3 py-2 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all" target="_blank">
Telecharger la facture
</a>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -36,6 +36,9 @@
<p class="text-xs font-bold text-gray-400 mb-6">Vos billets ont ete envoyes a {{ order.email }}</p> <p class="text-xs font-bold text-gray-400 mb-6">Vos billets ont ete envoyes a {{ order.email }}</p>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<a href="{{ path('app_order_invoice', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-gray-100 transition-all" target="_blank">
Telecharger la facture
</a>
<a href="{{ path('app_order_public', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all"> <a href="{{ path('app_order_public', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Voir ma commande Voir ma commande
</a> </a>

View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Facture {{ order.orderNumber }}</title>
<style>
@page { size: A4; margin: 0; }
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 11px;
color: #111;
margin: 0;
padding: 0;
width: 210mm;
}
.page { padding: 40px; }
.header-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
.header-table td { vertical-align: top; }
.logo { width: 120px; }
.logo img { max-width: 100px; max-height: 60px; }
.title { font-size: 28px; font-weight: bold; text-transform: uppercase; text-align: right; letter-spacing: 2px; }
.subtitle { font-size: 10px; color: #666; text-align: right; margin-top: 4px; }
.info-table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
.info-table td { vertical-align: top; padding: 0; }
.info-block { padding: 16px; border: 1px solid #eee; background: #fafafa; }
.info-title { font-size: 8px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; color: #999; margin-bottom: 6px; }
.info-text { font-size: 10px; font-weight: bold; line-height: 1.6; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table th { background: #111827; color: #fff; padding: 10px 12px; font-size: 9px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; text-align: left; }
.items-table th:nth-child(2) { text-align: center; }
.items-table th:nth-child(3), .items-table th:nth-child(4) { text-align: right; }
.items-table td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 11px; }
.items-table td:nth-child(2) { text-align: center; }
.items-table td:nth-child(3), .items-table td:nth-child(4) { text-align: right; }
.total-table { width: 300px; margin-left: auto; border-collapse: collapse; margin-bottom: 30px; }
.total-table td { padding: 6px 12px; font-size: 11px; }
.total-table .total-row td { border-top: 3px solid #111827; font-size: 14px; font-weight: bold; padding-top: 10px; }
.payment-block { padding: 14px 16px; border: 1px solid #eee; background: #fafafa; margin-bottom: 24px; }
.footer { padding: 20px 0; border-top: 2px solid #111827; margin-top: 30px; }
.footer-text { font-size: 8px; color: #999; line-height: 1.6; text-align: center; }
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<table class="header-table">
<tr>
<td class="logo">
{% if logoBase64 %}
<img src="{{ logoBase64 }}" alt="E-Ticket">
{% endif %}
<div style="font-size: 12px; font-weight: bold; margin-top: 4px;">E-Ticket</div>
<div style="font-size: 8px; color: #666;">by E-Cosplay</div>
</td>
<td>
<div class="title">Facture</div>
<div class="subtitle">{{ order.orderNumber }}</div>
<div class="subtitle">Date : {{ order.paidAt ? order.paidAt|date('d/m/Y') : order.createdAt|date('d/m/Y') }}</div>
</td>
</tr>
</table>
<!-- INFOS -->
<table class="info-table">
<tr>
<td style="width: 50%; padding-right: 8px;">
<div class="info-block">
<div class="info-title">Vendeur / Organisateur</div>
<div class="info-text">
{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}<br>
{% if organizer.siret %}SIRET : {{ organizer.siret }}<br>{% endif %}
{% if organizer.address %}{{ organizer.address }}<br>{% endif %}
{% if organizer.postalCode or organizer.city %}{{ organizer.postalCode }} {{ organizer.city }}<br>{% endif %}
{% if organizer.email %}{{ organizer.email }}<br>{% endif %}
{% if organizer.phone %}{{ organizer.phone }}{% endif %}
</div>
</div>
</td>
<td style="width: 50%; padding-left: 8px;">
<div class="info-block">
<div class="info-title">Acheteur</div>
<div class="info-text">
{{ order.firstName }} {{ order.lastName }}<br>
{{ order.email }}
</div>
<div class="info-title" style="margin-top: 10px;">Evenement</div>
<div class="info-text">
{{ order.event.title }}<br>
{{ order.event.startAt|date('d/m/Y') }}{{ order.event.startAt|date('H:i') }} a {{ order.event.endAt|date('H:i') }}<br>
{{ order.event.address }}, {{ order.event.zipcode }} {{ order.event.city }}
</div>
</div>
</td>
</tr>
</table>
<!-- ITEMS -->
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th>Quantite</th>
<th>Prix unitaire HT</th>
<th>Total HT</th>
</tr>
</thead>
<tbody>
{% for item in order.items %}
<tr>
<td style="font-weight: bold;">{{ item.billetName }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unitPriceHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
<td style="font-weight: bold;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- TOTALS -->
<table class="total-table">
<tr>
<td>Total HT</td>
<td style="text-align: right; font-weight: bold;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
<tr>
<td style="color: #666;">TVA (0%)</td>
<td style="text-align: right; color: #666;">0,00 &euro;</td>
</tr>
<tr class="total-row">
<td>Total TTC</td>
<td style="text-align: right;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</table>
<!-- PAYMENT -->
{% if order.paymentMethod %}
<div class="payment-block">
<div class="info-title">Paiement</div>
<div style="font-size: 10px; font-weight: bold;">
{{ order.paymentMethod }}
{% if order.cardBrand and order.cardLast4 %}{{ order.cardBrand|upper }} **** {{ order.cardLast4 }}{% endif %}
{% if order.paidAt %} — Paye le {{ order.paidAt|date('d/m/Y H:i') }}{% endif %}
</div>
</div>
{% endif %}
<!-- FOOTER -->
<div class="footer">
<div class="footer-text">
E-Ticket — Plateforme de billetterie proposee par l'association E-Cosplay<br>
42 rue de Saint-Quentin, 02800 Beautor, France — contact@e-cosplay.fr<br>
Cette facture est emise par l'organisateur {{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}{% if organizer.siret %} — SIRET {{ organizer.siret }}{% endif %}
</div>
</div>
</div>
</body>
</html>