From 1319b28b1f8876ea5f2abf5fff5ea5bdc898fdf2 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Wed, 8 Oct 2025 17:27:58 +0530 Subject: [PATCH 01/14] fix: tds for customer and supplier in Journal Entry --- .../doctype/journal_entry/journal_entry.py | 75 ++++++++++--------- .../tax_withholding_category.py | 3 + 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index b95e140b706..16959e0b53c 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -353,56 +353,52 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): - from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map - if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): return - parties = [d.party for d in self.get("accounts") if d.party] - parties = list(set(parties)) + party = None + party_type = None + party_account = None - if len(parties) > 1: - frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) + for d in self.get("accounts"): + if d.party and party and d.party != party: + frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) - account_type_map = get_account_type_map(self.company) - party_type = "supplier" if self.voucher_type == "Credit Note" else "customer" - doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice" - debit_or_credit = ( - "debit_in_account_currency" - if self.voucher_type == "Credit Note" - else "credit_in_account_currency" + if d.party_type in ("Customer", "Supplier") and d.party: + party = d.party + party_type = d.party_type + party_account = d.account + break + + # debit or credit based on party type + dr_or_cr = "credit_in_account_currency" if party_type == "Supplier" else "debit_in_account_currency" + rev_dr_or_cr = ( + "debit_in_account_currency" if party_type == "Supplier" else "credit_in_account_currency" ) - rev_debit_or_credit = ( - "credit_in_account_currency" - if debit_or_credit == "debit_in_account_currency" - else "debit_in_account_currency" - ) - - party_account = get_party_account(party_type.title(), parties[0], self.company) net_total = sum( - d.get(debit_or_credit) + d.get(dr_or_cr) - d.get(rev_dr_or_cr) for d in self.get("accounts") - if account_type_map.get(d.account) not in ("Tax", "Chargeable") + if d.party == party and d.party_type == party_type ) - party_amount = sum( - d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account - ) + # only apply tds if net total is positive + if net_total <= 0: + return inv = frappe._dict( { - party_type: parties[0], - "doctype": doctype, + "party_type": party_type, + "party": party, + "doctype": self.doctype, "company": self.company, "posting_date": self.posting_date, - "net_total": net_total, + "tax_withholding_net_total": net_total, + "base_tax_withholding_net_total": net_total, } ) - tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details( - inv, self.tax_withholding_category - ) + tax_withholding_details = get_party_tax_withholding_details(inv, self.tax_withholding_category) if not tax_withholding_details: return @@ -413,29 +409,36 @@ class JournalEntry(AccountsController): d.update( { "account": tax_withholding_details.get("account_head"), - debit_or_credit: tax_withholding_details.get("tax_amount"), + dr_or_cr: tax_withholding_details.get("tax_amount"), + rev_dr_or_cr: 0, } ) accounts.append(d.get("account")) if d.get("account") == party_account: - d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")}) + party_field = dr_or_cr + amount = net_total - tax_withholding_details.get("tax_amount") + if not d.get(party_field): + party_field = rev_dr_or_cr + amount = -1 * amount + + d.update({party_field: amount}) if not accounts or tax_withholding_details.get("account_head") not in accounts: self.append( "accounts", { "account": tax_withholding_details.get("account_head"), - rev_debit_or_credit: tax_withholding_details.get("tax_amount"), - "against_account": parties[0], + dr_or_cr: tax_withholding_details.get("tax_amount"), + "against_account": party, }, ) to_remove = [ d for d in self.get("accounts") - if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head") + if not d.get(dr_or_cr) and d.account == tax_withholding_details.get("account_head") ] for d in to_remove: diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 2c6d13b3147..8d6f0bc86f0 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -85,6 +85,9 @@ def get_party_details(inv): if inv.doctype == "Sales Invoice": party_type = "Customer" party = inv.customer + elif inv.doctype == "Journal Entry": + party_type = inv.party_type + party = inv.party else: party_type = "Supplier" party = inv.supplier From 47aa006ea92978208d859149aed5223161c3704f Mon Sep 17 00:00:00 2001 From: ljain112 Date: Wed, 8 Oct 2025 18:02:19 +0530 Subject: [PATCH 02/14] fix: recalculate totals after applying tds --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 16959e0b53c..920ed14dd00 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -444,6 +444,10 @@ class JournalEntry(AccountsController): for d in to_remove: self.remove(d) + self.set_amounts_in_company_currency() + self.set_total_debit_credit() + self.set_against_account() + def update_asset_value(self): self.update_asset_on_depreciation() self.update_asset_on_disposal() From 31434630b5a506edfd644d4a18969b44f412607b Mon Sep 17 00:00:00 2001 From: ljain112 Date: Wed, 8 Oct 2025 19:29:39 +0530 Subject: [PATCH 03/14] fix: handle multicurrency in tds jv --- .../doctype/journal_entry/journal_entry.py | 118 ++++++++++++------ .../tax_withholding_category.py | 7 +- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 920ed14dd00..e053dba1f40 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -353,12 +353,15 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): + from erpnext.setup.utils import get_exchange_rate + if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): return party = None party_type = None party_account = None + party_row = None for d in self.get("accounts"): if d.party and party and d.party != party: @@ -368,22 +371,25 @@ class JournalEntry(AccountsController): party = d.party party_type = d.party_type party_account = d.account + party_row = d break - # debit or credit based on party type - dr_or_cr = "credit_in_account_currency" if party_type == "Supplier" else "debit_in_account_currency" - rev_dr_or_cr = ( - "debit_in_account_currency" if party_type == "Supplier" else "credit_in_account_currency" - ) + if not (party and party_type): + return - net_total = sum( + # debit or credit based on party type + dr_or_cr = "credit" if party_type == "Supplier" else "debit" + rev_dr_or_cr = "debit" if party_type == "Supplier" else "credit" + + # net total in company currency. + net_total_in_company_currency = sum( d.get(dr_or_cr) - d.get(rev_dr_or_cr) for d in self.get("accounts") if d.party == party and d.party_type == party_type ) # only apply tds if net total is positive - if net_total <= 0: + if net_total_in_company_currency <= 0: return inv = frappe._dict( @@ -393,8 +399,8 @@ class JournalEntry(AccountsController): "doctype": self.doctype, "company": self.company, "posting_date": self.posting_date, - "tax_withholding_net_total": net_total, - "base_tax_withholding_net_total": net_total, + "tax_withholding_net_total": net_total_in_company_currency, + "base_tax_withholding_net_total": net_total_in_company_currency, } ) @@ -403,47 +409,77 @@ class JournalEntry(AccountsController): if not tax_withholding_details: return - accounts = [] + tds_account = tax_withholding_details.get("account_head") + company_default_currency = frappe.get_cached_value("Company", self.company, "default_currency") + tds_account_currency = get_account_currency(tds_account) + + tds_exch_rate = get_exchange_rate(tds_account_currency, company_default_currency, self.posting_date) + + tds_amt_in_company_currency = tax_withholding_details.get("tax_amount") + tds_amt_in_account_currency = tds_amt_in_company_currency / tds_exch_rate + tds_amt_in_party_currency = tds_amt_in_company_currency / party_row.get("exchange_rate", 1) + + # Update or create tax account row + tax_row = None for d in self.get("accounts"): - if d.get("account") == tax_withholding_details.get("account_head"): - d.update( - { - "account": tax_withholding_details.get("account_head"), - dr_or_cr: tax_withholding_details.get("tax_amount"), - rev_dr_or_cr: 0, - } - ) + if d.account == tds_account: + tax_row = d + break - accounts.append(d.get("account")) - - if d.get("account") == party_account: - party_field = dr_or_cr - amount = net_total - tax_withholding_details.get("tax_amount") - if not d.get(party_field): - party_field = rev_dr_or_cr - amount = -1 * amount - - d.update({party_field: amount}) - - if not accounts or tax_withholding_details.get("account_head") not in accounts: - self.append( + if not tax_row: + tax_row = self.append( "accounts", { - "account": tax_withholding_details.get("account_head"), - dr_or_cr: tax_withholding_details.get("tax_amount"), - "against_account": party, + "account": tds_account, + "account_currency": tds_account_currency, + "exchange_rate": tds_exch_rate, + "is_tax_withholding_account": 1, + "against_account": party_account, }, ) - to_remove = [ - d - for d in self.get("accounts") - if not d.get(dr_or_cr) and d.account == tax_withholding_details.get("account_head") - ] + # TDS will always be credit + tax_row.update( + { + "credit": flt(tds_amt_in_company_currency, self.precision("credit")), + "credit_in_account_currency": flt( + tds_amt_in_account_currency, self.precision("credit_in_account_currency") + ), + "debit": 0, + "debit_in_account_currency": 0, + } + ) - for d in to_remove: - self.remove(d) + party_field = dr_or_cr + if not party_row.get(party_field): + party_field = rev_dr_or_cr + tds_amt_in_company_currency = -1 * tds_amt_in_company_currency + tds_amt_in_party_currency = -1 * tds_amt_in_party_currency + if dr_or_cr == "debit": + # for customer,increase the receivable amount + party_row.update( + { + party_field: flt(party_row.get(party_field, 0)) + tds_amt_in_company_currency, + f"{party_field}_in_account_currency": flt( + party_row.get(f"{party_field}_in_account_currency", 0) + ) + + tds_amt_in_party_currency, + } + ) + else: + # for supplier,decrease the payable amount + party_row.update( + { + party_field: flt(party_row.get(party_field, 0)) - tds_amt_in_company_currency, + f"{party_field}_in_account_currency": flt( + party_row.get(f"{party_field}_in_account_currency", 0) + ) + - tds_amt_in_party_currency, + } + ) + + # Recalculate totals self.set_amounts_in_company_currency() self.set_total_debit_credit() self.set_against_account() diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 8d6f0bc86f0..4c727a41015 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -158,7 +158,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None): party_type, parties, inv, tax_details, posting_date, pan_no ) - if party_type == "Supplier": + if party_type == "Supplier" or inv.doctype == "Journal Entry": tax_row = get_tax_row_for_tds(tax_details, tax_amount) else: tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted) @@ -349,7 +349,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N elif party_type == "Customer": if tax_deducted: # if already TCS is charged, then amount will be calculated based on 'Previous Row Total' - tax_amount = 0 + if inv.doctype == "Sales Invoice": + tax_amount = 0 + else: + tax_amount = inv.base_tax_withholding_net_total * tax_details.rate / 100 else: # if no TCS has been charged in FY, # then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold From 2b4f621c8e8217508a50dbabb0897969c48410bc Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 12:24:54 +0530 Subject: [PATCH 04/14] refactor: proper variable naming --- .../doctype/journal_entry/journal_entry.py | 144 ++++++++++-------- 1 file changed, 77 insertions(+), 67 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index e053dba1f40..03190a8294c 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -360,36 +360,39 @@ class JournalEntry(AccountsController): party = None party_type = None - party_account = None party_row = None - for d in self.get("accounts"): - if d.party and party and d.party != party: - frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) + for row in self.get("accounts"): + if row.party_type in ("Customer", "Supplier") and row.party: + if party and row.party != party: + frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) - if d.party_type in ("Customer", "Supplier") and d.party: - party = d.party - party_type = d.party_type - party_account = d.account - party_row = d - break + if not party: + party = row.party + party_type = row.party_type + party_row = row - if not (party and party_type): + if not party: return # debit or credit based on party type - dr_or_cr = "credit" if party_type == "Supplier" else "debit" - rev_dr_or_cr = "debit" if party_type == "Supplier" else "credit" + dr_cr = "credit" if party_type == "Supplier" else "debit" + rev_dr_cr = "debit" if party_type == "Supplier" else "credit" + + precision = self.precision(dr_cr, party_row) # net total in company currency. - net_total_in_company_currency = sum( - d.get(dr_or_cr) - d.get(rev_dr_or_cr) - for d in self.get("accounts") - if d.party == party and d.party_type == party_type + net_total = flt( + sum( + d.get(dr_cr) - d.get(rev_dr_cr) + for d in self.get("accounts") + if d.party == party and d.party_type == party_type + ), + precision, ) # only apply tds if net total is positive - if net_total_in_company_currency <= 0: + if net_total <= 0: return inv = frappe._dict( @@ -399,87 +402,94 @@ class JournalEntry(AccountsController): "doctype": self.doctype, "company": self.company, "posting_date": self.posting_date, - "tax_withholding_net_total": net_total_in_company_currency, - "base_tax_withholding_net_total": net_total_in_company_currency, + "tax_withholding_net_total": net_total, + "base_tax_withholding_net_total": net_total, } ) - tax_withholding_details = get_party_tax_withholding_details(inv, self.tax_withholding_category) + tax_details = get_party_tax_withholding_details(inv, self.tax_withholding_category) - if not tax_withholding_details: + if not tax_details: return - tds_account = tax_withholding_details.get("account_head") - company_default_currency = frappe.get_cached_value("Company", self.company, "default_currency") - tds_account_currency = get_account_currency(tds_account) + tax_acc = tax_details.get("account_head") + acc_curr = get_account_currency(tax_acc) + comp_curr = frappe.get_cached_value("Company", self.company, "default_currency") - tds_exch_rate = get_exchange_rate(tds_account_currency, company_default_currency, self.posting_date) + exch_rate = get_exchange_rate(acc_curr, comp_curr, self.posting_date) - tds_amt_in_company_currency = tax_withholding_details.get("tax_amount") - tds_amt_in_account_currency = tds_amt_in_company_currency / tds_exch_rate - tds_amt_in_party_currency = tds_amt_in_company_currency / party_row.get("exchange_rate", 1) + tax_amt_in_comp_curr = flt(tax_details.get("tax_amount"), precision) + tax_amt_in_acc_curr = flt(tax_amt_in_comp_curr / exch_rate, precision) + tax_amt_in_party_curr = flt(tax_amt_in_comp_curr / party_row.get("exchange_rate", 1), precision) # Update or create tax account row tax_row = None - for d in self.get("accounts"): - if d.account == tds_account: - tax_row = d + + for row in self.get("accounts"): + if row.account == tax_acc: + tax_row = row break if not tax_row: tax_row = self.append( "accounts", { - "account": tds_account, - "account_currency": tds_account_currency, - "exchange_rate": tds_exch_rate, + "account": tax_acc, + "account_currency": acc_curr, + "exchange_rate": exch_rate, "is_tax_withholding_account": 1, - "against_account": party_account, + "debit": 0, + "credit": 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, }, ) # TDS will always be credit tax_row.update( { - "credit": flt(tds_amt_in_company_currency, self.precision("credit")), - "credit_in_account_currency": flt( - tds_amt_in_account_currency, self.precision("credit_in_account_currency") - ), + "credit": tax_amt_in_comp_curr, + "credit_in_account_currency": tax_amt_in_acc_curr, "debit": 0, "debit_in_account_currency": 0, } ) - party_field = dr_or_cr + # update party row + party_field = dr_cr + + # sometime user may enter amount in opposite field as negative value if not party_row.get(party_field): - party_field = rev_dr_or_cr - tds_amt_in_company_currency = -1 * tds_amt_in_company_currency - tds_amt_in_party_currency = -1 * tds_amt_in_party_currency + party_field = rev_dr_cr + tax_amt_in_comp_curr *= -1 + tax_amt_in_party_curr *= -1 - if dr_or_cr == "debit": - # for customer,increase the receivable amount - party_row.update( - { - party_field: flt(party_row.get(party_field, 0)) + tds_amt_in_company_currency, - f"{party_field}_in_account_currency": flt( - party_row.get(f"{party_field}_in_account_currency", 0) - ) - + tds_amt_in_party_currency, - } - ) - else: - # for supplier,decrease the payable amount - party_row.update( - { - party_field: flt(party_row.get(party_field, 0)) - tds_amt_in_company_currency, - f"{party_field}_in_account_currency": flt( - party_row.get(f"{party_field}_in_account_currency", 0) - ) - - tds_amt_in_party_currency, - } - ) + # for customer amount will be added. + if dr_cr == "debit": + tax_amt_in_comp_curr *= -1 + tax_amt_in_party_curr *= -1 - # Recalculate totals + party_field_in_acc_curr = f"{party_field}_in_account_currency" + party_amt_in_comp_curr = flt(party_row.get(party_field) - tax_amt_in_comp_curr, precision) + party_amt_in_acc_curr = flt(party_row.get(party_field_in_acc_curr) - tax_amt_in_party_curr, precision) + + party_row.update( + { + party_field: party_amt_in_comp_curr, + party_field_in_acc_curr: party_amt_in_acc_curr, + } + ) + + # remove other tds rows if any + dup_rows = [] + for row in self.get("accounts"): + if row.account == tax_acc and row != tax_row: + dup_rows.append(row) + + for row in dup_rows: + self.remove(row) + + # recalculate totals self.set_amounts_in_company_currency() self.set_total_debit_credit() self.set_against_account() From 84e6d278c38ddc0f7fa7d7e100c80af1afff462d Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 12:54:55 +0530 Subject: [PATCH 05/14] chore: remove redundant code --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 03190a8294c..f04772e221a 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -437,7 +437,6 @@ class JournalEntry(AccountsController): "account": tax_acc, "account_currency": acc_curr, "exchange_rate": exch_rate, - "is_tax_withholding_account": 1, "debit": 0, "credit": 0, "debit_in_account_currency": 0, From 004bd5924581dfe5885185df2a5d677696f18012 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 13:08:55 +0530 Subject: [PATCH 06/14] fix: calculate net_total excluding taxes --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index f04772e221a..7ff5ea26cc4 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -353,6 +353,7 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): + from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map from erpnext.setup.utils import get_exchange_rate if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): @@ -361,6 +362,7 @@ class JournalEntry(AccountsController): party = None party_type = None party_row = None + party_account = None for row in self.get("accounts"): if row.party_type in ("Customer", "Supplier") and row.party: @@ -370,6 +372,7 @@ class JournalEntry(AccountsController): if not party: party = row.party party_type = row.party_type + party_account = row.account party_row = row if not party: @@ -381,12 +384,14 @@ class JournalEntry(AccountsController): precision = self.precision(dr_cr, party_row) + account_type_map = get_account_type_map(self.company) + # net total in company currency. net_total = flt( sum( - d.get(dr_cr) - d.get(rev_dr_cr) + d.get(rev_dr_cr) - d.get(dr_cr) for d in self.get("accounts") - if d.party == party and d.party_type == party_type + if account_type_map.get(d.account) not in ("Tax", "Chargeable") and d.account != party_account ), precision, ) From 610877fb17d04c73aea8969f6557ab64907f085c Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 13:11:10 +0530 Subject: [PATCH 07/14] refactor: update exchange rate import to avoid redundancy --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 7ff5ea26cc4..d359a0689e1 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -33,6 +33,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched get_depr_schedule, ) from erpnext.controllers.accounts_controller import AccountsController +from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate class StockAccountInvalidTransaction(frappe.ValidationError): @@ -354,7 +355,6 @@ class JournalEntry(AccountsController): def apply_tax_withholding(self): from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map - from erpnext.setup.utils import get_exchange_rate if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): return @@ -421,7 +421,7 @@ class JournalEntry(AccountsController): acc_curr = get_account_currency(tax_acc) comp_curr = frappe.get_cached_value("Company", self.company, "default_currency") - exch_rate = get_exchange_rate(acc_curr, comp_curr, self.posting_date) + exch_rate = _get_exchange_rate(acc_curr, comp_curr, self.posting_date) tax_amt_in_comp_curr = flt(tax_details.get("tax_amount"), precision) tax_amt_in_acc_curr = flt(tax_amt_in_comp_curr / exch_rate, precision) @@ -1808,8 +1808,6 @@ def get_exchange_rate( credit=None, exchange_rate=None, ): - from erpnext.setup.utils import get_exchange_rate - account_details = frappe.get_cached_value( "Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1 ) @@ -1832,7 +1830,7 @@ def get_exchange_rate( # The date used to retreive the exchange rate here is the date passed # in as an argument to this function. elif (not exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date: - exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date) + exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date) else: exchange_rate = 1 From 2112f36577d8de4eaa655894cbd3896294178407 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 13:32:45 +0530 Subject: [PATCH 08/14] fix: add cost center to tds row in journal entry --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index d359a0689e1..294353eca16 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -442,6 +442,7 @@ class JournalEntry(AccountsController): "account": tax_acc, "account_currency": acc_curr, "exchange_rate": exch_rate, + "cost_center": tax_details.get("cost_center"), "debit": 0, "credit": 0, "debit_in_account_currency": 0, From 88f6d783b427c1c843acce8669dd3717c9b35687 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 9 Oct 2025 13:44:32 +0530 Subject: [PATCH 09/14] fix: include grand_total in journal entry and handle taxes correctly in invoice total calculation --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 1 + .../tax_withholding_category/tax_withholding_category.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 294353eca16..71220186dc7 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -409,6 +409,7 @@ class JournalEntry(AccountsController): "posting_date": self.posting_date, "tax_withholding_net_total": net_total, "base_tax_withholding_net_total": net_total, + "grand_total": net_total, } ) diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 4c727a41015..b1c0f426d99 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -724,7 +724,7 @@ def get_advance_adjusted_in_invoice(inv): def get_invoice_total_without_tcs(inv, tax_details): - tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head] + tcs_tax_row = [d for d in inv.get("taxes") or [] if d.account_head == tax_details.account_head] tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0 return inv.grand_total - tcs_tax_row_amount From 2de9f8f2e256cfd4c7d2b72e5e7c70f8a5532f45 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Tue, 14 Oct 2025 17:30:22 +0530 Subject: [PATCH 10/14] test: add TDS and TCS calculations for journal entries in Debit and Credit Notes --- .../test_tax_withholding_category.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index b2b05045c3e..84b381e2e1e 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -849,6 +849,84 @@ class TestTaxWithholdingCategory(IntegrationTestCase): self.assertEqual(payment.taxes[0].tax_amount, 6000) self.assertEqual(payment.taxes[0].allocated_amount, 6000) + def test_tds_on_journal_entry_for_supplier(self): + """Test TDS deduction for Supplier in Debit Note""" + frappe.db.set_value( + "Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS" + ) + + jv = make_journal_entry_with_tax_withholding( + party_type="Supplier", + party="Test TDS Supplier", + voucher_type="Debit Note", + amount=50000, + save=False, + ) + jv.apply_tds = 1 + jv.tax_withholding_category = "Cumulative Threshold TDS" + jv.save() + jv.submit() + + # TDS = 50000 * 10% = 5000 + self.assertEqual(len(jv.accounts), 3) + + # Find TDS account row + tds_row = None + supplier_row = None + for row in jv.accounts: + if row.account == "TDS - _TC": + tds_row = row + elif row.party == "Test TDS Supplier": + supplier_row = row + + self.assertEqual(tds_row.credit, 5000) + self.assertEqual(tds_row.debit, 0) + + # Supplier amount should be reduced by TDS + self.assertEqual(supplier_row.credit, 45000) + + jv.cancel() + + def test_tcs_on_journal_entry_for_customer(self): + """Test TCS collection for Customer in Credit Note""" + frappe.db.set_value( + "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" + ) + + # Create Credit Note with amount exceeding threshold + jv = make_journal_entry_with_tax_withholding( + party_type="Customer", + party="Test TCS Customer", + voucher_type="Credit Note", + amount=50000, + save=False, + ) + jv.apply_tds = 1 + jv.tax_withholding_category = "Cumulative Threshold TCS" + jv.save() + jv.submit() + + # Assert TCS calculation (10% on amount above threshold of 30000) + self.assertEqual(len(jv.accounts), 3) + + # Find TCS account row + tcs_row = None + customer_row = None + for row in jv.accounts: + if row.account == "TCS - _TC": + tcs_row = row + elif row.party == "Test TCS Customer": + customer_row = row + + # TCS should be credited (liability to government) + self.assertEqual(tcs_row.credit, 2000) # above threshold 20000*10% + self.assertEqual(tcs_row.debit, 0) + + # Customer amount should be increased by TCS + self.assertEqual(customer_row.debit, 52000) + + jv.cancel() + def cancel_invoices(): purchase_invoices = frappe.get_all( @@ -997,6 +1075,88 @@ def create_payment_entry(**args): return pe +def make_journal_entry_with_tax_withholding( + party_type, + party, + voucher_type, + amount, + cost_center=None, + posting_date=None, + save=True, + submit=False, +): + """Helper function to create Journal Entry for tax withholding""" + if not cost_center: + cost_center = "_Test Cost Center - _TC" + + jv = frappe.new_doc("Journal Entry") + jv.posting_date = posting_date or today() + jv.company = "_Test Company" + jv.voucher_type = voucher_type + jv.multi_currency = 0 + + if party_type == "Supplier": + # Debit Note: Expense Dr, Supplier Cr + expense_account = "Stock Received But Not Billed - _TC" + party_account = "Creditors - _TC" + + jv.append( + "accounts", + { + "account": expense_account, + "cost_center": cost_center, + "debit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + jv.append( + "accounts", + { + "account": party_account, + "party_type": party_type, + "party": party, + "cost_center": cost_center, + "credit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + else: # Customer + # Credit Note: Customer Dr, Income Cr + party_account = "Debtors - _TC" + income_account = "Sales - _TC" + + jv.append( + "accounts", + { + "account": party_account, + "party_type": party_type, + "party": party, + "cost_center": cost_center, + "debit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + jv.append( + "accounts", + { + "account": income_account, + "cost_center": cost_center, + "credit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + if save or submit: + jv.insert() + + if submit: + jv.submit() + + return jv + + def create_records(): # create a new suppliers for name in [ From 40b787827a37d4d4aa8988bd8d5923ca7ffb0abd Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 24 Nov 2025 18:04:56 +0530 Subject: [PATCH 11/14] fix: use alias for get_exchange_rate function in JournalEntry --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index c31705ee957..f114095d0ab 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1832,7 +1832,7 @@ def get_exchange_rate( # The date used to retreive the exchange rate here is the date passed # in as an argument to this function. elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date: - exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date) + exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date) else: exchange_rate = 1 From 6a66ce5a973792a6e3585a95508232d042e6528d Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 27 Nov 2025 13:40:23 +0530 Subject: [PATCH 12/14] fix: add is_tax_withholding_account field to JournalEntryAccount to avoid recursive tds --- .../doctype/journal_entry/journal_entry.py | 198 +++++++++++------- .../journal_entry_account.json | 10 +- .../journal_entry_account.py | 1 + .../test_tax_withholding_category.py | 10 +- 4 files changed, 143 insertions(+), 76 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index f114095d0ab..b828bcbdbe9 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -354,15 +354,14 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): - from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map - if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): return party = None party_type = None - party_row = None party_account = None + party_row = None + existing_tds_rows = [] for row in self.get("accounts"): if row.party_type in ("Customer", "Supplier") and row.party: @@ -375,64 +374,119 @@ class JournalEntry(AccountsController): party_account = row.account party_row = row + if row.get("is_tax_withholding_account"): + existing_tds_rows.append(row) + if not party: return - # debit or credit based on party type + party = party + party_type = party_type + party_account = party_account + party_row = party_row + dr_cr = "credit" if party_type == "Supplier" else "debit" rev_dr_cr = "debit" if party_type == "Supplier" else "credit" - precision = self.precision(dr_cr, party_row) + self._reset_existing_tds_rows(party_row, existing_tds_rows, dr_cr, rev_dr_cr, precision) + + net_total = self._calculate_tds_net_total(dr_cr, rev_dr_cr, party_account, precision) + if net_total <= 0: + return + + tds_details = get_party_tax_withholding_details( + frappe._dict( + { + "party_type": party_type, + "party": party, + "doctype": self.doctype, + "company": self.company, + "posting_date": self.posting_date, + "tax_withholding_net_total": net_total, + "base_tax_withholding_net_total": net_total, + "grand_total": net_total, + } + ), + self.tax_withholding_category, + ) + + if not tds_details or not tds_details.get("tax_amount"): + return + + tax_row = self._update_or_create_tds_row(tds_details, precision) + self._adjust_party_row_for_tds(party_row, tds_details, dr_cr, rev_dr_cr, precision) + self._remove_duplicate_tds_rows(tax_row) + + self.set_amounts_in_company_currency() + self.set_total_debit_credit() + self.set_against_account() + + def _reset_existing_tds_rows(self, party_row, existing_tds_rows, dr_cr, rev_dr_cr, precision): + for row in existing_tds_rows: + # Get the TDS amount from the row (TDS is always in credit) + tds_amount = flt(row.get("credit") - row.get("debit"), precision) + if not tds_amount: + continue + + tds_amount_in_party_currency = flt(tds_amount / party_row.get("exchange_rate", 1), precision) + + party_field = dr_cr if party_row.get(dr_cr) else rev_dr_cr + party_field_in_account_currency = f"{party_field}_in_account_currency" + + # For Supplier (dr_cr=credit): add back to credit + # For Customer (dr_cr=debit): subtract from debit (since TDS was added) + multiplier = 1 if dr_cr == "credit" else -1 + tds_amount *= multiplier + tds_amount_in_party_currency *= multiplier + + party_row.update( + { + party_field: flt(party_row.get(party_field) + tds_amount, precision), + party_field_in_account_currency: flt( + party_row.get(party_field_in_account_currency) + tds_amount_in_party_currency, + precision, + ), + } + ) + + row.update( + { + "credit": 0, + "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + } + ) + + def _calculate_tds_net_total(self, tds_field, reverse_field, party_account, precision): + from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map + account_type_map = get_account_type_map(self.company) - # net total in company currency. - net_total = flt( + return flt( sum( - d.get(rev_dr_cr) - d.get(dr_cr) + d.get(reverse_field) - d.get(tds_field) for d in self.get("accounts") - if account_type_map.get(d.account) not in ("Tax", "Chargeable") and d.account != party_account + if account_type_map.get(d.account) not in ("Tax", "Chargeable") + and d.account != party_account + and not d.get("is_tax_withholding_account") ), precision, ) - # only apply tds if net total is positive - if net_total <= 0: - return + def _update_or_create_tds_row(self, tax_details, precision): + tax_account = tax_details.get("account_head") + account_currency = get_account_currency(tax_account) + company_currency = frappe.get_cached_value("Company", self.company, "default_currency") + exch_rate = _get_exchange_rate(account_currency, company_currency, self.posting_date) - inv = frappe._dict( - { - "party_type": party_type, - "party": party, - "doctype": self.doctype, - "company": self.company, - "posting_date": self.posting_date, - "tax_withholding_net_total": net_total, - "base_tax_withholding_net_total": net_total, - "grand_total": net_total, - } - ) + tax_amount = flt(tax_details.get("tax_amount"), precision) + tax_amount_in_account_currency = flt(tax_amount / exch_rate, precision) - tax_details = get_party_tax_withholding_details(inv, self.tax_withholding_category) - - if not tax_details: - return - - tax_acc = tax_details.get("account_head") - acc_curr = get_account_currency(tax_acc) - comp_curr = frappe.get_cached_value("Company", self.company, "default_currency") - - exch_rate = _get_exchange_rate(acc_curr, comp_curr, self.posting_date) - - tax_amt_in_comp_curr = flt(tax_details.get("tax_amount"), precision) - tax_amt_in_acc_curr = flt(tax_amt_in_comp_curr / exch_rate, precision) - tax_amt_in_party_curr = flt(tax_amt_in_comp_curr / party_row.get("exchange_rate", 1), precision) - - # Update or create tax account row tax_row = None - for row in self.get("accounts"): - if row.account == tax_acc: + if row.account == tax_account and row.get("is_tax_withholding_account"): tax_row = row break @@ -440,66 +494,64 @@ class JournalEntry(AccountsController): tax_row = self.append( "accounts", { - "account": tax_acc, - "account_currency": acc_curr, + "account": tax_account, + "account_currency": account_currency, "exchange_rate": exch_rate, "cost_center": tax_details.get("cost_center"), - "debit": 0, "credit": 0, - "debit_in_account_currency": 0, "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + "is_tax_withholding_account": 1, }, ) - # TDS will always be credit tax_row.update( { - "credit": tax_amt_in_comp_curr, - "credit_in_account_currency": tax_amt_in_acc_curr, + "credit": tax_amount, + "credit_in_account_currency": tax_amount_in_account_currency, "debit": 0, "debit_in_account_currency": 0, } ) - # update party row - party_field = dr_cr + return tax_row - # sometime user may enter amount in opposite field as negative value + def _adjust_party_row_for_tds(self, party_row, tax_details, dr_cr, rev_dr_cr, precision): + tax_amount = flt(tax_details.get("tax_amount"), precision) + tax_amount_in_party_currency = flt(tax_amount / party_row.get("exchange_rate", 1), precision) + + party_field = dr_cr if not party_row.get(party_field): party_field = rev_dr_cr - tax_amt_in_comp_curr *= -1 - tax_amt_in_party_curr *= -1 + tax_amount *= -1 + tax_amount_in_party_currency *= -1 - # for customer amount will be added. if dr_cr == "debit": - tax_amt_in_comp_curr *= -1 - tax_amt_in_party_curr *= -1 + tax_amount *= -1 + tax_amount_in_party_currency *= -1 - party_field_in_acc_curr = f"{party_field}_in_account_currency" - party_amt_in_comp_curr = flt(party_row.get(party_field) - tax_amt_in_comp_curr, precision) - party_amt_in_acc_curr = flt(party_row.get(party_field_in_acc_curr) - tax_amt_in_party_curr, precision) + party_field_in_account_currency = f"{party_field}_in_account_currency" party_row.update( { - party_field: party_amt_in_comp_curr, - party_field_in_acc_curr: party_amt_in_acc_curr, + party_field: flt(party_row.get(party_field) - tax_amount, precision), + party_field_in_account_currency: flt( + party_row.get(party_field_in_account_currency) - tax_amount_in_party_currency, precision + ), } ) - # remove other tds rows if any - dup_rows = [] - for row in self.get("accounts"): - if row.account == tax_acc and row != tax_row: - dup_rows.append(row) + def _remove_duplicate_tds_rows(self, current_tax_row): + rows_to_remove = [ + row + for row in self.get("accounts") + if row.get("is_tax_withholding_account") and row != current_tax_row + ] - for row in dup_rows: + for row in rows_to_remove: self.remove(row) - # recalculate totals - self.set_amounts_in_company_currency() - self.set_total_debit_credit() - self.set_against_account() - def update_asset_value(self): self.update_asset_on_depreciation() self.update_asset_on_disposal() diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index f1832255122..675bfcf86c8 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -34,6 +34,7 @@ "reference_detail_no", "advance_voucher_type", "advance_voucher_no", + "is_tax_withholding_account", "col_break3", "is_advance", "user_remark", @@ -281,12 +282,19 @@ "options": "advance_voucher_type", "read_only": 1, "search_index": 1 + }, + { + "default": "0", + "fieldname": "is_tax_withholding_account", + "fieldtype": "Check", + "label": "Is Tax Withholding Account", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2025-10-27 13:48:32.805100", + "modified": "2025-11-27 12:23:33.157655", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py index b801ac8c9a5..d26224103c0 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py @@ -28,6 +28,7 @@ class JournalEntryAccount(Document): debit_in_account_currency: DF.Currency exchange_rate: DF.Float is_advance: DF.Literal["No", "Yes"] + is_tax_withholding_account: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index f511658067b..453f4f5ceab 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -865,6 +865,10 @@ class TestTaxWithholdingCategory(IntegrationTestCase): jv.apply_tds = 1 jv.tax_withholding_category = "Cumulative Threshold TDS" jv.save() + + # Again saving should not change tds amount + jv.user_remark = "Test TDS on Journal Entry for Supplier" + jv.save() jv.submit() # TDS = 50000 * 10% = 5000 @@ -884,7 +888,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase): # Supplier amount should be reduced by TDS self.assertEqual(supplier_row.credit, 45000) - jv.cancel() def test_tcs_on_journal_entry_for_customer(self): @@ -904,6 +907,10 @@ class TestTaxWithholdingCategory(IntegrationTestCase): jv.apply_tds = 1 jv.tax_withholding_category = "Cumulative Threshold TCS" jv.save() + + # Again saving should not change tds amount + jv.user_remark = "Test TCS on Journal Entry for Customer" + jv.save() jv.submit() # Assert TCS calculation (10% on amount above threshold of 30000) @@ -924,7 +931,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase): # Customer amount should be increased by TCS self.assertEqual(customer_row.debit, 52000) - jv.cancel() From 6079bee3a33c08f3d2c9a725ea285ce5a313a6bf Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 27 Nov 2025 13:44:28 +0530 Subject: [PATCH 13/14] fix: remove redundant party variable assignments --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index b828bcbdbe9..b2289ada6ee 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -380,11 +380,6 @@ class JournalEntry(AccountsController): if not party: return - party = party - party_type = party_type - party_account = party_account - party_row = party_row - dr_cr = "credit" if party_type == "Supplier" else "debit" rev_dr_cr = "debit" if party_type == "Supplier" else "credit" precision = self.precision(dr_cr, party_row) From 09325aad3dd0a0ca6af2a2a631ee2b5cc74c27ee Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 2 Dec 2025 14:08:05 +0530 Subject: [PATCH 14/14] refactor: tax witholding in JV out of document class --- .../doctype/journal_entry/journal_entry.py | 417 ++++++++++-------- 1 file changed, 225 insertions(+), 192 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index b2289ada6ee..a5bfe4a6b0a 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -354,198 +354,7 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): - if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): - return - - party = None - party_type = None - party_account = None - party_row = None - existing_tds_rows = [] - - for row in self.get("accounts"): - if row.party_type in ("Customer", "Supplier") and row.party: - if party and row.party != party: - frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) - - if not party: - party = row.party - party_type = row.party_type - party_account = row.account - party_row = row - - if row.get("is_tax_withholding_account"): - existing_tds_rows.append(row) - - if not party: - return - - dr_cr = "credit" if party_type == "Supplier" else "debit" - rev_dr_cr = "debit" if party_type == "Supplier" else "credit" - precision = self.precision(dr_cr, party_row) - - self._reset_existing_tds_rows(party_row, existing_tds_rows, dr_cr, rev_dr_cr, precision) - - net_total = self._calculate_tds_net_total(dr_cr, rev_dr_cr, party_account, precision) - if net_total <= 0: - return - - tds_details = get_party_tax_withholding_details( - frappe._dict( - { - "party_type": party_type, - "party": party, - "doctype": self.doctype, - "company": self.company, - "posting_date": self.posting_date, - "tax_withholding_net_total": net_total, - "base_tax_withholding_net_total": net_total, - "grand_total": net_total, - } - ), - self.tax_withholding_category, - ) - - if not tds_details or not tds_details.get("tax_amount"): - return - - tax_row = self._update_or_create_tds_row(tds_details, precision) - self._adjust_party_row_for_tds(party_row, tds_details, dr_cr, rev_dr_cr, precision) - self._remove_duplicate_tds_rows(tax_row) - - self.set_amounts_in_company_currency() - self.set_total_debit_credit() - self.set_against_account() - - def _reset_existing_tds_rows(self, party_row, existing_tds_rows, dr_cr, rev_dr_cr, precision): - for row in existing_tds_rows: - # Get the TDS amount from the row (TDS is always in credit) - tds_amount = flt(row.get("credit") - row.get("debit"), precision) - if not tds_amount: - continue - - tds_amount_in_party_currency = flt(tds_amount / party_row.get("exchange_rate", 1), precision) - - party_field = dr_cr if party_row.get(dr_cr) else rev_dr_cr - party_field_in_account_currency = f"{party_field}_in_account_currency" - - # For Supplier (dr_cr=credit): add back to credit - # For Customer (dr_cr=debit): subtract from debit (since TDS was added) - multiplier = 1 if dr_cr == "credit" else -1 - tds_amount *= multiplier - tds_amount_in_party_currency *= multiplier - - party_row.update( - { - party_field: flt(party_row.get(party_field) + tds_amount, precision), - party_field_in_account_currency: flt( - party_row.get(party_field_in_account_currency) + tds_amount_in_party_currency, - precision, - ), - } - ) - - row.update( - { - "credit": 0, - "credit_in_account_currency": 0, - "debit": 0, - "debit_in_account_currency": 0, - } - ) - - def _calculate_tds_net_total(self, tds_field, reverse_field, party_account, precision): - from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map - - account_type_map = get_account_type_map(self.company) - - return flt( - sum( - d.get(reverse_field) - d.get(tds_field) - for d in self.get("accounts") - if account_type_map.get(d.account) not in ("Tax", "Chargeable") - and d.account != party_account - and not d.get("is_tax_withholding_account") - ), - precision, - ) - - def _update_or_create_tds_row(self, tax_details, precision): - tax_account = tax_details.get("account_head") - account_currency = get_account_currency(tax_account) - company_currency = frappe.get_cached_value("Company", self.company, "default_currency") - exch_rate = _get_exchange_rate(account_currency, company_currency, self.posting_date) - - tax_amount = flt(tax_details.get("tax_amount"), precision) - tax_amount_in_account_currency = flt(tax_amount / exch_rate, precision) - - tax_row = None - for row in self.get("accounts"): - if row.account == tax_account and row.get("is_tax_withholding_account"): - tax_row = row - break - - if not tax_row: - tax_row = self.append( - "accounts", - { - "account": tax_account, - "account_currency": account_currency, - "exchange_rate": exch_rate, - "cost_center": tax_details.get("cost_center"), - "credit": 0, - "credit_in_account_currency": 0, - "debit": 0, - "debit_in_account_currency": 0, - "is_tax_withholding_account": 1, - }, - ) - - tax_row.update( - { - "credit": tax_amount, - "credit_in_account_currency": tax_amount_in_account_currency, - "debit": 0, - "debit_in_account_currency": 0, - } - ) - - return tax_row - - def _adjust_party_row_for_tds(self, party_row, tax_details, dr_cr, rev_dr_cr, precision): - tax_amount = flt(tax_details.get("tax_amount"), precision) - tax_amount_in_party_currency = flt(tax_amount / party_row.get("exchange_rate", 1), precision) - - party_field = dr_cr - if not party_row.get(party_field): - party_field = rev_dr_cr - tax_amount *= -1 - tax_amount_in_party_currency *= -1 - - if dr_cr == "debit": - tax_amount *= -1 - tax_amount_in_party_currency *= -1 - - party_field_in_account_currency = f"{party_field}_in_account_currency" - - party_row.update( - { - party_field: flt(party_row.get(party_field) - tax_amount, precision), - party_field_in_account_currency: flt( - party_row.get(party_field_in_account_currency) - tax_amount_in_party_currency, precision - ), - } - ) - - def _remove_duplicate_tds_rows(self, current_tax_row): - rows_to_remove = [ - row - for row in self.get("accounts") - if row.get("is_tax_withholding_account") and row != current_tax_row - ] - - for row in rows_to_remove: - self.remove(row) + JournalEntryTaxWithholding(self).apply() def update_asset_value(self): self.update_asset_on_depreciation() @@ -1488,6 +1297,230 @@ class JournalEntry(AccountsController): frappe.throw(_("Accounts table cannot be blank.")) +class JournalEntryTaxWithholding: + def __init__(self, journal_entry): + self.doc: JournalEntry = journal_entry + self.party = None + self.party_type = None + self.party_account = None + self.party_row = None + self.existing_tds_rows = [] + self.precision = None + self.has_multiple_parties = False + + # Direction fields based on party type + self.party_field = None # "credit" for Supplier, "debit" for Customer + self.reverse_field = None # opposite of party_field + + def apply(self): + if not self._set_party_info(): + return + + self._setup_direction_fields() + self._reset_existing_tds() + + if not self._should_apply_tds(): + self._cleanup_duplicate_tds_rows(None) + return + + if self.has_multiple_parties: + frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) + + net_total = self._calculate_net_total() + if net_total <= 0: + return + + tds_details = self._get_tds_details(net_total) + if not tds_details or not tds_details.get("tax_amount"): + return + + self._create_or_update_tds_row(tds_details) + self._update_party_amount(tds_details.get("tax_amount"), is_reversal=False) + + self._recalculate_totals() + + def _should_apply_tds(self): + return self.doc.apply_tds and self.doc.voucher_type in ("Debit Note", "Credit Note") + + def _set_party_info(self): + for row in self.doc.get("accounts"): + if row.party_type in ("Customer", "Supplier") and row.party: + if self.party and row.party != self.party: + self.has_multiple_parties = True + + if not self.party: + self.party = row.party + self.party_type = row.party_type + self.party_account = row.account + self.party_row = row + + if row.get("is_tax_withholding_account"): + self.existing_tds_rows.append(row) + + return bool(self.party) + + def _setup_direction_fields(self): + """ + For Supplier (TDS): party has credit, TDS reduces credit + For Customer (TCS): party has debit, TCS increases debit + """ + if self.party_type == "Supplier": + self.party_field = "credit" + self.reverse_field = "debit" + else: # Customer + self.party_field = "debit" + self.reverse_field = "credit" + + self.precision = self.doc.precision(self.party_field, self.party_row) + + def _reset_existing_tds(self): + for row in self.existing_tds_rows: + # TDS amount is always in credit (liability to government) + tds_amount = flt(row.get("credit") - row.get("debit"), self.precision) + if not tds_amount: + continue + + self._update_party_amount(tds_amount, is_reversal=True) + + # zero_out_tds_row + row.update( + { + "credit": 0, + "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + } + ) + + def _update_party_amount(self, amount, is_reversal=False): + amount = flt(amount, self.precision) + amount_in_party_currency = flt(amount / self.party_row.get("exchange_rate", 1), self.precision) + + # Determine which field the party amount is in + active_field = self.party_field if self.party_row.get(self.party_field) else self.reverse_field + + # If amount is in reverse field, flip the signs + if active_field == self.reverse_field: + amount = -amount + amount_in_party_currency = -amount_in_party_currency + + # Direction multiplier based on party type: + # Customer (TCS): +1 (add to debit) + # Supplier (TDS): -1 (subtract from credit) + direction = 1 if self.party_type == "Customer" else -1 + + # Reversal inverts the direction + if is_reversal: + direction = -direction + + adjustment = amount * direction + adjustment_in_party_currency = amount_in_party_currency * direction + + active_field_account_currency = f"{active_field}_in_account_currency" + + self.party_row.update( + { + active_field: flt(self.party_row.get(active_field) + adjustment, self.precision), + active_field_account_currency: flt( + self.party_row.get(active_field_account_currency) + adjustment_in_party_currency, + self.precision, + ), + } + ) + + def _calculate_net_total(self): + from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map + + account_type_map = get_account_type_map(self.doc.company) + + return flt( + sum( + d.get(self.reverse_field) - d.get(self.party_field) + for d in self.doc.get("accounts") + if account_type_map.get(d.account) not in ("Tax", "Chargeable") + and d.account != self.party_account + and not d.get("is_tax_withholding_account") + ), + self.precision, + ) + + def _get_tds_details(self, net_total): + return get_party_tax_withholding_details( + frappe._dict( + { + "party_type": self.party_type, + "party": self.party, + "doctype": self.doc.doctype, + "company": self.doc.company, + "posting_date": self.doc.posting_date, + "tax_withholding_net_total": net_total, + "base_tax_withholding_net_total": net_total, + "grand_total": net_total, + } + ), + self.doc.tax_withholding_category, + ) + + def _create_or_update_tds_row(self, tds_details): + tax_account = tds_details.get("account_head") + account_currency = get_account_currency(tax_account) + company_currency = frappe.get_cached_value("Company", self.doc.company, "default_currency") + exchange_rate = _get_exchange_rate(account_currency, company_currency, self.doc.posting_date) + + tax_amount = flt(tds_details.get("tax_amount"), self.precision) + tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision) + + # Find existing TDS row for this account + tax_row = None + for row in self.doc.get("accounts"): + if row.account == tax_account and row.get("is_tax_withholding_account"): + tax_row = row + break + + if not tax_row: + tax_row = self.doc.append( + "accounts", + { + "account": tax_account, + "account_currency": account_currency, + "exchange_rate": exchange_rate, + "cost_center": tds_details.get("cost_center"), + "credit": 0, + "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + "is_tax_withholding_account": 1, + }, + ) + + # TDS/TCS is always credited (liability to government) + tax_row.update( + { + "credit": tax_amount, + "credit_in_account_currency": tax_amount_in_account_currency, + "debit": 0, + "debit_in_account_currency": 0, + } + ) + + self._cleanup_duplicate_tds_rows(tax_row) + + def _cleanup_duplicate_tds_rows(self, current_tax_row): + rows_to_remove = [ + row + for row in self.doc.get("accounts") + if row.get("is_tax_withholding_account") and row != current_tax_row + ] + + for row in rows_to_remove: + self.doc.remove(row) + + def _recalculate_totals(self): + self.doc.set_amounts_in_company_currency() + self.doc.set_total_debit_credit() + self.doc.set_against_account() + + @frappe.whitelist() def get_default_bank_cash_account( company, account_type=None, mode_of_payment=None, account=None, *, fetch_balance=True