Steuerkram/src/Services/PdfInvoiceService.php

330 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Mpdf\Mpdf;
class PdfInvoiceService
{
public function renderToString(array $invoice, array $lines, array $settings): string
{
return $this->generate($invoice, $lines, $settings);
}
public function render(array $invoice, array $lines, array $settings): void
{
$pdfContent = $this->generate($invoice, $lines, $settings);
$prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-';
$filename = $prefix . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdfContent));
echo $pdfContent;
exit;
}
private function generate(array $invoice, array $lines, array $settings): string
{
$html = $this->buildHtml($invoice, $lines, $settings);
$fontDir = dirname(__DIR__) . '/fonts';
$mpdf = new Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'margin_top' => 15,
'margin_bottom' => 25,
'margin_left' => 25, // DIN 5008: 25mm links
'margin_right' => 15, // DIN 5008: 15mm rechts
'fontDir' => [$fontDir],
'fontdata' => [
'opensans' => [
'R' => 'OpenSans-Regular.ttf',
'B' => 'OpenSans-Bold.ttf',
'I' => 'OpenSans-Italic.ttf',
'BI' => 'OpenSans-BoldItalic.ttf',
],
],
'default_font' => 'opensans',
]);
$mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'));
$mpdf->SetAuthor($settings['firma'] ?? '');
$mpdf->SetHTMLFooter($this->buildFooter($settings));
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$pdfContent = $mpdf->Output('', 'S');
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
$zugferd = new ZugferdService();
return $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
}
private function buildHtml(array $inv, array $lines, array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
$fmtNum = fn($v) => number_format((float)$v, 1, ',', '.');
$fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–';
// Kundenname
$customerName = !empty($inv['firma'])
? $inv['firma']
: trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
// Logo
$logoHtml = '';
if (!empty($settings['logo_path'])) {
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
if (file_exists($logoPath)) {
$maxW = max(50, (int)($settings['logo_max_width'] ?? 600));
$maxH = max(20, (int)($settings['logo_max_height'] ?? 100));
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo"'
. ' style="max-width:' . $maxW . 'px;max-height:' . $maxH . 'px">';
}
}
$logoPosition = $settings['logo_position'] ?? 'center';
$logoMarginSide = max(0, (int)($settings['logo_margin_side'] ?? 75));
$logoMarginTop = max(0, (int)($settings['logo_margin_top'] ?? 6));
if ($logoPosition === 'left') {
$logoWrapStyle = "text-align:left;margin-left:{$logoMarginSide}px;margin-top:{$logoMarginTop}px";
} elseif ($logoPosition === 'right') {
$logoWrapStyle = "text-align:right;margin-right:{$logoMarginSide}px;margin-top:{$logoMarginTop}px";
} else {
$logoWrapStyle = 'text-align:center';
}
$lz = $inv['leistungszeitraum'] ?? '';
$isStorno = !empty($inv['storno_of_id']);
$docTitle = $isStorno ? 'STORNORECHNUNG' : 'Rechnung';
// Referenzzeile direkt unter dem Dokumententitel (rechte Spalte im Adressblock)
$stornoRefHtml = '';
if ($isStorno && !empty($inv['original_rechnungs_nr'])) {
$stornoRefHtml = '<div style="font-size:7pt;margin-top:2mm">'
. 'zu Rechnung Nr. <strong>' . $h($inv['original_rechnungs_nr']) . '</strong>'
. ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '')
. '</div>';
}
$payDays = $inv['payment_days'] ?? 14;
// Fälligkeitsdatum (nicht bei Stornorechnung)
$fälligText = '';
if (!$isStorno && !empty($inv['faellig_datum'])) {
$fälligText = ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>';
}
$zahlungsHinweis = $isStorno ? '' :
'<p class="payment-note">Bitte überweisen Sie den Rechnungsbetrag innerhalb '
. $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '</p>';
// Positionszeilen
$posRows = '';
foreach ($lines as $line) {
if (($line['type'] ?? 'position') === 'heading') {
$posRows .= '<tr>
<td colspan="7" class="pos-heading">' . nl2br($h($line['bezeichnung'])) . '</td>
</tr>';
} else {
$posRows .= '<tr>
<td class="pos-nr">' . $h($line['position']) . '</td>
<td class="pos-bez">' . nl2br($h($line['bezeichnung'])) . '</td>
<td class="pos-menge">' . $fmtNum($line['menge']) . '</td>
<td class="pos-einheit">' . $h($line['einheit']) . '</td>
<td class="pos-ust">' . $h(number_format((float)$line['ust_satz'], 0)) . ' %</td>
<td class="pos-preis">' . $fmt($line['einzelpreis']) . '</td>
<td class="pos-gesamt">' . $fmt($line['betrag_netto']) . '</td>
</tr>';
}
}
// Empfänger-Land nur wenn nicht DE
$land = ($inv['land'] ?? 'DE') !== 'DE' ? '<br>' . $h($inv['land']) : '';
return <<<HTML
<div class="invoice-wrap">
<!-- Logo -->
<div class="logo-wrap" style="{$logoWrapStyle}">{$logoHtml}</div>
<!-- Adressbereich DIN 5008 Typ A (Anschriftfeld 85mm × 45mm) -->
<div style="height:10mm"></div>
<table class="address-block">
<tr>
<!-- Linke Spalte: Anschriftfeld 85mm (Kuvertfenster) -->
<td class="address-left">
<table class="return-address-wrap"><tr><td class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr></table>
<div style="height:2.5mm;font-size:2.5mm;line-height:2.5mm">&nbsp;</div>
<div class="recipient">
{$h($customerName)}<br>
{$h($inv['strasse'])}<br>
{$h($inv['plz'])} {$h($inv['ort'])}{$land}
</div>
</td>
<!-- Rechte Spalte: Dokumententitel -->
<td class="address-right" style="text-align:right;vertical-align:top;font-size:16pt">
{$docTitle}{$stornoRefHtml}
</td>
</tr>
</table>
<!-- Meta-Felder -->
<div style="height:20mm"></div>
<table class="meta-table">
<thead>
<tr>
<th>Betreff</th>
<th>Rechnungsnummer</th>
<th>Kundennummer</th>
<th>Leistungszeitraum</th>
<th style="text-align:right">Datum</th>
</tr>
</thead>
<tbody>
<tr>
<td>{$h($inv['betreff'])}</td>
<td><strong>{$h($inv['rechnungs_nr'] ?? 'Entwurf')}</strong></td>
<td>{$h($inv['kundennummer'])}</td>
<td>{$h($lz ?: '–')}</td>
<td style="text-align:right">{$fmtDate($inv['rechnungs_datum'])}</td>
</tr>
</tbody>
</table>
<!-- Positionen -->
<table class="pos-table">
<thead>
<tr>
<th class="pos-nr">Pos.</th>
<th class="pos-bez">Bezeichnung</th>
<th class="pos-menge">Menge</th>
<th class="pos-einheit">Einheit</th>
<th class="pos-ust">USt.</th>
<th class="pos-preis">Einzelpreis</th>
<th class="pos-gesamt">Gesamt</th>
</tr>
</thead>
<tbody>
{$posRows}
</tbody>
</table>
<!-- Summen (rechtsbündig) -->
<table class="sum-table" align="right" style="margin-right:1.4mm">
<tr><td>Nettobetrag:</td> <td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
<tr class="sum-total">
<td><strong>Rechnungsbetrag:</strong></td>
<td class="sum-val"><strong>{$fmt($inv['betrag_brutto'])}</strong></td>
</tr>
</table>
<!-- Zahlungshinweis (nicht bei Stornorechnungen) -->
{$zahlungsHinweis}
</div>
HTML;
}
private function buildFooter(array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
$inhaber = trim((string)($settings['inhaber'] ?? ''));
$inhaberLine = $inhaber !== '' ? "Inh. {$h($inhaber)}<br>" : '';
return <<<HTML
<table class="footer-table">
<tr>
<td>
<strong>{$h($settings['firma'] ?? '')}</strong><br>
{$inhaberLine}{$h($settings['strasse'] ?? '')}<br>
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}<br>
USt-ID: {$h($settings['ust_id'] ?? '')}
</td>
<td>
Tel: {$h($settings['telefon'] ?? '')}<br>
E-Mail: {$h($settings['email'] ?? '')}<br>
Web: {$h($settings['website'] ?? '')}
</td>
<td>
{$h($settings['bank_name'] ?? '')}<br>
IBAN: {$h($settings['iban'] ?? '')}<br>
BIC: {$h($settings['bic'] ?? '')}
</td>
<td class="page-nr">
Seite {PAGENO} von {nbpg}
</td>
</tr>
</table>
HTML;
}
private function buildCss(): string
{
return <<<CSS
body { font-family: opensans, sans-serif; font-size: 7.8pt; color: #000; }
.invoice-wrap { width: 100%; }
/* Logo */
.logo-wrap { margin-bottom: 8mm; }
/* DIN 5008 Adressbereich */
.address-block { width: 100%; border-collapse: collapse; margin-bottom: 8mm; }
.address-left { width: 85mm; vertical-align: top; padding: 0; }
.address-right { vertical-align: top; padding: 0 0 0 5mm; }
.return-address-wrap { border-collapse: collapse; margin: 0; }
.return-address {
font-size: 5.5pt; color: #555;
border-bottom: 0.3pt solid #aaa;
padding: 0 0 1mm 0;
}
.recipient { font-size: 10pt; line-height: 1.5; min-height: 27.3mm; }
/* Meta-Felder */
.meta-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 8mm; font-size: 7pt; }
.meta-table th { padding: 2mm 2mm 1mm; text-align: left; font-size: 7pt; color: #000; font-weight: bold; }
.meta-table td { padding: 1mm 2mm 2mm; }
.meta-table th:last-child, .meta-table td:last-child { text-align: right; }
/* Positionen */
.pos-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 5mm; font-size: 7pt; }
.pos-table thead th { background: #f0f0f0; color: #000; padding: 2mm; text-align: right; }
.pos-table thead th.pos-nr, .pos-table thead th.pos-bez, .pos-table thead th.pos-einheit { text-align: left; }
.pos-table tbody tr:nth-child(even) { background: none; }
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; vertical-align: top; }
.pos-heading { font-weight: bold; }
.pos-nr { width: 8%; }
.pos-bez { /* nimmt den Rest */ }
.pos-menge { width: 11%; text-align: right; }
.pos-einheit { width: 10%; }
.pos-ust { width: 8%; text-align: right; }
.pos-preis { width: 15%; text-align: right; }
.pos-gesamt { width: 16%; text-align: right; }
/* Summen */
.sum-table { border-collapse: collapse; margin-bottom: 8mm; font-size: 7pt; }
.sum-table td { padding: 1.5mm 2mm 1.5mm 2mm; }
.sum-table .sum-val { text-align: right; padding-right: 0; width: 16%; }
/* Zahlungshinweis */
.payment-note { margin-top: 5mm; font-size: 7pt; padding: 3mm; background: #f0f4ff; border-left: 2pt solid #2563eb; }
/* Fußzeile */
.footer-table { width: 100%; border-collapse: collapse; font-size: 6pt; border-top: 0.5pt solid #ccc; }
.footer-table td { padding: 1mm 2mm; vertical-align: top; }
.footer-table td:nth-child(1) { width: 29%; }
.footer-table td:nth-child(2) { width: 29%; }
.footer-table td:nth-child(3) { width: 29%; }
.page-nr { width: 13%; text-align: right; }
CSS;
}
}