diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1cd6f203ec2..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,7 +528,6 @@ 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) <= 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 93de1d8890d..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): @@ -121,3 +121,124 @@ class TestTaxesAndTotals(ERPNextTestSuite): ] self.assertEqual(actual_values, expected_values) + + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_item_wise_tax_detail_high_conversion_rate(self): + """ + 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( + { + "doctype": "Sales Invoice", + "customer": "_Test Customer", + "company": "_Test Company", + "currency": "USD", + "debit_to": "_Test Receivable USD - _TC", + "conversion_rate": 1300, + "items": [ + { + "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", + }, + { + "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", + }, + ], + "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, 0.0) + details_by_tax[detail.tax_row] = bucket + detail.amount + + for tax in doc.taxes: + 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): + """ + 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)