Steuerkram/src/Services/PdfInvoiceService.php

290 lines
11 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 render(array $invoice, array $lines, array $settings): void
{
$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);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$mpdf->Output($filename, 'D');
}
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'] ?? '';
// Fälligkeitsdatum
$fälligText = !empty($inv['faellig_datum'])
? ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>'
: '';
// 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>';
}
}
$payDays = $inv['payment_days'] ?? 14;
// 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">
<div class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</div>
<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:14.7pt">Rechnung</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 -->
<p class="payment-note">
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText}
</p>
</div>
HTML;
}
private function buildFooter(array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
return <<<HTML
<table class="footer-table">
<tr>
<td>
<strong>{$h($settings['firma'] ?? '')}</strong><br>
Inh. {$h($settings['inhaber'] ?? '')}<br>
{$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 {
font-size: 5.5pt; color: #555;
border-bottom: 0.3pt solid #aaa;
padding-bottom: 1mm;
}
.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;
}
}