From 75852cecb64ea4108911ed5b853331c2af826921 Mon Sep 17 00:00:00 2001 From: arnodebeir Date: Mon, 19 Jan 2026 11:13:48 +0100 Subject: [PATCH] Add DebitNote support Add support for UBL 2.1 DebitNote documents, similar to existing CreditNote support. DebitNote differs from Invoice/CreditNote in that: - It has no TypeCode element - Uses RequestedMonetaryTotal instead of LegalMonetaryTotal - Uses DebitedQuantity instead of InvoicedQuantity New files: - src/DebitNote.php - DebitNote class extending Invoice - src/DebitNoteLine.php - DebitNoteLine class extending InvoiceLine - tests/Write/SimpleDebitNoteTest.php - Test for writing DebitNote Modified files: - src/Invoice.php - Add conditional logic for DebitNote specifics - src/InvoiceLine.php - Add DebitedQuantity support - src/Generator.php - Add debitNote() method - src/Reader.php - Add DebitNote deserializer mappings - src/Schema.php - Add DEBITNOTE constant --- src/DebitNote.php | 48 +++++++++++ src/DebitNoteLine.php | 48 +++++++++++ src/Generator.php | 17 ++++ src/Invoice.php | 7 +- src/InvoiceLine.php | 14 +++- src/Reader.php | 2 + src/Schema.php | 1 + tests/Write/SimpleDebitNoteTest.php | 122 ++++++++++++++++++++++++++++ 8 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/DebitNote.php create mode 100644 src/DebitNoteLine.php create mode 100644 tests/Write/SimpleDebitNoteTest.php diff --git a/src/DebitNote.php b/src/DebitNote.php new file mode 100644 index 0000000..0c93a5d --- /dev/null +++ b/src/DebitNote.php @@ -0,0 +1,48 @@ +invoiceLines; + } + + /** + * @param DebitNoteLine[] $debitNoteLines + * @return static + */ + public function setDebitNoteLines(array $debitNoteLines) + { + $this->invoiceLines = $debitNoteLines; + return $this; + } + + /** + * The xmlDeserialize method is called during xml reading. + * @param Reader $reader + * @return static + */ + public static function xmlDeserialize(Reader $reader) + { + $mixedContent = mixedContent($reader); + $collection = new ArrayCollection($mixedContent); + + return (static::deserializedTag($mixedContent)) + ->setDebitNoteLines(ReaderHelper::getArrayValue(Schema::CAC . 'DebitNoteLine', $collection)); + } +} diff --git a/src/DebitNoteLine.php b/src/DebitNoteLine.php new file mode 100644 index 0000000..e9fdf70 --- /dev/null +++ b/src/DebitNoteLine.php @@ -0,0 +1,48 @@ +invoicedQuantity; + } + + /** + * @param ?float $debitedQuantity + * @return static + */ + public function setDebitedQuantity(?float $debitedQuantity) + { + $this->invoicedQuantity = $debitedQuantity; + return $this; + } + + /** + * The xmlDeserialize method is called during xml reading. + * @param Reader $xml + * @return static + */ + public static function xmlDeserialize(Reader $reader) + { + $mixedContent = mixedContent($reader); + $collection = new ArrayCollection($mixedContent); + + $debitedQuantityTag = ReaderHelper::getTag(Schema::CBC . 'DebitedQuantity', $collection); + + return (static::deserializedTag($mixedContent)) + ->setDebitedQuantity(isset($debitedQuantityTag) ? floatval($debitedQuantityTag['value']) : null) + ; + } +} diff --git a/src/Generator.php b/src/Generator.php index 477c16e..cd589f7 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -41,4 +41,21 @@ public static function creditNote(CreditNote $creditNote, $currencyId = 'EUR') $creditNote ]); } + + public static function debitNote(DebitNote $debitNote, $currencyId = 'EUR') + { + self::$currencyID = $currencyId; + + $xmlService = new Service(); + + $xmlService->namespaceMap = [ + 'urn:oasis:names:specification:ubl:schema:xsd:' . $debitNote->xmlTagName . '-2' => '', + 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2' => 'cbc', + 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2' => 'cac' + ]; + + return $xmlService->write($debitNote->xmlTagName, [ + $debitNote + ]); + } } diff --git a/src/Invoice.php b/src/Invoice.php index 6028050..9a5a957 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -781,7 +781,8 @@ public function xmlSerialize(Writer $writer): void ]); } - if ($this->invoiceTypeCode !== null) { + // DebitNote does not have a TypeCode element in UBL 2.1 + if ($this->invoiceTypeCode !== null && $this->xmlTagName !== 'DebitNote') { $writer->write([ Schema::CBC . $this->xmlTagName . @@ -930,8 +931,10 @@ public function xmlSerialize(Writer $writer): void ]); } + // DebitNote uses RequestedMonetaryTotal instead of LegalMonetaryTotal + $monetaryTotalTagName = $this->xmlTagName === 'DebitNote' ? 'RequestedMonetaryTotal' : 'LegalMonetaryTotal'; $writer->write([ - Schema::CAC . "LegalMonetaryTotal" => $this->legalMonetaryTotal, + Schema::CAC . $monetaryTotalTagName => $this->legalMonetaryTotal, ]); foreach ($this->invoiceLines as $invoiceLine) { diff --git a/src/InvoiceLine.php b/src/InvoiceLine.php index 3b497a1..acb5407 100644 --- a/src/InvoiceLine.php +++ b/src/InvoiceLine.php @@ -34,6 +34,11 @@ private function isCreditNoteLine(): bool return $this->xmlTagName === 'CreditNoteLine'; } + private function isDebitNoteLine(): bool + { + return $this->xmlTagName === 'DebitNoteLine'; + } + /** * @return string */ @@ -311,8 +316,15 @@ public function xmlSerialize(Writer $writer): void $invoicedQuantityAttributes['unitCodeListID'] = $this->getUnitCodeListId(); } + $quantityTagName = 'InvoicedQuantity'; + if ($this->isCreditNoteLine()) { + $quantityTagName = 'CreditedQuantity'; + } elseif ($this->isDebitNoteLine()) { + $quantityTagName = 'DebitedQuantity'; + } + $writer->write([ - 'name' => Schema::CBC . ($this->isCreditNoteLine() ? 'CreditedQuantity' : 'InvoicedQuantity'), + 'name' => Schema::CBC . $quantityTagName, 'value' => NumberFormatter::format($this->invoicedQuantity), 'attributes' => $invoicedQuantityAttributes ]); diff --git a/src/Reader.php b/src/Reader.php index e802490..399f899 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -23,6 +23,7 @@ public static function ubl($currencyId = 'EUR'): Service $xmlService->elementMap = [ Schema::INVOICE. 'Invoice' => fn ($reader) => Invoice::xmlDeserialize($reader), Schema::CREDITNOTE.'CreditNote' => fn ($reader) => CreditNote::xmlDeserialize($reader), + Schema::DEBITNOTE. 'DebitNote' => fn ($reader) => DebitNote::xmlDeserialize($reader), Schema::CAC. 'AccountingCustomerParty' => fn ($reader) => AccountingParty::xmlDeserialize($reader), Schema::CAC. 'AccountingSupplierParty' => fn ($reader) => AccountingParty::xmlDeserialize($reader), Schema::CAC. 'AdditionalDocumentReference' => fn ($reader) => AdditionalDocumentReference::xmlDeserialize($reader), @@ -38,6 +39,7 @@ public static function ubl($currencyId = 'EUR'): Service Schema::CAC. 'Country' => fn ($reader) => Country::xmlDeserialize($reader), Schema::CAC. 'DespatchDocumentReference' => fn ($reader) => DespatchDocumentReference::xmlDeserialize($reader), Schema::CAC. 'CreditNoteLine' => fn ($reader) => CreditNoteLine::xmlDeserialize($reader), + Schema::CAC. 'DebitNoteLine' => fn ($reader) => DebitNoteLine::xmlDeserialize($reader), Schema::CAC. 'Delivery' => fn ($reader) => Delivery::xmlDeserialize($reader), Schema::CAC. 'FinancialInstitutionBranch' => fn ($reader) => FinancialInstitutionBranch::xmlDeserialize($reader), Schema::CAC. 'InvoiceDocumentReference' => fn ($reader) => InvoiceDocumentReference::xmlDeserialize($reader), diff --git a/src/Schema.php b/src/Schema.php index f394c92..279ae7c 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -6,6 +6,7 @@ class Schema { public const INVOICE = '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}'; public const CREDITNOTE = '{urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2}'; + public const DEBITNOTE = '{urn:oasis:names:specification:ubl:schema:xsd:DebitNote-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}'; } diff --git a/tests/Write/SimpleDebitNoteTest.php b/tests/Write/SimpleDebitNoteTest.php new file mode 100644 index 0000000..40c10bf --- /dev/null +++ b/tests/Write/SimpleDebitNoteTest.php @@ -0,0 +1,122 @@ +setIdentificationCode('BE'); + + // Full address + $address = (new \NumNum\UBL\Address()) + ->setStreetName('Korenmarkt') + ->setBuildingNumber(1) + ->setCityName('Gent') + ->setPostalZone('9000') + ->setCountry($country); + + // Supplier company node + $supplierCompany = (new \NumNum\UBL\Party()) + ->setName('Supplier Company Name') + ->setPhysicalLocation($address) + ->setPostalAddress($address); + + // Client company node + $clientCompany = (new \NumNum\UBL\Party()) + ->setName('My client') + ->setPostalAddress($address); + + $legalMonetaryTotal = (new \NumNum\UBL\LegalMonetaryTotal()) + ->setPayableAmount(10 + 2) + ->setAllowanceTotalAmount(0); + + // Tax scheme + $taxScheme = (new \NumNum\UBL\TaxScheme()) + ->setId(0); + + // Product + $productItem = (new \NumNum\UBL\Item()) + ->setName('Product Name') + ->setDescription('Product Description') + ->setSellersItemIdentification('SELLERID') + ->setBuyersItemIdentification('BUYERID'); + + // Price + $price = (new \NumNum\UBL\Price()) + ->setBaseQuantity(1) + ->setUnitCode(\NumNum\UBL\UnitCode::UNIT) + ->setPriceAmount(10); + + // Invoice Line tax totals + $lineTaxTotal = (new \NumNum\UBL\TaxTotal()) + ->setTaxAmount(2.1); + + // Debit Note Line(s) + $debitNoteLine = (new \NumNum\UBL\DebitNoteLine()) + ->setId(0) + ->setItem($productItem) + ->setPrice($price) + ->setTaxTotal($lineTaxTotal) + ->setDebitedQuantity(1); + + $debitNoteLines = [$debitNoteLine]; + + // Total Taxes + $taxCategory = (new \NumNum\UBL\TaxCategory()) + ->setId(0) + ->setName('VAT21%') + ->setPercent(.21) + ->setTaxScheme($taxScheme); + + $taxSubTotal = (new \NumNum\UBL\TaxSubTotal()) + ->setTaxableAmount(10) + ->setTaxAmount(2.1) + ->setTaxCategory($taxCategory); + + $taxTotal = (new \NumNum\UBL\TaxTotal()) + ->addTaxSubTotal($taxSubTotal) + ->setTaxAmount(2.1); + + $accountingSupplierParty = (new \NumNum\UBL\AccountingParty()) + ->setParty($supplierCompany); + + $accountingCustomerParty = (new \NumNum\UBL\AccountingParty()) + ->setParty($clientCompany); + + // Debit Note object + $debitNote = (new \NumNum\UBL\DebitNote()) + ->setId(1234) + ->setCopyIndicator(false) + ->setIssueDate(new \DateTime()) + ->setAccountingSupplierParty($accountingSupplierParty) + ->setAccountingCustomerParty($accountingCustomerParty) + ->setDebitNoteLines($debitNoteLines) + ->setLegalMonetaryTotal($legalMonetaryTotal) + ->setTaxTotal($taxTotal); + + // Test created object + // Use \NumNum\UBL\Generator to generate an XML string + $generator = new \NumNum\UBL\Generator(); + $outputXMLString = $generator->debitNote($debitNote); + + // Create PHP Native DomDocument object, that can be + // used to validate the generate XML + $dom = new \DOMDocument(); + $dom->loadXML($outputXMLString); + + $dom->save('./tests/SimpleDebitNoteTest.xml'); + + $this->assertEquals(true, $dom->schemaValidate($this->schema)); + } +}