diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 9d3c9eb501e..f1e9e261309 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): @@ -273,93 +274,7 @@ 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)) - - if len(parties) > 1: - 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" - ) - 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) - for d in self.get("accounts") - if account_type_map.get(d.account) not in ("Tax", "Chargeable") - ) - - party_amount = sum( - d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account - ) - - inv = frappe._dict( - { - party_type: parties[0], - "doctype": doctype, - "company": self.company, - "posting_date": self.posting_date, - "net_total": net_total, - } - ) - - tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details( - inv, self.tax_withholding_category - ) - - if not tax_withholding_details: - return - - accounts = [] - 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"), - debit_or_credit: tax_withholding_details.get("tax_amount"), - } - ) - - 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")}) - - 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], - }, - ) - - 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") - ] - - for d in to_remove: - self.remove(d) + JournalEntryTaxWithholding(self).apply() def update_asset_value(self): if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry": @@ -1281,6 +1196,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): from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account @@ -1649,8 +1788,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 ) @@ -1672,8 +1809,8 @@ 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) + 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) else: exchange_rate = 1 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 0b520ecd0e4..a0535c4e1ca 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", @@ -282,12 +283,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/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index fa9b226374c..97178f95281 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 @@ -155,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) @@ -346,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 @@ -718,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 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 3ea6801ad35..3096a25c3ed 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 @@ -848,6 +848,90 @@ class TestTaxWithholdingCategory(FrappeTestCase): 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() + + # 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 + 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() + + # 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) + 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( @@ -996,6 +1080,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 [