From 7f87a5e5c6fb7c283b59484da8e4d9cd798aea95 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 30 Mar 2026 17:58:47 +0530 Subject: [PATCH 1/4] fix(taxes): increase rounding threshold for tax breakup calculations --- erpnext/controllers/taxes_and_totals.py | 2 +- .../tests/test_item_wise_tax_details.py | 104 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1cd6f203ec2..518d88702fb 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -522,7 +522,7 @@ class calculate_taxes_and_totals: diff = flt(expected_amount - actual_breakup, 5) # TODO: fix rounding difference issues - if abs(diff) <= 0.5: + if abs(diff) <= 1: detail_row = self.doc._item_wise_tax_details[last_idx] detail_row["amount"] = flt(detail_row["amount"] + diff, 5) diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index 93de1d8890d..8089cbca7ea 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -121,3 +121,107 @@ class TestTaxesAndTotals(ERPNextTestSuite): ] self.assertEqual(actual_values, expected_values) + + def test_item_wise_tax_detail_with_multi_currency(self): + """ + For multi-item, multi-currency invoices, item-wise tax breakup should + still reconcile with base tax totals. + """ + doc = frappe.get_doc( + { + "doctype": "Sales Invoice", + "customer": "_Test Customer", + "company": "_Test Company", + "currency": "USD", + "debit_to": "_Test Receivable USD - _TC", + "conversion_rate": 129.99, + "items": [ + { + "item_code": "_Test Item", + "qty": 1, + "rate": 47.41, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }, + { + "item_code": "_Test Item", + "qty": 2, + "rate": 33.33, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }, + ], + "taxes": [ + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "rate": 16, + }, + { + "charge_type": "On Previous Row Amount", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 10, + "row_id": 1, + }, + ], + } + ) + doc.save() + + details_by_tax = {} + for detail in doc.item_wise_tax_details: + bucket = details_by_tax.setdefault(detail.tax_row, {"amount": 0.0, "taxable_amount": 0.0}) + bucket["amount"] += detail.amount + + for tax in doc.taxes: + detail_totals = details_by_tax[tax.name] + self.assertAlmostEqual( + detail_totals["amount"], tax.base_tax_amount_after_discount_amount, places=2 + ) + + def test_item_wise_tax_detail_with_multi_currency_with_single_item(self): + """ + When the tax amount (in transaction currency) has more decimals than + the field precision, rounding must happen *before* multiplying by + conversion_rate — the same order used by _set_in_company_currency. + """ + doc = frappe.get_doc( + { + "doctype": "Sales Invoice", + "customer": "_Test Customer", + "company": "_Test Company", + "currency": "USD", + "debit_to": "_Test Receivable USD - _TC", + "conversion_rate": 129.99, + "items": [ + { + "item_code": "_Test Item", + "qty": 1, + "rate": 47.41, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + } + ], + "taxes": [ + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "rate": 16, + }, + ], + } + ) + doc.save() + + tax = doc.taxes[0] + detail = doc.item_wise_tax_details[0] + self.assertEqual(detail.amount, tax.base_tax_amount_after_discount_amount) From 3449ab063aac954f38bf3dbf7048f13457f60dcb Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 30 Mar 2026 18:37:07 +0530 Subject: [PATCH 2/4] fix(tests): update item code and quantity in tax detail test case --- erpnext/controllers/tests/test_item_wise_tax_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index 8089cbca7ea..3dbc03c3754 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -145,7 +145,7 @@ class TestTaxesAndTotals(ERPNextTestSuite): "cost_center": "_Test Cost Center - _TC", }, { - "item_code": "_Test Item", + "item_code": "_Test Item 2", "qty": 2, "rate": 33.33, "income_account": "Sales - _TC", From a18196f584d4d234e669e23842223f6042360c1d Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 19:37:59 +0530 Subject: [PATCH 3/4] fix(taxes): improve tax calculation accuracy and update test assertions --- erpnext/controllers/taxes_and_totals.py | 29 +++++++++++++++---- .../tests/test_item_wise_tax_details.py | 7 ++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 518d88702fb..50823b4f129 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -285,6 +285,13 @@ class calculate_taxes_and_totals: self.doc._item_wise_tax_details = item_wise_tax_details self.doc.item_wise_tax_details = [] + for tax in self.doc.get("taxes"): + if not tax.get("dont_recompute_tax"): + tax._running_txn_tax_total = 0.0 + tax._running_base_tax_total = 0.0 + tax._running_txn_taxable_total = 0.0 + tax._running_base_taxable_total = 0.0 + def determine_exclusive_rate(self): if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")): return @@ -521,8 +528,7 @@ class calculate_taxes_and_totals: actual_breakup = tax._total_tax_breakup diff = flt(expected_amount - actual_breakup, 5) - # TODO: fix rounding difference issues - if abs(diff) <= 1: + if abs(diff) <= 0.5: detail_row = self.doc._item_wise_tax_details[last_idx] detail_row["amount"] = flt(detail_row["amount"] + diff, 5) @@ -597,14 +603,25 @@ class calculate_taxes_and_totals: def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount): # store tax breakup for each item multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1 - item_wise_tax_amount = flt( - current_tax_amount * self.doc.conversion_rate * multiplier, tax.precision("tax_amount") + + # Error diffusion: derive each item's base amount as a delta of the running cumulative total + # so the sum always equals base_tax_amount_after_discount_amount. + tax._running_txn_tax_total += current_tax_amount * multiplier + new_base_tax_total = flt( + flt(tax._running_txn_tax_total, tax.precision("tax_amount")) * self.doc.conversion_rate, + tax.precision("base_tax_amount"), ) + item_wise_tax_amount = new_base_tax_total - tax._running_base_tax_total + tax._running_base_tax_total = new_base_tax_total if tax.charge_type != "On Item Quantity": - item_wise_taxable_amount = flt( - current_net_amount * self.doc.conversion_rate * multiplier, tax.precision("tax_amount") + tax._running_txn_taxable_total += current_net_amount * multiplier + new_base_taxable_total = flt( + flt(tax._running_txn_taxable_total, tax.precision("net_amount")) * self.doc.conversion_rate, + tax.precision("base_net_amount"), ) + item_wise_taxable_amount = new_base_taxable_total - tax._running_base_taxable_total + tax._running_base_taxable_total = new_base_taxable_total else: item_wise_taxable_amount = 0.0 diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index 3dbc03c3754..b6fbf7cad74 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -176,14 +176,11 @@ class TestTaxesAndTotals(ERPNextTestSuite): details_by_tax = {} for detail in doc.item_wise_tax_details: - bucket = details_by_tax.setdefault(detail.tax_row, {"amount": 0.0, "taxable_amount": 0.0}) + bucket = details_by_tax.setdefault(detail.tax_row, {"amount": 0.0}) bucket["amount"] += detail.amount for tax in doc.taxes: - detail_totals = details_by_tax[tax.name] - self.assertAlmostEqual( - detail_totals["amount"], tax.base_tax_amount_after_discount_amount, places=2 - ) + self.assertEqual(details_by_tax[tax.name]["amount"], tax.base_tax_amount_after_discount_amount) def test_item_wise_tax_detail_with_multi_currency_with_single_item(self): """ From fc8437c499e3746c82cc64c26751f8b546fd8d94 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 30 Mar 2026 20:04:53 +0530 Subject: [PATCH 4/4] test: update item-wise tax detail test for high conversion rates --- .../tests/test_item_wise_tax_details.py | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index b6fbf7cad74..9159dcf6fd5 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -1,6 +1,6 @@ import frappe -from erpnext.tests.utils import ERPNextTestSuite +from erpnext.tests.utils import ERPNextTestSuite, change_settings class TestTaxesAndTotals(ERPNextTestSuite): @@ -122,10 +122,22 @@ class TestTaxesAndTotals(ERPNextTestSuite): self.assertEqual(actual_values, expected_values) - def test_item_wise_tax_detail_with_multi_currency(self): + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_item_wise_tax_detail_high_conversion_rate(self): """ - For multi-item, multi-currency invoices, item-wise tax breakup should - still reconcile with base tax totals. + With a high conversion rate (e.g. USD -> KRW ~1300), independently rounding + each item's base tax amount causes per-item errors that accumulate and exceed + the 0.5-unit safety threshold, raising a validation error. + + Error diffusion fixes this: the cumulative base total after the last item + equals base_tax_amount_after_discount_amount exactly, so the sum of all + per-item amounts is always exact regardless of item count or rate magnitude. + + Analytically with conversion_rate=1300, rate=7.77 x3 items, VAT 16%: + per-item txn tax = 1.2432 + OLD independent: flt(1.2432 * 1300, 2) = 1616.16 -> sum 4848.48 + expected base: flt(flt(3.7296, 2) * 1300, 0) = flt(3.73 * 1300, 0) = 4849 + diff = 0.52 -> exceeds 0.5 threshold -> would throw with old code """ doc = frappe.get_doc( { @@ -134,20 +146,28 @@ class TestTaxesAndTotals(ERPNextTestSuite): "company": "_Test Company", "currency": "USD", "debit_to": "_Test Receivable USD - _TC", - "conversion_rate": 129.99, + "conversion_rate": 1300, "items": [ { "item_code": "_Test Item", "qty": 1, - "rate": 47.41, + "rate": 7.77, "income_account": "Sales - _TC", "expense_account": "Cost of Goods Sold - _TC", "cost_center": "_Test Cost Center - _TC", }, { - "item_code": "_Test Item 2", - "qty": 2, - "rate": 33.33, + "item_code": "_Test Item", + "qty": 1, + "rate": 7.77, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }, + { + "item_code": "_Test Item", + "qty": 1, + "rate": 7.77, "income_account": "Sales - _TC", "expense_account": "Cost of Goods Sold - _TC", "cost_center": "_Test Cost Center - _TC", @@ -176,11 +196,11 @@ class TestTaxesAndTotals(ERPNextTestSuite): details_by_tax = {} for detail in doc.item_wise_tax_details: - bucket = details_by_tax.setdefault(detail.tax_row, {"amount": 0.0}) - bucket["amount"] += detail.amount + bucket = details_by_tax.setdefault(detail.tax_row, 0.0) + details_by_tax[detail.tax_row] = bucket + detail.amount for tax in doc.taxes: - self.assertEqual(details_by_tax[tax.name]["amount"], tax.base_tax_amount_after_discount_amount) + self.assertEqual(details_by_tax[tax.name], tax.base_tax_amount_after_discount_amount) def test_item_wise_tax_detail_with_multi_currency_with_single_item(self): """