From e0bab2e49d9a5ff0a78807cce8fbacb250170ef0 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 28 Apr 2026 07:27:27 +0200 Subject: [PATCH] Feature: E-Rechnung ZUGFeRD EN16931 in Ausgangsrechnungs-PDF eingebettet --- Changes.md | 9 ++ docs/funktionen.md | 10 ++ src/Services/PdfInvoiceService.php | 14 ++- src/Services/ZugferdService.php | 163 +++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/Services/ZugferdService.php diff --git a/Changes.md b/Changes.md index 060f028..701a441 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,15 @@ --- +## 2026-04-28 – Feature: E-Rechnung / ZUGFeRD EN16931 + +### Hinzugefügt +- `src/Services/ZugferdService.php`: Erzeugt ZUGFeRD-konformes EN16931-XML und bettet es in den PDF-String ein (via `horstoeko/zugferd`) +- `src/Services/PdfInvoiceService.php`: PDF wird nach der mPDF-Generierung durch `ZugferdService::attachToPdf()` geleitet; Ausgabe erfolgt jetzt manuell per Header/echo statt `mpdf->Output('D')` +- Alle Ausgangsrechnungs-PDFs sind ab sofort gültige E-Rechnungen (ZUGFeRD EN16931 / Factur-X) + +--- + ## 2026-04-27 – Feature: Neues Rechnungsnummernformat (PPPYYYYMM-nn) ### Geändert diff --git a/docs/funktionen.md b/docs/funktionen.md index 35e30ca..9a6b015 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -1,5 +1,15 @@ # Funktionen +## Ausgangsrechnungen / E-Rechnung + +- Rechnungserstellung mit DIN-5008-Layout (PDF, Open Sans) +- **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931 +- Pflichtangaben laut § 14 UStG vollständig abgedeckt (Verkäufer, Käufer, USt-ID, IBAN, Positionen, Steuersätze, Fälligkeit) +- Validierung möglich unter: https://portal.e-rechnung-bund.de/validator +- Bibliothek: `horstoeko/zugferd` v1.x + +--- + ## Kontenrahmen SKR03 DATEV-konformer SKR03 2026 mit 157 Systemkonten (Klassen 0, 1, 2, 4, 8), zugeschnitten auf EÜR-Buchhalter ohne Bilanzierungspflicht. Alle Konten sind mit geprüften UStVA-Kennzahlen (ELSTER) und EÜR-Zeilennummern hinterlegt. diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 3f4cd41..7d31bc5 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -40,8 +40,18 @@ class PdfInvoiceService $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'); + $filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; + $pdfContent = $mpdf->Output('', 'S'); + + // ZUGFeRD EN16931-XML einbetten (E-Rechnung) + $zugferd = new ZugferdService(); + $pdfContent = $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings); + + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . strlen($pdfContent)); + echo $pdfContent; + exit; } private function buildHtml(array $inv, array $lines, array $settings): string diff --git a/src/Services/ZugferdService.php b/src/Services/ZugferdService.php new file mode 100644 index 0000000..11bebc5 --- /dev/null +++ b/src/Services/ZugferdService.php @@ -0,0 +1,163 @@ +setDocumentInformation( + $inv['rechnungs_nr'], + '380', // UN/EDIFACT: Handelsrechnung + $rechnungsDatum, + 'EUR' + ); + + // Käuferreferenz (BT-10) + $doc->setDocumentBuyerReference($inv['kundennummer']); + + // Verkäufer (BG-4) + $doc->setDocumentSeller($settings['firma'] ?? ''); + $doc->setDocumentSellerAddress( + $settings['strasse'] ?? '', + null, + null, + $settings['plz'] ?? '', + $settings['ort'] ?? '', + 'DE' + ); + if (!empty($settings['ust_id'])) { + $doc->addDocumentSellerVATRegistrationNumber($settings['ust_id']); + } elseif (!empty($settings['steuernummer'])) { + $doc->addDocumentSellerTaxNumber($settings['steuernummer']); + } + + // Käufer (BG-7) + $buyerName = !empty($inv['firma']) + ? $inv['firma'] + : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . ($inv['nachname'] ?? '')); + $doc->setDocumentBuyer($buyerName, $inv['kundennummer']); + $doc->setDocumentBuyerAddress( + $inv['strasse'] ?? '', + null, + null, + $inv['plz'] ?? '', + $inv['ort'] ?? '', + $inv['land'] ?? 'DE' + ); + if (!empty($inv['ust_id'])) { + $doc->addDocumentBuyerVATRegistrationNumber($inv['ust_id']); + } + + // Zahlungsart: Überweisung (BG-16) + $iban = preg_replace('/\s+/', '', $settings['iban'] ?? ''); + $bic = preg_replace('/\s+/', '', $settings['bic'] ?? ''); + if (!empty($iban)) { + $doc->addDocumentPaymentMeanToCreditTransfer( + $iban, + $settings['bank_name'] ?? null, + null, + $bic ?: null, + $inv['rechnungs_nr'] + ); + } + + // Zahlungsziel (BT-9) + if ($faelligDatum !== null) { + $doc->addDocumentPaymentTerm(null, $faelligDatum); + } + + // USt-Zusammenfassung nach Steuersatz gruppieren (BG-23) + $taxGroups = []; + foreach ($lines as $line) { + if (($line['type'] ?? 'position') === 'heading') { + continue; + } + $rate = (float)$line['ust_satz']; + $key = number_format($rate, 2); + if (!isset($taxGroups[$key])) { + $taxGroups[$key] = ['rate' => $rate, 'basis' => 0.0, 'tax' => 0.0]; + } + $taxGroups[$key]['basis'] += (float)$line['betrag_netto']; + $taxGroups[$key]['tax'] += (float)$line['betrag_ust']; + } + foreach ($taxGroups as $group) { + $categoryCode = $group['rate'] > 0 ? 'S' : 'E'; + $exemption = $group['rate'] > 0 ? null : 'Kleinunternehmer gemäß § 19 UStG'; + $doc->addDocumentTax( + $categoryCode, + 'VAT', + round($group['basis'], 2), + round($group['tax'], 2), + $group['rate'] > 0 ? $group['rate'] : null, + $exemption + ); + } + + // Gesamtsummen (BG-22) + $doc->setDocumentSummation( + round((float)$inv['betrag_brutto'], 2), // Brutto (Zahlbetrag) + round((float)$inv['betrag_brutto'], 2), // fällig + round((float)$inv['betrag_netto'], 2), // Positionssumme Netto + null, + null, + round((float)$inv['betrag_netto'], 2), // Steuerbemessungsgrundlage + round((float)$inv['betrag_ust'], 2) // Steuer gesamt + ); + + // Positionen (BG-25) – Überschriften werden übersprungen + $posNr = 0; + foreach ($lines as $line) { + if (($line['type'] ?? 'position') === 'heading') { + continue; + } + $posNr++; + $doc->addNewPosition((string)$posNr); + $doc->setDocumentPositionProductDetails($line['bezeichnung']); + $doc->setDocumentPositionNetPrice((float)$line['einzelpreis']); + $doc->setDocumentPositionQuantity( + (float)$line['menge'], + $this->mapEinheit($line['einheit'] ?? '') + ); + $rate = (float)$line['ust_satz']; + $categoryCode = $rate > 0 ? 'S' : 'E'; + $doc->addDocumentPositionTax($categoryCode, 'VAT', $rate); + $doc->setDocumentPositionLineSummation(round((float)$line['betrag_netto'], 2)); + } + + $pdfBuilder = new ZugferdDocumentPdfBuilder($doc, $pdfContent); + $pdfBuilder->generateDocument(); + + return $pdfBuilder->downloadString(); + } + + private function mapEinheit(string $einheit): string + { + return match (strtolower(trim($einheit))) { + 'h', 'std', 'stunden', 'stunde' => 'HUR', + 'tag', 'tage', 'd' => 'DAY', + 'km' => 'KMT', + 'monat', 'monate', 'mon' => 'MON', + 'kg' => 'KGM', + 'l', 'liter' => 'LTR', + default => 'C62', // Stück / Pauschale + }; + } +}