diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json index 8ea57191024..5382fa4f9d6 100644 --- a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json @@ -94,11 +94,11 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "allow_on_submit": 1, + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" }, { "fieldname": "section_break_8", @@ -187,12 +187,14 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-11-25 11:10:10.945027", + "modified": "2026-05-01 00:38:53.368737", "modified_by": "Administrator", "module": "Accounts", "name": "Advance Taxes and Charges", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "modified", - "sort_order": "ASC" -} \ No newline at end of file + "sort_order": "ASC", + "states": [] +} diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py index 47e97ba015a..7e2bcfd5834 100644 --- a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py @@ -30,6 +30,7 @@ class AdvanceTaxesandCharges(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + project: DF.Link | None rate: DF.Float row_id: DF.Data | None tax_amount: DF.Currency 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 02b7455fb9c..57bc2b60f36 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 7e487cccf19..42ef8832fd4 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,12 +22,21 @@ "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": "2026-04-30 23:49:27.020639", + "modified": "2026-04-30 23:59:22.020639", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template Detail", 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/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json index ea26d2b5460..aa6a3c5a1f2 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json @@ -25,10 +25,12 @@ "project", "section_break_9", "account_currency", + "net_amount", "tax_amount", "tax_amount_after_discount_amount", "total", "column_break_14", + "base_net_amount", "base_tax_amount", "base_total", "base_tax_amount_after_discount_amount", @@ -213,11 +215,11 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "allow_on_submit": 1, + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" }, { "default": "0", @@ -241,20 +243,38 @@ "fieldtype": "Check", "label": "Is Tax Withholding Account", "read_only": 1 + }, + { + "description": "Basis for tax calculation", + "fieldname": "net_amount", + "fieldtype": "Currency", + "label": "Net Amount", + "options": "currency", + "read_only": 1 + }, + { + "description": "Basis for tax calculation", + "fieldname": "base_net_amount", + "fieldtype": "Currency", + "label": "Net Amount (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2025-04-15 13:14:48.936047", + "modified": "2026-05-01 00:38:29.543523", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Taxes and Charges", "naming_rule": "Random", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py index 585d5e65ad1..66c2b29d04b 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py @@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document): account_currency: DF.Link | None account_head: DF.Link add_deduct_tax: DF.Literal["Add", "Deduct"] + base_net_amount: DF.Currency base_tax_amount: DF.Currency base_tax_amount_after_discount_amount: DF.Currency base_total: DF.Currency @@ -35,9 +36,11 @@ class PurchaseTaxesandCharges(Document): included_in_print_rate: DF.Check is_tax_withholding_account: DF.Check item_wise_tax_detail: DF.Code | None + net_amount: DF.Currency parent: DF.Data parentfield: DF.Data parenttype: DF.Data + project: DF.Link | None rate: DF.Float row_id: DF.Data | None tax_amount: DF.Currency diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json index 8f7b1ece3c7..96992b364a0 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json @@ -21,10 +21,12 @@ "rate", "section_break_9", "account_currency", + "net_amount", "tax_amount", "total", "tax_amount_after_discount_amount", "column_break_13", + "base_net_amount", "base_tax_amount", "base_total", "base_tax_amount_after_discount_amount", @@ -190,11 +192,11 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "allow_on_submit": 1, + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" }, { "default": "0", @@ -220,19 +222,36 @@ "label": "Account Currency", "options": "Currency", "read_only": 1 + }, + { + "description": "Basis for tax calculation", + "fieldname": "net_amount", + "fieldtype": "Currency", + "label": "Net Amount", + "options": "currency", + "read_only": 1 + }, + { + "description": "Basis for tax calculation", + "fieldname": "base_net_amount", + "fieldtype": "Currency", + "label": "Net Amount (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-01-14 10:08:17.776528", + "modified": "2026-05-01 00:37:57.880071", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Taxes and Charges", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "ASC", "states": [] -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py index 7936178fda8..6aa05432622 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py @@ -16,6 +16,7 @@ class SalesTaxesandCharges(Document): account_currency: DF.Link | None account_head: DF.Link + base_net_amount: DF.Currency base_tax_amount: DF.Currency base_tax_amount_after_discount_amount: DF.Currency base_total: DF.Currency @@ -33,9 +34,11 @@ class SalesTaxesandCharges(Document): included_in_paid_amount: DF.Check included_in_print_rate: DF.Check item_wise_tax_detail: DF.Code | None + net_amount: DF.Currency parent: DF.Data parentfield: DF.Data parenttype: DF.Data + project: DF.Link | None rate: DF.Float row_id: DF.Data | None tax_amount: DF.Currency diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 30bac060a48..74325b7c0bc 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -67,6 +67,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, _get_item_tax_template, _get_item_tax_template_from_item_group, get_bin_details, @@ -1294,7 +1295,10 @@ class AccountsController(TransactionBase): if isinstance(item_tax_rate, str): item_tax_rate = parse_json(item_tax_rate) - for account_head, _rate in item_tax_rate.items(): + for account_head, rate in item_tax_rate.items(): + if rate == NOT_APPLICABLE_TAX: + continue + row = self.get_tax_row(account_head) if not row: @@ -3721,8 +3725,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/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index aa36cab6f8d..33d253ddc14 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -19,7 +19,11 @@ from erpnext.controllers.accounts_controller import ( validate_inclusive_tax, validate_taxes_and_charges, ) -from erpnext.stock.get_item_details import _get_item_tax_template, get_item_tax_map +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + _get_item_tax_template, + get_item_tax_map, +) from erpnext.utilities.regional import temporary_flag @@ -275,6 +279,7 @@ class calculate_taxes_and_totals: tax.item_wise_tax_detail = {} tax_fields = [ + "net_amount", "total", "tax_amount_after_discount_amount", "tax_amount_for_current_item", @@ -345,6 +350,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 tax_slope, tax_intercept + if tax.charge_type == "On Net Total": tax_slope = tax_rate / 100.0 @@ -376,9 +384,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 = ( @@ -426,9 +437,12 @@ class calculate_taxes_and_totals: item_tax_map = self._load_item_tax_rate(item.item_tax_rate) for i, tax in enumerate(doc.taxes): # tax_amount represents the amount of tax for the current step - current_tax_amount = self.get_current_tax_amount(item, tax, item_tax_map) + current_net_amount, current_tax_amount = self.get_current_tax_and_net_amount( + item, tax, item_tax_map + ) if frappe.flags.round_row_wise_tax: current_tax_amount = flt(current_tax_amount, tax.precision("tax_amount")) + current_net_amount = flt(current_net_amount, tax.precision("net_amount")) # Adjust divisional loss to the last item if tax.charge_type == "Actual": @@ -436,6 +450,10 @@ class calculate_taxes_and_totals: if n == len(self._items) - 1: current_tax_amount += actual_tax_dict[tax.idx] + # net_amount is the taxable basis, it feeds no total and is always + # accumulated, unlike tax_amount which is kept from the first pass + tax.net_amount += current_net_amount + # accumulate tax amount into tax.tax_amount if tax.charge_type != "Actual" and not ( self.discount_amount_applied and self.doc.apply_discount_on == "Grand Total" @@ -486,7 +504,9 @@ class calculate_taxes_and_totals: for i, tax in enumerate(doc.taxes): self.round_off_totals(tax) - self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]) + self._set_in_company_currency( + tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"] + ) self.round_off_base_values(tax) self.set_cumulative_total(i, tax) @@ -517,8 +537,17 @@ class calculate_taxes_and_totals: tax.total = flt(self.doc.get("taxes")[row_idx - 1].total + tax_amount, tax.precision("total")) def get_current_tax_amount(self, item, tax, item_tax_map): + # kept for backwards compatibility with callers outside this module + _, current_tax_amount = self.get_current_tax_and_net_amount(item, tax, item_tax_map) + return current_tax_amount + + def get_current_tax_and_net_amount(self, item, tax, item_tax_map): tax_rate = self._get_tax_rate(tax, item_tax_map) 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": # distribute the tax amount proportionally to each item row @@ -528,23 +557,25 @@ class calculate_taxes_and_totals: if not item.get("apply_tds") or not self.doc.tax_withholding_net_total: current_tax_amount = 0.0 else: - current_tax_amount = item.net_amount * actual / self.doc.tax_withholding_net_total + current_net_amount = item.net_amount + current_tax_amount = current_net_amount * actual / self.doc.tax_withholding_net_total else: + current_net_amount = item.net_amount current_tax_amount = ( - item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0 + current_net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0 ) elif tax.charge_type == "On Net Total": + current_net_amount = item.net_amount current_tax_amount = (tax_rate / 100.0) * item.net_amount elif tax.charge_type == "On Previous Row Amount": - current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[ - cint(tax.row_id) - 1 - ].tax_amount_for_current_item + current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item + current_tax_amount = (tax_rate / 100.0) * current_net_amount elif tax.charge_type == "On Previous Row Total": - current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[ - cint(tax.row_id) - 1 - ].grand_total_for_current_item + current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].grand_total_for_current_item + current_tax_amount = (tax_rate / 100.0) * current_net_amount elif tax.charge_type == "On Item Quantity": + # don't sum current net amount: net_amount field is currency-denominated current_tax_amount = tax_rate * item.qty else: # Custom charge_type: rate applies to the resolver-provided base. @@ -553,7 +584,7 @@ class calculate_taxes_and_totals: 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 + 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. diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index d9fcbda701c..481651c0cce 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -158,3 +158,141 @@ class TestTaxesAndTotals(FrappeTestCase): self.assertEqual(so.rounding_adjustment, 0) self.assertEqual(so.base_rounded_total, 0) self.assertEqual(so.base_rounding_adjustment, 0) + + def test_tax_net_amount_with_not_applicable_item_tax(self): + """Each tax row records only the net of the items it actually applies to. + + Two items of 100 each, one per template. Template A applies VAT 7 and + marks VAT 19 not applicable, template B does the reverse. Both tax rows + must report a net_amount of 100, not the full net total of 200. + """ + vat_7 = "_Test Account VAT - _TC" + vat_19 = "_Test Account Service Tax - _TC" + + templates = {} + for title, rows in { + "_Test NA Template A": [(vat_7, 7, 0), (vat_19, 0, 1)], + "_Test NA Template B": [(vat_7, 0, 1), (vat_19, 19, 0)], + }.items(): + doc = frappe.new_doc("Item Tax Template") + doc.title = title + doc.company = "_Test Company" + for tax_type, tax_rate, not_applicable in rows: + doc.append( + "taxes", + {"tax_type": tax_type, "tax_rate": tax_rate, "not_applicable": not_applicable}, + ) + templates[title] = doc.insert().name + + so = make_sales_order(do_not_save=True) + so.items = [] + for title in templates: + so.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + "item_tax_template": templates[title], + }, + ) + + so.set("taxes", []) + for account_head in (vat_7, vat_19): + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": account_head, + "description": account_head, + "rate": 0, + "cost_center": "_Test Cost Center - _TC", + }, + ) + + so.save() + + self.assertEqual(so.net_total, 200.0) + self.assertEqual(so.taxes[0].net_amount, 100.0) + self.assertEqual(so.taxes[0].tax_amount, 7.0) + self.assertEqual(so.taxes[1].net_amount, 100.0) + self.assertEqual(so.taxes[1].tax_amount, 19.0) + + def test_inclusive_tax_with_not_applicable_item_tax(self): + """An inclusive tax row meeting an item that marks it not applicable must + contribute no fraction, instead of raising in get_current_tax_fraction.""" + vat_19 = "_Test Account Service Tax - _TC" + + template = frappe.new_doc("Item Tax Template") + template.title = "_Test NA Template Inclusive" + template.company = "_Test Company" + template.append("taxes", {"tax_type": vat_19, "tax_rate": 0, "not_applicable": 1}) + template.insert() + + so = make_sales_order(do_not_save=True) + so.items = [] + so.append( + "items", + { + "item_code": "_Test Item", + "qty": 1, + "rate": 119, + "warehouse": "_Test Warehouse - _TC", + "item_tax_template": template.name, + }, + ) + so.set("taxes", []) + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": vat_19, + "description": vat_19, + "rate": 19, + "included_in_print_rate": 1, + "cost_center": "_Test Cost Center - _TC", + }, + ) + + so.save() + + # the tax does not apply, so nothing is backed out of the printed rate + self.assertEqual(so.net_total, 119.0) + self.assertEqual(so.taxes[0].tax_amount, 0.0) + self.assertEqual(so.taxes[0].net_amount, 0.0) + self.assertEqual(so.grand_total, 119.0) + + def test_tax_net_amount_survives_grand_total_discount(self): + """A discount on Grand Total re-runs the calculation with + discount_amount_applied set. net_amount is reset on that second pass, so + it has to be accumulated there too instead of being left at zero.""" + so = make_sales_order(do_not_save=True) + so.items = [] + so.append( + "items", + { + "item_code": "_Test Item", + "qty": 10, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + }, + ) + so.set("taxes", []) + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "description": "VAT", + "rate": 19, + "cost_center": "_Test Cost Center - _TC", + }, + ) + so.apply_discount_on = "Grand Total" + so.discount_amount = 100 + + calculate_taxes_and_totals(so) + + self.assertEqual(so.taxes[0].net_amount, so.net_total) + self.assertEqual(so.grand_total, 1090.0) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 5fb9a6b6080..dc1d85b87ab 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -1,6 +1,9 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt +// mirror of erpnext.stock.get_item_details.NOT_APPLICABLE_TAX +erpnext.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. @@ -300,6 +303,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 === erpnext.NOT_APPLICABLE_TAX) { + return [tax_slope, tax_intercept]; + } + if(tax.charge_type == "On Net Total") { tax_slope = (tax_rate / 100.0); @@ -339,8 +346,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 === erpnext.NOT_APPLICABLE_TAX) { + return erpnext.NOT_APPLICABLE_TAX; + } + return flt(rate, precision("rate", tax)); + } + return tax.rate; } calculate_net_total() { @@ -379,6 +392,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } $.each(item_tax_map, function(tax, rate) { + if (rate === erpnext.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"); @@ -429,11 +445,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } + // net_amount is the taxable basis, it feeds no total and is always + // accumulated, unlike tax_amount which is kept from the first pass + tax.net_amount += current_net_amount; + // accumulate tax amount into tax.tax_amount if (tax.charge_type != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { tax.tax_amount += current_tax_amount; - tax.net_amount += current_net_amount; } // store tax_amount for current item as it will be used for @@ -519,6 +538,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 === erpnext.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/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index e7f4cdec979..7a133acaf9e 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -818,6 +818,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } $.each(item_tax_map, function(tax, rate) { + if (rate === erpnext.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"); @@ -1611,9 +1614,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } if (this.frm.doc.taxes && this.frm.doc.taxes.length > 0) { - this.frm.set_currency_labels(["tax_amount", "total", "tax_amount_after_discount"], this.frm.doc.currency, "taxes"); + this.frm.set_currency_labels(["net_amount", "tax_amount", "total", "tax_amount_after_discount"], this.frm.doc.currency, "taxes"); - this.frm.set_currency_labels(["base_tax_amount", "base_total", "base_tax_amount_after_discount"], company_currency, "taxes"); + this.frm.set_currency_labels(["base_net_amount", "base_tax_amount", "base_total", "base_tax_amount_after_discount"], company_currency, "taxes"); } if (this.frm.doc.advances && this.frm.doc.advances.length > 0) { diff --git a/erpnext/regional/italy/test_italy.py b/erpnext/regional/italy/test_italy.py new file mode 100644 index 00000000000..57b6704e867 --- /dev/null +++ b/erpnext/regional/italy/test_italy.py @@ -0,0 +1,80 @@ +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.regional.italy.utils import get_invoice_summary + +VAT_7 = "_Test Italy VAT 7 - _TC" +VAT_19 = "_Test Italy VAT 19 - _TC" + + +def make_item(item_code, net_amount, tax_amount, item_tax_rate): + return frappe._dict( + item_code=item_code, + net_amount=net_amount, + tax_amount=tax_amount, + item_tax_rate=item_tax_rate, + ) + + +def make_tax(account_head, total, charge_type="On Net Total", **kwargs): + return frappe._dict( + charge_type=charge_type, + account_head=account_head, + rate=0, + total=total, + tax_exemption_reason="N4-esenti", + tax_exemption_law="Art.10", + **kwargs, + ) + + +class TestItalyInvoiceSummary(FrappeTestCase): + def test_not_applicable_tax_excluded_from_summary(self): + """An item that marks a tax not applicable belongs to another summary + block. Counting it here inflates DatiRiepilogo and emits a block with + AliquotaIVA 0.00 and no Natura, which SDI rejects.""" + items = [ + make_item("A", 100.0, 7.0, {VAT_7: 7.0, VAT_19: "N/A"}), + make_item("B", 100.0, 19.0, {VAT_7: "N/A", VAT_19: 19.0}), + ] + taxes = [make_tax(VAT_7, 107.0), make_tax(VAT_19, 126.0)] + + summary = get_invoice_summary(items, taxes) + + self.assertEqual(sorted(summary.keys()), ["19.0", "7.0"]) + self.assertEqual(summary["7.0"]["taxable_amount"], 100.0) + self.assertEqual(summary["19.0"]["taxable_amount"], 100.0) + + def test_zero_rated_tax_keeps_exemption_reason(self): + """A genuine 0% rate is still exempt and must carry its Natura.""" + items = [make_item("C", 100.0, 0.0, {VAT_7: 0.0})] + + summary = get_invoice_summary(items, [make_tax(VAT_7, 100.0)]) + + self.assertEqual(list(summary.keys()), ["0.0"]) + self.assertEqual(summary["0.0"]["taxable_amount"], 100.0) + self.assertEqual(summary["0.0"]["tax_exemption_reason"], "N4-esenti") + + def test_all_items_not_applicable_falls_back_to_zero_vat(self): + """With every item excluded the summary would be empty, so the existing + zero VAT fallback has to supply the block and its Natura.""" + items = [make_item("D", 100.0, 0.0, {VAT_7: "N/A"})] + + summary = get_invoice_summary(items, [make_tax(VAT_7, 100.0)]) + + self.assertEqual(list(summary.keys()), ["0.0"]) + self.assertEqual(summary["0.0"]["tax_exemption_reason"], "N4-esenti") + + def test_previous_row_tax_with_only_not_applicable_items(self): + """The summary key leaks out of the item loop and is read again for + previous-row charges. Every item being excluded leaves it unset.""" + items = [make_item("A", 100.0, 0.0, {VAT_7: 0.0, VAT_19: "N/A"})] + taxes = [ + make_tax(VAT_7, 100.0, idx=1), + make_tax(VAT_19, 100.0, charge_type="On Previous Row Total", idx=2, row_id=None), + ] + + summary = get_invoice_summary(items, taxes) + + self.assertEqual(list(summary.keys()), ["0.0"]) + self.assertEqual(summary["0.0"]["taxable_amount"], 100.0) diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index 8e216644857..ef35ad26ab6 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -8,6 +8,7 @@ from frappe.utils.file_manager import remove_file from erpnext.controllers.taxes_and_totals import get_itemised_tax from erpnext.regional.italy import state_codes +from erpnext.stock.get_item_details import NOT_APPLICABLE_TAX def update_itemised_tax_data(doc): @@ -171,13 +172,20 @@ def get_invoice_summary(items, taxes): # Check item tax rates if tax rate is zero. if tax.rate == 0: + key = None for item in items: item_tax_rate = item.item_tax_rate if isinstance(item.item_tax_rate, str): item_tax_rate = json.loads(item.item_tax_rate) if item_tax_rate and tax.account_head in item_tax_rate: - key = cstr(item_tax_rate[tax.account_head]) + rate = item_tax_rate[tax.account_head] + if rate == NOT_APPLICABLE_TAX: + # the tax does not apply to this item, so the item belongs + # to another summary block and must not be counted here + continue + + key = cstr(rate) if key not in summary_data: summary_data.setdefault( key, @@ -195,10 +203,15 @@ def get_invoice_summary(items, taxes): summary_data[key]["tax_exemption_reason"] = tax.tax_exemption_reason summary_data[key]["tax_exemption_law"] = tax.tax_exemption_law - if summary_data.get("0.0") and tax.charge_type in [ - "On Previous Row Total", - "On Previous Row Amount", - ]: + if ( + key + and summary_data.get("0.0") + and tax.charge_type + in [ + "On Previous Row Total", + "On Previous Row Amount", + ] + ): summary_data[key]["taxable_amount"] = tax.total if summary_data == {}: # Implies that Zero VAT has not been set on any item. diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index aecc20d8aa2..c9fa5fe6e16 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -34,6 +34,8 @@ purchase_doctypes = [ "Purchase Invoice", ] +NOT_APPLICABLE_TAX = "N/A" + @frappe.whitelist() def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): @@ -806,7 +808,10 @@ def get_item_tax_map(company, item_tax_template, as_json=True): template = frappe.get_cached_doc("Item Tax Template", item_tax_template) for d in template.taxes: if frappe.get_cached_value("Account", d.tax_type, "company") == 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