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;