Steuerkram/tests/72_InvoiceXmlExportTest.php
fds2610-bot d83d63cc14 Fix #xml-rechnung: USt-IdNr. des Kunden (BT-48) landet wieder im XML
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.
2026-07-31 18:35:20 +02:00

220 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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