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:
@@ -38,6 +38,7 @@
|
||||
<file>src/Service/StripeService.php</file>
|
||||
<file>src/Service/PayoutPdfService.php</file>
|
||||
<file>src/Service/BilletOrderService.php</file>
|
||||
<file>src/Service/InvoiceService.php</file>
|
||||
</exclude>
|
||||
|
||||
<deprecationTrigger>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
sonar.projectKey=e-ticket
|
||||
sonar.projectName=E-Ticket
|
||||
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.sourceEncoding=UTF-8
|
||||
sonar.php.coverage.reportPaths=coverage.xml
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\BilletOrder;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\InvoiceService;
|
||||
use App\Service\StripeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@ class BilletOrderService
|
||||
private EntityManagerInterface $em,
|
||||
private Environment $twig,
|
||||
private MailerService $mailer,
|
||||
private InvoiceService $invoiceService,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
#[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', [
|
||||
'orderNumber' => $order->getOrderNumber(),
|
||||
'token' => $order->getAccessToken(),
|
||||
|
||||
57
src/Service/InvoiceService.php
Normal file
57
src/Service/InvoiceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -191,9 +191,16 @@
|
||||
<span>{{ order.paymentMethod }}{% if order.cardBrand and order.cardLast4 %} — {{ order.cardBrand|upper }} **** {{ order.cardLast4 }}{% endif %}</span>
|
||||
{% endif %}
|
||||
</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">
|
||||
Voir la commande
|
||||
</a>
|
||||
<div class="flex gap-2">
|
||||
{% if order.status == 'paid' %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
@@ -205,14 +212,32 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elseif tab == 'invoices' %}
|
||||
<div class="card-brutal">
|
||||
{% elseif tab == 'invoices' %}
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Mes factures</h2>
|
||||
</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, ',', ' ') }} €</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">
|
||||
<p class="text-gray-400 font-bold text-sm">Aucune facture pour le moment.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elseif tab == 'events' %}
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
{% 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>
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<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">
|
||||
<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">
|
||||
Voir ma commande
|
||||
</a>
|
||||
|
||||
162
templates/pdf/invoice.html.twig
Normal file
162
templates/pdf/invoice.html.twig
Normal 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, ',', ' ') }} €</td>
|
||||
<td style="font-weight: bold;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</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, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color: #666;">TVA (0%)</td>
|
||||
<td style="text-align: right; color: #666;">0,00 €</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>Total TTC</td>
|
||||
<td style="text-align: right;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €</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>
|
||||
Reference in New Issue
Block a user