From 8c9e941614b10438d36f6a55d6470f60628c5516 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 28 Jul 2026 17:21:55 +0530 Subject: [PATCH] feat: taxable-base resolver hook for custom charge types (#56175) (cherry picked from commit 986cea2331ccd9965eff94893cc4fdd482326eaf) # Conflicts: # erpnext/controllers/taxes_and_totals.py # erpnext/controllers/tests/test_taxes_and_totals.py # erpnext/public/js/controllers/taxes_and_totals.js --- erpnext/controllers/taxes_and_totals.py | 129 ++++++++++++++---- .../tests/test_taxes_and_totals.py | 106 ++++++++++++++ .../public/js/controllers/taxes_and_totals.js | 95 +++++++++++-- 3 files changed, 291 insertions(+), 39 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index a8a48140bdd..7b659341b97 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -298,33 +298,37 @@ class calculate_taxes_and_totals: for item in self.doc.items: 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 +<<<<<<< HEAD item.net_amount = flt(amount / (1 + cumulated_tax_fraction), item.precision("net_amount")) +======= + item._unrounded_net_amount = amount / (1 + total_tax_slope) + item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount")) +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate")) item.discount_percentage = flt( item.discount_percentage, item.precision("discount_percentage") @@ -335,38 +339,51 @@ class calculate_taxes_and_totals: def _load_item_tax_rate(self, item_tax_rate): return json.loads(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) +<<<<<<< HEAD +======= + if tax_rate == NOT_APPLICABLE_TAX: + return tax_slope, tax_intercept + +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) 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: @@ -529,7 +546,21 @@ 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 +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) elif tax.charge_type == "On Previous Row Amount": current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[ cint(tax.row_id) - 1 @@ -540,13 +571,51 @@ class calculate_taxes_and_totals: ].grand_total_for_current_item elif tax.charge_type == "On Item Quantity": 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 (self.doc.get("is_consolidated") or tax.get("dont_recompute_tax")): self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount) return current_tax_amount +<<<<<<< HEAD def set_item_wise_tax(self, item, tax, tax_rate, 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): +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) # store tax breakup for each item key = item.item_code or item.item_name item_wise_tax_amount = current_tax_amount * self.doc.conversion_rate diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index 715acf8782f..a17263863e1 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -1,13 +1,32 @@ +from unittest import mock from unittest.mock import patch import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase +======= +from frappe.utils import flt +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +<<<<<<< HEAD class TestTaxesAndTotals(FrappeTestCase): +======= +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): +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) def test_regional_round_off_accounts(self): """ Regional overrides cannot extend the list in-place — the return @@ -30,6 +49,93 @@ class TestTaxesAndTotals(FrappeTestCase): 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 4d980d7e277..e6aad4684a5 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -1,6 +1,16 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt +<<<<<<< HEAD +======= +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 || {}; + +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { setup() { this.fetch_round_off_accounts(); @@ -252,28 +262,53 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { $.each(this.frm.doc.items || [], function(n, item) { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); +<<<<<<< HEAD var cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 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 total_tax_slope = 0.0; + var total_tax_intercept = 0; + $.each(me.frm.doc["taxes"] || [], function (i, tax) { + 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; +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) 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 = +<<<<<<< HEAD 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; +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) } - 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); }); +<<<<<<< HEAD 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)); +======= + 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)); +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) 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"]); @@ -281,15 +316,16 @@ 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); +<<<<<<< HEAD if(tax.charge_type == "On Net Total") { current_tax_fraction = (tax_rate / 100.0); @@ -300,16 +336,52 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } 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; +======= + if (tax_rate === NOT_APPLICABLE_TAX) { + return [tax_slope, tax_intercept]; + } + + if (tax.charge_type == "On Net Total") { + tax_slope = tax_rate / 100.0; + } else if (tax.charge_type == "On Previous Row Amount") { + 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") { + 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); +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) } 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; } } +<<<<<<< HEAD if(tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") { current_tax_fraction *= -1; inclusive_tax_amount_per_qty *= -1; +======= + if (tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") { + tax_slope *= -1; + tax_intercept *= -1; +>>>>>>> 986cea2331 (feat: taxable-base resolver hook for custom charge types (#56175)) } - 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) { @@ -526,6 +598,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; } if (!tax.dont_recompute_tax) {