diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.js b/erpnext/accounts/doctype/item_tax_template/item_tax_template.js index b608ccd3568..94c87fcae93 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.js +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.js @@ -47,3 +47,12 @@ frappe.ui.form.on("Item Tax Template", { }); }, }); + +frappe.ui.form.on("Item Tax Template Detail", { + not_applicable: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.not_applicable) { + frappe.model.set_value(cdt, cdn, "tax_rate", 0); + } + }, +}); diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py index 464fb0f8227..8a23331b3f6 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py @@ -27,8 +27,15 @@ class ItemTaxTemplate(Document): # end: auto-generated types def validate(self): + self.set_zero_rate_for_not_applicable_tax() self.validate_tax_accounts() + def set_zero_rate_for_not_applicable_tax(self): + """Ensure tax_rate is 0 for any row marked as not applicable.""" + for row in self.get("taxes"): + if row.not_applicable: + row.tax_rate = 0 + def autoname(self): if self.company and self.title: abbr = frappe.get_cached_value("Company", self.company, "abbr") diff --git a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json index 5092489c012..d11d249894d 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json @@ -6,7 +6,8 @@ "engine": "InnoDB", "field_order": [ "tax_type", - "tax_rate" + "tax_rate", + "not_applicable" ], "fields": [ { @@ -21,20 +22,30 @@ "fieldname": "tax_rate", "fieldtype": "Float", "in_list_view": 1, - "label": "Tax Rate" + "label": "Tax Rate", + "read_only_depends_on": "eval:doc.not_applicable" + }, + { + "default": "0", + "description": "Check if this tax is not applicable to items (distinct from 0% rate)", + "fieldname": "not_applicable", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Not Applicable" } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:09:55.735360", + "modified": "2025-12-26 17:19:18.791891", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template Detail", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py index 810235e3691..a98fbc6ba86 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.py @@ -14,6 +14,7 @@ class ItemTaxTemplateDetail(Document): if TYPE_CHECKING: from frappe.types import DF + not_applicable: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 805433027cc..1d4fc994f90 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -69,6 +69,7 @@ from erpnext.setup.utils import get_exchange_rate from erpnext.stock.doctype.item.item import get_uom_conv_factor from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, ItemDetailsCtx, _get_item_tax_template, get_conversion_factor, @@ -3696,8 +3697,11 @@ def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True): if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: tax_map = json.loads(child_item.get("item_tax_rate")) - for tax_type in tax_map: - tax_rate = flt(tax_map[tax_type]) + for tax_type, tax_rate in tax_map.items(): + if tax_rate == NOT_APPLICABLE_TAX: + continue + + tax_rate = flt(tax_rate) taxes = parent_doc.get("taxes") or [] # add new row for tax head only if missing found = any(tax.account_head == tax_type for tax in taxes) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index fd86291027e..5627dffea95 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -18,7 +18,11 @@ from erpnext.buying.utils import update_last_purchase_rate, validate_for_items from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.subcontracting_controller import SubcontractingController -from erpnext.stock.get_item_details import get_conversion_factor, get_item_defaults +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + get_conversion_factor, + get_item_defaults, +) from erpnext.stock.utils import get_incoming_rate @@ -523,6 +527,9 @@ class BuyingController(SubcontractingController): if account not in tax_accounts: continue + if rate == NOT_APPLICABLE_TAX: + continue + net_rate = item.base_net_amount if item.sales_incoming_rate: net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 2f45a55eabf..69b8234e6a3 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -19,7 +19,12 @@ from erpnext.controllers.accounts_controller import ( validate_taxes_and_charges, ) from erpnext.deprecation_dumpster import deprecated -from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template, get_item_tax_map +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + ItemDetailsCtx, + _get_item_tax_template, + get_item_tax_map, +) from erpnext.utilities.regional import temporary_flag @@ -358,6 +363,9 @@ class calculate_taxes_and_totals: 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 + if tax.charge_type == "On Net Total": current_tax_fraction = tax_rate / 100.0 @@ -382,9 +390,12 @@ class calculate_taxes_and_totals: def _get_tax_rate(self, tax, item_tax_map): if tax.account_head in item_tax_map: - return flt(item_tax_map.get(tax.account_head), self.doc.precision("rate", tax)) - else: - return tax.rate + rate = item_tax_map[tax.account_head] + if rate == NOT_APPLICABLE_TAX: + return NOT_APPLICABLE_TAX + return flt(rate, self.doc.precision("rate", tax)) + + return tax.rate def calculate_net_total(self): self.doc.total_qty = ( @@ -594,6 +605,9 @@ class calculate_taxes_and_totals: current_tax_amount = 0.0 current_net_amount = 0.0 + if tax_rate == NOT_APPLICABLE_TAX: + return current_net_amount, current_tax_amount + if tax.charge_type == "Actual": current_net_amount = item.net_amount # distribute the tax amount proportionally to each item row diff --git a/erpnext/controllers/tests/test_item_wise_tax_details.py b/erpnext/controllers/tests/test_item_wise_tax_details.py index fcd4493103a..b5b77b36d5d 100644 --- a/erpnext/controllers/tests/test_item_wise_tax_details.py +++ b/erpnext/controllers/tests/test_item_wise_tax_details.py @@ -299,3 +299,238 @@ class TestTaxesAndTotals(ERPNextTestSuite): tax = doc.taxes[0] detail = doc.item_wise_tax_details[0] self.assertEqual(detail.amount, tax.base_tax_amount_after_discount_amount) + + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_not_applicable_tax_in_item_tax_template(self): + """Test that items with 'not applicable' tax don't contribute to net amount of that tax.""" + template_7pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 7% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 7, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + template_19pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 19% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 0, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 19, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + self.doc.items[0].item_tax_template = template_7pct.name + + self.doc.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 100, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + "item_tax_template": template_19pct.name, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 7%", + "rate": 7, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 19%", + "rate": 19, + }, + ) + + self.doc.save() + + # VAT 7%: Both items contribute (Item 2 has 0% rate, not "not applicable") + self.assertEqual(self.doc.taxes[0].net_amount, 200.0) + # Service Tax 19%: Only Item 2 contributes (Item 1 has not_applicable) + self.assertEqual(self.doc.taxes[1].net_amount, 100.0) + + expected_values = [ + { + "item_row": self.doc.items[0].name, + "tax_row": self.doc.taxes[0].name, + "rate": 7.0, + "amount": 7.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[0].name, + "rate": 0.0, + "amount": 0.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[1].name, + "rate": 19.0, + "amount": 19.0, + "taxable_amount": 100.0, + }, + ] + + actual_values = [ + { + "item_row": row.item_row, + "tax_row": row.tax_row, + "rate": row.rate, + "amount": row.amount, + "taxable_amount": row.taxable_amount, + } + for row in self.doc.item_wise_tax_details + ] + + self.assertEqual(actual_values, expected_values) + + def test_not_applicable_tax_in_item_tax_template_with_different_items(self): + """Test that items with 'not applicable' tax don't contribute to net amount of that tax.""" + template_7pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 7% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 7, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + template_19pct = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": "_Test VAT 19% Template", + "company": "_Test Company", + "taxes": [ + { + "tax_type": "_Test Account VAT - _TC", + "tax_rate": 0, + "not_applicable": 1, + }, + { + "tax_type": "_Test Account Service Tax - _TC", + "tax_rate": 19, + }, + ], + } + ).insert(ignore_if_duplicate=True) + + self.doc.items[0].item_tax_template = template_7pct.name + + self.doc.append( + "items", + { + "item_code": "_Test Item 2", + "qty": 1, + "rate": 100, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + "item_tax_template": template_19pct.name, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 7%", + "rate": 0, + }, + ) + + self.doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT 19%", + "rate": 0, + }, + ) + + self.doc.save() + + # VAT 7%: Only Item 1 contributes (Item 2 has not_applicable) + self.assertEqual(self.doc.taxes[0].net_amount, 100.0) + # Service Tax 19%: Only Item 2 contributes (Item 1 has not_applicable) + self.assertEqual(self.doc.taxes[1].net_amount, 100.0) + + expected_values = [ + { + "item_row": self.doc.items[0].name, + "tax_row": self.doc.taxes[0].name, + "rate": 7.0, + "amount": 7.0, + "taxable_amount": 100.0, + }, + { + "item_row": self.doc.items[1].name, + "tax_row": self.doc.taxes[1].name, + "rate": 19.0, + "amount": 19.0, + "taxable_amount": 100.0, + }, + ] + + actual_values = [ + { + "item_row": row.item_row, + "tax_row": row.tax_row, + "rate": row.rate, + "amount": row.amount, + "taxable_amount": row.taxable_amount, + } + for row in self.doc.item_wise_tax_details + ] + + self.assertEqual(actual_values, expected_values) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index ad1b64413ac..e3074f711ef 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -1,6 +1,8 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt +const NOT_APPLICABLE_TAX = "N/A"; + erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { setup() { this.fetch_round_off_accounts(); @@ -299,6 +301,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { 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]; + } + if (tax.charge_type == "On Net Total") { current_tax_fraction = tax_rate / 100.0; } else if (tax.charge_type == "On Previous Row Amount") { @@ -322,9 +328,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } _get_tax_rate(tax, item_tax_map) { - return Object.keys(item_tax_map).indexOf(tax.account_head) != -1 - ? flt(item_tax_map[tax.account_head], precision("rate", tax)) - : tax.rate; + if (tax.account_head in item_tax_map) { + let rate = item_tax_map[tax.account_head]; + if (rate === NOT_APPLICABLE_TAX) { + return NOT_APPLICABLE_TAX; + } + return flt(rate, precision("rate", tax)); + } + return tax.rate; } calculate_net_total() { @@ -368,6 +379,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } $.each(item_tax_map, function (tax, rate) { + if (rate === NOT_APPLICABLE_TAX) { + return; + } + let found = (me.frm.doc.taxes || []).find((d) => d.account_head === tax); if (!found) { let child = frappe.model.add_child(me.frm.doc, "taxes"); @@ -524,6 +539,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var current_tax_amount = 0.0; var current_net_amount = 0.0; + if (tax_rate === NOT_APPLICABLE_TAX) { + return [current_net_amount, current_tax_amount]; + } + // To set row_id by default as previous row. if (["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) { if (tax.idx === 1) { diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index b21b9987535..b05559e6ca1 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -40,6 +40,8 @@ purchase_doctypes = [ "Purchase Invoice", ] +NOT_APPLICABLE_TAX = "N/A" + def _preprocess_ctx(ctx): if not ctx.price_list: @@ -843,7 +845,10 @@ def get_item_tax_map(*, doc: str | dict | Document, tax_template: str | None = N template = frappe.get_cached_doc("Item Tax Template", tax_template) for d in template.taxes: if frappe.get_cached_value("Account", d.tax_type, "company") == doc.get("company"): - item_tax_map[d.tax_type] = d.tax_rate + if d.get("not_applicable"): + item_tax_map[d.tax_type] = NOT_APPLICABLE_TAX + else: + item_tax_map[d.tax_type] = d.tax_rate return json.dumps(item_tax_map) if as_json else item_tax_map diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 4c3836938c6..df863cb09ab 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -9,7 +9,7 @@ from frappe.utils import cint, flt, get_time, now_datetime from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.controllers.status_updater import StatusUpdater -from erpnext.stock.get_item_details import get_item_details +from erpnext.stock.get_item_details import NOT_APPLICABLE_TAX, get_item_details from erpnext.stock.utils import get_incoming_rate @@ -367,6 +367,9 @@ class TransactionBase(StatusUpdater): ): item_tax_template = frappe.json.loads(item_details.item_tax_rate) for tax_head, _rate in item_tax_template.items(): + if _rate == NOT_APPLICABLE_TAX: + continue + found = [x for x in self.taxes if x.account_head == tax_head] if not found: self.append("taxes", {"charge_type": "On Net Total", "account_head": tax_head, "rate": 0})