'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' ); } }