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