diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index a2afea8c8ec..968bb1fc7b5 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -307,33 +307,32 @@ class calculate_taxes_and_totals: 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 + total_tax_slope = 0 + total_tax_intercept = 0 for i, tax in enumerate(self.doc.get("taxes")): ( tax.tax_fraction_for_current_item, - inclusive_tax_amount_per_qty, - ) = self.get_current_tax_fraction(tax, item_tax_map) + tax_intercept_per_qty, + ) = self.get_current_tax_fraction(tax, item_tax_map, item) + tax.inclusive_amount_per_qty = tax_intercept_per_qty if i == 0: tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item + tax.grand_total_amount_per_qty = tax_intercept_per_qty else: + prev = self.doc.get("taxes")[i - 1] tax.grand_total_fraction_for_current_item = ( - self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item - + tax.tax_fraction_for_current_item + prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item ) + tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty - cumulated_tax_fraction += tax.tax_fraction_for_current_item - total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty) + total_tax_slope += tax.tax_fraction_for_current_item + total_tax_intercept += tax_intercept_per_qty * flt(item.qty) - if ( - not self.discount_amount_applied - and item.qty - and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty) - ): - amount = flt(item.amount) - total_inclusive_tax_amount_per_qty + if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept): + amount = flt(item.amount) - total_tax_intercept - item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction) + item._unrounded_net_amount = amount / (1 + total_tax_slope) 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( @@ -345,41 +344,48 @@ class calculate_taxes_and_totals: def _load_item_tax_rate(self, item_tax_rate): return frappe.parse_json(item_tax_rate) if item_tax_rate else {} - def get_current_tax_fraction(self, tax, item_tax_map): + def get_current_tax_fraction(self, tax, item_tax_map, item): """ - Get tax fraction for calculating tax exclusive amount - from tax inclusive amount + tax = slope * net + intercept. + Returns (slope, intercept_per_qty) """ - current_tax_fraction = 0 - inclusive_tax_amount_per_qty = 0 + tax_slope = 0 + tax_intercept = 0 if cint(tax.included_in_print_rate): tax_rate = self._get_tax_rate(tax, item_tax_map) if tax_rate == NOT_APPLICABLE_TAX: - return current_tax_fraction, inclusive_tax_amount_per_qty + return tax_slope, tax_intercept if tax.charge_type == "On Net Total": - current_tax_fraction = tax_rate / 100.0 + tax_slope = tax_rate / 100.0 elif tax.charge_type == "On Previous Row Amount": - current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[ - cint(tax.row_id) - 1 - ].tax_fraction_for_current_item + row = self.doc.get("taxes")[cint(tax.row_id) - 1] + tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item + tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0)) elif tax.charge_type == "On Previous Row Total": - current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[ - cint(tax.row_id) - 1 - ].grand_total_fraction_for_current_item + row = self.doc.get("taxes")[cint(tax.row_id) - 1] + tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item + tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0)) elif tax.charge_type == "On Item Quantity": - inclusive_tax_amount_per_qty = flt(tax_rate) + tax_intercept = flt(tax_rate) + + else: + # Custom charge_type: the rate applies to a resolved (fixed) base, + # e.g. a tax on MRP included in the printed price. + qty = flt(item.qty) or 1 + base = self.get_item_taxable_base(item, tax) + tax_intercept = (tax_rate / 100.0) * base / qty if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct": - current_tax_fraction *= -1.0 - inclusive_tax_amount_per_qty *= -1.0 + tax_slope *= -1.0 + tax_intercept *= -1.0 - return current_tax_fraction, inclusive_tax_amount_per_qty + return tax_slope, tax_intercept def _get_tax_rate(self, tax, item_tax_map): if tax.account_head in item_tax_map: @@ -605,7 +611,6 @@ 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 - # Use unrounded net for inclusive taxes to avoid double rounding if ( cint(tax.included_in_print_rate) @@ -624,12 +629,46 @@ class calculate_taxes_and_totals: elif 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 + else: + # Custom charge_type: rate applies to the resolver-provided base. + base = self.get_item_taxable_base(item, tax) + current_net_amount = base + current_tax_amount = (tax_rate / 100.0) * base if not tax.get("dont_recompute_tax"): self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount) return current_net_amount, current_tax_amount + def get_item_taxable_base(self, item, tax): + """Per-item base a custom charge_type's rate is applied to. + + Override the base (gross, MRP, net of other taxes, …) via the + `erpnext_taxable_base_resolvers` hook + + Register a resolver in `hooks.py`, keyed by charge_type: + + erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"} + + It receives (calc, item, tax) — calc is this instance, calc.doc the parent — + and returns the base (flt-coerced by the caller): + + def gross_base(calc, item, tax): + return item.custom_field_mrp * item.qty + + A resolver may stamp transient attributes on `item`; it can be called more than once + per item, so such stamping must be idempotent. + """ + resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {} + path = resolvers.get(tax.charge_type) + + if path: + method = path[-1] if isinstance(path, list | tuple) else path + return flt(frappe.get_attr(method)(self, item, tax)) + + # fallback + return flt(item.net_amount) + def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount): # store tax breakup for each item multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1 diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index 54067c4ce22..90eba36bcd7 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -1,12 +1,24 @@ +from unittest import mock from unittest.mock import patch import frappe +from frappe.utils import flt from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite +def resolve_on_gross(calc, item, tax): + # base = gross printed line amount + return flt(item.amount) + + +def resolve_on_mrp(calc, item, tax): + # base = MRP, not net + return flt(item.price_list_rate) * flt(item.qty) + + class TestTaxesAndTotals(ERPNextTestSuite): def test_regional_round_off_accounts(self): """ @@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite): self.assertIn(test_account, frappe.flags.round_off_applicable_accounts) + def test_exclusive_custom_charge_on_resolved_base(self): + """Added (exclusive) custom charge_type whose base is resolved by the + `erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000 + -> tax 100, net 1000, grand 1100.""" + so = make_sales_order(do_not_save=True) + so.items = [] + so.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 1000, + "price_list_rate": 1000, + "warehouse": "_Test Warehouse - _TC", + }, + ) + so.set("taxes", []) + so.append( + "taxes", + { + "charge_type": "On Gross Value", + "account_head": "_Test Account Excise Duty - _TC", + "description": "IPI 10% on gross product value", + "rate": 10, + "cost_center": "_Test Cost Center - _TC", + }, + ) + + real_get_hooks = frappe.get_hooks + + def fake_get_hooks(hook=None, *args, **kwargs): + if hook == "erpnext_taxable_base_resolvers": + return { + "On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"] + } + return real_get_hooks(hook, *args, **kwargs) + + with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks): + calculate_taxes_and_totals(so) + + self.assertEqual(so.net_total, 1000.0) + self.assertEqual(so.taxes[0].tax_amount, 100.0) + self.assertEqual(so.grand_total, 1100.0) + + def test_inclusive_custom_charge_on_resolved_base(self): + """Inclusive custom charge on a resolved base backs out non-compounding + (tax = rate x resolved base) — a resolved base is fixed, so it never + compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880.""" + so = make_sales_order(do_not_save=True) + so.items = [] + so.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 1000, + "price_list_rate": 1200, + "warehouse": "_Test Warehouse - _TC", + }, + ) + so.set("taxes", []) + so.append( + "taxes", + { + "charge_type": "On MRP", + "account_head": "_Test Account VAT - _TC", + "description": "Tax 10% on MRP, inclusive", + "rate": 10, + "included_in_print_rate": 1, + "cost_center": "_Test Cost Center - _TC", + }, + ) + + real_get_hooks = frappe.get_hooks + + def fake_get_hooks(hook=None, *args, **kwargs): + if hook == "erpnext_taxable_base_resolvers": + return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]} + return real_get_hooks(hook, *args, **kwargs) + + with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks): + calculate_taxes_and_totals(so) + + self.assertEqual(so.taxes[0].tax_amount, 120.0) + self.assertEqual(so.net_total, 880.0) + self.assertEqual(so.grand_total, 1000.0) + def test_disabling_rounded_total_resets_base_fields(self): """Disabling rounded total should also clear base rounded values.""" so = make_sales_order(do_not_save=True) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 8a0719c6d3f..dfdf2827cee 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -3,6 +3,11 @@ const NOT_APPLICABLE_TAX = "N/A"; +// Per-charge_type base resolvers, mirror of the `erpnext_taxable_base_resolvers` +// server hook. A localization registers `fn(calc, item, tax)` returning the per-item +// base, so the client preview matches the server for custom charge types. +erpnext.taxable_base_resolvers = erpnext.taxable_base_resolvers || {}; + erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { setup() { this.fetch_round_off_accounts(); @@ -263,32 +268,32 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { $.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; + var total_tax_slope = 0.0; + var total_tax_intercept = 0; $.each(me.frm.doc["taxes"] || [], function (i, tax) { - var current_tax_fraction = me.get_current_tax_fraction(tax, item_tax_map); - tax.tax_fraction_for_current_item = current_tax_fraction[0]; - var inclusive_tax_amount_per_qty = current_tax_fraction[1]; + var tax_contribution = me.get_current_tax_fraction(tax, item_tax_map, item); + tax.tax_fraction_for_current_item = tax_contribution[0]; + var tax_intercept_per_qty = tax_contribution[1]; + tax.inclusive_amount_per_qty = tax_intercept_per_qty; if (i == 0) { tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item; + tax.grand_total_amount_per_qty = tax_intercept_per_qty; } else { + var prev = me.frm.doc["taxes"][i - 1]; tax.grand_total_fraction_for_current_item = - me.frm.doc["taxes"][i - 1].grand_total_fraction_for_current_item + - tax.tax_fraction_for_current_item; + prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item; + tax.grand_total_amount_per_qty = + flt(prev.grand_total_amount_per_qty) + tax_intercept_per_qty; } - cumulated_tax_fraction += tax.tax_fraction_for_current_item; - total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty); + total_tax_slope += tax.tax_fraction_for_current_item; + total_tax_intercept += tax_intercept_per_qty * flt(item.qty); }); - 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._unrounded_net_amount = amount / (1 + cumulated_tax_fraction); + if (!me.discount_amount_applied && item.qty && (total_tax_intercept || total_tax_slope)) { + var amount = flt(item.amount) - total_tax_intercept; + item._unrounded_net_amount = amount / (1 + total_tax_slope); 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; @@ -297,39 +302,53 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); } - get_current_tax_fraction(tax, item_tax_map) { - // Get tax fraction for calculating tax exclusive amount - // from tax inclusive amount - var current_tax_fraction = 0.0; - var inclusive_tax_amount_per_qty = 0; + get_current_tax_fraction(tax, item_tax_map, item) { + // tax = slope * net + intercept. + // Returns [slope, intercept_per_qty] + var tax_slope = 0.0; + var tax_intercept = 0; if (cint(tax.included_in_print_rate)) { var tax_rate = this._get_tax_rate(tax, item_tax_map); if (tax_rate === NOT_APPLICABLE_TAX) { - return [current_tax_fraction, inclusive_tax_amount_per_qty]; + return [tax_slope, tax_intercept]; } if (tax.charge_type == "On Net Total") { - current_tax_fraction = tax_rate / 100.0; + tax_slope = tax_rate / 100.0; } else if (tax.charge_type == "On Previous Row Amount") { - current_tax_fraction = - (tax_rate / 100.0) * - this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_fraction_for_current_item; + const row = this.frm.doc["taxes"][cint(tax.row_id) - 1]; + tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item; + tax_intercept = (tax_rate / 100.0) * flt(row.inclusive_amount_per_qty); } else if (tax.charge_type == "On Previous Row Total") { - current_tax_fraction = - (tax_rate / 100.0) * - this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_fraction_for_current_item; + const row = this.frm.doc["taxes"][cint(tax.row_id) - 1]; + tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item; + tax_intercept = (tax_rate / 100.0) * flt(row.grand_total_amount_per_qty); } else if (tax.charge_type == "On Item Quantity") { - inclusive_tax_amount_per_qty = flt(tax_rate); + tax_intercept = flt(tax_rate); + } else { + // Custom charge_type: the rate applies to a resolved (fixed) base, + // e.g. a tax on MRP included in the printed price. + const qty = flt(item.qty) || 1; + const base = this.get_item_taxable_base(item, tax); + tax_intercept = ((tax_rate / 100.0) * base) / qty; } } if (tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") { - current_tax_fraction *= -1; - inclusive_tax_amount_per_qty *= -1; + tax_slope *= -1; + tax_intercept *= -1; } - return [current_tax_fraction, inclusive_tax_amount_per_qty]; + return [tax_slope, tax_intercept]; + } + + get_item_taxable_base(item, tax) { + // Mirror of the server get_item_taxable_base: a custom charge_type's resolver + // overrides the base value; otherwise the net amount. + const resolver = erpnext.taxable_base_resolvers[tax.charge_type]; + if (resolver) return flt(resolver(this, item, tax)); + return flt(item.net_amount); } _get_tax_rate(tax, item_tax_map) { @@ -591,6 +610,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } 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; + } else { + // Custom charge_type: rate applies to the resolver-provided base. + var resolved_base = this.get_item_taxable_base(item, tax); + current_net_amount = resolved_base; + current_tax_amount = (tax_rate / 100.0) * resolved_base; } return [current_net_amount, current_tax_amount];