Merge pull request 'Feature: E-Rechnung ZUGFeRD EN16931' (#61) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/61
This commit is contained in:
fds2610 2026-04-28 08:18:36 +02:00
commit 5e6f3333f4
4 changed files with 194 additions and 2 deletions

View file

@ -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) ## 2026-04-27 – Feature: Neues Rechnungsnummernformat (PPPYYYYMM-nn)
### Geändert ### Geändert

View file

@ -1,5 +1,15 @@
# Funktionen # 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 ## 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. 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.

View file

@ -41,7 +41,17 @@ class PdfInvoiceService
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY); $mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; $filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$mpdf->Output($filename, 'D'); $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 private function buildHtml(array $inv, array $lines, array $settings): string

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use DateTime;
use horstoeko\zugferd\ZugferdDocumentBuilder;
use horstoeko\zugferd\ZugferdDocumentPdfBuilder;
use horstoeko\zugferd\ZugferdProfiles;
class ZugferdService
{
/**
* Bettet ZUGFeRD EN16931-XML in den übergebenen PDF-String ein
* und gibt das resultierende PDF als String zurück.
*/
public function attachToPdf(string $pdfContent, array $inv, array $lines, array $settings): string
{
$doc = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931);
$rechnungsDatum = new DateTime($inv['rechnungs_datum']);
$faelligDatum = !empty($inv['faellig_datum']) ? new DateTime($inv['faellig_datum']) : null;
// Dokumentkopf
$doc->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
};
}
}