diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 147bb76064d..24c5416b7ee 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -384,6 +384,262 @@ class TestSalesInvoice(FrappeTestCase): 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(test_records[3]) si.discount_amount = 104.94 diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 54aee03e083..6a940889d21 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -290,6 +290,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 @@ -317,7 +318,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") @@ -480,6 +482,65 @@ class calculate_taxes_and_totals: self._set_in_company_currency(tax, ["total"]) +<<<<<<< HEAD +======= + self.adjust_rounding_in_item_wise_tax_details() + + def adjust_rounding_in_item_wise_tax_details(self): + if ignore_item_wise_tax_details(self.doc): + return + + if not self.doc.get("_item_wise_tax_details"): + return + + invalid_rows = [] + + # reset temporary attributes + for tax in self.doc.taxes: + tax._total_tax_breakup = 0 + tax._last_row_idx = None + + for idx, d in enumerate(self.doc._item_wise_tax_details): + tax = d.get("tax") + if not tax or (tax.get("charge_type") == "Actual" and d.rate == 0): + continue + + tax._total_tax_breakup += d.amount or 0 + tax._last_row_idx = idx + + # Apply rounding difference to the last row + for tax in self.doc.taxes: + last_idx = tax._last_row_idx + if last_idx is None: + continue + + multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1 + expected_amount = tax.base_tax_amount_after_discount_amount * multiplier + actual_breakup = tax._total_tax_breakup + diff = flt(expected_amount - actual_breakup, 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) + + else: + invalid_rows.append(f"Row {tax.idx} (Difference: {diff})") + + if self.doc.flags.ignore_validate: + return + + if invalid_rows: + message = ( + _("Item Wise Tax Details do not match with Taxes and Charges at the following rows:") + + "
" + + "
".join(invalid_rows) + ) + + frappe.throw(_(message)) + +>>>>>>> 36dc196a1d (fix: prevent double rounding in inclusive tax calculations (#52512)) def get_tax_amount_if_for_valuation_or_deduction(self, tax_amount, tax): # if just for valuation, do not add the tax amount in total # if tax/charges is for deduction, multiply by -1 @@ -522,7 +583,22 @@ class calculate_taxes_and_totals: ) elif tax.charge_type == "On Net Total": +<<<<<<< HEAD current_tax_amount = (tax_rate / 100.0) * item.net_amount +======= + if tax.account_head in item_tax_map: + 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 +>>>>>>> 36dc196a1d (fix: prevent double rounding in inclusive tax calculations (#52512)) elif tax.charge_type == "On Previous Row Amount": current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[ cint(tax.row_id) - 1 diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 7b07c13bf11..879a9aea365 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -234,7 +234,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); if(has_inclusive_tax==false) return; +<<<<<<< HEAD $.each(this.frm.doc.items || [], function(n, item) { +======= + $.each(this.frm.doc.items || [], function (n, item) { + item._unrounded_net_amount = null; +>>>>>>> 36dc196a1d (fix: prevent double rounding in inclusive tax calculations (#52512)) 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; @@ -257,7 +262,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if(!me.discount_amount_applied && item.qty && (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"]); @@ -496,6 +502,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else if(tax.charge_type == "On Net Total") { if (tax.account_head in item_tax_map) { +<<<<<<< HEAD current_net_amount = item.net_amount }; current_tax_amount = (tax_rate / 100.0) * item.net_amount; @@ -507,6 +514,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item current_tax_amount = (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item; +======= + current_net_amount = 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 = + (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; + } else if (tax.charge_type == "On Previous Row Total") { + current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item; + current_tax_amount = + (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item; +>>>>>>> 36dc196a1d (fix: prevent double rounding in inclusive tax calculations (#52512)) } else if (tax.charge_type == "On Item Quantity") { // don't sum current net amount due to the field being a currency field current_tax_amount = tax_rate * item.qty;