330 lines
13 KiB
PHP
330 lines
13 KiB
PHP
<?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"> </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;
|
||
}
|
||
}
|