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.
220 lines
12 KiB
PHP
220 lines
12 KiB
PHP
<?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'
|
||
);
|
||
}
|
||
}
|