fix!: tax net_amount and not_applicable (#54687)

This commit is contained in:
Raffael Meyer
2026-08-25 13:50:03 +02:00
committed by GitHub
parent 07b6b3defd
commit 3ae3009129
18 changed files with 429 additions and 54 deletions

View File

@@ -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"
}
"sort_order": "ASC",
"states": []
}

View File

@@ -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

View File

@@ -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);
}
},
});

View File

@@ -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")

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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": []
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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.

View File

@@ -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