From 09df4ad0092c6b96e5e68f8346d467b7864d723d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Tue, 6 Jan 2026 13:00:11 +0100 Subject: [PATCH 1/6] Include IssueTime in XML serialization when set, formatted as H:i:s in Invoice xml tag --- src/Invoice.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Invoice.php b/src/Invoice.php index 141d9d9..8102f56 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -21,6 +21,7 @@ class Invoice implements XmlSerializable, XmlDeserializable private $id; private $copyIndicator; private $issueDate; + private ?DateTime $issueTime; protected $invoiceTypeCode = InvoiceTypeCode::INVOICE; private $note; private $taxPointDate; @@ -144,6 +145,25 @@ public function setIssueDate(?DateTime $issueDate) return $this; } + /** + * @return DateTime + */ + public function getIssueTime() : ?DateTime + { + return $this->issueTime ?? null; + } + + /** + * @param DateTime $issueTime + * @return static + */ + public function setIssueTime(?DateTime $issueTime = null) : self + { + $this->issueTime = $issueTime; + + return $this; + } + /** * @return DateTime */ @@ -756,6 +776,12 @@ public function xmlSerialize(Writer $writer): void Schema::CBC . "IssueDate" => $this->issueDate->format("Y-m-d"), ]); + if (isset($this->issueTime)) { + $writer->write([ + Schema::CBC . 'IssueTime' => $this->issueTime->format('H:i:s'), + ]); + } + if ($this->dueDate !== null && $this->xmlTagName === "Invoice") { $writer->write([ Schema::CBC . "DueDate" => $this->dueDate->format("Y-m-d"), From 3cabacd0495e12016b2eca521141f733107d8f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Tue, 6 Jan 2026 13:25:34 +0100 Subject: [PATCH 2/6] Added issueTime for the SimpleInvoiceTest --- tests/Write/SimpleInvoiceTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Write/SimpleInvoiceTest.php b/tests/Write/SimpleInvoiceTest.php index 8b6a87a..5208312 100644 --- a/tests/Write/SimpleInvoiceTest.php +++ b/tests/Write/SimpleInvoiceTest.php @@ -138,6 +138,7 @@ public function testIfXMLIsValid() ->setId(1234) ->setCopyIndicator(false) ->setIssueDate(new \DateTime()) + ->setIssueTime(new \DateTime()) ->setAccountingSupplierParty($accountingSupplierParty) ->setAccountingCustomerParty($accountingCustomerParty) ->setInvoiceLines($invoiceLines) From 4ae1bc6374cdb1687cfe7114fa9b8e48552dfdf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Wed, 7 Jan 2026 13:50:21 +0100 Subject: [PATCH 3/6] IssueTime is filled in deserializeTag --- src/Invoice.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Invoice.php b/src/Invoice.php index 8102f56..e06d12b 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -1022,6 +1022,14 @@ protected static function deserializedTag(array $mixedContent) ), )->toDateTime(), ) + ->setIssueTime( + Carbon::parse( + ReaderHelper::getTagValue( + Schema::CBC . "IssueTime", + $collection, + ), + )->toDateTime(), + ) ->setDueDate( Carbon::parse( ReaderHelper::getTagValue( From b6491050631167b9d75b7787e6524f8bbbe609b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Thu, 8 Jan 2026 16:50:41 +0100 Subject: [PATCH 4/6] Add Extension support for UBL documents - Add Extension class with content and attributes support - Add extensions property to Invoice with getter/setter methods - Add EXT namespace constant to Schema class - Update Generator to conditionally include ext namespace and support additional namespaces - Update Reader to deserialize UBLExtension elements - Add ExtensionTest demonstrating extension usage with custom namespaces --- src/Extension.php | 69 +++++++++++ src/Generator.php | 10 +- src/Invoice.php | 45 ++++++++ src/Reader.php | 1 + src/Schema.php | 1 + tests/Write/ExtensionTest.php | 208 ++++++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/Extension.php create mode 100644 tests/Write/ExtensionTest.php diff --git a/src/Extension.php b/src/Extension.php new file mode 100644 index 0000000..2f79f70 --- /dev/null +++ b/src/Extension.php @@ -0,0 +1,69 @@ +content; + } + + public function setContent($content): self + { + $this->content = $content; + + return $this; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function setAttributes(array $attributes): self + { + $this->attributes = $attributes; + + return $this; + } + + public function xmlSerialize(Writer $writer): void + { + $writer->write([ + [ + 'name' => Schema::EXT . 'UBLExtension', + 'value' => [ + Schema::EXT . 'ExtensionContent' => $this->content + ], + 'attributes' => $this->attributes + ] + ]); + } + + public static function xmlDeserialize(Reader $reader) + { + $keyValues = keyValue($reader); + $mixedContent = mixedContent($reader); + $collection = new ArrayCollection($mixedContent); + + $extensionContentTag = ReaderHelper::getTag(Schema::EXT . 'ExtensionContent', $collection); + + return (new static()) + ->setContent($keyValues[Schema::EXT . 'ExtensionContent'] ?? null) + ->setAttributes(isset($extensionContentTag, $extensionContentTag['attributes']) ? $extensionContentTag['attributes'] : []) + ; + } +} \ No newline at end of file diff --git a/src/Generator.php b/src/Generator.php index 477c16e..7ecb951 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -8,7 +8,7 @@ class Generator { public static $currencyID; - public static function invoice(Invoice $invoice, $currencyId = 'EUR') + public static function invoice(Invoice $invoice, $currencyId = 'EUR', array $additionalNamespaces = []) { self::$currencyID = $currencyId; @@ -20,6 +20,14 @@ public static function invoice(Invoice $invoice, $currencyId = 'EUR') 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2' => 'cac' ]; + if ($invoice->getExtensions()) { + $xmlService->namespaceMap['urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2'] = 'ext'; + } + + foreach ($additionalNamespaces as $namespace => $prefix) { + $xmlService->namespaceMap[$namespace] = $prefix; + } + return $xmlService->write($invoice->xmlTagName, [ $invoice ]); diff --git a/src/Invoice.php b/src/Invoice.php index e06d12b..ea6ac0c 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -51,6 +51,8 @@ class Invoice implements XmlSerializable, XmlDeserializable private $despatchDocumentReference; private $receiptDocumentReference; private $originatorDocumentReference; + /** @var Extension[] $extensions */ + private array $extensions = []; /** * @return string @@ -695,6 +697,36 @@ public function setOriginatorDocumentReference( return $this; } + /** + * @param Extension $extension + * @return static + */ + public function addExtension(Extension $extension): self + { + $this->extensions[] = $extension; + + return $this; + } + + /** + * @param Extension[] $extensions + * @return static + */ + public function setExtensions(array $extensions): self + { + $this->extensions = $extensions; + + return $this; + } + + /** + * @return Extension[] + */ + public function getExtensions(): array + { + return $this->extensions; + } + /** * The validate function that is called during xml writing to valid the data of the object. * @@ -749,6 +781,13 @@ public function xmlSerialize(Writer $writer): void { $this->validate(); + if ($this->extensions) { + $writer->write([ + 'name' => Schema::EXT . 'UBLExtensions', + 'value' => $this->extensions + ]); + } + $writer->write([ Schema::CBC . "UBLVersionID" => $this->UBLVersionID, Schema::CBC . "CustomizationID" => $this->customizationID, @@ -989,6 +1028,12 @@ protected static function deserializedTag(array $mixedContent) ); return (new static()) + ->setExtensions( + ReaderHelper::getArrayValue( + Schema::EXT . "UBLExtensions", + $collection, + ), + ) ->setUBLVersionId( ReaderHelper::getTagValue( Schema::CBC . "UBLVersionID", diff --git a/src/Reader.php b/src/Reader.php index 2ec38e4..5417974 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -67,6 +67,7 @@ public static function ubl($currencyId = 'EUR'): Service Schema::CAC. 'TaxScheme' => fn ($reader) => TaxScheme::xmlDeserialize($reader), Schema::CAC. 'TaxSubtotal' => fn ($reader) => TaxSubTotal::xmlDeserialize($reader), Schema::CAC. 'TaxTotal' => fn ($reader) => TaxTotal::xmlDeserialize($reader), + Schema::EXT. 'UBLExtension' => fn ($reader) => Extension::xmlDeserialize($reader), ]; return $xmlService; diff --git a/src/Schema.php b/src/Schema.php index f394c92..8f21424 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -8,4 +8,5 @@ class Schema public const CREDITNOTE = '{urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2}'; public const CBC = '{urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2}'; public const CAC = '{urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2}'; + public const EXT = '{urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2}'; } diff --git a/tests/Write/ExtensionTest.php b/tests/Write/ExtensionTest.php new file mode 100644 index 0000000..fa2abdc --- /dev/null +++ b/tests/Write/ExtensionTest.php @@ -0,0 +1,208 @@ +setIdentificationCode('BE'); + + $address = (new \NumNum\UBL\Address()) + ->setStreetName('Korenmarkt') + ->setBuildingNumber(1) + ->setCityName('Gent') + ->setPostalZone('9000') + ->setCountry($country); + + $supplierCompany = (new \NumNum\UBL\Party()) + ->setName('Supplier Company Name') + ->setPhysicalLocation($address) + ->setPostalAddress($address); + + $clientContact = (new \NumNum\UBL\Contact()) + ->setName('Client name') + ->setElectronicMail('email@client.com') + ->setTelephone('0032 472 123 456') + ->setTelefax('0032 9 1234 567'); + + $clientCompany = (new \NumNum\UBL\Party()) + ->setName('My client') + ->setPostalAddress($address) + ->setContact($clientContact); + + $legalMonetaryTotal = (new \NumNum\UBL\LegalMonetaryTotal()) + ->setPayableAmount(10 + 2) + ->setAllowanceTotalAmount(0); + + $taxScheme = (new \NumNum\UBL\TaxScheme()) + ->setId(0); + + $productItem = (new \NumNum\UBL\Item()) + ->setName('Product Name') + ->setDescription('Product Description') + ->setSellersItemIdentification('SELLERID'); + + $price = (new \NumNum\UBL\Price()) + ->setBaseQuantity(1) + ->setUnitCode(\NumNum\UBL\UnitCode::UNIT) + ->setPriceAmount(10); + + $lineTaxTotal = (new \NumNum\UBL\TaxTotal()) + ->setTaxAmount(2.5); + + $invoicePeriod = (new \NumNum\UBL\InvoicePeriod()) + ->setStartDate(new \DateTime()); + + $invoiceLines = []; + + $orderLineReference = (new \NumNum\UBL\OrderLineReference()) + ->setLineId('#ABC123'); + + $invoiceLines[] = (new \NumNum\UBL\InvoiceLine()) + ->setId(0) + ->setItem($productItem) + ->setInvoicePeriod($invoicePeriod) + ->setPrice($price) + ->setTaxTotal($lineTaxTotal) + ->setInvoicedQuantity(1) + ->setOrderLineReference($orderLineReference); + + $invoiceLines[] = (new \NumNum\UBL\InvoiceLine()) + ->setId(0) + ->setItem($productItem) + ->setInvoicePeriod($invoicePeriod) + ->setPrice($price) + ->setAccountingCost('Product 123') + ->setTaxTotal($lineTaxTotal) + ->setInvoicedQuantity(1) + ->setOrderLineReference($orderLineReference); + + $invoiceLines[] = (new \NumNum\UBL\InvoiceLine()) + ->setId(0) + ->setItem($productItem) + ->setInvoicePeriod($invoicePeriod) + ->setPrice($price) + ->setAccountingCostCode('Product 123') + ->setTaxTotal($lineTaxTotal) + ->setInvoicedQuantity(1) + ->setOrderLineReference($orderLineReference); + + $taxCategory = (new \NumNum\UBL\TaxCategory()) + ->setId(0) + ->setName('VAT25%') + ->setPercent(.25) + ->setTaxScheme($taxScheme); + + $taxSubTotal = (new \NumNum\UBL\TaxSubTotal()) + ->setTaxableAmount(10) + ->setTaxAmount(2.5) + ->setTaxCategory($taxCategory); + + $taxTotal = (new \NumNum\UBL\TaxTotal()) + ->addTaxSubTotal($taxSubTotal) + ->setTaxAmount(2.5); + + $accountingSupplierParty = (new \NumNum\UBL\AccountingParty()) + ->setParty($supplierCompany); + + $accountingCustomerParty = (new \NumNum\UBL\AccountingParty()) + ->setSupplierAssignedAccountId('10001') + ->setParty($clientCompany); + + $extension = (new \NumNum\UBL\Extension()) + ->setContent([ + 'hrextac:HRFISK20Data' => [ + 'hrextac:HRTaxTotal' => [ + [ + 'name' => 'cbc:TaxAmount', + 'value' => 2.5, + 'attributes' => [ + 'currencyID' => 'EUR' + ] + ], + 'hrextac:HRTaxSubtotal' => [ + [ + [ + 'name' => 'cbc:TaxableAmount', + 'value' => 10, + 'attributes' => [ + 'currencyID' => 'EUR' + ] + ], + [ + 'name' => 'cbc:TaxAmount', + 'value' => 2.5, + 'attributes' => [ + 'currencyID' => 'EUR' + ] + ], + 'hrextac:HRTaxCategory' => [ + 'cbc:ID' => 'S', + 'cbc:Name' => 'HR:PDV25', + 'cbc:Percent' => '25', + 'hrextac:HRTaxScheme' => [ + 'cbc:ID' => 'VAT' + ] + ] + ] + ] + ], + 'hrextac:HRLegalMonetaryTotal' => [ + [ + 'name' => 'cbc:TaxExclusiveAmount', + 'value' => 10, + 'attributes' => [ + 'currencyID' => 'EUR' + ] + ], + [ + 'name' => 'hrextac:OutOfScopeOfVATAmount', + 'value' => 0, + 'attributes' => [ + 'currencyID' => 'EUR' + ] + ] + ] + ] + ]) + ->setAttributes([ + 'xmlns:cct' => 'urn:un:unece:uncefact:data:specification:CoreComponentTypeSchemaModule:2', + 'xmlns:p3' => 'urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2', + 'xmlns:xsi' => 'https://www.w3.org/2001/XMLSchema-instance' + ]); + + $invoice = (new \NumNum\UBL\Invoice()) + ->setId(1234) + ->setCopyIndicator(false) + ->setIssueDate(new \DateTime()) + ->setIssueTime(new \DateTime()) + ->setAccountingSupplierParty($accountingSupplierParty) + ->setAccountingCustomerParty($accountingCustomerParty) + ->setInvoiceLines($invoiceLines) + ->setLegalMonetaryTotal($legalMonetaryTotal) + ->setTaxTotal($taxTotal) + ->addExtension($extension); + + $generator = new \NumNum\UBL\Generator(); + $outputXMLString = $generator->invoice($invoice, 'EUR', [ + 'urn:mfin.gov.hr:schema:xsd:HRExtensionAggregateComponents-1' => 'hrextac' + ]); + + $dom = new \DOMDocument(); + $dom->loadXML($outputXMLString); + + $dom->save('./tests/ExtensionTest.xml'); + + $this->assertEquals(true, $dom->schemaValidate($this->schema)); + } +} From 09f564381967f69c2f564a3a2f842edcf68575ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Thu, 8 Jan 2026 16:55:31 +0100 Subject: [PATCH 5/6] removed some accidental commit --- src/Invoice.php | 34 ------------------------------- tests/Write/SimpleInvoiceTest.php | 1 - 2 files changed, 35 deletions(-) diff --git a/src/Invoice.php b/src/Invoice.php index ea6ac0c..ab8cdcf 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -21,7 +21,6 @@ class Invoice implements XmlSerializable, XmlDeserializable private $id; private $copyIndicator; private $issueDate; - private ?DateTime $issueTime; protected $invoiceTypeCode = InvoiceTypeCode::INVOICE; private $note; private $taxPointDate; @@ -147,25 +146,6 @@ public function setIssueDate(?DateTime $issueDate) return $this; } - /** - * @return DateTime - */ - public function getIssueTime() : ?DateTime - { - return $this->issueTime ?? null; - } - - /** - * @param DateTime $issueTime - * @return static - */ - public function setIssueTime(?DateTime $issueTime = null) : self - { - $this->issueTime = $issueTime; - - return $this; - } - /** * @return DateTime */ @@ -815,12 +795,6 @@ public function xmlSerialize(Writer $writer): void Schema::CBC . "IssueDate" => $this->issueDate->format("Y-m-d"), ]); - if (isset($this->issueTime)) { - $writer->write([ - Schema::CBC . 'IssueTime' => $this->issueTime->format('H:i:s'), - ]); - } - if ($this->dueDate !== null && $this->xmlTagName === "Invoice") { $writer->write([ Schema::CBC . "DueDate" => $this->dueDate->format("Y-m-d"), @@ -1067,14 +1041,6 @@ protected static function deserializedTag(array $mixedContent) ), )->toDateTime(), ) - ->setIssueTime( - Carbon::parse( - ReaderHelper::getTagValue( - Schema::CBC . "IssueTime", - $collection, - ), - )->toDateTime(), - ) ->setDueDate( Carbon::parse( ReaderHelper::getTagValue( diff --git a/tests/Write/SimpleInvoiceTest.php b/tests/Write/SimpleInvoiceTest.php index 5208312..8b6a87a 100644 --- a/tests/Write/SimpleInvoiceTest.php +++ b/tests/Write/SimpleInvoiceTest.php @@ -138,7 +138,6 @@ public function testIfXMLIsValid() ->setId(1234) ->setCopyIndicator(false) ->setIssueDate(new \DateTime()) - ->setIssueTime(new \DateTime()) ->setAccountingSupplierParty($accountingSupplierParty) ->setAccountingCustomerParty($accountingCustomerParty) ->setInvoiceLines($invoiceLines) From ca8aeb7fef7d6cee7fe52a95e46df2be8504ce9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hevesi=20Gerg=C5=91?= Date: Thu, 15 Jan 2026 10:29:00 +0100 Subject: [PATCH 6/6] fixed a test: removed a function call which does not exist (yet) --- tests/Write/ExtensionTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Write/ExtensionTest.php b/tests/Write/ExtensionTest.php index fa2abdc..9e87252 100644 --- a/tests/Write/ExtensionTest.php +++ b/tests/Write/ExtensionTest.php @@ -185,7 +185,6 @@ public function testIfXMLIsValid() ->setId(1234) ->setCopyIndicator(false) ->setIssueDate(new \DateTime()) - ->setIssueTime(new \DateTime()) ->setAccountingSupplierParty($accountingSupplierParty) ->setAccountingCustomerParty($accountingCustomerParty) ->setInvoiceLines($invoiceLines)