From f5e6f0e9bd8c6bc004457d0ccfd879b4cd15639e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 4 Dec 2022 10:32:16 +0100 Subject: [PATCH 1/3] Fixed rounding of amounts - Updated Invoice::getTotals() method - Updated InvoiceTotals::fromInvoice() method - Updated UblWriter class - Renamed rounding field "invoice/taxAmount" to "invoice/vatAmount" - Updated unit tests for InvoiceTest class > Fixes #31 --- src/Invoice.php | 7 ++- src/Models/InvoiceTotals.php | 65 ++++++++++++++------------- src/Writers/UblWriter.php | 85 ++++++++++-------------------------- tests/InvoiceTest.php | 13 +++--- 4 files changed, 66 insertions(+), 104 deletions(-) diff --git a/src/Invoice.php b/src/Invoice.php index 8526378..22af0d4 100644 --- a/src/Invoice.php +++ b/src/Invoice.php @@ -669,10 +669,9 @@ public function clearLines(): self { /** * Get invoice total - * @param boolean $round Whether to round values or not - * @return InvoiceTotals Invoice totals + * @return InvoiceTotals Invoice totals */ - public function getTotals(bool $round=true): InvoiceTotals { - return InvoiceTotals::fromInvoice($this, $round); + public function getTotals(): InvoiceTotals { + return InvoiceTotals::fromInvoice($this); } } diff --git a/src/Models/InvoiceTotals.php b/src/Models/InvoiceTotals.php index 68d4111..1917db4 100644 --- a/src/Models/InvoiceTotals.php +++ b/src/Models/InvoiceTotals.php @@ -86,11 +86,10 @@ class InvoiceTotals { /** * Create instance from invoice - * @param Invoice $inv Invoice instance - * @param boolean $round Whether to round values or not - * @return self Totals instance + * @param Invoice $inv Invoice instance + * @return self Totals instance */ - static public function fromInvoice(Invoice $inv, bool $round=true): InvoiceTotals { + static public function fromInvoice(Invoice $inv): InvoiceTotals { $totals = new self(); $vatMap = []; @@ -100,56 +99,60 @@ static public function fromInvoice(Invoice $inv, bool $round=true): InvoiceTotal // Process all invoice lines foreach ($inv->getLines() as $line) { - $lineNetAmount = $line->getNetAmount() ?? 0.0; + $lineNetAmount = $inv->round($line->getNetAmount() ?? 0.0, 'line/netAmount'); $totals->netAmount += $lineNetAmount; self::updateVatMap($vatMap, $line, $lineNetAmount); } + $totals->netAmount = $inv->round($totals->netAmount, 'invoice/netAmount'); - // Apply allowance and charge totals + // Process allowances foreach ($inv->getAllowances() as $item) { - $allowanceAmount = $item->getEffectiveAmount($totals->netAmount); + $allowanceAmount = $inv->round($item->getEffectiveAmount($totals->netAmount), 'line/allowanceChargeAmount'); $totals->allowancesAmount += $allowanceAmount; self::updateVatMap($vatMap, $item, -$allowanceAmount); } + $totals->allowancesAmount = $inv->round($totals->allowancesAmount, 'invoice/allowancesChargesAmount'); + + // Process charges foreach ($inv->getCharges() as $item) { - $chargeAmount = $item->getEffectiveAmount($totals->netAmount); + $chargeAmount = $inv->round($item->getEffectiveAmount($totals->netAmount), 'line/allowanceChargeAmount'); $totals->chargesAmount += $chargeAmount; self::updateVatMap($vatMap, $item, $chargeAmount); } + $totals->chargesAmount = $inv->round($totals->chargesAmount, 'invoice/allowancesChargesAmount'); // Calculate VAT amounts foreach ($vatMap as $item) { - $item->taxAmount = $item->taxableAmount * ($item->rate / 100); + $item->taxableAmount = $inv->round($item->taxableAmount, 'invoice/allowancesChargesAmount'); + $item->taxAmount = $inv->round($item->taxableAmount * ($item->rate / 100), 'invoice/vatAmount'); $totals->vatAmount += $item->taxAmount; } + $totals->vatAmount = $inv->round($totals->vatAmount, 'invoice/vatAmount'); - // Calculate rest of properties - $totals->taxExclusiveAmount = $totals->netAmount - $totals->allowancesAmount + $totals->chargesAmount; - $totals->taxInclusiveAmount = $totals->taxExclusiveAmount + $totals->vatAmount; - $totals->paidAmount = $inv->getPaidAmount(); - $totals->roundingAmount = $inv->getRoundingAmount(); + // Add custom VAT amount $totals->customVatAmount = $inv->getCustomVatAmount(); - $totals->payableAmount = $totals->taxInclusiveAmount - $totals->paidAmount + $totals->roundingAmount; + if ($totals->customVatAmount !== null) { + $totals->customVatAmount = $inv->round($inv->getCustomVatAmount(), 'invoice/vatAmount'); + } // Attach VAT breakdown $totals->vatBreakdown = array_values($vatMap); - // Round values - if ($round) { - $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 = $inv->round($item->taxableAmount, 'invoice/allowancesChargesAmount'); - $item->taxAmount = $inv->round($item->taxAmount, 'invoice/taxAmount'); - } - } + // Calculate rest of properties + $totals->taxExclusiveAmount = $inv->round( + $totals->netAmount - $totals->allowancesAmount + $totals->chargesAmount, + 'invoice/taxExclusiveAmount' + ); + $totals->taxInclusiveAmount = $inv->round( + $totals->taxExclusiveAmount + $totals->vatAmount, + 'invoice/taxInclusiveAmount' + ); + $totals->paidAmount = $inv->round($inv->getPaidAmount(), 'invoice/paidAmount'); + $totals->roundingAmount = $inv->round($inv->getRoundingAmount(), 'invoice/roundingAmount'); + $totals->payableAmount = $inv->round( + $totals->taxInclusiveAmount - $totals->paidAmount + $totals->roundingAmount, + 'invoice/payableAmount' + ); return $totals; } diff --git a/src/Writers/UblWriter.php b/src/Writers/UblWriter.php index b807d4f..18af343 100644 --- a/src/Writers/UblWriter.php +++ b/src/Writers/UblWriter.php @@ -25,7 +25,7 @@ class UblWriter extends AbstractWriter { * @inheritdoc */ public function export(Invoice $invoice): string { - $totals = $invoice->getTotals(false); + $totals = $invoice->getTotals(); $xml = UXML::newInstance('Invoice', null, [ 'xmlns' => self::NS_INVOICE, 'xmlns:cac' => self::NS_CAC, @@ -169,8 +169,8 @@ public function export(Invoice $invoice): string { } // Invoice totals - $this->addTaxTotalNodes($xml, $invoice, $totals); - $this->addDocumentTotalsNode($xml, $invoice, $totals); + $this->addTaxTotalNodes($xml, $totals); + $this->addDocumentTotalsNode($xml, $totals); // Invoice lines $lines = $invoice->getLines(); @@ -657,7 +657,7 @@ private function addPaymentMandateNode(UXML $parent, Mandate $mandate) { * @param AllowanceOrCharge $item Allowance or charge instance * @param boolean $isCharge Is charge (TRUE) or allowance (FALSE) * @param Invoice $invoice Invoice instance - * @param InvoiceTotals|null $totals Unrounded invoice totals or NULL in case at line level + * @param InvoiceTotals|null $totals Invoice totals or NULL in case at line level * @param InvoiceLine|null $line Invoice line or NULL in case of at document level */ private function addAllowanceOrCharge( @@ -721,36 +721,20 @@ private function addAllowanceOrCharge( /** * Add tax total nodes - * @param UXML $parent Parent element - * @param Invoice $invoice Invoice instance - * @param InvoiceTotals $totals Unrounded invoice totals + * @param UXML $parent Parent element + * @param InvoiceTotals $totals Invoice totals */ - private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $totals) { + private function addTaxTotalNodes(UXML $parent, InvoiceTotals $totals) { $xml = $parent->add('cac:TaxTotal'); // Add tax amount - $this->addAmountNode( - $xml, - 'cbc:TaxAmount', - $invoice->round($totals->vatAmount, 'invoice/taxAmount'), - $totals->currency - ); + $this->addAmountNode($xml, 'cbc:TaxAmount', $totals->vatAmount, $totals->currency); // Add each tax details foreach ($totals->vatBreakdown as $item) { $vatBreakdownNode = $xml->add('cac:TaxSubtotal'); - $this->addAmountNode( - $vatBreakdownNode, - 'cbc:TaxableAmount', - $invoice->round($item->taxableAmount, 'invoice/allowancesChargesAmount'), - $totals->currency - ); - $this->addAmountNode( - $vatBreakdownNode, - 'cbc:TaxAmount', - $invoice->round($item->taxAmount, 'invoice/taxAmount'), - $totals->currency - ); + $this->addAmountNode($vatBreakdownNode, 'cbc:TaxableAmount', $item->taxableAmount, $totals->currency); + $this->addAmountNode($vatBreakdownNode, 'cbc:TaxAmount', $item->taxAmount, $totals->currency); $this->addVatNode( $vatBreakdownNode, 'cac:TaxCategory', @@ -767,7 +751,7 @@ private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals $this->addAmountNode( $parent->add('cac:TaxTotal'), 'cbc:TaxAmount', - $invoice->round($customVatAmount, 'invoice/taxAmount'), + $customVatAmount, $totals->vatCurrency ?? $totals->currency ); } @@ -776,55 +760,30 @@ private function addTaxTotalNodes(UXML $parent, Invoice $invoice, InvoiceTotals /** * Add document totals node - * @param UXML $parent Parent element - * @param Invoice $invoice Invoice instance - * @param InvoiceTotals $totals Unrounded invoice totals + * @param UXML $parent Parent element + * @param InvoiceTotals $totals Invoice totals */ - private function addDocumentTotalsNode(UXML $parent, Invoice $invoice, InvoiceTotals $totals) { + private function addDocumentTotalsNode(UXML $parent, InvoiceTotals $totals) { $xml = $parent->add('cac:LegalMonetaryTotal'); // Build totals matrix $totalsMatrix = []; - $totalsMatrix['cbc:LineExtensionAmount'] = $invoice->round( - $totals->netAmount, - 'invoice/netAmount' - ); - $totalsMatrix['cbc:TaxExclusiveAmount'] = $invoice->round( - $totals->taxExclusiveAmount, - 'invoice/taxExclusiveAmount' - ); - $totalsMatrix['cbc:TaxInclusiveAmount'] = $invoice->round( - $totals->taxInclusiveAmount, - 'invoice/taxInclusiveAmount' - ); + $totalsMatrix['cbc:LineExtensionAmount'] = $totals->netAmount; + $totalsMatrix['cbc:TaxExclusiveAmount'] = $totals->taxExclusiveAmount; + $totalsMatrix['cbc:TaxInclusiveAmount'] = $totals->taxInclusiveAmount; if ($totals->allowancesAmount > 0) { - $totalsMatrix['cbc:AllowanceTotalAmount'] = $invoice->round( - $totals->allowancesAmount, - 'invoice/allowancesChargesAmount' - ); + $totalsMatrix['cbc:AllowanceTotalAmount'] = $totals->allowancesAmount; } if ($totals->chargesAmount > 0) { - $totalsMatrix['cbc:ChargeTotalAmount'] = $invoice->round( - $totals->chargesAmount, - 'invoice/allowancesChargesAmount' - ); + $totalsMatrix['cbc:ChargeTotalAmount'] = $totals->chargesAmount; } if ($totals->paidAmount > 0) { - $totalsMatrix['cbc:PrepaidAmount'] = $invoice->round( - $totals->paidAmount, - 'invoice/paidAmount' - ); + $totalsMatrix['cbc:PrepaidAmount'] = $totals->paidAmount; } if ($totals->roundingAmount > 0) { - $totalsMatrix['cbc:PayableRoundingAmount'] = $invoice->round( - $totals->roundingAmount, - 'invoice/roundingAmount' - ); + $totalsMatrix['cbc:PayableRoundingAmount'] = $totals->roundingAmount; } - $totalsMatrix['cbc:PayableAmount'] = $invoice->round( - $totals->payableAmount, - 'invoice/payableAmount' - ); + $totalsMatrix['cbc:PayableAmount'] = $totals->payableAmount; // Create and append XML nodes foreach ($totalsMatrix as $field=>$amount) { diff --git a/tests/InvoiceTest.php b/tests/InvoiceTest.php index 3c597b5..165a3db 100644 --- a/tests/InvoiceTest.php +++ b/tests/InvoiceTest.php @@ -86,17 +86,17 @@ public function testCanRoundNegativeZeroes(): void { public function testDecimalMatrixIsUsed(): void { $this->invoice->setRoundingMatrix([ 'invoice/paidAmount' => 4, - 'invoice/netAmount' => 8, - '' => 3 + 'invoice/netAmount' => 5, + '' => 8, ])->setPaidAmount(123.456789) ->setRoundingAmount(987.654321) ->addLine((new InvoiceLine)->setPrice(12.121212121)) ->addLine((new InvoiceLine)->setPrice(34.343434343)); $totals = $this->invoice->getTotals(); - $this->assertEquals(123.4568, $totals->paidAmount); - $this->assertEquals(987.654, $totals->roundingAmount); - $this->assertEquals(46.46464646, $totals->netAmount); + $this->assertEquals(123.4568, $totals->paidAmount); + $this->assertEquals(987.654321, $totals->roundingAmount); + $this->assertEquals(46.46465, $totals->netAmount); } public function testTotalAmountsAreCalculatedCorrectly(): void { @@ -105,13 +105,14 @@ public function testTotalAmountsAreCalculatedCorrectly(): void { $firstLine = (new InvoiceLine)->setPrice(100)->setVatRate(10); $secondLine = (new InvoiceLine)->setPrice(200.5)->setVatRate(21); $this->invoice->clearLines() + ->setRoundingMatrix(['' => 8]) // Increase decimal precision ->setPaidAmount(10.2) ->addLine($firstLine) ->addLine($secondLine) ->addAllowance($allowance) ->addCharge($charge); - $totals = $this->invoice->getTotals(false); + $totals = $this->invoice->getTotals(); $this->assertEquals(300.5, $totals->netAmount); $this->assertEquals(12.34, $totals->allowancesAmount); $this->assertEquals(22.5375, $totals->chargesAmount); From 0b60be1cc1cdbbf4a554a637ff37c0037196faa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Sun, 4 Dec 2022 10:34:31 +0100 Subject: [PATCH 2/3] Updated rounding unit tests - Updated InvoiceTotalsTest class - Added "peppol-rounding.xml" integration test sample > Related to #31 --- tests/Integration/IntegrationTest.php | 4 ++ tests/Integration/peppol-rounding.xml | 100 ++++++++++++++++++++++++++ tests/Models/InvoiceTotalsTest.php | 15 +++- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/peppol-rounding.xml diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/IntegrationTest.php index 38cd09c..68b9cc1 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/IntegrationTest.php @@ -62,6 +62,10 @@ public function testCanRecreatePeppolAllowanceExample(): void { $this->importAndExportInvoice(__DIR__ . "/peppol-allowance.xml"); } + public function testCanRecreatePeppolRoundingExample(): void { + $this->importAndExportInvoice(__DIR__ . "/peppol-rounding.xml"); + } + public function testCanRecreateCiusRoTaxCurrencyCodeExample(): void { $this->importAndExportInvoice(__DIR__ . "/cius-ro-tax-currency-code.xml"); } diff --git a/tests/Integration/peppol-rounding.xml b/tests/Integration/peppol-rounding.xml new file mode 100644 index 0000000..c6e5db4 --- /dev/null +++ b/tests/Integration/peppol-rounding.xml @@ -0,0 +1,100 @@ + + + + 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 + SampleForDecimals + 2022-11-03 + 2022-11-11 + 380 + EUR + + + admin@example.com + + 12345678 + + + Yellow Brick Road + Kuki + 400001 + RO-CJ + + RO + + + + 12345678 + + + + Test S.r.o + J12/1234/2016 + + + + + + + Strada Zebreiou 432 + Bacau + 57433 + RO-BC + + RO + + + + RO17364910 + + + + POP Alexandra SRL + RO17364910 + + + + + 31 + + + 1279.45 + + 6733.95 + 1279.45 + + S + 19 + + VAT + + + + + + 6733.95 + 6733.95 + 8013.4 + 8013.4 + + + 101 + 26935.78 + 6733.95 + + Test + + S + 19 + + VAT + + + + + 0.25 + + + diff --git a/tests/Models/InvoiceTotalsTest.php b/tests/Models/InvoiceTotalsTest.php index 7f6b551..db5564b 100644 --- a/tests/Models/InvoiceTotalsTest.php +++ b/tests/Models/InvoiceTotalsTest.php @@ -12,7 +12,7 @@ final class InvoiceTotalsTest extends TestCase { private $invoice; protected function setUp(): void { - $this->invoice = new Invoice(); + $this->invoice = (new Invoice)->setRoundingMatrix(['' => 3]); } public function testClassConstructors(): void { @@ -29,6 +29,19 @@ public function testClassConstructors(): void { $this->assertEquals(100, $totalsB->payableAmount); } + public function testRoundingOfTotals(): void { + $line = (new InvoiceLine()) + ->setPrice(0.25) + ->setQuantity(26935.78) + ->setVatRate(19); + $this->invoice->addLine($line); + + $totals = $this->invoice->getTotals(); + $this->assertEquals(6733.945, $totals->taxExclusiveAmount); + $this->assertEquals(1279.45, $totals->vatAmount); + $this->assertEquals(8013.395, $totals->taxInclusiveAmount); + } + public function testVatExemptionReasons(): void { $firstLine = (new InvoiceLine()) ->setName('Line #1') From e93d4ef9fec0b7cd2cb4fc78b4b861ad839f5333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Miguel=20Moreno?= Date: Thu, 8 Dec 2022 21:00:49 +0100 Subject: [PATCH 3/3] Added PHP 8.3 to tests - Updated CI workflow --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fb1a07..56447e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,16 +10,16 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.1', '7.2', '7.3', '7.4', '8.0'] + php-version: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] include: - - php-version: '8.1' - deploy: ${{ github.ref == 'refs/heads/master' }} - php-version: '8.2' + deploy: ${{ github.ref == 'refs/heads/master' }} + - php-version: '8.3' experimental: true steps: # Download code from repository - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Setup PHP - name: Setup PHP @@ -52,7 +52,7 @@ jobs: # Deploy documentation - name: Deploy documentation if: ${{ success() && matrix.deploy || false }} - uses: JamesIves/github-pages-deploy-action@v4.2.5 + uses: JamesIves/github-pages-deploy-action@v4 with: branch: gh-pages folder: site