diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 5ccf2f2cc10..9a15c1feb14 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite): self.assertEqual(si.net_total, 3859.65) 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): si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3]) si.discount_amount = 104.94 diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 86bb26ac607..a4474b7780e 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -304,6 +304,7 @@ class calculate_taxes_and_totals: return for item in self.doc.items: + item._unrounded_net_amount = None item_tax_map = self._load_item_tax_rate(item.item_tax_rate) cumulated_tax_fraction = 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 - 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.discount_percentage = flt( item.discount_percentage, item.precision("discount_percentage") @@ -541,7 +543,9 @@ class calculate_taxes_and_totals: actual_breakup = tax._total_tax_breakup 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["amount"] = flt(detail_row["amount"] + diff, 5) @@ -600,7 +604,16 @@ class calculate_taxes_and_totals: elif tax.charge_type == "On Net Total": if tax.account_head in item_tax_map: 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": 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 diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index e3074f711ef..cb6c32a41b0 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -258,6 +258,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (has_inclusive_tax == false) return; $.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 cumulated_tax_fraction = 0.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) ) { 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; 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) { 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") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; current_tax_amount =