From 117c9a5c88c904766461255fcf25c7c16525e42f Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Fri, 31 Jul 2026 18:20:16 +0200 Subject: [PATCH 1/2] Fix #xml-rechnung: XML-Download (EN 16931 / XRechnung 3.0) in der Rechnungs-Detailansicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XML-Symbol neben dem PDF-Symbol lädt die Rechnung als reine XML-Datei (Profil XRechnung 3.0, CIUS von EN 16931). Fehlende Stamm- oder Rechnungsdaten verhindern den Download nicht, werden aber mit BT-Nummer in der Detailansicht ausgewiesen. Die PDF-Einbettung bleibt bei Factur-X EN16931. --- Changes.md | 20 +++ docs/funktionen.md | 2 + public/index.php | 1 + src/Controllers/InvoiceController.php | 31 ++++ src/Services/ZugferdService.php | 210 +++++++++++++++++++++----- src/Views/invoices/show.php | 37 +++++ tests/72_InvoiceXmlExportTest.php | 209 +++++++++++++++++++++++++ 7 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 tests/72_InvoiceXmlExportTest.php diff --git a/Changes.md b/Changes.md index 1ff70eb..aa017f4 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,26 @@ --- +## 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-.xml` bzw. `Stornorechnung-.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. + ## 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. diff --git a/docs/funktionen.md b/docs/funktionen.md index f13cdce..d589d03 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -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-.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) diff --git a/public/index.php b/public/index.php index 9a26742..7bd028c 100644 --- a/public/index.php +++ b/public/index.php @@ -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)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index a7fb793..31d3caa 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -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']; diff --git a/src/Services/ZugferdService.php b/src/Services/ZugferdService.php index 02e91f6..75262c5 100644 --- a/src/Services/ZugferdService.php +++ b/src/Services/ZugferdService.php @@ -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', diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index b6850be..eab2c43 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -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']) { + + +
+ XML-Rechnung (EN 16931 / XRechnung): unvollständige Daten. + Der Download ist trotzdem möglich, die Datei erfüllt die Norm aber nur eingeschränkt. + $felder): ?> +
+ : +
    + +
  • + +
+
+ + +
Stammdaten pflegen unter Einstellungen.
+ +
+ +
@@ -63,6 +89,17 @@ $badgeClass = match($invoice['status']) { + + + + + XML + + diff --git a/tests/72_InvoiceXmlExportTest.php b/tests/72_InvoiceXmlExportTest.php new file mode 100644 index 0000000..9aacf1a --- /dev/null +++ b/tests/72_InvoiceXmlExportTest.php @@ -0,0 +1,209 @@ + 'XMLmann', + 'firma' => 'XML Test GmbH', + 'strasse' => 'Normstraße 16931', + 'plz' => '54321', + 'ort' => 'Normstadt', + 'land' => 'DE', + 'email' => 'buchhaltung@xmltest.example', + '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' + ); + + $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'), + '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' + ); + } +} From d83d63cc14880bbe553857acf9903be89b0f9ab2 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Fri, 31 Jul 2026 18:35:20 +0200 Subject: [PATCH 2/2] Fix #xml-rechnung: USt-IdNr. des Kunden (BT-48) landet wieder im XML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoice::find() selektierte c.ust_id nicht, invoices hat keine eigene Spalte dafür - der Zweig addDocumentBuyerVATRegistrationNumber() lief deshalb nie. Bei Kategorie S optional, bei Reverse Charge und innergemeinschaftlichen Lieferungen aber Pflicht. --- Changes.md | 1 + src/Models/Invoice.php | 2 +- tests/72_InvoiceXmlExportTest.php | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Changes.md b/Changes.md index aa017f4..37563fb 100644 --- a/Changes.md +++ b/Changes.md @@ -21,6 +21,7 @@ Der E-Rechnungsvalidator (KoSIT-Prüfkonfiguration) meldete für die heruntergel - **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/` diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index f326f6a..6c0c5e2 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -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 = ?', diff --git a/tests/72_InvoiceXmlExportTest.php b/tests/72_InvoiceXmlExportTest.php index 9aacf1a..7170d11 100644 --- a/tests/72_InvoiceXmlExportTest.php +++ b/tests/72_InvoiceXmlExportTest.php @@ -25,6 +25,7 @@ class InvoiceXmlExportTest extends TestCase 'ort' => 'Normstadt', 'land' => 'DE', 'email' => 'buchhaltung@xmltest.example', + 'ust_id' => 'DE987654321', 'payment_days' => 14, ]); $invoiceId = \Steuerkram\Models\Invoice::create([ @@ -123,6 +124,16 @@ class InvoiceXmlExportTest extends TestCase '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)');