mirror of
https://github.com/frappe/erpnext.git
synced 2026-10-29 22:57:42 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user