From d41201a083564b0679851b4026f685af6fcaec75 Mon Sep 17 00:00:00 2001 From: Jasper Briers Date: Mon, 24 Aug 2026 19:38:14 +0200 Subject: [PATCH] Fix document reference elements being written in the wrong order UBL declares the document reference elements in an xsd:sequence, so the order they are written in is not cosmetic. Invoice, CreditNote and DebitNote share Invoice::xmlSerialize(), but each schema requires a different order, and the single hardcoded order matched none of them. On an Invoice, cac:ContractDocumentReference was written before cac:DespatchDocumentReference, and cac:OriginatorDocumentReference after cac:AdditionalDocumentReference. Any document combining a contract reference with a despatch, receipt or additional reference therefore failed schema validation. Also re-enables the contract reference in ContractDocumentReferenceTest, which built the object but never attached it to the invoice. --- changelog/next-release.md | 7 + src/Invoice.php | 104 ++++++---- tests/Write/ContractDocumentReferenceTest.php | 2 +- tests/Write/DocumentReferenceOrderTest.php | 194 ++++++++++++++++++ 4 files changed, 261 insertions(+), 46 deletions(-) create mode 100644 tests/Write/DocumentReferenceOrderTest.php diff --git a/changelog/next-release.md b/changelog/next-release.md index ea178bf..246f560 100644 --- a/changelog/next-release.md +++ b/changelog/next-release.md @@ -2,6 +2,13 @@ ## Fixed +- Fix document reference elements being written in the wrong order, producing XML that fails UBL schema validation + - UBL declares these elements in an `xsd:sequence`, and the required order differs per document type + - On an `Invoice`, `cac:ContractDocumentReference` was written before `cac:DespatchDocumentReference` + and `cac:OriginatorDocumentReference` after `cac:AdditionalDocumentReference` + - A `CreditNote` and a `DebitNote` inherit the same serializer but require a different order again + - Any document combining a contract reference with a despatch, receipt or additional reference was affected + - Fix TypeError: Change setter types to nullable in reference classes to handle empty XML elements gracefully during parsing - `OrderReference::setId()` now accepts `?string` - `ProjectReference::setId()` now accepts `?string` diff --git a/src/Invoice.php b/src/Invoice.php index c4aede7..b604b77 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -775,6 +775,44 @@ public function validate() } } + /** + * The order in which the document reference elements have to be written. + * + * UBL 2.1 declares these elements in an xsd:sequence, so writing them in + * any other order produces a document that fails schema validation. The + * required order is not the same for every document type. + * + * Elements that a document type does not declare at all (ProjectReference + * on a CreditNote or DebitNote, OriginatorDocumentReference on a DebitNote) + * are written last, which keeps the previous behaviour for those documents. + * + * @return string[] + */ + protected function documentReferenceOrder(): array + { + if ($this->xmlTagName === 'Invoice') { + return [ + 'BillingReference', + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'OriginatorDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'ProjectReference', + ]; + } + + return [ + 'BillingReference', + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'OriginatorDocumentReference', + 'ProjectReference', + ]; + } + /** * The xmlSerialize method is called during xml writing. * @param Writer $writer @@ -874,58 +912,34 @@ public function xmlSerialize(Writer $writer): void ]); } - if ($this->billingReference != null) { - $writer->write([ - Schema::CAC . "BillingReference" => $this->billingReference, - ]); - } - - if ($this->contractDocumentReference !== null) { - $writer->write([ - Schema::CAC . - "ContractDocumentReference" => $this->contractDocumentReference, - ]); - } - - if ($this->despatchDocumentReference !== null) { - $writer->write([ - Schema::CAC . - "DespatchDocumentReference" => $this->despatchDocumentReference, - ]); - } + $documentReferences = [ + "BillingReference" => $this->billingReference, + "DespatchDocumentReference" => $this->despatchDocumentReference, + "ReceiptDocumentReference" => $this->receiptDocumentReference, + "OriginatorDocumentReference" => $this->originatorDocumentReference, + "ContractDocumentReference" => $this->contractDocumentReference, + "AdditionalDocumentReference" => $this->additionalDocumentReferences, + "ProjectReference" => $this->projectReference, + ]; + + foreach ($this->documentReferenceOrder() as $elementName) { + $documentReference = $documentReferences[$elementName]; + + if ($documentReference === null) { + continue; + } - if ($this->receiptDocumentReference !== null) { - $writer->write([ - Schema::CAC . - "ReceiptDocumentReference" => $this->receiptDocumentReference, - ]); - } + if (!is_array($documentReference)) { + $documentReference = [$documentReference]; + } - if (!empty($this->additionalDocumentReferences)) { - foreach ( - $this->additionalDocumentReferences - as $additionalDocumentReference - ) { + foreach ($documentReference as $reference) { $writer->write([ - Schema::CAC . - "AdditionalDocumentReference" => $additionalDocumentReference, + Schema::CAC . $elementName => $reference, ]); } } - if ($this->originatorDocumentReference !== null) { - $writer->write([ - Schema::CAC . - "OriginatorDocumentReference" => $this->originatorDocumentReference, - ]); - } - - if ($this->projectReference != null) { - $writer->write([ - Schema::CAC . "ProjectReference" => $this->projectReference, - ]); - } - $writer->write([ Schema::CAC . "AccountingSupplierParty" => $this->accountingSupplierParty, diff --git a/tests/Write/ContractDocumentReferenceTest.php b/tests/Write/ContractDocumentReferenceTest.php index c41a090..8b674e0 100644 --- a/tests/Write/ContractDocumentReferenceTest.php +++ b/tests/Write/ContractDocumentReferenceTest.php @@ -112,7 +112,7 @@ public function testIfXMLIsValid() ->setInvoiceLines($invoiceLines) ->setLegalMonetaryTotal($legalMonetaryTotal) ->setTaxTotal($taxTotal) - // ->setContractDocumentReference($contractDocumentReference) + ->setContractDocumentReference($contractDocumentReference) ->setBuyerReference("SomeReference") ->setInvoicePeriod($invoicePeriod); diff --git a/tests/Write/DocumentReferenceOrderTest.php b/tests/Write/DocumentReferenceOrderTest.php new file mode 100644 index 0000000..add0840 --- /dev/null +++ b/tests/Write/DocumentReferenceOrderTest.php @@ -0,0 +1,194 @@ +setIdentificationCode('BE'); + + $address = (new \NumNum\UBL\Address()) + ->setStreetName('Korenmarkt') + ->setCityName('Gent') + ->setPostalZone('9000') + ->setCountry($country); + + $supplierCompany = (new \NumNum\UBL\Party()) + ->setName('Supplier Company Name') + ->setPostalAddress($address); + + $clientCompany = (new \NumNum\UBL\Party()) + ->setName('Client Company Name') + ->setPostalAddress($address); + + $legalMonetaryTotal = (new \NumNum\UBL\LegalMonetaryTotal()) + ->setPayableAmount(12.1) + ->setTaxExclusiveAmount(10); + + $price = (new \NumNum\UBL\Price()) + ->setPriceAmount(10) + ->setUnitCode(\NumNum\UBL\UnitCode::UNIT); + + $item = (new \NumNum\UBL\Item()) + ->setName('Product Name'); + + $invoiceLine = (new \NumNum\UBL\InvoiceLine()) + ->setId(1) + ->setItem($item) + ->setPrice($price) + ->setLineExtensionAmount(10) + ->setInvoicedQuantity(1); + + return $document + ->setId(1234) + ->setIssueDate(new \DateTime()) + ->setAccountingSupplierParty( + (new \NumNum\UBL\AccountingParty())->setParty($supplierCompany) + ) + ->setAccountingCustomerParty( + (new \NumNum\UBL\AccountingParty())->setParty($clientCompany) + ) + ->setInvoiceLines([$invoiceLine]) + ->setLegalMonetaryTotal($legalMonetaryTotal) + ->setOrderReference((new \NumNum\UBL\OrderReference())->setId('ORD-1')) + ->setBillingReference( + (new \NumNum\UBL\BillingReference())->setInvoiceDocumentReference( + (new \NumNum\UBL\InvoiceDocumentReference())->setOriginalInvoiceId('PREC-1') + ) + ) + ->setDespatchDocumentReference( + (new \NumNum\UBL\DespatchDocumentReference())->setId('DESP-1') + ) + ->setReceiptDocumentReference( + (new \NumNum\UBL\ReceiptDocumentReference())->setId('RCPT-1') + ) + ->setOriginatorDocumentReference( + (new \NumNum\UBL\OriginatorDocumentReference())->setId('ORIG-1') + ) + ->setContractDocumentReference( + (new \NumNum\UBL\ContractDocumentReference())->setId('CTR-1') + ) + ->setAdditionalDocumentReferences([ + (new \NumNum\UBL\AdditionalDocumentReference())->setId('ADD-1'), + ]); + } + + /** + * The names of the direct child elements of the document that carry a + * reference, in the order they were written. + * + * @param string $xml + * @return string[] + */ + private function referenceElementsIn(string $xml): array + { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $elements = []; + + foreach ($dom->documentElement->childNodes as $node) { + if (!$node instanceof \DOMElement) { + continue; + } + + if (substr($node->localName, -9) === 'Reference') { + $elements[] = $node->localName; + } + } + + return $elements; + } + + /** @test */ + public function testInvoiceWritesDocumentReferencesInSchemaOrder() + { + $invoice = $this->withAllDocumentReferences(new \NumNum\UBL\Invoice()); + $invoice->setProjectReference((new \NumNum\UBL\ProjectReference())->setId('PRJ-1')); + + $xml = (new \NumNum\UBL\Generator())->invoice($invoice); + + $this->assertEquals([ + 'OrderReference', + 'BillingReference', + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'OriginatorDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'ProjectReference', + ], $this->referenceElementsIn($xml)); + } + + /** @test */ + public function testCreditNoteWritesDocumentReferencesInSchemaOrder() + { + $creditNote = $this->withAllDocumentReferences(new \NumNum\UBL\CreditNote()); + + $xml = (new \NumNum\UBL\Generator())->creditNote($creditNote); + + // A CreditNote puts ContractDocumentReference before, and + // OriginatorDocumentReference after, AdditionalDocumentReference. + $this->assertEquals([ + 'OrderReference', + 'BillingReference', + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'OriginatorDocumentReference', + ], $this->referenceElementsIn($xml)); + } + + /** @test */ + public function testDebitNoteWritesDocumentReferencesInSchemaOrder() + { + $debitNote = $this->withAllDocumentReferences(new \NumNum\UBL\DebitNote()); + + $xml = (new \NumNum\UBL\Generator())->debitNote($debitNote); + + $this->assertEquals([ + 'OrderReference', + 'BillingReference', + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'OriginatorDocumentReference', + ], $this->referenceElementsIn($xml)); + } + + /** @test */ + public function testOmittedDocumentReferencesAreNotWritten() + { + $invoice = $this->withAllDocumentReferences(new \NumNum\UBL\Invoice()) + ->setDespatchDocumentReference(null) + ->setOriginatorDocumentReference(null) + ->setAdditionalDocumentReferences([]); + + $xml = (new \NumNum\UBL\Generator())->invoice($invoice); + + $this->assertEquals([ + 'OrderReference', + 'BillingReference', + 'ReceiptDocumentReference', + 'ContractDocumentReference', + ], $this->referenceElementsIn($xml)); + } +}