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:
commit
5e6f3333f4
4 changed files with 194 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
src/Services/ZugferdService.php
Normal file
163
src/Services/ZugferdService.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue