Merge pull request 'XML-Rechnung (EN 16931 / XRechnung 3.0) als Download in der Rechnungs-Detailansicht' (#153) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/153
This commit is contained in:
fds2610 2026-07-31 18:50:41 +02:00
commit fbc24b4b28
8 changed files with 488 additions and 36 deletions

View file

@ -2,6 +2,27 @@
---
## 2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht
- Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (`Rechnung-<Nr>.xml` bzw. `Stornorechnung-<Nr>.xml`) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen.
- **`src/Services/ZugferdService.php`:** Der Dokumentaufbau steckt jetzt in `buildDocument()`; `attachToPdf()` bettet das Ergebnis wie bisher in die PDF ein, neu liefert `createXml()` dasselbe Dokument als XML-String. Alle Feldzugriffe sind gegen fehlende Schlüssel und `null` abgesichert, sodass auch unvollständige Rechnungen (z.B. Entwürfe ohne Rechnungsnummer) ein wohlgeformtes XML ergeben statt eines Fehlers.
- **Neue Methode `collectMissingData()`:** Prüft die für EN 16931 relevanten Angaben und liefert die fehlenden getrennt nach Stammdaten (BT-27, BT-35/37/38, BT-31/32, BT-84) und Rechnungsdaten (BT-1, BT-2, BT-9, BT-10, BT-44, BT-50/52/53/55, BT-72, BG-25) zurück.
- **`src/Controllers/InvoiceController.php`, `public/index.php`:** Neue Action `xml()` und Route `GET /invoices/{id}/xml`; die Detailansicht bekommt zusätzlich die Liste der fehlenden Angaben übergeben.
- **`src/Views/invoices/show.php`:** XML-Download-Symbol neben dem PDF-Symbol (auch bei Entwürfen sichtbar). Fehlen Angaben, erscheint über der Aktionsleiste ein Hinweisbanner mit den betroffenen Feldern samt BT-Nummer, und das Symbol wird in Warnfarbe dargestellt – der Download bleibt in jedem Fall möglich.
- **Fix nebenbei:** `mapEinheit()` verglich Einheiten ohne den im UI üblichen Punkt („Std.", „Stk."), sodass Stunden im ZUGFeRD-XML bislang als Stück (`C62`) statt als `HUR` ausgewiesen wurden. Das betraf auch das in die PDF eingebettete XML.
- **`tests/72_InvoiceXmlExportTest.php`** (neu): Prüft Profilkennung, Rechnungsnummer, Käufer- und Verkäuferdaten, Positionen, Bruttosumme und Einheiten-Mapping im erzeugten XML sowie die Meldung fehlender Stamm-/Rechnungsdaten und die Anbindung in Route und Detailansicht.
### Nachtrag: Konformität zur XRechnung-CIUS hergestellt
Der E-Rechnungsvalidator (KoSIT-Prüfkonfiguration) meldete für die heruntergeladene Datei einen Fehler und eine Warnung. Die EN16931-Schematron-Prüfung war bereits fehlerfrei – beanstandet wurden ausschließlich die zusätzlichen deutschen Regeln der CIUS XRechnung:
- **BR-DE-2 (Fehler):** Die Gruppe „SELLER CONTACT" (BG-6) fehlte. `ZugferdService` setzt jetzt Ansprechpartner (BT-41, aus `inhaber` mit Rückfall auf `firma`), Telefon (BT-42) und E-Mail (BT-43) aus den Firmeneinstellungen.
- **BR-DE-21 (Warnung):** Die Spezifikationskennung (BT-24) soll der XRechnung entsprechen. Der XML-Download verwendet dafür jetzt das Profil **XRechnung 3.0** (`ZugferdProfiles::PROFILE_XRECHNUNG_3`) statt des reinen EN16931-Profils. XRechnung ist eine CIUS von EN 16931, die Datei bleibt damit normkonform und erfüllt zusätzlich die BR-DE-Regeln.
- **PEPPOL-EN16931-R001/R010/R020 (Hinweise):** Geschäftsprozess (BT-23) sowie die elektronischen Adressen von Verkäufer (BT-34) und Käufer (BT-49, aus der Kunden-E-Mail) werden jetzt mit ausgegeben.
- **Die PDF-Einbettung bleibt unverändert** beim Profil EN16931 (Factur-X, `factur-x.xml`) – dem üblichen Format für hybride B2B-Rechnungen. Die neuen Kontakt- und Adressangaben stehen aber auch dort, das PDF-XML wird dadurch nur vollständiger.
- `collectMissingData()` prüft zusätzlich Ansprechpartner (BT-41), Telefon (BT-42), E-Mail (BT-43/BT-34) und die Kunden-E-Mail (BT-49); Test 72 deckt die neuen Elemente sowie eine XSD-Prüfung des erzeugten XML ab.
- **Fix: USt-IdNr. des Kunden (BT-48) fehlte immer im XML.** `Invoice::find()` selektierte vom Kunden zwar Adresse, E-Mail und Zahlungsziel, aber nicht `c.ust_id` – und `invoices` hat keine eigene Spalte dafür. Der Zweig `addDocumentBuyerVATRegistrationNumber()` in `ZugferdService` lief deshalb nie, auch wenn beim Kunden eine USt-IdNr. gepflegt war (Bestandsfehler, betraf auch das in die PDF eingebettete XML). Bei inländischen Leistungen mit Steuerkategorie „S" ist BT-48 optional, weshalb es nicht auffiel; bei Reverse Charge (§ 13b UStG) und innergemeinschaftlichen Lieferungen ist die Angabe dagegen Pflicht. `Invoice::find()` liefert `c.ust_id` jetzt mit, Test 72 prüft BT-31 und BT-48 samt Schemakennung „VA".
## 2026-07-31 – Firmenlogo persistent in `data/logo`, Nutzdaten gebündelt unter `data/`
- Fix #116 hatte die Beleg-Uploads aus dem Web-Root nach `data/uploads` verschoben – inklusive des Unterordners `logo/`. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code; `Setting`, `SettingsController` und `PdfInvoiceService` zeigten weiterhin auf `public/static/uploads/logo/`. Dadurch war das hochgeladene Firmenlogo ab diesem Zeitpunkt nicht mehr auffindbar (Datei vorhanden, Pfad falsch) und ein Neu-Hochladen legte es erneut im Web-Root ab, wo es bei jedem Update verloren ging.

View file

@ -55,6 +55,8 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Bezahlt / Storniert
- Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung
- **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; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x
- **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-<Nr>.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein.
- Für XRechnung zwingend sind unter anderem der Ansprechpartner des Verkäufers (BG-6: Inhaber, Telefon, E-Mail aus den Firmeneinstellungen) sowie die Kundennummer als Käuferreferenz (BT-10); fehlen sie, weist die Detailansicht darauf hin.
- E-Mail-Versand via SMTP direkt aus der Anwendung
- **Stornorechnung (§ 14 UStG)**: Finalisierte, gesendete oder bezahlte Rechnungen können storniert werden; dabei entsteht automatisch eine nummerierte Stornorechnung mit negierten Beträgen (`storno_of_id`), GoBD-konforme Stornobuchungen heben die Originalbuchungen auf; Querverweise in der Detailansicht, angepasstes PDF-Layout (Titel „STORNORECHNUNG", Referenzzeile)

View file

@ -168,6 +168,7 @@ $router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
$router->get('/invoices/{id}/xml', fn($p) => (new InvoiceController())->xml($p));
$router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
$router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p));
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));

View file

@ -13,6 +13,7 @@ use Steuerkram\Models\Account;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\Services\ZugferdService;
use Steuerkram\View;
class InvoiceController extends BaseController
@ -137,6 +138,9 @@ class InvoiceController extends BaseController
? \Steuerkram\Models\Customer::find((int)$invoice['customer_id'])
: null;
// Fehlende Pflichtangaben für die EN16931-XML-Rechnung ermitteln
$xmlHinweise = (new ZugferdService())->collectMissingData($invoice, $lines, $settings);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
@ -144,6 +148,7 @@ class InvoiceController extends BaseController
'invoice' => $invoice,
'lines' => $lines,
'settings' => $settings,
'xmlHinweise' => $xmlHinweise,
'stornorechnung' => $stornorechnung ?: null,
'originalrechnung' => $originalrechnung,
'bookings' => $bookings,
@ -350,6 +355,32 @@ class InvoiceController extends BaseController
echo $pdf;
}
public function xml(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
$lines = Invoice::lines($id);
$settings = Setting::get();
try {
$xml = (new ZugferdService())->createXml($invoice, $lines, $settings);
} catch (\Throwable $e) {
error_log('[Steuerkram] Fehler in InvoiceController::xml(): ' . $e->getMessage());
View::redirect("/invoices/{$id}", 'XML-Rechnung konnte nicht erzeugt werden.', 'error');
return;
}
$prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-';
$filename = $prefix . ($invoice['rechnungs_nr'] ?: 'Entwurf') . '.xml';
header('Content-Type: application/xml; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($xml));
echo $xml;
}
public function book(array $params): void
{
$id = (int)$params['id'];

View file

@ -54,7 +54,7 @@ class Invoice
{
return Database::fetchOne(
'SELECT i.*, i.storno_of_id, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days, c.ust_id
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.id = ?',

View file

@ -11,20 +11,132 @@ use horstoeko\zugferd\ZugferdProfiles;
class ZugferdService
{
/** Geschäftsprozess (BT-23) – von PEPPOL/XRechnung erwartet */
private const BUSINESS_PROCESS = 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0';
/**
* 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);
$doc = $this->buildDocument($inv, $lines, $settings);
$rechnungsDatum = new DateTime($inv['rechnungs_datum']);
$pdfBuilder = new ZugferdDocumentPdfBuilder($doc, $pdfContent);
$pdfBuilder->generateDocument();
return $pdfBuilder->downloadString();
}
/**
* Erzeugt die reine XML-Rechnung (ohne PDF) als String.
*
* Verwendet das Profil XRechnung 3.0 – eine CIUS von EN 16931, die zusätzlich
* die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem
* E-Rechnungsvalidator ohne Beanstandung akzeptiert wird.
*/
public function createXml(array $inv, array $lines, array $settings): string
{
return $this->buildDocument($inv, $lines, $settings, ZugferdProfiles::PROFILE_XRECHNUNG_3)->getContent();
}
/**
* Prüft, welche für EN 16931 bzw. die XRechnung-CIUS nötigen Stamm- und
* Rechnungsdaten fehlen.
*
* @return array{Stammdaten: string[], Rechnungsdaten: string[]} nur nicht-leere Bereiche
*/
public function collectMissingData(array $inv, array $lines, array $settings): array
{
$stamm = [];
if (trim((string)($settings['firma'] ?? '')) === '') $stamm[] = 'Firmenname (BT-27)';
if (trim((string)($settings['strasse'] ?? '')) === '') $stamm[] = 'Straße des Verkäufers (BT-35)';
if (trim((string)($settings['plz'] ?? '')) === '') $stamm[] = 'PLZ des Verkäufers (BT-38)';
if (trim((string)($settings['ort'] ?? '')) === '') $stamm[] = 'Ort des Verkäufers (BT-37)';
if (trim((string)($settings['ust_id'] ?? '')) === '' && trim((string)($settings['steuernummer'] ?? '')) === '') {
$stamm[] = 'USt-IdNr. oder Steuernummer (BT-31/BT-32)';
}
if (trim((string)($settings['iban'] ?? '')) === '') {
$stamm[] = 'IBAN für die Zahlungsangaben (BT-84)';
}
if ($this->sellerContactName($settings) === '') {
$stamm[] = 'Inhaber als Ansprechpartner (BT-41)';
}
if (trim((string)($settings['telefon'] ?? '')) === '') {
$stamm[] = 'Telefonnummer des Ansprechpartners (BT-42)';
}
if (trim((string)($settings['email'] ?? '')) === '') {
$stamm[] = 'E-Mail-Adresse des Ansprechpartners (BT-43/BT-34)';
}
$rechnung = [];
if (trim((string)($inv['rechnungs_nr'] ?? '')) === '') {
$rechnung[] = 'Rechnungsnummer (BT-1) – Rechnung ist noch ein Entwurf';
}
if (trim((string)($inv['rechnungs_datum'] ?? '')) === '') {
$rechnung[] = 'Rechnungsdatum (BT-2)';
}
if ($this->buyerName($inv) === '') {
$rechnung[] = 'Name des Kunden (BT-44)';
}
if (trim((string)($inv['strasse'] ?? '')) === '') $rechnung[] = 'Straße des Kunden (BT-50)';
if (trim((string)($inv['plz'] ?? '')) === '') $rechnung[] = 'PLZ des Kunden (BT-53)';
if (trim((string)($inv['ort'] ?? '')) === '') $rechnung[] = 'Ort des Kunden (BT-52)';
if (trim((string)($inv['land'] ?? '')) === '') $rechnung[] = 'Land des Kunden (BT-55) – es wird ersatzweise DE gesetzt';
if (trim((string)($inv['kundennummer'] ?? '')) === '') {
$rechnung[] = 'Kundennummer als Käuferreferenz (BT-10)';
}
if (trim((string)($inv['email'] ?? '')) === '') {
$rechnung[] = 'E-Mail-Adresse des Kunden als elektronische Adresse (BT-49)';
}
if (trim((string)($inv['faellig_datum'] ?? '')) === '') {
$rechnung[] = 'Fälligkeitsdatum (BT-9)';
}
if (trim((string)($inv['leistungszeitraum'] ?? '')) === '') {
$rechnung[] = 'Leistungsdatum/-zeitraum (BT-72) – es wird ersatzweise das Rechnungsdatum gesetzt';
}
$positionen = array_filter($lines, fn($l) => ($l['type'] ?? 'position') !== 'heading');
if (empty($positionen)) {
$rechnung[] = 'Mindestens eine Rechnungsposition (BG-25)';
}
foreach ($positionen as $line) {
if (trim((string)($line['einheit'] ?? '')) === '') {
$rechnung[] = 'Einheit mindestens einer Position (BT-130) – es wird ersatzweise "Stück" gesetzt';
break;
}
}
if (trim((string)($inv['land'] ?? 'DE')) !== 'DE' && trim((string)($inv['ust_id'] ?? '')) === '') {
$rechnung[] = 'USt-IdNr. des ausländischen Kunden (BT-48)';
}
$result = [];
if ($stamm) $result['Stammdaten'] = $stamm;
if ($rechnung) $result['Rechnungsdaten'] = $rechnung;
return $result;
}
/**
* Baut das EN16931-Dokument aus Rechnungs-, Positions- und Stammdaten auf.
* Fehlende Daten führen nicht zum Abbruch – sie bleiben im XML leer.
*/
private function buildDocument(
array $inv,
array $lines,
array $settings,
int $profile = ZugferdProfiles::PROFILE_EN16931
): ZugferdDocumentBuilder {
$doc = ZugferdDocumentBuilder::createNew($profile);
$doc->setDocumentBusinessProcess(self::BUSINESS_PROCESS);
$rechnungsDatum = !empty($inv['rechnungs_datum']) ? new DateTime($inv['rechnungs_datum']) : new DateTime();
$faelligDatum = !empty($inv['faellig_datum']) ? new DateTime($inv['faellig_datum']) : null;
// Dokumentkopf
$doc->setDocumentInformation(
$inv['rechnungs_nr'],
(string)($inv['rechnungs_nr'] ?? ''),
'380', // UN/EDIFACT: Handelsrechnung
$rechnungsDatum,
'EUR'
@ -35,16 +147,16 @@ class ZugferdService
$doc->setDocumentSupplyChainEvent($deliveryDate);
// Käuferreferenz (BT-10)
$doc->setDocumentBuyerReference($inv['kundennummer']);
$doc->setDocumentBuyerReference((string)($inv['kundennummer'] ?? ''));
// Verkäufer (BG-4)
$doc->setDocumentSeller($settings['firma'] ?? '');
$doc->setDocumentSeller((string)($settings['firma'] ?? ''));
$doc->setDocumentSellerAddress(
$settings['strasse'] ?: null,
($settings['strasse'] ?? '') ?: null,
null,
null,
$settings['plz'] ?: null,
$settings['ort'] ?: null,
($settings['plz'] ?? '') ?: null,
($settings['ort'] ?? '') ?: null,
'DE'
);
if (!empty($settings['ust_id'])) {
@ -53,22 +165,36 @@ class ZugferdService
$doc->addDocumentSellerTaxNumber($settings['steuernummer']);
}
// Ansprechpartner des Verkäufers (BG-6) – für XRechnung Pflicht (BR-DE-2/5/6/7)
$doc->setDocumentSellerContact(
$this->sellerContactName($settings) ?: null,
null,
($settings['telefon'] ?? '') ?: null,
null,
($settings['email'] ?? '') ?: null
);
// Elektronische Adresse des Verkäufers (BT-34), Schema "EM" = E-Mail
if (!empty($settings['email'])) {
$doc->setDocumentSellerCommunication('EM', $settings['email']);
}
// Käufer (BG-7)
$buyerName = !empty($inv['firma'])
? $inv['firma']
: trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . ($inv['nachname'] ?? ''));
$doc->setDocumentBuyer($buyerName, $inv['kundennummer']);
$doc->setDocumentBuyer($this->buyerName($inv), (string)($inv['kundennummer'] ?? ''));
$doc->setDocumentBuyerAddress(
$inv['strasse'] ?: null,
($inv['strasse'] ?? '') ?: null,
null,
null,
$inv['plz'] ?: null,
$inv['ort'] ?: null,
$inv['land'] ?: 'DE'
($inv['plz'] ?? '') ?: null,
($inv['ort'] ?? '') ?: null,
($inv['land'] ?? '') ?: 'DE'
);
if (!empty($inv['ust_id'])) {
$doc->addDocumentBuyerVATRegistrationNumber($inv['ust_id']);
}
// Elektronische Adresse des Käufers (BT-49)
if (!empty($inv['email'])) {
$doc->setDocumentBuyerCommunication('EM', $inv['email']);
}
// Zahlungsart: Überweisung (BG-16)
$iban = preg_replace('/\s+/', '', $settings['iban'] ?? '');
@ -79,7 +205,7 @@ class ZugferdService
$settings['bank_name'] ?? null,
null,
$bic ?: null,
$inv['rechnungs_nr']
(string)($inv['rechnungs_nr'] ?? '')
);
}
@ -94,13 +220,13 @@ class ZugferdService
if (($line['type'] ?? 'position') === 'heading') {
continue;
}
$rate = (float)$line['ust_satz'];
$rate = (float)($line['ust_satz'] ?? 0);
$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'];
$taxGroups[$key]['basis'] += (float)($line['betrag_netto'] ?? 0);
$taxGroups[$key]['tax'] += (float)($line['betrag_ust'] ?? 0);
}
foreach ($taxGroups as $group) {
$categoryCode = $group['rate'] > 0 ? 'S' : 'E';
@ -116,14 +242,16 @@ class ZugferdService
}
// Gesamtsummen (BG-22)
$brutto = round((float)($inv['betrag_brutto'] ?? 0), 2);
$netto = round((float)($inv['betrag_netto'] ?? 0), 2);
$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
$brutto, // Brutto (Zahlbetrag)
$brutto, // fällig
$netto, // Positionssumme Netto
null,
null,
round((float)$inv['betrag_netto'], 2), // Steuerbemessungsgrundlage
round((float)$inv['betrag_ust'], 2) // Steuer gesamt
$netto, // Steuerbemessungsgrundlage
round((float)($inv['betrag_ust'] ?? 0), 2) // Steuer gesamt
);
// Positionen (BG-25) – Überschriften werden übersprungen
@ -134,22 +262,33 @@ class ZugferdService
}
$posNr++;
$doc->addNewPosition((string)$posNr);
$doc->setDocumentPositionProductDetails($line['bezeichnung']);
$doc->setDocumentPositionNetPrice((float)$line['einzelpreis']);
$doc->setDocumentPositionProductDetails((string)($line['bezeichnung'] ?? ''));
$doc->setDocumentPositionNetPrice((float)($line['einzelpreis'] ?? 0));
$doc->setDocumentPositionQuantity(
(float)$line['menge'],
$this->mapEinheit($line['einheit'] ?? '')
(float)($line['menge'] ?? 0),
$this->mapEinheit((string)($line['einheit'] ?? ''))
);
$rate = (float)$line['ust_satz'];
$rate = (float)($line['ust_satz'] ?? 0);
$categoryCode = $rate > 0 ? 'S' : 'E';
$doc->addDocumentPositionTax($categoryCode, 'VAT', $rate);
$doc->setDocumentPositionLineSummation(round((float)$line['betrag_netto'], 2));
$doc->setDocumentPositionLineSummation(round((float)($line['betrag_netto'] ?? 0), 2));
}
$pdfBuilder = new ZugferdDocumentPdfBuilder($doc, $pdfContent);
$pdfBuilder->generateDocument();
return $doc;
}
return $pdfBuilder->downloadString();
private function sellerContactName(array $settings): string
{
$name = trim((string)($settings['inhaber'] ?? ''));
return $name !== '' ? $name : trim((string)($settings['firma'] ?? ''));
}
private function buyerName(array $inv): string
{
if (trim((string)($inv['firma'] ?? '')) !== '') {
return trim((string)$inv['firma']);
}
return trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . ($inv['nachname'] ?? ''));
}
private function parseLeistungszeitraumDate(string $lz, DateTime $fallback): DateTime
@ -168,7 +307,8 @@ class ZugferdService
private function mapEinheit(string $einheit): string
{
return match (strtolower(trim($einheit))) {
// Abkürzungen im UI enden häufig auf einen Punkt ("Std.", "Stk.")
return match (rtrim(strtolower(trim($einheit)), '.')) {
'h', 'std', 'stunden', 'stunde' => 'HUR',
'tag', 'tage', 'd' => 'DAY',
'km' => 'KMT',

View file

@ -9,6 +9,11 @@ $customerName = !empty($invoice['firma'])
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
$xmlHinweise = $xmlHinweise ?? [];
$xmlTooltip = empty($xmlHinweise)
? 'XML-Rechnung (EN 16931 / XRechnung 3.0) herunterladen'
: 'XML-Rechnung (EN 16931 / XRechnung 3.0) herunterladen – es fehlen Angaben, siehe Hinweis oben';
$badgeClass = match($invoice['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
@ -30,6 +35,27 @@ $badgeClass = match($invoice['status']) {
</div>
<?php endif; ?>
<!-- Hinweis auf fehlende Daten für die XML-Rechnung (EN 16931) -->
<?php if (!empty($xmlHinweise)): ?>
<div class="alert alert-warning" style="margin-bottom:.75rem;padding:.6rem 1rem;background:#fff3cd;border-left:3px solid #f59e0b;font-size:.8rem">
<strong>XML-Rechnung (EN 16931 / XRechnung): unvollständige Daten.</strong>
Der Download ist trotzdem möglich, die Datei erfüllt die Norm aber nur eingeschränkt.
<?php foreach ($xmlHinweise as $bereich => $felder): ?>
<div style="margin-top:.4rem">
<em><?= $h($bereich) ?>:</em>
<ul style="margin:.2rem 0 0 1.2rem">
<?php foreach ($felder as $feld): ?>
<li><?= $h($feld) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
<?php if (isset($xmlHinweise['Stammdaten'])): ?>
<div style="margin-top:.4rem">Stammdaten pflegen unter <a href="/settings">Einstellungen</a>.</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Aktionsleiste -->
<div class="toolbar mb-2">
<div class="toolbar-left">
@ -63,6 +89,17 @@ $badgeClass = match($invoice['status']) {
</svg>
</a>
<?php endif; ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/xml"
style="background:none;border:none;cursor:pointer;color:<?= empty($xmlHinweise) ? 'var(--color-muted)' : 'var(--color-warning)' ?>;padding:.2rem;line-height:1;display:inline-flex;align-items:center"
title="<?= $h($xmlTooltip) ?>">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="12" y="18.5" text-anchor="middle" font-size="6.5" font-family="sans-serif"
font-weight="700" fill="currentColor" stroke="none">XML</text>
</svg>
</a>
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert'])): ?>
<button type="button" class="btn btn-primary"
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>

View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
* Test 72: XML-Rechnung (EN 16931) aus der Rechnungs-Detailansicht.
*
* Prüft die XML-Erzeugung über ZugferdService::createXml(), den Hinweis auf
* fehlende Stamm-/Rechnungsdaten sowie Route und Icon in der Detailansicht.
*/
class InvoiceXmlExportTest extends TestCase
{
protected string $testName = 'Test 72: XML-Rechnung (EN 16931) – Download und Hinweis auf fehlende Daten';
public function run(): void
{
$svc = new \Steuerkram\Services\ZugferdService();
// ── Testrechnung mit vollständigen Daten ────────────────────────────
$customerId = \Steuerkram\Models\Customer::create([
'nachname' => 'XMLmann',
'firma' => 'XML Test GmbH',
'strasse' => 'Normstraße 16931',
'plz' => '54321',
'ort' => 'Normstadt',
'land' => 'DE',
'email' => 'buchhaltung@xmltest.example',
'ust_id' => 'DE987654321',
'payment_days' => 14,
]);
$invoiceId = \Steuerkram\Models\Invoice::create([
'customer_id' => $customerId,
'betreff' => 'XML-Testrechnung',
'rechnungs_datum' => date('Y-m-d'),
'leistungszeitraum' => date('m.Y'),
'lines' => [
['bezeichnung' => 'Beratung', 'menge' => 4, 'einheit' => 'Std.', 'einzelpreis' => 100.00, 'ust_satz' => 19.0],
['bezeichnung' => 'Fahrtkosten', 'menge' => 50, 'einheit' => 'km', 'einzelpreis' => 0.30, 'ust_satz' => 19.0],
],
]);
\Steuerkram\Database::execute(
"UPDATE invoices SET rechnungs_nr='AR-XML-TEST', status='Finalisiert' WHERE id=?",
[$invoiceId]
);
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$lines = \Steuerkram\Models\Invoice::lines($invoiceId);
$settings = [
'firma' => 'Muster Einzelunternehmen',
'inhaber' => 'Erika Mustermann',
'strasse' => 'Hauptstraße 1',
'plz' => '10115',
'ort' => 'Berlin',
'telefon' => '+49 30 1234567',
'email' => 'rechnung@muster.example',
'ust_id' => 'DE123456789',
'iban' => 'DE02120300000000202051',
'bic' => 'BYLADEM1001',
'bank_name' => 'Testbank',
];
// ── XML wird erzeugt und ist wohlgeformt ────────────────────────────
$xml = $svc->createXml($invoice, $lines, $settings);
$this->assertGreaterThan(1000, strlen($xml), 'XML wurde erzeugt');
$dom = new \DOMDocument();
$this->assert($dom->loadXML($xml), 'XML ist wohlgeformt');
// Profilkennung (BT-24): XRechnung 3.0 als CIUS von EN 16931 – erfüllt BR-DE-21
$this->assert(
str_contains($xml, 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0'),
'XML trägt die Kennung der XRechnung 3.0 (CIUS von EN 16931, BT-24)'
);
$this->assert(str_contains($xml, 'AR-XML-TEST'), 'Rechnungsnummer (BT-1) im XML enthalten');
$this->assert(str_contains($xml, 'XML Test GmbH'), 'Kundenname (BT-44) im XML enthalten');
$this->assert(str_contains($xml, 'DE123456789'), 'USt-IdNr. des Verkäufers (BT-31) im XML enthalten');
$this->assert(str_contains($xml, 'DE02120300000000202051'), 'IBAN (BT-84) im XML enthalten');
// ── Beträge und Positionen prüfen ───────────────────────────────────
$xp = new \DOMXPath($dom);
$xp->registerNamespace('rsm', 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100');
$xp->registerNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
$positionen = $xp->query('//ram:IncludedSupplyChainTradeLineItem');
$this->assertEquals(2, $positionen->length, 'Beide Positionen (BG-25) im XML enthalten');
$brutto = $xp->query('//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount');
$this->assertEquals(
round((float)$invoice['betrag_brutto'], 2),
round((float)$brutto->item(0)->nodeValue, 2),
'Bruttobetrag (BT-112) stimmt mit der Rechnung überein'
);
$einheiten = [];
foreach ($xp->query('//ram:BilledQuantity') as $node) {
$einheiten[] = $node->getAttribute('unitCode');
}
$this->assertEquals(['HUR', 'KMT'], $einheiten, 'Einheiten auf UN/ECE-Codes gemappt (Std.→HUR, km→KMT)');
// ── Pflichtangaben der XRechnung-CIUS (BR-DE / PEPPOL) ──────────────
$this->assertEquals(
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
$xp->query('//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID')->item(0)?->nodeValue,
'Geschäftsprozess (BT-23) gesetzt – PEPPOL-EN16931-R001'
);
$this->assertEquals(
1,
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact')->length,
'Ansprechpartner des Verkäufers (BG-6) vorhanden – BR-DE-2'
);
$this->assertEquals(
'Erika Mustermann',
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact/ram:PersonName')->item(0)?->nodeValue,
'Kontaktname (BT-41) aus dem Inhaber übernommen – BR-DE-5'
);
$this->assertNotEmpty(
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact//ram:CompleteNumber')->item(0)?->nodeValue,
'Telefonnummer des Ansprechpartners (BT-42) vorhanden – BR-DE-6'
);
$this->assertEquals(
'rechnung@muster.example',
$xp->query('//ram:SellerTradeParty/ram:DefinedTradeContact//ram:EmailURIUniversalCommunication/ram:URIID')->item(0)?->nodeValue,
'E-Mail des Ansprechpartners (BT-43) vorhanden – BR-DE-7'
);
// USt-IdNr. beider Parteien (BT-31 / BT-48) – BT-48 nur, wenn beim Kunden gepflegt
$sellerVat = $xp->query('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]')->item(0);
$buyerVat = $xp->query('//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]')->item(0);
$this->assertEquals('DE123456789', $sellerVat?->nodeValue, 'USt-IdNr. des Verkäufers als BT-31 mit Schema "VA"');
$this->assertEquals('DE987654321', $buyerVat?->nodeValue, 'USt-IdNr. des Kunden als BT-48 mit Schema "VA"');
$this->assertNotEmpty(
$invoice['ust_id'] ?? '',
'Invoice::find() liefert die USt-IdNr. des Kunden mit (Voraussetzung für BT-48)'
);
$sellerUri = $xp->query('//ram:SellerTradeParty/ram:URIUniversalCommunication/ram:URIID')->item(0);
$buyerUri = $xp->query('//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')->item(0);
$this->assertEquals('rechnung@muster.example', $sellerUri?->nodeValue, 'Elektronische Adresse des Verkäufers (BT-34)');
$this->assertEquals('EM', $sellerUri?->getAttribute('schemeID'), 'BT-34 mit Schema "EM" gekennzeichnet');
$this->assertEquals('buchhaltung@xmltest.example', $buyerUri?->nodeValue, 'Elektronische Adresse des Käufers (BT-49)');
$this->assertEquals('EM', $buyerUri?->getAttribute('schemeID'), 'BT-49 mit Schema "EM" gekennzeichnet');
// ── Prüfung gegen das CII-Schema (entspricht dem Schritt val-xsd) ───
$xsd = dirname(__DIR__) . '/vendor/horstoeko/zugferd/src/schema/FACTUR-X_EN16931.xsd';
if (is_file($xsd)) {
$prevErrors = libxml_use_internal_errors(true);
$this->assert($dom->schemaValidate($xsd), 'XML ist schemakonform zum CII-XSD');
libxml_clear_errors();
libxml_use_internal_errors($prevErrors);
}
// ── PDF-Einbettung bleibt beim Factur-X-Profil EN16931 ──────────────
$refl = new \ReflectionMethod(\Steuerkram\Services\ZugferdService::class, 'buildDocument');
$this->assertEquals(
\horstoeko\zugferd\ZugferdProfiles::PROFILE_EN16931,
$refl->getParameters()[3]->getDefaultValue(),
'PDF-Einbettung verwendet unverändert das Profil EN16931 (Factur-X)'
);
// ── Vollständige Daten → keine Hinweise ─────────────────────────────
$this->assert(
$svc->collectMissingData($invoice, $lines, $settings) === [],
'Bei vollständigen Daten werden keine fehlenden Angaben gemeldet'
);
// ── Unvollständige Daten: Download trotzdem möglich + Hinweise ──────
$entwurf = \Steuerkram\Models\Invoice::find($invoiceId);
$entwurf['rechnungs_nr'] = null;
$entwurf['faellig_datum'] = null;
$entwurf['leistungszeitraum'] = null;
$entwurf['strasse'] = '';
$entwurf['email'] = '';
$xmlUnvollstaendig = $svc->createXml($entwurf, $lines, ['firma' => 'Muster Einzelunternehmen']);
$this->assertGreaterThan(500, strlen($xmlUnvollstaendig), 'XML wird auch bei fehlenden Daten erzeugt');
$domU = new \DOMDocument();
$this->assert($domU->loadXML($xmlUnvollstaendig), 'XML bei fehlenden Daten ist wohlgeformt');
$hinweise = $svc->collectMissingData($entwurf, $lines, ['firma' => 'Muster Einzelunternehmen']);
$this->assertNotEmpty($hinweise['Stammdaten'] ?? [], 'Fehlende Stammdaten werden gemeldet');
$this->assertNotEmpty($hinweise['Rechnungsdaten'] ?? [], 'Fehlende Rechnungsdaten werden gemeldet');
$alleRechnung = implode(' | ', $hinweise['Rechnungsdaten']);
$alleStamm = implode(' | ', $hinweise['Stammdaten']);
$this->assert(str_contains($alleRechnung, 'BT-1'), 'Fehlende Rechnungsnummer (BT-1) wird benannt');
$this->assert(str_contains($alleRechnung, 'BT-9'), 'Fehlendes Fälligkeitsdatum (BT-9) wird benannt');
$this->assert(str_contains($alleRechnung, 'BT-50'), 'Fehlende Kundenstraße (BT-50) wird benannt');
$this->assert(str_contains($alleRechnung, 'BT-49'), 'Fehlende E-Mail-Adresse des Kunden (BT-49) wird benannt');
$this->assert(str_contains($alleStamm, 'BT-31'), 'Fehlende USt-IdNr./Steuernummer wird benannt');
$this->assert(str_contains($alleStamm, 'BT-84'), 'Fehlende IBAN wird benannt');
$this->assert(str_contains($alleStamm, 'BT-42'), 'Fehlende Telefonnummer des Ansprechpartners wird benannt');
$this->assert(str_contains($alleStamm, 'BT-43'), 'Fehlende E-Mail des Ansprechpartners wird benannt');
// ── Leere Rechnung ohne jede Angabe bricht nicht ab ─────────────────
$leer = ['id' => 0, 'rechnungs_datum' => date('Y-m-d')];
$domL = new \DOMDocument();
$this->assert(
$domL->loadXML($svc->createXml($leer, [], [])),
'Auch ohne Stamm- und Positionsdaten entsteht ein wohlgeformtes XML'
);
// ── Route und Icon in der Detailansicht ─────────────────────────────
$routes = (string)file_get_contents(dirname(__DIR__) . '/public/index.php');
$this->assert(
str_contains($routes, "\$router->get('/invoices/{id}/xml'"),
'Route GET /invoices/{id}/xml ist registriert'
);
$showView = (string)file_get_contents(dirname(__DIR__) . '/src/Views/invoices/show.php');
$this->assert(
str_contains($showView, '/xml"') && str_contains($showView, '>XML</text>'),
'Detailansicht enthält das XML-Download-Symbol'
);
$this->assert(
str_contains($showView, 'XML-Rechnung (EN 16931 / XRechnung): unvollständige Daten'),
'Detailansicht weist auf fehlende Stamm-/Rechnungsdaten hin'
);
}
}