diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eea27a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, libxml, simplexml, mbstring, gd + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPUnit + run: composer test + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: dom, libxml, simplexml + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer phpstan + + cs-fixer: + name: PHP CS Fixer + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: dom, libxml, simplexml + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHP CS Fixer + run: composer cs-check diff --git a/.gitignore b/.gitignore index 57872d0..8c3adde 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /vendor/ +composer.lock +.phpstan.cache +.php-cs-fixer.cache +.phpunit.cache +.phpunit.result.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..cb19d78 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,32 @@ +in(__DIR__) + ->exclude('var') +; + +return (new PhpCsFixer\Config()) + ->setHideProgress(true) + ->setRules([ + '@Symfony' => true, + '@PER-CS' => true, + 'no_unused_imports' => true, + 'php_unit_method_casing' => ['case' => 'snake_case'], + 'global_namespace_import' => ['import_classes' => true], + 'yoda_style' => [ + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + 'trailing_comma_in_multiline' => [ + 'elements' => [ + 'arrays', + 'arguments', + 'parameters', + ], + ], + 'native_function_invocation' => false, + ]) + ->setUsingCache(false) + ->setFinder($finder) + ; diff --git a/composer.json b/composer.json index df46417..987bd29 100644 --- a/composer.json +++ b/composer.json @@ -2,18 +2,37 @@ "name": "stann/factur-x", "type": "library", "require": { + "php": "^8.1", "mpdf/mpdf": "^8.1", - "ext-libxml": "*" + "ext-libxml": "*", + "ext-simplexml": "*", + "ext-dom": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "phpstan/phpstan": "^2.1", + "friendsofphp/php-cs-fixer": "^3.92" }, "autoload": { "psr-4": { "Stann\\FacturX\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Stann\\FacturX\\Tests\\": "tests/" + } + }, "authors": [ { "name": "Camille Baronnet", "email": "git@camillebaronnet.fr" } - ] + ], + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse --memory-limit=512M", + "cs-fix": "php-cs-fixer fix", + "cs-check": "php-cs-fixer fix --dry-run --diff" + } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..bb79a50 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src + - tests + tmpDir: .phpstan.cache diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..1c14620 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/AppendixParts/ExchangedDocument.php b/src/AppendixParts/ExchangedDocument.php index b42eff1..87154f0 100644 --- a/src/AppendixParts/ExchangedDocument.php +++ b/src/AppendixParts/ExchangedDocument.php @@ -1,10 +1,11 @@ addChild('ram:ID', $this->invoiceId, Namespaces::NS_RAM->value); - $element->addChild('ram:TypeCode', $this->typeCode->value, Namespaces::NS_RAM->value); - $element->addChild('ram:IssueDateTime', null, Namespaces::NS_RAM->value) - ?->addChild('udt:DateTimeString', $this->issuedAt->format('Ymd'), Namespaces::NS_UDT->value) - ?->addAttribute('format', '102') - ; + $xml->ram('ID', $this->invoiceId); + $xml->ram('TypeCode', (string) $this->typeCode->value); + $xml->ram('IssueDateTime') + ->udt('DateTimeString', $this->issuedAt->format('Ymd'))->attr('format', '102'); } } diff --git a/src/AppendixParts/MonetarySummation.php b/src/AppendixParts/MonetarySummation.php index 93227c9..c4d7c29 100644 --- a/src/AppendixParts/MonetarySummation.php +++ b/src/AppendixParts/MonetarySummation.php @@ -1,9 +1,10 @@ addChild('ram:InvoiceCurrencyCode', $this->currency, Namespaces::NS_RAM->value); - $summation = $xml->addChild('ram:SpecifiedTradeSettlementHeaderMonetarySummation', null, Namespaces::NS_RAM->value); - $summation?->addChild('ram:TaxBasisTotalAmount', $this->totalAmountExcludingTaxes, Namespaces::NS_RAM->value); - $summation?->addChild('ram:TaxTotalAmount', $this->totalAmountIncludingTaxes - $this->totalAmountExcludingTaxes, Namespaces::NS_RAM->value); - $summation?->addChild('ram:GrandTotalAmount', $this->totalAmountIncludingTaxes, Namespaces::NS_RAM->value); - $summation?->addChild('ram:DuePayableAmount', $this->duePayableAmount, Namespaces::NS_RAM->value); + $xml->ram('InvoiceCurrencyCode', $this->currency); + $summation = $xml->ram('SpecifiedTradeSettlementHeaderMonetarySummation'); + $summation->ram('TaxBasisTotalAmount', (string) $this->totalAmountExcludingTaxes); + $summation->ram('TaxTotalAmount', (string) ($this->totalAmountIncludingTaxes - $this->totalAmountExcludingTaxes)); + $summation->ram('GrandTotalAmount', (string) $this->totalAmountIncludingTaxes); + $summation->ram('DuePayableAmount', (string) $this->duePayableAmount); } } diff --git a/src/AppendixParts/TradeParty.php b/src/AppendixParts/TradeParty.php index 209361f..5d5e9f7 100644 --- a/src/AppendixParts/TradeParty.php +++ b/src/AppendixParts/TradeParty.php @@ -1,43 +1,42 @@ addChild('ram:Name', $this->legalName, Namespaces::NS_RAM->value); + $xml->ram('Name', $this->legalName); - if(!empty($this->registrationNumber)) { - $xml->addChild('ram:SpecifiedLegalOrganization', null, Namespaces::NS_RAM->value) - ?->addChild('ram:ID', $this->registrationNumber, Namespaces::NS_RAM->value)?->addAttribute('schemeID', '0002'); + if (!empty($this->registrationNumber)) { + $xml->ram('SpecifiedLegalOrganization') + ->ram('ID', $this->registrationNumber)->attr('schemeID', '0002'); } - if(!empty($this->countryCode)) { - $xml->addChild('ram:PostalTradeAddress', null, Namespaces::NS_RAM->value) - ?->addChild('ram:CountryID', $this->countryCode->value, Namespaces::NS_RAM->value); + if (!empty($this->countryCode)) { + $xml->ram('PostalTradeAddress') + ->ram('CountryID', $this->countryCode->value); } - if(!empty($this->vatNumber)) { - $xml->addChild('ram:SpecifiedTaxRegistration', null, Namespaces::NS_RAM->value) - ?->addChild('ram:ID', $this->vatNumber->value, Namespaces::NS_RAM->value)?->addAttribute('schemeID', 'VA'); + if (!empty($this->vatNumber)) { + $xml->ram('SpecifiedTaxRegistration') + ->ram('ID', $this->vatNumber->value)->attr('schemeID', 'VA'); } } } diff --git a/src/AppendixParts/TypeCode.php b/src/AppendixParts/TypeCode.php index 3c97ce6..8860ffb 100644 --- a/src/AppendixParts/TypeCode.php +++ b/src/AppendixParts/TypeCode.php @@ -1,5 +1,7 @@ + xmlns:qdt="' . Namespaces::NS_QDT->value . '" + xmlns:ram="' . Namespaces::NS_RAM->value . '" + xmlns:rsm="' . Namespaces::NS_RSM->value . '" + xmlns:udt="' . Namespaces::NS_UDT->value . '" + xmlns:xsi="' . Namespaces::NS_XSI->value . '"> urn:factur-x.eu:1p0:minimum @@ -55,36 +56,35 @@ public function toXml(): string ')); - $this->document->toXml($xml->addChild('rsm:ExchangedDocument')); + $b = XmlBuilder::wrap($xml); - $supplyChainTradeTransaction = $xml->addChild('rsm:SupplyChainTradeTransaction', null, Namespaces::NS_RSM->value); + $this->document->toXml($b->rsm('ExchangedDocument')); - $applicableHeaderTradeAgreement = $supplyChainTradeTransaction?->addChild('ram:ApplicableHeaderTradeAgreement', null, Namespaces::NS_RAM->value); + $supplyChainTradeTransaction = $b->rsm('SupplyChainTradeTransaction'); + $applicableHeaderTradeAgreement = $supplyChainTradeTransaction->ram('ApplicableHeaderTradeAgreement'); - if(!empty($this->buyerReference)) { - $applicableHeaderTradeAgreement?->addChild('ram:BuyerReference', $this->buyerReference, Namespaces::NS_RAM->value); + if (!empty($this->buyerReference)) { + $applicableHeaderTradeAgreement->ram('BuyerReference', $this->buyerReference); } - $this->sellerTradeParty->toXml($applicableHeaderTradeAgreement?->addChild('ram:SellerTradeParty', null, Namespaces::NS_RAM->value)); - $this->buyerTradeParty->toXml($applicableHeaderTradeAgreement?->addChild('ram:BuyerTradeParty', null, Namespaces::NS_RAM->value)); + $this->sellerTradeParty->toXml($applicableHeaderTradeAgreement->ram('SellerTradeParty')); + $this->buyerTradeParty->toXml($applicableHeaderTradeAgreement->ram('BuyerTradeParty')); - if(!empty($this->buyerOrderReference)) { - $applicableHeaderTradeAgreement - ?->addChild('ram:BuyerOrderReferencedDocument', null, Namespaces::NS_RAM->value) - ?->addChild('ram:IssuerAssignedID', $this->buyerOrderReference, Namespaces::NS_RAM->value); + if (!empty($this->buyerOrderReference)) { + $applicableHeaderTradeAgreement->ram('BuyerOrderReferencedDocument') + ->ram('IssuerAssignedID', $this->buyerOrderReference); } - if(!empty($this->buyerContractReference)) { - $applicableHeaderTradeAgreement - ?->addChild('ram:ContractReferencedDocument', null, Namespaces::NS_RAM->value) - ?->addChild('ram:IssuerAssignedID', $this->buyerContractReference, Namespaces::NS_RAM->value); + if (!empty($this->buyerContractReference)) { + $applicableHeaderTradeAgreement->ram('ContractReferencedDocument') + ->ram('IssuerAssignedID', $this->buyerContractReference); } - $supplyChainTradeTransaction?->addChild('ram:ApplicableHeaderTradeDelivery', null, Namespaces::NS_RAM->value); + $supplyChainTradeTransaction->ram('ApplicableHeaderTradeDelivery'); - $this->monetarySummation->toXml($supplyChainTradeTransaction?->addChild('ram:ApplicableHeaderTradeSettlement', null, Namespaces::NS_RAM->value)); + $this->monetarySummation->toXml($supplyChainTradeTransaction->ram('ApplicableHeaderTradeSettlement')); - return $this->prettify($xml->asXML()); + return $this->prettify((string) $xml->asXML()); } public function __toString(): string @@ -94,10 +94,11 @@ public function __toString(): string private function prettify(string $xml): string { - $doc = new \DOMDocument('1.0'); + $doc = new DOMDocument('1.0'); $doc->preserveWhiteSpace = false; $doc->formatOutput = true; $doc->loadXML($xml); - return $doc->saveXML(); + + return (string) $doc->saveXML(); } } diff --git a/src/Exception/InvalidXMLSchema.php b/src/Exception/InvalidXMLSchema.php index 8c479d9..5c853a9 100644 --- a/src/Exception/InvalidXMLSchema.php +++ b/src/Exception/InvalidXMLSchema.php @@ -1,5 +1,7 @@ true, 'PDFAauto' => true,]); + $mpdf = new Mpdf(['PDFA' => true, 'PDFAauto' => true]); $pageCount = $mpdf->setSourceFile( - is_string($stream) ? StreamReader::createByString($stream) : $stream + is_string($stream) ? StreamReader::createByString($stream) : $stream, ); - for ($i = 1; $i <= $pageCount; $i++) { + for ($i = 1; $i <= $pageCount; ++$i) { $mpdf->AddPage(); $mpdf->UseTemplate($mpdf->ImportPage($i)); } @@ -39,12 +40,15 @@ public function createFile($stream, string $appendix): string 'mime' => 'text/xml', 'description' => 'Factur-X Invoice', 'AFRelationship' => 'Alternative', - 'content' => $appendix + 'content' => $appendix, ]]); $mpdf->SetAdditionalXmpRdf(file_get_contents(__DIR__ . '/XmlSpecifications/Factur-X_extension_schema.xmp')); - return $mpdf->Output('', Destination::STRING_RETURN); + /** @var string $output */ + $output = $mpdf->Output('', Destination::STRING_RETURN); + + return $output; } /** diff --git a/src/Schema.php b/src/Schema.php index 8459340..476dd0b 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -1,5 +1,7 @@ element->addChild("ram:$name", $value, Namespaces::NS_RAM->value); + + return new self($child); + } + + public function udt(string $name, ?string $value = null): self + { + $child = $this->element->addChild("udt:$name", $value, Namespaces::NS_UDT->value); + + return new self($child); + } + + public function rsm(string $name, ?string $value = null): self + { + $child = $this->element->addChild("rsm:$name", $value, Namespaces::NS_RSM->value); + + return new self($child); + } + + public function attr(string $name, string $value): self + { + $this->element->addAttribute($name, $value); + + return $this; + } + + public function unwrap(): SimpleXMLElement + { + return $this->element; + } +} diff --git a/src/XmlSpecifications/Namespaces.php b/src/XmlSpecifications/Namespaces.php index ff3c85a..b9e35e2 100644 --- a/src/XmlSpecifications/Namespaces.php +++ b/src/XmlSpecifications/Namespaces.php @@ -1,5 +1,7 @@ facturX = new FacturX(); + } + + // ======================================== + // Tests for validate() + // ======================================== + + public function test_validate_throws_exception_for_invalid_xml(): void + { + $invalidXml = 'content'; + + $this->expectException(InvalidXMLSchema::class); + $this->facturX->validate($invalidXml, Schema::MINIMUM); + } + + public function test_validate_does_not_throw_for_valid_xml(): void + { + $validXml = $this->getValidMinimumXml(); + + $this->facturX->validate($validXml, Schema::MINIMUM); + $this->expectNotToPerformAssertions(); + } + + public function test_validate_exception_contains_errors(): void + { + $invalidXml = 'content'; + + try { + $this->facturX->validate($invalidXml, Schema::MINIMUM); + $this->fail('Expected InvalidXMLSchema exception'); + } catch (InvalidXMLSchema $e) { + $this->assertNotEmpty($e->errors); + $this->assertContainsOnlyInstancesOf(LibXMLError::class, $e->errors); + } + } + + // ======================================== + // Tests for isValid() + // ======================================== + + public function test_is_valid_returns_true_for_valid_xml(): void + { + $validXml = $this->getValidMinimumXml(); + + $this->assertTrue($this->facturX->isValid($validXml, Schema::MINIMUM)); + } + + public function test_is_valid_returns_false_for_invalid_xml(): void + { + $invalidXml = 'content'; + + $this->assertFalse($this->facturX->isValid($invalidXml, Schema::MINIMUM)); + } + + public function test_is_valid_returns_false_for_wrong_schema(): void + { + $validXml = $this->getValidMinimumXml(); + + // MINIMUM XML should not validate against BASIC_WL schema + $this->assertFalse($this->facturX->isValid($validXml, Schema::BASIC_WL)); + } + + // ======================================== + // Tests for getValidationErrors() + // ======================================== + + public function test_get_validation_errors_returns_empty_array_for_valid_xml(): void + { + $validXml = $this->getValidMinimumXml(); + + $errors = $this->facturX->getValidationErrors($validXml, Schema::MINIMUM); + + $this->assertEmpty($errors); + } + + public function test_get_validation_errors_returns_errors_for_invalid_xml(): void + { + $invalidXml = 'content'; + + $errors = $this->facturX->getValidationErrors($invalidXml, Schema::MINIMUM); + + $this->assertNotEmpty($errors); + $this->assertContainsOnlyInstancesOf(LibXMLError::class, $errors); + } + + // ======================================== + // Tests for createFile() + // ======================================== + + public function test_create_file_with_string_pdf(): void + { + $pdfContent = $this->generateValidPdf(); + $xmlAppendix = $this->getValidMinimumXml(); + + $result = $this->facturX->createFile($pdfContent, $xmlAppendix); + + $this->assertStringStartsWith('%PDF', $result); + } + + public function test_create_file_with_resource(): void + { + $pdfContent = $this->generateValidPdf(); + $stream = fopen('php://memory', 'r+'); + $this->assertIsResource($stream); + fwrite($stream, $pdfContent); + rewind($stream); + + $xmlAppendix = $this->getValidMinimumXml(); + + $result = $this->facturX->createFile($stream, $xmlAppendix); + + fclose($stream); + + $this->assertStringStartsWith('%PDF', $result); + } + + public function test_create_file_throws_exception_for_invalid_stream_type(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('$stream must be a resource or a string'); + + // @phpstan-ignore-next-line Testing invalid type + $this->facturX->createFile(12345, ''); + } + + public function test_create_file_throws_exception_for_null(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('$stream must be a resource or a string'); + + // @phpstan-ignore-next-line Testing invalid type + $this->facturX->createFile(null, ''); + } + + public function test_create_file_throws_exception_for_array(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('$stream must be a resource or a string'); + + // @phpstan-ignore-next-line Testing invalid type + $this->facturX->createFile(['invalid'], ''); + } + + public function test_create_file_output_contains_xml_appendix(): void + { + $pdfContent = $this->generateValidPdf(); + $xmlAppendix = $this->getValidMinimumXml(); + + $result = $this->facturX->createFile($pdfContent, $xmlAppendix); + + // The PDF should contain the factur-x.xml filename reference + $this->assertStringContainsString('factur-x.xml', $result); + } + + // ======================================== + // Helper methods + // ======================================== + + private function getValidMinimumXml(): string + { + return << + + + + urn:factur-x.eu:1p0:minimum + + + + F0001 + 380 + + 20240115 + + + + + + Seller Company + + 12345678901234 + + + FR + + + FR12345678901 + + + + Buyer Company + + 98765432109876 + + + FR + + + FR98765432109 + + + + + + EUR + + 100.00 + 120.00 + 120.00 + + + + +XML; + } + + private function generateValidPdf(): string + { + $mpdf = new Mpdf(['tempDir' => sys_get_temp_dir()]); + $mpdf->WriteHTML('

Test Invoice

'); + + /** @var string $output */ + $output = $mpdf->Output('', Destination::STRING_RETURN); + + return $output; + } +} diff --git a/tests/SchemaMinimumTest.php b/tests/SchemaMinimumTest.php new file mode 100644 index 0000000..babac71 --- /dev/null +++ b/tests/SchemaMinimumTest.php @@ -0,0 +1,251 @@ +facturX = new FacturX(); + } + + #[DataProvider('validMinimumInvoicesProvider')] + public function test_valid_minimum_invoices(CrossIndustryInvoice $invoice, string $description): void + { + $xml = $invoice->toXml(); + $errors = $this->facturX->getValidationErrors($xml, Schema::MINIMUM); + + $this->assertEmpty( + $errors, + sprintf( + "Invoice '%s' should be valid against MINIMUM schema.\nErrors: %s", + $description, + implode("\n", array_map(fn($e) => $e->message, $errors)), + ), + ); + } + + #[DataProvider('invalidMinimumInvoicesProvider')] + public function test_invalid_minimum_invoices(CrossIndustryInvoice $invoice, string $description): void + { + $xml = $invoice->toXml(); + + $this->assertFalse( + $this->facturX->isValid($xml, Schema::MINIMUM), + sprintf("Invoice '%s' should NOT be valid against MINIMUM schema.", $description), + ); + } + + /** + * @return iterable + */ + public static function validMinimumInvoicesProvider(): iterable + { + yield 'basic invoice' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller Company', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer Company', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ), + 'basic invoice', + ]; + + yield 'invoice with buyer reference (Chorus Pro)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0002', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Stann', '34261828837536', CountryCode::FRANCE, new VATNumber('FR520000000')), + buyerTradeParty: new TradeParty('Mairie de Toulouse', '10881392400937', CountryCode::FRANCE, new VATNumber('FR980000000')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + buyerReference: '1004', + ), + 'invoice with buyer reference (Chorus Pro)', + ]; + + yield 'invoice with buyer reference and order reference' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0003', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Stann', '34261828837536', CountryCode::FRANCE, new VATNumber('FR520000000')), + buyerTradeParty: new TradeParty('Mairie de Toulouse', '10881392400937', CountryCode::FRANCE, new VATNumber('FR980000000')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + buyerReference: '1004', + buyerOrderReference: 'BDC000001', + ), + 'invoice with buyer reference and order reference', + ]; + + yield 'credit note' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('AV0001', TypeCode::CREDIT_NOTE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', -100.00, -120.00, -120.00), + ), + 'credit note', + ]; + + yield 'invoice with German parties' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('DE-001', TypeCode::INVOICE, new DateTimeImmutable('2024-02-20')), + sellerTradeParty: new TradeParty('Deutsche Firma GmbH', 'HRB123456', CountryCode::GERMANY, new VATNumber('DE123456789')), + buyerTradeParty: new TradeParty('Käufer AG', 'HRB654321', CountryCode::GERMANY, new VATNumber('DE987654321')), + monetarySummation: new MonetarySummation('EUR', 500.00, 595.00, 595.00), + ), + 'invoice with German parties', + ]; + + yield 'invoice with different currencies (USD)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('USD-001', TypeCode::INVOICE, new DateTimeImmutable('2024-03-01')), + sellerTradeParty: new TradeParty('US Seller Inc', '123456789', CountryCode::UNITED_STATES_OF_AMERICA, new VATNumber('US123456789')), + buyerTradeParty: new TradeParty('US Buyer LLC', '987654321', CountryCode::UNITED_STATES_OF_AMERICA, new VATNumber('US987654321')), + monetarySummation: new MonetarySummation('USD', 1000.00, 1100.00, 1100.00), + ), + 'invoice with different currencies (USD)', + ]; + + yield 'invoice with zero tax' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('NOTAX-001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 100.00, 100.00), + ), + 'invoice with zero tax', + ]; + + yield 'invoice with large amounts' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('BIG-001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Big Corp', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Mega Corp', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 999999999.99, 1199999999.99, 1199999999.99), + ), + 'invoice with large amounts', + ]; + + yield 'self-billing invoice' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('SB-001', TypeCode::SELF_BILLING_INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ), + 'self-billing invoice', + ]; + + yield 'deposit invoice' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('DEP-001', TypeCode::INVOICE_DEPOSIT, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 500.00, 600.00, 600.00), + ), + 'deposit invoice', + ]; + } + + /** + * @return iterable + */ + public static function invalidMinimumInvoicesProvider(): iterable + { + yield 'invoice with ContractReferencedDocument (not allowed in MINIMUM)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + buyerContractReference: 'CONTRACT-001', + ), + 'invoice with ContractReferencedDocument (not allowed in MINIMUM)', + ]; + } + + /** + * @return iterable + */ + public static function validMinimumOptionalFieldsProvider(): iterable + { + yield 'invoice without VAT number (optional in MINIMUM)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, null), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, null), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ), + 'invoice without VAT number (optional in MINIMUM)', + ]; + + yield 'invoice without country code (optional in MINIMUM)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', null, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', null, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ), + 'invoice without country code (optional in MINIMUM)', + ]; + + yield 'invoice with minimal data (only required fields)' => [ + new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '', null, null), + buyerTradeParty: new TradeParty('Buyer', '', null, null), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ), + 'invoice with minimal data (only required fields)', + ]; + } + + #[DataProvider('validMinimumOptionalFieldsProvider')] + public function test_valid_minimum_with_optional_fields(CrossIndustryInvoice $invoice, string $description): void + { + $xml = $invoice->toXml(); + $errors = $this->facturX->getValidationErrors($xml, Schema::MINIMUM); + + $this->assertEmpty( + $errors, + sprintf( + "Invoice '%s' should be valid against MINIMUM schema.\nErrors: %s", + $description, + implode("\n", array_map(fn($e) => $e->message, $errors)), + ), + ); + } + + public function test_minimum_schema_does_not_validate_against_other_schemas(): void + { + $invoice = new CrossIndustryInvoice( + document: new ExchangedDocument('F0001', TypeCode::INVOICE, new DateTimeImmutable('2024-01-15')), + sellerTradeParty: new TradeParty('Seller', '12345678901234', CountryCode::FRANCE, new VATNumber('FR12345678901')), + buyerTradeParty: new TradeParty('Buyer', '98765432109876', CountryCode::FRANCE, new VATNumber('FR98765432109')), + monetarySummation: new MonetarySummation('EUR', 100.00, 120.00, 120.00), + ); + + $xml = $invoice->toXml(); + + $this->assertTrue($this->facturX->isValid($xml, Schema::MINIMUM), 'Should be valid for MINIMUM'); + $this->assertFalse($this->facturX->isValid($xml, Schema::BASIC_WL), 'Should NOT be valid for BASIC_WL'); + $this->assertFalse($this->facturX->isValid($xml, Schema::BASIC), 'Should NOT be valid for BASIC'); + $this->assertFalse($this->facturX->isValid($xml, Schema::EN16931), 'Should NOT be valid for EN16931'); + $this->assertFalse($this->facturX->isValid($xml, Schema::EXTENDED), 'Should NOT be valid for EXTENDED'); + } +}