Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/next-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
104 changes: 59 additions & 45 deletions src/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/Write/ContractDocumentReferenceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public function testIfXMLIsValid()
->setInvoiceLines($invoiceLines)
->setLegalMonetaryTotal($legalMonetaryTotal)
->setTaxTotal($taxTotal)
// ->setContractDocumentReference($contractDocumentReference)
->setContractDocumentReference($contractDocumentReference)
->setBuyerReference("SomeReference")
->setInvoicePeriod($invoicePeriod);

Expand Down
194 changes: 194 additions & 0 deletions tests/Write/DocumentReferenceOrderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

namespace NumNum\UBL\Tests\Write;

use PHPUnit\Framework\TestCase;

/**
* UBL 2.1 declares the document reference elements in an xsd:sequence, and the
* required order differs per document type. Writing them in any other order
* produces a document that fails schema validation.
*/
class DocumentReferenceOrderTest extends TestCase
{
/**
* Every document reference element this library can write, applied to a
* document that is otherwise minimal but complete.
*
* @param \NumNum\UBL\Invoice $document
* @return \NumNum\UBL\Invoice
*/
private function withAllDocumentReferences($document)
{
$country = (new \NumNum\UBL\Country())
->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));
}
}
Loading