fix: prevent double rounding in inclusive tax calculations (backport #52512) (#55570)

Co-authored-by: Luis Mendoza <mendozal@gmail.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
fix: prevent double rounding in inclusive tax calculations (#52512)
This commit is contained in:
mergify[bot]
2026-06-03 14:10:32 +05:30
committed by GitHub
parent 201e62195f
commit 37b61f06ae
3 changed files with 283 additions and 5 deletions

View File

@@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(si.net_total, 3859.65) self.assertEqual(si.net_total, 3859.65)
self.assertEqual(si.grand_total, 4900.00) self.assertEqual(si.grand_total, 4900.00)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency(self):
"""Tax-included prices in zero-decimal currencies (e.g. JPY) must not produce
net + tax != gross due to double rounding of the net amount."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# With currency_precision=0 (like JPY, KRW):
# 50,000 / 1.10 = 45,454.545... → net rounds to 45,455
# Tax from unrounded net: 0.10 * 45,454.545 = 4,545.4545 → rounds to 4,545
# The fix ensures net + tax = gross without double rounding error
self.assertEqual(si.items[0].net_amount, 45455)
self.assertEqual(si.taxes[0].tax_amount, 4545)
self.assertEqual(si.grand_total, 50000)
def test_inclusive_tax_decimal_value_currency(self):
"""Tax-included prices with decimal currency values must preserve gross total."""
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
# Tax from unrounded net: 0.10 * 9,090.94545... = 909.0945... → rounds to 909.09
# If tax were calculated from rounded net instead, it would become 909.10 and grand total 10,000.05.
self.assertEqual(si.items[0].net_amount, 9090.95)
self.assertEqual(si.taxes[0].tax_amount, 909.09)
self.assertEqual(si.grand_total, 10000.04)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency_multiple_items(self):
"""Multiple items with tax-included prices in zero-decimal currency."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
create_item("_Test Inclusive Tax Item 2")
si.append(
"items",
{
"item_code": "_Test Inclusive Tax Item 2",
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": 30000,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# With currency_precision=0:
# Item 1: 50,000 / 1.10 = 45,454.545 → net 45,455, tax 4,545
# Item 2: 30,000 / 1.10 = 27,272.727 → net 27,273, tax 2,727
# Per-item: net + tax = gross holds (45455+4545=50000, 27273+2727=30000)
# Accumulated tax rounds separately: flt(7272.72, 0) = 7273
# adjust_grand_total_for_inclusive_tax patches grand_total back to 80000
self.assertEqual(si.items[0].net_amount, 45455)
self.assertEqual(si.items[1].net_amount, 27273)
self.assertEqual(si.net_total, 72728)
self.assertEqual(si.taxes[0].tax_amount, 7273)
self.assertEqual(si.grand_total, 80000)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency_many_items(self):
"""Test with 10 items (mixed 10% and 5% tax) to verify tolerance of 1 is sufficient."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
# Add 9 more items - mix of amounts and tax rates
# Using similar amounts to maximize same-direction rounding
item_configs = [
("_Test Inclusive Tax Item 2", 50100, None), # 10% (default)
("_Test Inclusive Tax Item 3", 50200, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 4", 50300, None), # 10%
("_Test Inclusive Tax Item 5", 50400, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 6", 50500, None), # 10%
("_Test Inclusive Tax Item 7", 50600, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 8", 50700, None), # 10%
("_Test Inclusive Tax Item 9", 50800, None), # 10%
("_Test Inclusive Tax Item 10", 50900, '{"_Test Account Service Tax - _TC": 5}'), # 5%
]
for item_code, rate, item_tax_rate in item_configs:
create_item(item_code)
item_dict = {
"item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": rate,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
}
if item_tax_rate:
item_dict["item_tax_rate"] = item_tax_rate
si.append("items", item_dict)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# Verify each item: net + tax = gross (within rounding tolerance)
total_gross = 0
for item in si.items:
total_gross += item.amount
# Grand total should match sum of gross amounts
# This tests that the tolerance of 1 handles mixed tax rates and similar amounts
self.assertEqual(si.grand_total, total_gross)
def test_inclusive_tax_with_decimal_value_on_previous_row_amount(self):
"""Inclusive tax with decimal value and On Previous Row Amount must not double-round net amount."""
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Tax 10%",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 1,
},
)
si.insert()
# Tax fractions: 10% + (5% of 10%) = 10.5%
# 50,000.55 / 1.105 = 45,249.3665... → net rounds to 45,249.37
# Taxes are calculated from the unrounded net to keep the inclusive gross stable.
self.assertEqual(si.items[0].net_amount, 45249.37)
self.assertEqual(si.taxes[0].tax_amount, 4524.94)
self.assertEqual(si.taxes[1].tax_amount, 226.25)
self.assertEqual(si.grand_total, 50000.55)
def test_inclusive_tax_with_decimal_value_on_previous_row_amount_non_inclusive(self):
"""Non-inclusive previous-row tax should be added after inclusive tax extraction."""
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Tax 10%",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 0,
},
)
si.insert()
# Only the first tax is inclusive:
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
# Inclusive tax = 909.09, restoring the original gross of 10,000.04
# The non-inclusive previous-row tax is added afterward: 5% of 909.09 = 45.45
self.assertEqual(si.items[0].net_amount, 9090.95)
self.assertEqual(si.taxes[0].tax_amount, 909.09)
self.assertEqual(si.taxes[1].tax_amount, 45.45)
self.assertEqual(si.grand_total, 10045.49)
def test_inclusive_tax_with_decimal_value_on_previous_row_total(self):
"""Inclusive tax with decimal value and On Previous Row Total must not double-round net amount."""
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Total",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Previous Total",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 1,
},
)
si.insert()
# Tax fractions: 10% + (5% of 110%) = 15.5%
# 50,000.55 / 1.155 = 43,290.5195... → net rounds to 43,290.52
# Taxes are calculated from the unrounded net/previous total to keep the inclusive gross stable.
self.assertEqual(si.items[0].net_amount, 43290.52)
self.assertEqual(si.taxes[0].tax_amount, 4329.05)
self.assertEqual(si.taxes[1].tax_amount, 2380.98)
self.assertEqual(si.grand_total, 50000.55)
def test_sales_invoice_discount_amount(self): def test_sales_invoice_discount_amount(self):
si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3]) si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3])
si.discount_amount = 104.94 si.discount_amount = 104.94

View File

@@ -304,6 +304,7 @@ class calculate_taxes_and_totals:
return return
for item in self.doc.items: for item in self.doc.items:
item._unrounded_net_amount = None
item_tax_map = self._load_item_tax_rate(item.item_tax_rate) item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0 cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0 total_inclusive_tax_amount_per_qty = 0
@@ -331,7 +332,8 @@ class calculate_taxes_and_totals:
): ):
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
item.net_amount = flt(amount / (1 + cumulated_tax_fraction), item.precision("net_amount")) item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction)
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate")) item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt( item.discount_percentage = flt(
item.discount_percentage, item.precision("discount_percentage") item.discount_percentage, item.precision("discount_percentage")
@@ -541,7 +543,9 @@ class calculate_taxes_and_totals:
actual_breakup = tax._total_tax_breakup actual_breakup = tax._total_tax_breakup
diff = flt(expected_amount - actual_breakup, 5) diff = flt(expected_amount - actual_breakup, 5)
if abs(diff) <= 0.5: # TODO: fix rounding difference issues
# Allow up to 1 for zero-precision currencies (e.g. JPY, KRW)
if abs(diff) <= (1 if tax.precision("tax_amount") == 0 else 0.5):
detail_row = self.doc._item_wise_tax_details[last_idx] detail_row = self.doc._item_wise_tax_details[last_idx]
detail_row["amount"] = flt(detail_row["amount"] + diff, 5) detail_row["amount"] = flt(detail_row["amount"] + diff, 5)
@@ -600,6 +604,15 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Net Total": elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map: if tax.account_head in item_tax_map:
current_net_amount = item.net_amount current_net_amount = item.net_amount
# Use unrounded net for inclusive taxes to avoid double rounding
if (
cint(tax.included_in_print_rate)
and not self.discount_amount_applied
and item._unrounded_net_amount is not None
):
current_tax_amount = (tax_rate / 100.0) * item._unrounded_net_amount
else:
current_tax_amount = (tax_rate / 100.0) * item.net_amount current_tax_amount = (tax_rate / 100.0) * item.net_amount
elif tax.charge_type == "On Previous Row Amount": elif tax.charge_type == "On Previous Row Amount":
current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item

View File

@@ -258,6 +258,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if (has_inclusive_tax == false) return; if (has_inclusive_tax == false) return;
$.each(this.frm.doc.items || [], function (n, item) { $.each(this.frm.doc.items || [], function (n, item) {
item._unrounded_net_amount = null;
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0; var cumulated_tax_fraction = 0.0;
var total_inclusive_tax_amount_per_qty = 0; var total_inclusive_tax_amount_per_qty = 0;
@@ -284,7 +285,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
(total_inclusive_tax_amount_per_qty || cumulated_tax_fraction) (total_inclusive_tax_amount_per_qty || cumulated_tax_fraction)
) { ) {
var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty; var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty;
item.net_amount = flt(amount / (1 + cumulated_tax_fraction), precision("net_amount", item)); item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction);
item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item));
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
me.set_in_company_currency(item, ["net_rate", "net_amount"]); me.set_in_company_currency(item, ["net_rate", "net_amount"]);
@@ -567,7 +569,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if (tax.account_head in item_tax_map) { if (tax.account_head in item_tax_map) {
current_net_amount = item.net_amount; current_net_amount = item.net_amount;
} }
current_tax_amount = (tax_rate / 100.0) * item.net_amount; // Use unrounded net for inclusive taxes to avoid double rounding
var net_for_tax =
cint(tax.included_in_print_rate) &&
!this.discount_amount_applied &&
item._unrounded_net_amount !== null
? item._unrounded_net_amount
: item.net_amount;
current_tax_amount = (tax_rate / 100.0) * net_for_tax;
} else if (tax.charge_type == "On Previous Row Amount") { } else if (tax.charge_type == "On Previous Row Amount") {
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item;
current_tax_amount = current_tax_amount =