mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-05 05:09:11 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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,7 +604,16 @@ 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
|
||||||
current_tax_amount = (tax_rate / 100.0) * 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
|
||||||
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
|
||||||
current_tax_amount = (tax_rate / 100.0) * current_net_amount
|
current_tax_amount = (tax_rate / 100.0) * current_net_amount
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user