From 0dd112f606c5530df69494c8369db0db4b5b373d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Fri, 20 May 2022 12:51:08 +0200 Subject: [PATCH 01/11] Fixed link rot - Updated broken links to EU Commission website --- README.md | 6 +++--- docs/getting-started/eu-einvoicing-concepts.md | 6 +++--- docs/getting-started/installation.md | 2 +- docs/index.md | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e51662f..4d85903 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@

## About -eInvoicing is a PHP library for creating and reading electronic invoices according to the [eInvoicing Directive and European standard](https://ec.europa.eu/cefdigital/wiki/display/CEFDIGITAL/eInvoicing). +eInvoicing is a PHP library for creating and reading electronic invoices according to the [eInvoicing Directive and European standard](https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/eInvoicing). -It aims to be 100% compliant with [EN 16931](https://ec.europa.eu/cefdigital/wiki/x/kwFVBg) as well as with the most popular CIUS and extensions, such as [PEPPOL BIS](https://docs.peppol.eu/poacc/billing/3.0/bis/). +It aims to be 100% compliant with [EN 16931](https://ec.europa.eu/digital-building-blocks/wikis/x/boTXGw) as well as with the most popular CIUS and extensions, such as [PEPPOL BIS](https://docs.peppol.eu/poacc/billing/3.0/bis/). ## Installation First of all, make sure your environment meets the following requirements: @@ -98,7 +98,7 @@ echo $writer->export($inv); These are the expected features for the library and how's it going so far: - [x] Representation of invoices, parties and invoice lines as objects -- [x] Compatibility with the most used [CIUS and extensions](https://ec.europa.eu/cefdigital/wiki/x/5xLoAg) +- [x] Compatibility with the most used [CIUS and extensions](https://ec.europa.eu/digital-building-blocks/wikis/display/EINVCOMMUNITY/Registry+of+CIUS+%28Core+Invoice+Usage+Specifications%29+and+Extensions) - [x] Export invoices to UBL documents - [x] Import invoices from UBL documents - [ ] Export invoices to CII documents diff --git a/docs/getting-started/eu-einvoicing-concepts.md b/docs/getting-started/eu-einvoicing-concepts.md index e307fec..004d6c1 100644 --- a/docs/getting-started/eu-einvoicing-concepts.md +++ b/docs/getting-started/eu-einvoicing-concepts.md @@ -39,7 +39,7 @@ more particular cases, such us the ones mentioned before. The most popular CIUS is [PEPPOL BIS Billing 3.0][4], a cross-border specification used by multiple countries and international companies from both public and private sectors. -[1]: https://ec.europa.eu/cefdigital/wiki/display/CEFDIGITAL/Navigating+the+eInvoicing+standard+documentation -[2]: https://ec.europa.eu/cefdigital/wiki/display/CEFDIGITAL/Required+syntaxes -[3]: https://ec.europa.eu/cefdigital/wiki/x/5xLoAg +[1]: https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/Navigating+the+eInvoicing+standard+documentation +[2]: https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/Required+syntaxes +[3]: https://ec.europa.eu/digital-building-blocks/wikis/display/EINVCOMMUNITY/Registry+of+CIUS+%28Core+Invoice+Usage+Specifications%29+and+Extensions [4]: http://docs.peppol.eu/poacc/billing/3.0/ diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 9aacb43..28d462b 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,6 +1,6 @@ # Getting Started eInvoicing (short for "European Invoicing") is a free and open-source library written in PHP for creating and reading -electronic invoices compliant with [EN 16931](https://ec.europa.eu/cefdigital/wiki/x/kwFVBg). +electronic invoices compliant with [EN 16931](https://ec.europa.eu/digital-building-blocks/wikis/x/boTXGw). ## Requirements In order to install this library, your environment has to meet the following requirements: diff --git a/docs/index.md b/docs/index.md index 341f878..028f819 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,9 @@ # European Invoicing (eInvoicing) -eInvoicing is a PHP library for creating and reading electronic invoices according to the [eInvoicing Directive and European standard](https://ec.europa.eu/cefdigital/wiki/display/CEFDIGITAL/eInvoicing). +eInvoicing is a PHP library for creating and reading electronic invoices according to the [eInvoicing Directive and European standard](https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/eInvoicing). -It aims to be 100% compliant with [EN 16931](https://ec.europa.eu/cefdigital/wiki/x/kwFVBg) as well as with the most popular CIUS and extensions, such as [PEPPOL BIS](https://docs.peppol.eu/poacc/billing/3.0/bis/). +It aims to be 100% compliant with [EN 16931](https://ec.europa.eu/digital-building-blocks/wikis/x/boTXGw) as well as with the most popular CIUS and extensions, such as [PEPPOL BIS](https://docs.peppol.eu/poacc/billing/3.0/bis/). [Get Started](getting-started/installation.md){: .md-button .md-button--primary }   -[Go to GitHub](https://github.com/josemmo/einvoicing){: .md-button } \ No newline at end of file +[Go to GitHub](https://github.com/josemmo/einvoicing){: .md-button } From 13aa515575836a69acfee31ba7c98628cb5dd030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 26 May 2022 19:39:50 +0200 Subject: [PATCH 02/11] Fixed VAT exemption reasons - Updated UblReader - Added new Peppol VAT test > UblReader incorrectly threw an exception on `null` VAT rates --- src/Readers/UblReader.php | 9 +-- tests/Integration/IntegrationTest.php | 4 + tests/Integration/peppol-vat-o.xml | 106 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/peppol-vat-o.xml diff --git a/src/Readers/UblReader.php b/src/Readers/UblReader.php index f257b7e..73ebb66 100644 --- a/src/Readers/UblReader.php +++ b/src/Readers/UblReader.php @@ -62,10 +62,8 @@ public function import(string $document): Invoice { throw new InvalidArgumentException('Missing node from tax item'); } $rateNode = $node->get("{{$cbc}}Percent"); - if ($rateNode === null) { - throw new InvalidArgumentException('Missing node from tax item'); - } - $key = "{$categoryNode->asText()}:{$rateNode->asText()}"; + $rateKey = ($rateNode === null) ? '' : floatval($rateNode->asText()); + $key = "{$categoryNode->asText()}:{$rateKey}"; // Save reasons $taxExemptions[$key] = [ @@ -639,7 +637,8 @@ private function setVatAttributes($target, UXML $xml, array $taxExemptions) { } // Tax exemption reasons - $key = "{$target->getVatCategory()}:{$target->getVatRate()}"; + $rateKey = $target->getVatRate() ?? ''; + $key = "{$target->getVatCategory()}:{$rateKey}"; $target->setVatExemptionReasonCode($taxExemptions[$key]['code'] ?? null); $target->setVatExemptionReason($taxExemptions[$key]['reason'] ?? null); } diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/IntegrationTest.php index 331b8e6..fc93c45 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/IntegrationTest.php @@ -46,6 +46,10 @@ public function testCanRecreatePeppolVatExample(): void { $this->importAndExportInvoice(__DIR__ . "/peppol-vat-s.xml"); } + public function testCanRecreatePeppolOptionalVatExample(): void { + $this->importAndExportInvoice(__DIR__ . "/peppol-vat-o.xml"); + } + public function testCanRecreatePeppolAllowanceExample(): void { $this->importAndExportInvoice(__DIR__ . "/peppol-allowance.xml"); } diff --git a/tests/Integration/peppol-vat-o.xml b/tests/Integration/peppol-vat-o.xml new file mode 100644 index 0000000..d04071e --- /dev/null +++ b/tests/Integration/peppol-vat-o.xml @@ -0,0 +1,106 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0 + urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 + Vat-O + 2018-08-30 + 380 + SEK + test reference + + + 7300010000001 + + 7300010000001 + + + Main street 2, Building 4 + Big city + 54321 + + SE + + + + The Sellercompany Incorporated + + + + + + 987654325 + + Anystreet 8 + Back door + Anytown + 101 + RegionB + + NO + + + + The Buyercompany + + + + + 30 + + SE1212341234123412 + + SEXDABCD + + + + + Payment within 30 days + + + 0 + + 3200 + 0 + + O + Not subject to VAT + + VAT + + + + + + 3200 + 3200 + 3200 + 3200 + + + 1 + 1 + 3200 + + 1 + + + Weight-based tax, vehicles >3000 KGM + Road tax + + RT3000 + + + O + + VAT + + + + + 3200 + + + From 8ce204e9c709db50b7780acba0aa6a2e119ec12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 28 May 2022 12:55:29 +0200 Subject: [PATCH 03/11] Added support for VAT in accounting currency - Added VAT currency and custom VAT amount properties to Invoice - Updated UblReader and UblWriter - Updated integration tests > Closes #22 --- src/Invoice.php | 42 ++++++++ src/Models/InvoiceTotals.php | 18 +++- src/Readers/UblReader.php | 18 ++++ src/Writers/UblWriter.php | 23 ++++- tests/Integration/IntegrationTest.php | 4 + .../Integration/cius-ro-tax-currency-code.xml | 96 +++++++++++++++++++ 6 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/cius-ro-tax-currency-code.xml diff --git a/src/Invoice.php b/src/Invoice.php index 082d657..1684c16 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -27,6 +27,7 @@ class Invoice { protected $number = null; protected $type = 380; // TODO: add constants protected $currency = "EUR"; // TODO: add constants + protected $vatCurrency = null; protected $issueDate = null; protected $dueDate = null; protected $taxPointDate = null; @@ -38,6 +39,7 @@ class Invoice { protected $contractReference = null; protected $paidAmount = 0; protected $roundingAmount = 0; + protected $customVatAmount = null; protected $seller = null; protected $buyer = null; protected $payee = null; @@ -194,6 +196,26 @@ public function setCurrency(string $currencyCode): self { } + /** + * Get VAT accounting currency code + * @return string|null VAT accounting currency code or NULL if same as document's + */ + public function getVatCurrency(): ?string { + return $this->vatCurrency; + } + + + /** + * Set VAT accounting currency code + * @param string|null $currencyCode VAT accounting currency code or NULL if same as document's + * @return self Invoice instance + */ + public function setVatCurrency(?string $currencyCode): self { + $this->vatCurrency = $currencyCode; + return $this; + } + + /** * Get invoice issue date * @return DateTime|null Invoice issue date @@ -414,6 +436,26 @@ public function setRoundingAmount(float $roundingAmount): self { } + /** + * Get total VAT amount in VAT accounting currency + * @return float|null Total amount in accounting currency + */ + public function getCustomVatAmount(): ?float { + return $this->customVatAmount; + } + + + /** + * Set total VAT amount in VAT accounting currency + * @param float|null $customVatAmount Total amount in accounting currency + * @return self Invoice instance + */ + public function setCustomVatAmount(?float $customVatAmount): self { + $this->customVatAmount = $customVatAmount; + return $this; + } + + /** * Get seller * @return Party|null Seller instance diff --git a/src/Models/InvoiceTotals.php b/src/Models/InvoiceTotals.php index 52c0867..f58ea8e 100644 --- a/src/Models/InvoiceTotals.php +++ b/src/Models/InvoiceTotals.php @@ -8,11 +8,17 @@ class InvoiceTotals { /** - * Totals currency code + * Invoice currency code * @var string */ public $currency; + /** + * VAT accounting currency code + * @var string|null + */ + public $vatCurrency = null; + /** * Sum of all invoice line net amounts * @var float @@ -61,6 +67,12 @@ class InvoiceTotals { */ public $roundingAmount = 0; + /** + * Total VAT amount in accounting currency + * @var float|null + */ + public $customVatAmount = null; + /** * Amount due for payment * @var float @@ -83,8 +95,9 @@ static public function fromInvoice(Invoice $inv, bool $round=true): InvoiceTotal $totals = new self(); $vatMap = []; - // Set currency code + // Set currency codes $totals->currency = $inv->getCurrency(); + $totals->vatCurrency = $inv->getVatCurrency(); // Process all invoice lines foreach ($inv->getLines() as $line) { @@ -116,6 +129,7 @@ static public function fromInvoice(Invoice $inv, bool $round=true): InvoiceTotal $totals->taxInclusiveAmount = $totals->taxExclusiveAmount + $totals->vatAmount; $totals->paidAmount = $inv->getPaidAmount(); $totals->roundingAmount = $inv->getRoundingAmount(); + $totals->customVatAmount = $inv->getCustomVatAmount(); $totals->payableAmount = $totals->taxInclusiveAmount - $totals->paidAmount + $totals->roundingAmount; // Attach VAT breakdown diff --git a/src/Readers/UblReader.php b/src/Readers/UblReader.php index 73ebb66..71b3978 100644 --- a/src/Readers/UblReader.php +++ b/src/Readers/UblReader.php @@ -120,6 +120,12 @@ public function import(string $document): Invoice { $invoice->setCurrency($currencyNode->asText()); } + // BT-6: VAT accounting currency code + $vatCurrencyNode = $xml->get("{{$cbc}}TaxCurrencyCode"); + if ($vatCurrencyNode !== null) { + $invoice->setVatCurrency($vatCurrencyNode->asText()); + } + // BT-19: Buyer accounting reference $buyerAccountingReferenceNode = $xml->get("{{$cbc}}AccountingCost"); if ($buyerAccountingReferenceNode !== null) { @@ -211,6 +217,18 @@ public function import(string $document): Invoice { $this->addAllowanceOrCharge($invoice, $node, $taxExemptions); } + // BT-111: Total VAT amount in accounting currency + foreach ($xml->getAll("{{$cac}}TaxTotal") as $taxTotalNode) { + if ($taxTotalNode->get("{{$cac}}TaxSubtotal") !== null) { + // The other tax total node, then + continue; + } + $taxAmountNode = $taxTotalNode->get("{{$cbc}}TaxAmount"); + if ($taxAmountNode !== null) { + $invoice->setCustomVatAmount((float) $taxAmountNode->asText()); + } + } + // BT-113: Paid amount $paidAmountNode = $xml->get("{{$cac}}LegalMonetaryTotal/{{$cbc}}PrepaidAmount"); if ($paidAmountNode !== null) { diff --git a/src/Writers/UblWriter.php b/src/Writers/UblWriter.php index 151d479..c5409a0 100644 --- a/src/Writers/UblWriter.php +++ b/src/Writers/UblWriter.php @@ -81,6 +81,12 @@ public function export(Invoice $invoice): string { // BT-5: Invoice currency code $xml->add('cbc:DocumentCurrencyCode', $invoice->getCurrency()); + // BT-6: VAT accounting currency code + $vatCurrency = $invoice->getVatCurrency(); + if ($vatCurrency !== null) { + $xml->add('cbc:TaxCurrencyCode', $vatCurrency); + } + // BT-19: Buyer accounting reference $buyerAccountingReference = $invoice->getBuyerAccountingReference(); if ($buyerAccountingReference !== null) { @@ -165,7 +171,7 @@ public function export(Invoice $invoice): string { } // Invoice totals - $this->addTaxTotalNode($xml, $invoice, $totals); + $this->addTaxTotalNodes($xml, $invoice, $totals); $this->addDocumentTotalsNode($xml, $invoice, $totals); // Invoice lines @@ -708,12 +714,12 @@ private function addAllowanceOrCharge( /** - * Add tax total node + * Add tax total nodes * @param UXML $parent Parent element * @param Invoice $invoice Invoice instance * @param InvoiceTotals $totals Unrounded invoice totals */ - private function addTaxTotalNode(UXML $parent, Invoice $invoice, InvoiceTotals $totals) { + private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $totals) { $xml = $parent->add('cac:TaxTotal'); // Add tax amount @@ -748,6 +754,17 @@ private function addTaxTotalNode(UXML $parent, Invoice $invoice, InvoiceTotals $ $item->exemptionReason ); } + + // Add tax amount in VAT accounting currency (if any) + $customVatAmount = $totals->customVatAmount; + if ($customVatAmount !== null) { + $this->addAmountNode( + $parent->add('cac:TaxTotal'), + 'cbc:TaxAmount', + round($customVatAmount, $invoice->getDecimals('invoice/taxAmount')), + $totals->vatCurrency ?? $totals->currency + ); + } } diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/IntegrationTest.php index fc93c45..9a67fed 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/IntegrationTest.php @@ -53,4 +53,8 @@ public function testCanRecreatePeppolOptionalVatExample(): void { public function testCanRecreatePeppolAllowanceExample(): void { $this->importAndExportInvoice(__DIR__ . "/peppol-allowance.xml"); } + + public function testCanRecreateCiusRoTaxCurrencyCodeExample(): void { + $this->importAndExportInvoice(__DIR__ . "/cius-ro-tax-currency-code.xml"); + } } diff --git a/tests/Integration/cius-ro-tax-currency-code.xml b/tests/Integration/cius-ro-tax-currency-code.xml new file mode 100644 index 0000000..8d42ff2 --- /dev/null +++ b/tests/Integration/cius-ro-tax-currency-code.xml @@ -0,0 +1,96 @@ + + + + urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.0 + 10009 + 2022-05-26 + 2022-05-26 + 380 + EUR + RON + + + + Name of the Street + Name of the City + RO-BH + + RO + + + + RO124533553 + + VAT + + + + Legal Name + + + + + + + Another Street Name + Another City + RO-BH + + RO + + + + 23453553 + + VA + + + + Another Legal Name + + + + + 19 + + 100 + 19 + + S + 19 + + VAT + + + + + + 93.89 + + + 100 + 100 + 119 + 119 + + + 1 + 1 + 100 + + Item Name + + S + 19 + + VAT + + + + + 100 + + + From e0b9c1164a4bc22dbbee84f0ed0c682263b2a907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 28 May 2022 13:20:38 +0200 Subject: [PATCH 04/11] Added support for additional legal information (BT-33) - Added legal information field to Party - Updated UblReader and UblWriter - Updated integration tests > Closes #23 --- src/Party.php | 21 +++++++++++++++++++++ src/Readers/UblReader.php | 8 +++++++- src/Writers/UblWriter.php | 6 ++++++ tests/Integration/peppol-vat-s.xml | 3 ++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Party.php b/src/Party.php index a945bcf..2213453 100644 --- a/src/Party.php +++ b/src/Party.php @@ -11,6 +11,7 @@ class Party { protected $companyId = null; protected $vatNumber = null; protected $taxRegistrationId = null; + protected $legalInformation = null; protected $contactName = null; protected $contactPhone = null; protected $contactEmail = null; @@ -138,6 +139,26 @@ public function setTaxRegistrationId(?Identifier $taxRegistrationId): self { } + /** + * Get additional legal information + * @return string|null Additional legal information + */ + public function getLegalInformation(): ?string { + return $this->legalInformation; + } + + + /** + * Set additional legal information + * @param string|null $legalInformation Additional legal information + * @return self Party instance + */ + public function setLegalInformation(?string $legalInformation): self { + $this->legalInformation = $legalInformation; + return $this; + } + + /** * Get contact point name * @return string|null Contact name diff --git a/src/Readers/UblReader.php b/src/Readers/UblReader.php index 71b3978..4d90d1c 100644 --- a/src/Readers/UblReader.php +++ b/src/Readers/UblReader.php @@ -387,13 +387,19 @@ private function parseSellerOrBuyerNode(UXML $xml): Party { if ($legalNameNode !== null) { $party->setName($legalNameNode->asText()); } - + // Company ID $companyIdNode = $xml->get("{{$cac}}PartyLegalEntity/{{$cbc}}CompanyID"); if ($companyIdNode !== null) { $party->setCompanyId($this->parseIdentifierNode($companyIdNode)); } + // BT-33: Seller additional legal information + $companyLegalFormNode = $xml->get("{{$cac}}PartyLegalEntity/{{$cbc}}CompanyLegalForm"); + if ($companyLegalFormNode !== null) { + $party->setLegalInformation($companyLegalFormNode->asText()); + } + // Contact name $contactNameNode = $xml->get("{{$cac}}Contact/{{$cbc}}Name"); if ($contactNameNode !== null) { diff --git a/src/Writers/UblWriter.php b/src/Writers/UblWriter.php index c5409a0..19d8cc5 100644 --- a/src/Writers/UblWriter.php +++ b/src/Writers/UblWriter.php @@ -421,6 +421,12 @@ private function addSellerOrBuyerNode(UXML $parent, Party $party) { $this->addIdentifierNode($legalEntityNode, 'cbc:CompanyID', $companyId); } + // BT-33: Seller additional legal information + $legalInformation = $party->getLegalInformation(); + if ($legalInformation !== null) { + $legalEntityNode->add('cbc:CompanyLegalForm', $legalInformation); + } + // Contact point if ($party->hasContactInformation()) { $contactNode = $xml->add('cac:Contact'); diff --git a/tests/Integration/peppol-vat-s.xml b/tests/Integration/peppol-vat-s.xml index 150a159..185068d 100644 --- a/tests/Integration/peppol-vat-s.xml +++ b/tests/Integration/peppol-vat-s.xml @@ -38,7 +38,8 @@ SupplierOfficialName Ltd - GB983294 + GB983294 + AdditionalLegalInformation John Doe From e671343fc5e21958f35503e5bb1a34bed3b15de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sat, 28 May 2022 13:38:02 +0200 Subject: [PATCH 05/11] Added CIUS-RO preset - Created Presets/CiusRo class --- src/Presets/CiusRo.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/Presets/CiusRo.php diff --git a/src/Presets/CiusRo.php b/src/Presets/CiusRo.php new file mode 100644 index 0000000..dec5b55 --- /dev/null +++ b/src/Presets/CiusRo.php @@ -0,0 +1,16 @@ + Date: Sun, 29 May 2022 10:54:05 +0200 Subject: [PATCH 06/11] Fixed rounding of negative zeroes - Added custom Invoice::round() method - Updated InvoiceTotals - Updated UblWriter - Updated unit tests --- src/Invoice.php | 16 ++++++++++++ src/Models/InvoiceTotals.php | 23 ++++++++--------- src/Writers/UblWriter.php | 49 ++++++++++++++++++------------------ tests/InvoiceTest.php | 9 +++++++ 4 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/Invoice.php b/src/Invoice.php index 1684c16..3fba3e5 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -16,6 +16,7 @@ use function array_splice; use function count; use function is_subclass_of; +use function round; class Invoice { const DEFAULT_DECIMALS = 8; @@ -85,6 +86,21 @@ public function getDecimals(string $field): int { } + /** + * Round value + * @param float $value Value to round + * @param string $field Field name + * @return float Rounded value + */ + public function round(float $value, string $field): float { + $rounded = round($value, $this->getDecimals($field)); + if ($rounded == 0) { + $rounded += 0; // To fix negative zero + } + return $rounded; + } + + /** * Set rounding matrix * @param array $matrix Rounding matrix diff --git a/src/Models/InvoiceTotals.php b/src/Models/InvoiceTotals.php index f58ea8e..68d4111 100644 --- a/src/Models/InvoiceTotals.php +++ b/src/Models/InvoiceTotals.php @@ -4,7 +4,6 @@ use Einvoicing\Invoice; use Einvoicing\Traits\VatTrait; use function array_values; -use function round; class InvoiceTotals { /** @@ -137,18 +136,18 @@ static public function fromInvoice(Invoice $inv, bool $round=true): InvoiceTotal // Round values if ($round) { - $totals->netAmount = round($totals->netAmount, $inv->getDecimals('invoice/netAmount')); - $totals->allowancesAmount = round($totals->allowancesAmount, $inv->getDecimals('invoice/allowancesChargesAmount')); - $totals->chargesAmount = round($totals->chargesAmount, $inv->getDecimals('invoice/allowancesChargesAmount')); - $totals->vatAmount = round($totals->vatAmount, $inv->getDecimals('invoice/vatAmount')); - $totals->taxExclusiveAmount = round($totals->taxExclusiveAmount, $inv->getDecimals('invoice/taxExclusiveAmount')); - $totals->taxInclusiveAmount = round($totals->taxInclusiveAmount, $inv->getDecimals('invoice/taxInclusiveAmount')); - $totals->paidAmount = round($totals->paidAmount, $inv->getDecimals('invoice/paidAmount')); - $totals->roundingAmount = round($totals->roundingAmount, $inv->getDecimals('invoice/roundingAmount')); - $totals->payableAmount = round($totals->payableAmount, $inv->getDecimals('invoice/payableAmount')); + $totals->netAmount = $inv->round($totals->netAmount, 'invoice/netAmount'); + $totals->allowancesAmount = $inv->round($totals->allowancesAmount, 'invoice/allowancesChargesAmount'); + $totals->chargesAmount = $inv->round($totals->chargesAmount, 'invoice/allowancesChargesAmount'); + $totals->vatAmount = $inv->round($totals->vatAmount, 'invoice/vatAmount'); + $totals->taxExclusiveAmount = $inv->round($totals->taxExclusiveAmount, 'invoice/taxExclusiveAmount'); + $totals->taxInclusiveAmount = $inv->round($totals->taxInclusiveAmount, 'invoice/taxInclusiveAmount'); + $totals->paidAmount = $inv->round($totals->paidAmount, 'invoice/paidAmount'); + $totals->roundingAmount = $inv->round($totals->roundingAmount, 'invoice/roundingAmount'); + $totals->payableAmount = $inv->round($totals->payableAmount, 'invoice/payableAmount'); foreach ($totals->vatBreakdown as $item) { - $item->taxableAmount = round($item->taxableAmount, $inv->getDecimals('invoice/allowancesChargesAmount')); - $item->taxAmount = round($item->taxAmount, $inv->getDecimals('invoice/taxAmount')); + $item->taxableAmount = $inv->round($item->taxableAmount, 'invoice/allowancesChargesAmount'); + $item->taxAmount = $inv->round($item->taxAmount, 'invoice/taxAmount'); } } diff --git a/src/Writers/UblWriter.php b/src/Writers/UblWriter.php index 19d8cc5..0cbca06 100644 --- a/src/Writers/UblWriter.php +++ b/src/Writers/UblWriter.php @@ -15,7 +15,6 @@ use Einvoicing\Payments\Transfer; use UXML\UXML; use function in_array; -use function round; class UblWriter extends AbstractWriter { const NS_INVOICE = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"; @@ -698,7 +697,7 @@ private function addAllowanceOrCharge( $this->addAmountNode( $xml, 'cbc:Amount', - round($item->getEffectiveAmount($baseAmount), $invoice->getDecimals('line/allowanceChargeAmount')), + $invoice->round($item->getEffectiveAmount($baseAmount), 'line/allowanceChargeAmount'), $invoice->getCurrency() ); @@ -707,7 +706,7 @@ private function addAllowanceOrCharge( $this->addAmountNode( $xml, 'cbc:BaseAmount', - round($baseAmount, $invoice->getDecimals('line/netAmount')), + $invoice->round($baseAmount, 'line/netAmount'), $invoice->getCurrency() ); } @@ -732,7 +731,7 @@ private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $this->addAmountNode( $xml, 'cbc:TaxAmount', - round($totals->vatAmount, $invoice->getDecimals('invoice/taxAmount')), + $invoice->round($totals->vatAmount, 'invoice/taxAmount'), $totals->currency ); @@ -742,13 +741,13 @@ private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $this->addAmountNode( $vatBreakdownNode, 'cbc:TaxableAmount', - round($item->taxableAmount, $invoice->getDecimals('invoice/allowancesChargesAmount')), + $invoice->round($item->taxableAmount, 'invoice/allowancesChargesAmount'), $totals->currency ); $this->addAmountNode( $vatBreakdownNode, 'cbc:TaxAmount', - round($item->taxAmount, $invoice->getDecimals('invoice/taxAmount')), + $invoice->round($item->taxAmount, 'invoice/taxAmount'), $totals->currency ); $this->addVatNode( @@ -767,7 +766,7 @@ private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $this->addAmountNode( $parent->add('cac:TaxTotal'), 'cbc:TaxAmount', - round($customVatAmount, $invoice->getDecimals('invoice/taxAmount')), + $invoice->round($customVatAmount, 'invoice/taxAmount'), $totals->vatCurrency ?? $totals->currency ); } @@ -785,45 +784,45 @@ private function addDocumentTotalsNode(UXML $parent, Invoice $invoice, InvoiceTo // Build totals matrix $totalsMatrix = []; - $totalsMatrix['cbc:LineExtensionAmount'] = round( + $totalsMatrix['cbc:LineExtensionAmount'] = $invoice->round( $totals->netAmount, - $invoice->getDecimals('invoice/netAmount') + 'invoice/netAmount' ); - $totalsMatrix['cbc:TaxExclusiveAmount'] = round( + $totalsMatrix['cbc:TaxExclusiveAmount'] = $invoice->round( $totals->taxExclusiveAmount, - $invoice->getDecimals('invoice/taxExclusiveAmount') + 'invoice/taxExclusiveAmount' ); - $totalsMatrix['cbc:TaxInclusiveAmount'] = round( + $totalsMatrix['cbc:TaxInclusiveAmount'] = $invoice->round( $totals->taxInclusiveAmount, - $invoice->getDecimals('invoice/taxInclusiveAmount') + 'invoice/taxInclusiveAmount' ); if ($totals->allowancesAmount > 0) { - $totalsMatrix['cbc:AllowanceTotalAmount'] = round( + $totalsMatrix['cbc:AllowanceTotalAmount'] = $invoice->round( $totals->allowancesAmount, - $invoice->getDecimals('invoice/allowancesChargesAmount') + 'invoice/allowancesChargesAmount' ); } if ($totals->chargesAmount > 0) { - $totalsMatrix['cbc:ChargeTotalAmount'] = round( + $totalsMatrix['cbc:ChargeTotalAmount'] = $invoice->round( $totals->chargesAmount, - $invoice->getDecimals('invoice/allowancesChargesAmount') + 'invoice/allowancesChargesAmount' ); } if ($totals->paidAmount > 0) { - $totalsMatrix['cbc:PrepaidAmount'] = round( + $totalsMatrix['cbc:PrepaidAmount'] = $invoice->round( $totals->paidAmount, - $invoice->getDecimals('invoice/paidAmount') + 'invoice/paidAmount' ); } if ($totals->roundingAmount > 0) { - $totalsMatrix['cbc:PayableRoundingAmount'] = round( + $totalsMatrix['cbc:PayableRoundingAmount'] = $invoice->round( $totals->roundingAmount, - $invoice->getDecimals('invoice/roundingAmount') + 'invoice/roundingAmount' ); } - $totalsMatrix['cbc:PayableAmount'] = round( + $totalsMatrix['cbc:PayableAmount'] = $invoice->round( $totals->payableAmount, - $invoice->getDecimals('invoice/payableAmount') + 'invoice/payableAmount' ); // Create and append XML nodes @@ -868,7 +867,7 @@ private function addLineNode(UXML $parent, InvoiceLine $line, Invoice $invoice, $this->addAmountNode( $xml, 'cbc:LineExtensionAmount', - round($netAmount, $invoice->getDecimals('line/netAmount')), + $invoice->round($netAmount, 'line/netAmount'), $invoice->getCurrency() ); } @@ -960,7 +959,7 @@ private function addLineNode(UXML $parent, InvoiceLine $line, Invoice $invoice, $this->addAmountNode( $priceNode, 'cbc:PriceAmount', - round($price, $invoice->getDecimals('line/price')), + $invoice->round($price, 'line/price'), $invoice->getCurrency() ); } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index f2264b5..491d369 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -52,6 +52,15 @@ public function testCannotRemoveOutOfBoundsLines(): void { $this->invoice->addLine(new InvoiceLine)->removeLine(1); } + public function testCanRoundNegativeZeroes(): void { + $this->assertEquals('-1', (string) $this->invoice->round(-0.9999, 'invoice/netAmount')); + $this->assertEquals('0', (string) $this->invoice->round(-0.0001, 'invoice/netAmount')); + $this->assertEquals('0', (string) $this->invoice->round(-0, 'invoice/netAmount')); + $this->assertEquals('0', (string) $this->invoice->round(0, 'invoice/netAmount')); + $this->assertEquals('0', (string) $this->invoice->round(0.0001, 'invoice/netAmount')); + $this->assertEquals('1', (string) $this->invoice->round(0.9999, 'invoice/netAmount')); + } + public function testDecimalMatrixIsUsed(): void { $this->invoice->setRoundingMatrix([ 'invoice/paidAmount' => 4, From 97a133ac2585980ee104237ff9058403b63e00d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 29 May 2022 11:04:54 +0200 Subject: [PATCH 07/11] Improved diffing in integration tests - Updated IntegrationTest::normalize() --- tests/Integration/IntegrationTest.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/IntegrationTest.php index 9a67fed..38cd09c 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/IntegrationTest.php @@ -21,10 +21,18 @@ protected function setUp(): void { } protected function normalize(string $xml): string { + // Normalize input document $doc = new DOMDocument(); $doc->preserveWhiteSpace = false; $doc->loadXML($xml, LIBXML_NOERROR); - return $doc->C14N(); + $normalizedXml = $doc->C14N(); + unset($doc); + + // Export formatted XML for better diffing + $doc = new DOMDocument(); + $doc->formatOutput = true; + $doc->loadXML($normalizedXml, LIBXML_NOERROR); + return $doc->saveXML(); } protected function importAndExportInvoice(string $xmlPath): void { From 673a8b94bee6b8fcd64476f2a1b9fceb8a6772d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 29 May 2022 11:06:32 +0200 Subject: [PATCH 08/11] Upgraded dependencies - Updated composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6259839..71f28f1 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ }, "require": { "php": ">=7.1", - "josemmo/uxml": "^0.1.3" + "josemmo/uxml": "^0.1.4" }, "require-dev": { "ext-openssl": "*", From 6bdb39f75b458c75fa5164385d177104cc3c87da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 1 Jun 2022 19:40:36 +0200 Subject: [PATCH 09/11] Added support for multiple invoice notes - Updated Invoice class - Updated UblReader and UblWriter - Updated Peppol preset rules - Updated CIUS-RO preset rules - Updated unit tests > Closes #24 --- src/Invoice.php | 56 ++++++++++++++++++++++++++++++++++++--- src/Presets/CiusRo.php | 20 ++++++++++++++ src/Presets/Peppol.php | 5 ++++ src/Readers/UblReader.php | 7 +++-- src/Writers/UblWriter.php | 5 ++-- tests/InvoiceTest.php | 22 +++++++++++++++ 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/Invoice.php b/src/Invoice.php index 3fba3e5..8526378 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -32,7 +32,7 @@ class Invoice { protected $issueDate = null; protected $dueDate = null; protected $taxPointDate = null; - protected $note = null; + protected $notes = []; protected $buyerReference = null; protected $purchaseOrderReference = null; protected $salesOrderReference = null; @@ -292,12 +292,59 @@ public function setTaxPointDate(?DateTime $taxPointDate): self { } + /** + * Get invoice notes + * @return string[] Invoice notes + */ + public function getNotes(): array { + return $this->notes; + } + + + /** + * Add invoice note + * @param string $note Invoice note + * @return self Invoice instance + */ + public function addNote(string $note): self { + $this->notes[] = $note; + return $this; + } + + + /** + * Remove invoice note + * @param int $index Invoice note index + * @return self Invoice instance + * @throws OutOfBoundsException if invoice note index is out of bounds + */ + public function removeNote(int $index): self { + if ($index < 0 || $index >= count($this->notes)) { + throw new OutOfBoundsException('Could not find invoice note by index'); + } + array_splice($this->notes, $index, 1); + return $this; + } + + + /** + * Clear all invoice notes + * @return self Invoice instance + */ + public function clearNotes(): self { + $this->notes = []; + return $this; + } + + /** * Get invoice note * @return string|null Invoice note + * @deprecated 0.2.1 + * @see Invoice::getNotes() */ public function getNote(): ?string { - return $this->note; + return $this->notes[0] ?? null; } @@ -305,9 +352,12 @@ public function getNote(): ?string { * Set invoice note * @param string|null $note Invoice note * @return self Invoice instance + * @deprecated 0.2.1 + * @see Invoice::addNote() */ public function setNote(?string $note): self { - $this->note = $note; + // @phan-suppress-next-line PhanPartialTypeMismatchProperty + $this->notes = ($note === null) ? [] : [$note]; return $this; } diff --git a/src/Presets/CiusRo.php b/src/Presets/CiusRo.php index dec5b55..36edf04 100644 --- a/src/Presets/CiusRo.php +++ b/src/Presets/CiusRo.php @@ -1,6 +1,10 @@ getNotes()) > 20) { + return "The allowed maximum number of occurrences of Invoice note (BG-1) is 20."; + } + }; + + return $res; + } } diff --git a/src/Presets/Peppol.php b/src/Presets/Peppol.php index a61eb7a..8496ccc 100644 --- a/src/Presets/Peppol.php +++ b/src/Presets/Peppol.php @@ -25,6 +25,11 @@ public function getSpecification(): string { public function getRules(): array { $res = []; + $res['PEPPOL-EN16931-R002'] = static function(Invoice $inv) { + if (count($inv->getNotes()) > 1) { + return "No more than one note is allowed on document level."; + } + }; $res['PEPPOL-EN16931-R003'] = static function(Invoice $inv) { if ($inv->getBuyerReference() !== null) return; if ($inv->getPurchaseOrderReference() !== null) return; diff --git a/src/Readers/UblReader.php b/src/Readers/UblReader.php index 4d90d1c..74733a8 100644 --- a/src/Readers/UblReader.php +++ b/src/Readers/UblReader.php @@ -102,10 +102,9 @@ public function import(string $document): Invoice { $invoice->setType((int) $typeNode->asText()); } - // BT-22: Note - $noteNode = $xml->get("{{$cbc}}Note"); - if ($noteNode !== null) { - $invoice->setNote($noteNode->asText()); + // BT-22: Notes + foreach ($xml->getAll("{{$cbc}}Note") as $noteNode) { + $invoice->addNote($noteNode->asText()); } // BT-7: Tax point date diff --git a/src/Writers/UblWriter.php b/src/Writers/UblWriter.php index 0cbca06..225873d 100644 --- a/src/Writers/UblWriter.php +++ b/src/Writers/UblWriter.php @@ -65,9 +65,8 @@ public function export(Invoice $invoice): string { // BT-3: Invoice type code $xml->add('cbc:InvoiceTypeCode', (string) $invoice->getType()); - // BT-22: Note - $note = $invoice->getNote(); - if ($note !== null) { + // BT-22: Notes + foreach ($invoice->getNotes() as $note) { $xml->add('cbc:Note', $note); } diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 491d369..3c597b5 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -31,6 +31,28 @@ public function testCannotCreateInvoiceFromInvalidPreset(): void { new Invoice(self::class); } + public function testCanReadAndWriteNotes(): void { + $note = "This is a test"; + $this->assertSame($note, $this->invoice->addNote($note)->getNotes()[0]); + $this->invoice->removeNote(0); + $this->assertEmpty($this->invoice->getNotes()); + } + + public function testCanRemoveNotes(): void { + $this->invoice + ->addNote('Note #1') + ->addNote('Note #2') + ->addNote('Note #3') + ->removeNote(2) + ->removeNote(0); + $this->assertSame('Note #2', $this->invoice->getNotes()[0]); + } + + public function testCannotRemoveOutOfBoundsNotes(): void { + $this->expectException(OutOfBoundsException::class); + $this->invoice->addNote('A sample note')->removeNote(1); + } + public function testCanReadAndWriteLines(): void { $this->assertSame($this->line, $this->invoice->addLine($this->line)->getLines()[0]); $this->invoice->removeLine(0); From 75bdfc4f43f192dd86efacf11413cd1cfa9716d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 1 Jun 2022 20:32:00 +0200 Subject: [PATCH 10/11] Added deprecated warnings to documentation - Updated build-docs.php script > Related to #24 --- scripts/build-docs.php | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/scripts/build-docs.php b/scripts/build-docs.php index fd4f9a6..6490797 100644 --- a/scripts/build-docs.php +++ b/scripts/build-docs.php @@ -1,7 +1,9 @@ Array of documentable classes */ function getProjectFiles(): array { $files = []; @@ -55,7 +57,7 @@ function getProjectFiles(): array { * Get class public elements * @param Class_ $class Class instance * @param string $type Element type ("constants", "properties" or "methods") - * @param Element[string] &$project Project files + * @param array &$project Project files * @return Constant[]|Property[]|Method[] Public elements */ function getPublicElements(Class_ $class, string $type, array &$project): array { @@ -102,9 +104,9 @@ function getClassUrl(string $fqsen): string { /** * Render class - * @param Class_ $class Class instance - * @param Element[string] &$project Project files - * @return string Markdown documentation + * @param Class_ $class Class instance + * @param array &$project Project files + * @return string Markdown documentation */ function renderClass(Class_ $class, array &$project): string { $doc = "# {$class->getFqsen()}\n\n"; @@ -180,9 +182,22 @@ function renderMethod(Method $method, array $addDocblocks, Class_ $class): strin $return = $docblock->getTagsByName('return')[0] ?? null; // Method summary - $doc = "## `{$method->getName()}()`\n"; + $doc = "## `{$method->getName()}()`\n"; $doc .= $docblock->getSummary() . "\n"; + // Deprecation warning + /** @var Deprecated|null */ + $deprecated = $docblock->getTagsByName('deprecated')[0] ?? null; + if ($deprecated !== null) { + /** @var See */ + $see = $docblock->getTagsByName('see')[0]; + $seeRef = $see->getReference(); + $doc .= "\n"; + $doc .= "!!! warning \"Deprecated since v{$deprecated->getVersion()}\"\n"; + $doc .= "\n"; + $doc .= " Use [`$seeRef`](#" . strtolower(substr($seeRef, strpos($seeRef, '::')+2, -2)) . ") instead.\n"; + } + // Signature $doc .= "\n```php\n"; $doc .= "public " . ($method->isStatic() ? "static " : "") . $method->getName() . "("; From 9ddfb643700e6dbccd25acdc801d225b65d7fc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Wed, 8 Jun 2022 18:06:09 +0200 Subject: [PATCH 11/11] Added NLCIUS preset - Created Nlcius preset class --- src/Presets/Nlcius.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/Presets/Nlcius.php diff --git a/src/Presets/Nlcius.php b/src/Presets/Nlcius.php new file mode 100644 index 0000000..e6b904a --- /dev/null +++ b/src/Presets/Nlcius.php @@ -0,0 +1,16 @@ +