Merge pull request #59670 from aerele/investigate/pi-actual-tax-zero-total

fix(accounts): split actual tax over applicable items only
This commit is contained in:
mergify[bot]
2026-10-29 17:17:20 +00:00
committed by GitHub
4 changed files with 337 additions and 6 deletions
@@ -3899,6 +3899,68 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
self.assertEqual(entries[0].status, "Settled")
self.assertEqual(entries[0].withholding_amount, 2000.0)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 1})
def test_tds_on_gross_total_with_actual_tax_not_applicable_item(self):
fiscal_year = get_fiscal_year(today(), company="_Test Company")
create_tax_withholding_category(
category_name="Gross Total TDS Category",
rate=10,
from_date=fiscal_year[1],
to_date=fiscal_year[2],
account="TDS - _TC",
tax_deduction_basis="Gross Total",
)
vat_exempt = frappe.get_doc(
{
"doctype": "Item Tax Template",
"title": "_Test VAT Not Applicable",
"company": "_Test Company",
"taxes": [{"tax_type": "_Test Account VAT - _TC", "tax_rate": 0, "not_applicable": 1}],
}
).insert(ignore_if_duplicate=True)
self.setup_party_with_category("Supplier", "Test TDS Supplier5", "Gross Total TDS Category")
pi = create_purchase_invoice(supplier="Test TDS Supplier5", rate=100)
pi.append(
"items",
{
"item_code": pi.items[0].item_code,
"qty": 1,
"rate": 100,
"cost_center": "Main - _TC",
"expense_account": "Stock Received But Not Billed - _TC",
"item_tax_template": vat_exempt.name,
},
)
pi.append(
"taxes",
{
"category": "Total",
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "Main - _TC",
"tax_amount": 10,
"description": "VAT",
},
)
pi.save()
pi.submit()
# item 1 carries the full VAT, so taxable amount is 100 + 10 + 100
entry = pi.tax_withholding_entries[0]
self.assertEqual(entry.taxable_amount, 210)
self.assertEqual(entry.withholding_amount, 21)
self.validate_tax_deduction(pi, 21)
tds_gl = frappe.get_all(
"GL Entry",
filters={"voucher_no": pi.name, "account": "TDS - _TC", "is_cancelled": 0},
fields=["credit"],
)
self.assertEqual([d.credit for d in tds_gl], [21])
self.cleanup_invoices([pi])
def create_purchase_invoice(**args):
# return sales invoice doc object
+29 -2
View File
@@ -496,6 +496,7 @@ class calculate_taxes_and_totals:
if tax.charge_type == "Actual"
]
)
self.set_actual_tax_distribution()
for n, item in enumerate(self._items):
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
@@ -511,7 +512,7 @@ class calculate_taxes_and_totals:
# Adjust divisional loss to the last item
if tax.charge_type == "Actual":
actual_tax_dict[tax.idx] -= current_tax_amount
if n == len(self._items) - 1:
if n == self._actual_tax_distribution[tax.idx].last_idx:
current_tax_amount += actual_tax_dict[tax.idx]
# accumulate tax amount into tax.tax_amount
@@ -576,6 +577,27 @@ class calculate_taxes_and_totals:
self.adjust_rounding_in_item_wise_tax_details()
def set_actual_tax_distribution(self):
# Actual taxes are split only over the items they apply to
self._actual_tax_distribution = {}
item_tax_maps = [self._load_item_tax_rate(item.item_tax_rate) for item in self._items]
for tax in self.doc.taxes:
if tax.charge_type != "Actual":
continue
net_total, last_idx = 0.0, None
for n, item in enumerate(self._items):
if self._get_tax_rate(tax, item_tax_maps[n]) != NOT_APPLICABLE_TAX:
net_total += flt(item.net_amount)
last_idx = n
# no applicable item: keep the remainder on the last item as before
if last_idx is None:
last_idx = len(self._items) - 1
self._actual_tax_distribution[tax.idx] = frappe._dict(net_total=net_total, last_idx=last_idx)
def adjust_rounding_in_item_wise_tax_details(self):
if ignore_item_wise_tax_details(self.doc):
return
@@ -665,7 +687,12 @@ class calculate_taxes_and_totals:
current_net_amount = item.net_amount
# distribute the tax amount proportionally to each item row
actual = flt(tax.tax_amount, tax.precision("tax_amount"))
current_tax_amount = item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
distribution = self._actual_tax_distribution[tax.idx]
if distribution.net_total:
current_tax_amount = item.net_amount * actual / distribution.net_total
elif item is self._items[distribution.last_idx]:
# nothing to distribute over, allocate to the last applicable item
current_tax_amount = actual
elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map:
@@ -1,6 +1,8 @@
import frappe
from frappe.utils import flt
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
from erpnext.tests.utils import ERPNextTestSuite, change_settings
@@ -534,3 +536,213 @@ class TestTaxesAndTotals(ERPNextTestSuite):
]
self.assertEqual(actual_values, expected_values)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_actual_tax_with_zero_net_total(self):
self.doc.items[0].rate = 0
self.doc.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 0,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
self.doc.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"tax_amount": 10,
},
)
self.doc.save()
self.assertEqual(self.doc.net_total, 0)
self.assertEqual(self.doc.taxes[0].tax_amount, 10)
self.assertEqual(self.doc.grand_total, 10)
# the whole amount lands on the last item row
self.assertEqual([row.taxable_amount for row in self.doc.item_wise_tax_details], [0, 0])
self.assertEqual([row.amount for row in self.doc.item_wise_tax_details], [0, 10])
tax_breakup = get_itemised_tax_breakup_data(self.doc)
self.assertEqual(tax_breakup[0]["VAT"].tax_amount, 10)
def make_doc_with_exempt_last_item(self, rates):
template = frappe.get_doc(
{
"doctype": "Item Tax Template",
"title": "_Test VAT Not Applicable",
"company": "_Test Company",
"taxes": [{"tax_type": "_Test Account VAT - _TC", "tax_rate": 0, "not_applicable": 1}],
}
).insert(ignore_if_duplicate=True)
self.doc.items[0].rate = rates[0]
self.doc.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": rates[1],
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
"item_tax_template": template.name,
},
)
self.doc.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"tax_amount": 10,
},
)
self.doc.save()
return self.doc
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_actual_tax_skips_not_applicable_item(self):
doc = self.make_doc_with_exempt_last_item([100, 100])
self.assertEqual(doc.taxes[0].tax_amount, 10)
self.assertEqual(len(doc.item_wise_tax_details), 1)
self.assertEqual(doc.item_wise_tax_details[0].item_row, doc.items[0].name)
self.assertEqual(doc.item_wise_tax_details[0].amount, 10)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_actual_tax_with_zero_value_items_and_not_applicable_item(self):
doc = self.make_doc_with_exempt_last_item([0, 0])
self.assertEqual(doc.net_total, 0)
self.assertEqual(doc.grand_total, 10)
self.assertEqual(len(doc.item_wise_tax_details), 1)
self.assertEqual(doc.item_wise_tax_details[0].item_row, doc.items[0].name)
self.assertEqual(doc.item_wise_tax_details[0].amount, 10)
def make_doc_with_cascading_tax(self, charge_type):
exempt_from = {}
for title, account in [
("_Test Service Tax Not Applicable", "_Test Account Service Tax - _TC"),
("_Test VAT Not Applicable", "_Test Account VAT - _TC"),
]:
exempt_from[account] = frappe.get_doc(
{
"doctype": "Item Tax Template",
"title": title,
"company": "_Test Company",
"taxes": [{"tax_type": account, "tax_rate": 0, "not_applicable": 1}],
}
).insert(ignore_if_duplicate=True)
self.doc.items[0].item_tax_template = exempt_from["_Test Account Service Tax - _TC"].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": exempt_from["_Test Account VAT - _TC"].name,
},
)
self.doc.extend(
"taxes",
[
{
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"tax_amount": 10,
},
{
"charge_type": charge_type,
"row_id": 1,
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Service Tax",
"rate": 10,
},
],
)
self.doc.disable_rounded_total = 1
self.doc.save()
self.doc.submit()
return self.doc
def get_gl_map(self, doc):
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": doc.doctype, "voucher_no": doc.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
)
return {d.account: d.debit - d.credit for d in gl_entries}
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_actual_tax_cascading_on_previous_row_total(self):
doc = self.make_doc_with_cascading_tax("On Previous Row Total")
# exempt item 2 gets no VAT share, so service tax is 10% of 100
self.assertEqual(doc.taxes[1].tax_amount, 10)
self.assertEqual(doc.grand_total, 220)
gl_map = self.get_gl_map(doc)
self.assertEqual(gl_map["_Test Account VAT - _TC"], -10)
self.assertEqual(gl_map["_Test Account Service Tax - _TC"], -10)
self.assertEqual(gl_map[doc.debit_to], 220)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_actual_tax_cascading_on_previous_row_amount(self):
doc = self.make_doc_with_cascading_tax("On Previous Row Amount")
# exempt item 2 has no VAT amount to charge service tax on
self.assertEqual(doc.taxes[1].tax_amount, 0)
self.assertEqual(doc.grand_total, 210)
gl_map = self.get_gl_map(doc)
self.assertEqual(gl_map["_Test Account VAT - _TC"], -10)
self.assertNotIn("_Test Account Service Tax - _TC", gl_map)
self.assertEqual(gl_map[doc.debit_to], 210)
def make_full_return(self, doc):
return_doc = make_return_doc(doc.doctype, doc.name)
return_doc.save()
return_doc.submit()
return return_doc
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_full_return_reverses_actual_tax_cascading_rows(self):
doc = self.make_doc_with_cascading_tax("On Previous Row Total")
return_doc = self.make_full_return(doc)
self.assertEqual(return_doc.taxes[1].tax_amount, -10)
self.assertEqual(return_doc.grand_total, -220)
gl_map = self.get_gl_map(doc)
for account, amount in self.get_gl_map(return_doc).items():
self.assertEqual(gl_map[account] + amount, 0)
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_full_return_recomputes_cascading_rows_of_old_split_invoice(self):
doc = self.make_doc_with_cascading_tax("On Previous Row Total")
# amounts as saved by the old split, where the exempt item took a VAT share
doc.taxes[1].db_set({"tax_amount": 10.5, "total": 220.5})
doc.db_set("grand_total", 220.5)
return_doc = self.make_full_return(doc)
# the return uses the new split, it does not copy the original amounts
self.assertEqual(return_doc.taxes[1].tax_amount, -10)
self.assertEqual(return_doc.grand_total, -220)
@@ -540,6 +540,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
actual_tax_dict[tax.idx] = flt(tax.tax_amount, precision("tax_amount", tax));
}
});
this.set_actual_tax_distribution();
$.each(this.frm._items || [], function (n, item) {
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
@@ -558,7 +559,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// Adjust divisional loss to the last item
if (tax.charge_type == "Actual") {
actual_tax_dict[tax.idx] -= current_tax_amount;
if (n == me.frm._items.length - 1) {
if (n == me._actual_tax_distribution[tax.idx].last_idx) {
current_tax_amount += actual_tax_dict[tax.idx];
}
}
@@ -663,6 +664,31 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
return item_tax_rate ? JSON.parse(item_tax_rate) : {};
}
set_actual_tax_distribution() {
// Actual taxes are split only over the items they apply to
const items = this.frm._items || [];
const item_tax_maps = items.map((item) => this._load_item_tax_rate(item.item_tax_rate));
this._actual_tax_distribution = {};
for (const tax of this.frm.doc.taxes || []) {
if (tax.charge_type != "Actual") continue;
let net_total = 0.0;
let last_idx = null;
items.forEach((item, n) => {
if (this._get_tax_rate(tax, item_tax_maps[n]) !== NOT_APPLICABLE_TAX) {
net_total += flt(item.net_amount);
last_idx = n;
}
});
// no applicable item: keep the remainder on the last item as before
if (last_idx === null) last_idx = items.length - 1;
this._actual_tax_distribution[tax.idx] = { net_total, last_idx };
}
}
get_current_tax_amount(item, tax, item_tax_map) {
var tax_rate = this._get_tax_rate(tax, item_tax_map);
var current_tax_amount = 0.0;
@@ -689,9 +715,13 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
current_net_amount = item.net_amount;
// distribute the tax amount proportionally to each item row
var actual = flt(tax.tax_amount, precision("tax_amount", tax));
current_tax_amount = this.frm.doc.net_total
? (item.net_amount / this.frm.doc.net_total) * actual
: 0.0;
var distribution = this._actual_tax_distribution[tax.idx];
if (distribution.net_total) {
current_tax_amount = (item.net_amount / distribution.net_total) * actual;
} else if (item === this.frm._items[distribution.last_idx]) {
// nothing to distribute over, allocate to the last applicable item
current_tax_amount = actual;
}
} else if (tax.charge_type == "On Net Total") {
if (tax.account_head in item_tax_map) {
current_net_amount = item.net_amount;