From 283221c9ff67b86d265c57d8c49def60ede49408 Mon Sep 17 00:00:00 2001 From: Lakshit Jain Date: Tue, 23 Dec 2025 20:47:53 +0530 Subject: [PATCH] feat: Introduce tax withholding entry (cherry picked from commit c66f78c78493fd71651ea92395d71a333ddb7934) # Conflicts: # erpnext/accounts/doctype/journal_entry/journal_entry.py # erpnext/accounts/doctype/payment_entry/payment_entry.json # erpnext/accounts/doctype/sales_invoice/sales_invoice.py # erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py # erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py # erpnext/patches.txt --- .../doctype/advance_tax/advance_tax.json | 57 - .../doctype/advance_tax/advance_tax.py | 27 - .../advance_taxes_and_charges.json | 30 +- .../advance_taxes_and_charges.py | 3 +- .../doctype/journal_entry/journal_entry.js | 5 + .../doctype/journal_entry/journal_entry.json | 42 +- .../doctype/journal_entry/journal_entry.py | 21 +- .../doctype/payment_entry/payment_entry.js | 11 +- .../doctype/payment_entry/payment_entry.json | 67 +- .../doctype/payment_entry/payment_entry.py | 103 +- .../payment_entry_deduction.json | 5 +- .../purchase_invoice/purchase_invoice.js | 37 +- .../purchase_invoice/purchase_invoice.json | 103 +- .../purchase_invoice/purchase_invoice.py | 140 +- .../purchase_invoice/test_purchase_invoice.py | 21 +- .../purchase_invoice_item.json | 10 +- .../purchase_invoice_item.py | 1 + .../purchase_taxes_and_charges.json | 24 +- .../purchase_taxes_and_charges.py | 2 + .../doctype/sales_invoice/sales_invoice.js | 18 + .../doctype/sales_invoice/sales_invoice.json | 46 + .../doctype/sales_invoice/sales_invoice.py | 58 +- .../sales_invoice_item.json | 17 + .../sales_invoice_item/sales_invoice_item.py | 2 + .../sales_taxes_and_charges.json | 10 +- .../sales_taxes_and_charges.py | 2 + .../doctype/subscription/subscription.py | 5 +- .../tax_withheld_vouchers.json | 47 - .../tax_withholding_category.js | 50 + .../tax_withholding_category.json | 37 +- .../tax_withholding_category.py | 470 +-- .../test_tax_withholding_category.py | 3339 +++++++++++++++-- .../__init__.py | 0 .../tax_withholding_entry.json | 237 ++ .../tax_withholding_entry.py | 1460 +++++++ .../test_tax_withholding_entry.py | 20 + .../__init__.py | 0 .../tax_withholding_group.js | 8 + .../tax_withholding_group.json | 48 + .../tax_withholding_group.py} | 11 +- .../test_tax_withholding_group.py | 20 + .../tax_withholding_rate.json | 23 +- .../tax_withholding_rate.py | 1 + erpnext/accounts/party.py | 11 +- .../tax_withholding_details.js | 5 +- .../tax_withholding_details.py | 662 ++-- .../tds_computation_summary.py | 105 +- .../doctype/purchase_order/purchase_order.js | 42 - .../purchase_order/purchase_order.json | 39 - .../doctype/purchase_order/purchase_order.py | 53 - .../purchase_order_item.json | 7 - .../purchase_order_item.py | 1 - erpnext/buying/doctype/supplier/supplier.json | 11 +- erpnext/buying/doctype/supplier/supplier.py | 1 + erpnext/controllers/accounts_controller.py | 8 +- .../controllers/sales_and_purchase_return.py | 11 +- erpnext/controllers/taxes_and_totals.py | 29 +- erpnext/hooks.py | 4 + erpnext/patches.txt | 8 +- .../v14_0/update_partial_tds_fields.py | 33 - .../v16_0/migrate_tax_withholding_data.py | 1289 +++++++ ..._tax_withholding_field_in_payment_entry.py | 10 + erpnext/public/js/controllers/transaction.js | 21 + erpnext/public/js/utils/party.js | 3 +- .../selling/doctype/customer/customer.json | 9 +- erpnext/selling/doctype/customer/customer.py | 1 + erpnext/stock/doctype/item/item.json | 37 + erpnext/stock/doctype/item/item.py | 2 + .../purchase_receipt/purchase_receipt.json | 20 - .../purchase_receipt/purchase_receipt.py | 2 - .../purchase_receipt_item.json | 9 - .../purchase_receipt_item.py | 1 - erpnext/stock/get_item_details.py | 29 + .../subcontracting_receipt.py | 1 - 74 files changed, 7203 insertions(+), 1899 deletions(-) delete mode 100644 erpnext/accounts/doctype/advance_tax/advance_tax.json delete mode 100644 erpnext/accounts/doctype/advance_tax/advance_tax.py delete mode 100644 erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json rename erpnext/accounts/doctype/{advance_tax => tax_withholding_entry}/__init__.py (100%) create mode 100644 erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.json create mode 100644 erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py create mode 100644 erpnext/accounts/doctype/tax_withholding_entry/test_tax_withholding_entry.py rename erpnext/accounts/doctype/{tax_withheld_vouchers => tax_withholding_group}/__init__.py (100%) create mode 100644 erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.js create mode 100644 erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.json rename erpnext/accounts/doctype/{tax_withheld_vouchers/tax_withheld_vouchers.py => tax_withholding_group/tax_withholding_group.py} (57%) create mode 100644 erpnext/accounts/doctype/tax_withholding_group/test_tax_withholding_group.py delete mode 100644 erpnext/patches/v14_0/update_partial_tds_fields.py create mode 100644 erpnext/patches/v16_0/migrate_tax_withholding_data.py create mode 100644 erpnext/patches/v16_0/update_tax_withholding_field_in_payment_entry.py diff --git a/erpnext/accounts/doctype/advance_tax/advance_tax.json b/erpnext/accounts/doctype/advance_tax/advance_tax.json deleted file mode 100644 index f030d7f47a2..00000000000 --- a/erpnext/accounts/doctype/advance_tax/advance_tax.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "creation": "2021-11-25 10:24:39.836195", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "reference_type", - "reference_name", - "reference_detail", - "account_head", - "allocated_amount" - ], - "fields": [ - { - "fieldname": "reference_type", - "fieldtype": "Link", - "label": "Reference Type", - "options": "DocType" - }, - { - "fieldname": "reference_name", - "fieldtype": "Dynamic Link", - "label": "Reference Name", - "options": "reference_type" - }, - { - "fieldname": "reference_detail", - "fieldtype": "Data", - "label": "Reference Detail" - }, - { - "fieldname": "account_head", - "fieldtype": "Link", - "label": "Account Head", - "options": "Account" - }, - { - "fieldname": "allocated_amount", - "fieldtype": "Currency", - "label": "Allocated Amount", - "options": "party_account_currency" - } - ], - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2024-03-27 13:05:58.308002", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Advance Tax", - "owner": "Administrator", - "permissions": [], - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/advance_tax/advance_tax.py b/erpnext/accounts/doctype/advance_tax/advance_tax.py deleted file mode 100644 index 59bdac7bc5a..00000000000 --- a/erpnext/accounts/doctype/advance_tax/advance_tax.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -# import frappe -from frappe.model.document import Document - - -class AdvanceTax(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - account_head: DF.Link | None - allocated_amount: DF.Currency - parent: DF.Data - parentfield: DF.Data - parenttype: DF.Data - reference_detail: DF.Data | None - reference_name: DF.DynamicLink | None - reference_type: DF.Link | None - # end: auto-generated types - - pass diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json index ddc9b573c78..7850637a5e8 100644 --- a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.json @@ -14,6 +14,7 @@ "description", "included_in_paid_amount", "set_by_item_tax_template", + "is_tax_withholding_account", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -25,7 +26,6 @@ "net_amount", "tax_amount", "total", - "allocated_amount", "column_break_13", "base_tax_amount", "base_net_amount", @@ -97,11 +97,11 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "allow_on_submit": 1, + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" }, { "fieldname": "section_break_8", @@ -172,12 +172,6 @@ "fieldtype": "Check", "label": "Considered In Paid Amount" }, - { - "fieldname": "allocated_amount", - "fieldtype": "Currency", - "label": "Allocated Amount", - "options": "currency" - }, { "fetch_from": "account_head.account_currency", "fieldname": "currency", @@ -213,18 +207,26 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "default": "0", + "fieldname": "is_tax_withholding_account", + "fieldtype": "Check", + "label": "Is Tax Withholding Account", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-22 19:16:22.346267", + "modified": "2025-12-15 06:42:18.707671", "modified_by": "Administrator", "module": "Accounts", "name": "Advance Taxes and Charges", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py index 70012da76c3..e214bdfa0ba 100644 --- a/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py +++ b/erpnext/accounts/doctype/advance_taxes_and_charges/advance_taxes_and_charges.py @@ -17,7 +17,6 @@ class AdvanceTaxesandCharges(Document): account_head: DF.Link add_deduct_tax: DF.Literal["Add", "Deduct"] - allocated_amount: DF.Currency base_net_amount: DF.Currency base_tax_amount: DF.Currency base_total: DF.Currency @@ -28,10 +27,12 @@ class AdvanceTaxesandCharges(Document): currency: DF.Link | None description: DF.SmallText included_in_paid_amount: DF.Check + is_tax_withholding_account: DF.Check net_amount: DF.Currency parent: DF.Data parentfield: DF.Data parenttype: DF.Data + project: DF.Link | None rate: DF.Float row_id: DF.Data | None set_by_item_tax_template: DF.Check diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index c81acb57865..38f8c60f3b2 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -201,6 +201,7 @@ frappe.ui.form.on("Journal Entry", { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); erpnext.utils.set_letter_head(frm); + frm.clear_table("tax_withholding_entries"); }, voucher_type: function (frm) { @@ -251,6 +252,10 @@ frappe.ui.form.on("Journal Entry", { }); } }, + + apply_tds: function (frm) { + frm.clear_table("tax_withholding_entries"); + }, }); var update_jv_details = function (doc, r) { diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 7058cd90461..2465948c5ef 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -43,6 +43,11 @@ "total_amount_currency", "total_amount", "total_amount_in_words", + "section_tax_withholding_entry", + "tax_withholding_group", + "ignore_tax_withholding_threshold", + "override_tax_withholding_entries", + "tax_withholding_entries", "reference", "clearance_date", "remark", @@ -517,7 +522,7 @@ "depends_on": "eval:['Credit Note', 'Debit Note'].includes(doc.voucher_type)", "fieldname": "apply_tds", "fieldtype": "Check", - "label": "Apply Tax Withholding Amount " + "label": "Consider for Tax Withholding " }, { "depends_on": "eval:doc.docstatus", @@ -586,6 +591,39 @@ "hidden": 1, "label": "Party Not Required", "no_copy": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0", + "depends_on": "eval: doc.apply_tds", + "fieldname": "section_tax_withholding_entry", + "fieldtype": "Section Break", + "label": "Tax Withholding Entry" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" + }, + { + "default": "0", + "fieldname": "ignore_tax_withholding_threshold", + "fieldtype": "Check", + "label": "Ignore Tax Withholding Threshold" + }, + { + "default": "0", + "fieldname": "override_tax_withholding_entries", + "fieldtype": "Check", + "label": "Edit Tax Withholding Entries" + }, + { + "fieldname": "tax_withholding_entries", + "fieldtype": "Table", + "label": "Tax Withholding Entries", + "options": "Tax Withholding Entry", + "read_only_depends_on": "eval: !doc.override_tax_withholding_entries" } ], "icon": "fa fa-file-text", @@ -600,7 +638,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2025-09-29 13:05:46.982277", + "modified": "2025-11-13 17:54:14.542903", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index e4aaf4e7a5a..dbc3476a75d 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -17,9 +17,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, ) -from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( - get_party_tax_withholding_details, -) +from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import JournalTaxWithholding from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, @@ -49,6 +47,7 @@ class JournalEntry(AccountsController): from frappe.types import DF from erpnext.accounts.doctype.journal_entry_account.journal_entry_account import JournalEntryAccount + from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry accounts: DF.Table[JournalEntryAccount] amended_from: DF.Link | None @@ -65,6 +64,7 @@ class JournalEntry(AccountsController): finance_book: DF.Link | None for_all_stock_asset_accounts: DF.Check from_template: DF.Link | None + ignore_tax_withholding_threshold: DF.Check inter_company_journal_entry_reference: DF.Link | None is_opening: DF.Literal["No", "Yes"] is_system_generated: DF.Check @@ -73,6 +73,7 @@ class JournalEntry(AccountsController): multi_currency: DF.Check naming_series: DF.Literal["ACC-JV-.YYYY.-"] party_not_required: DF.Check + override_tax_withholding_entries: DF.Check pay_to_recd_from: DF.Data | None payment_order: DF.Link | None periodic_entry_difference_account: DF.Link | None @@ -84,6 +85,8 @@ class JournalEntry(AccountsController): stock_asset_account: DF.Link | None stock_entry: DF.Link | None tax_withholding_category: DF.Link | None + tax_withholding_entries: DF.Table[TaxWithholdingEntry] + tax_withholding_group: DF.Link | None title: DF.Data | None total_amount: DF.Currency total_amount_currency: DF.Link | None @@ -150,8 +153,8 @@ class JournalEntry(AccountsController): self.validate_company_in_accounting_dimension() self.validate_advance_accounts() - if self.docstatus == 0: - self.apply_tax_withholding() + JournalTaxWithholding(self).on_validate() + if self.is_new() or not self.title: self.title = self.get_title() @@ -199,6 +202,7 @@ class JournalEntry(AccountsController): self.update_asset_value() self.update_inter_company_jv() self.update_invoice_discounting() + JournalTaxWithholding(self).on_submit() @frappe.whitelist() def get_balance_for_periodic_accounting(self): @@ -282,6 +286,8 @@ class JournalEntry(AccountsController): self.repost_accounting_entries() def on_cancel(self): + # Cancel tax withholding entries + # References for this Journal are removed on the `on_cancel` event in accounts_controller super().on_cancel() self.ignore_linked_doctypes = ( @@ -295,8 +301,10 @@ class JournalEntry(AccountsController): "Unreconcile Payment", "Unreconcile Payment Entries", "Advance Payment Ledger Entry", + "Tax Withholding Entry", ) self.make_gl_entries(1) + JournalTaxWithholding(self).on_cancel() self.unlink_advance_entry_reference() self.unlink_asset_reference() self.unlink_inter_company_jv() @@ -352,6 +360,7 @@ class JournalEntry(AccountsController): StockAccountInvalidTransaction, ) +<<<<<<< HEAD def apply_tax_withholding(self): from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map @@ -441,6 +450,8 @@ class JournalEntry(AccountsController): for d in to_remove: self.remove(d) +======= +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) def update_asset_value(self): self.update_asset_on_depreciation() self.update_asset_on_disposal() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 939cd0d113d..63ce704ac0b 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -41,6 +41,7 @@ frappe.ui.form.on("Payment Entry", { if (frm.is_new()) { set_default_party_type(frm); + frm.clear_table("tax_withholding_entries"); } }, @@ -532,6 +533,7 @@ frappe.ui.form.on("Payment Entry", { }, () => frm.set_value("party_name", r.message.party_name), () => frm.clear_table("references"), + () => frm.clear_table("tax_withholding_entries"), () => frm.events.hide_unhide_fields(frm), () => frm.events.set_dynamic_labels(frm), () => { @@ -564,14 +566,15 @@ frappe.ui.form.on("Payment Entry", { } }, - apply_tax_withholding_amount: function (frm) { - if (!frm.doc.apply_tax_withholding_amount) { + apply_tds: function (frm) { + if (!frm.doc.apply_tds) { frm.set_value("tax_withholding_category", ""); - } else { - frappe.db.get_value("Supplier", frm.doc.party, "tax_withholding_category", (values) => { + } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) { + frappe.db.get_value(frm.doc.party_type, frm.doc.party, "tax_withholding_category", (values) => { frm.set_value("tax_withholding_category", values.tax_withholding_category); }); } + frm.clear_table("tax_withholding_entries"); }, paid_from: function (frm) { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 551c70cab38..5e11065a50c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -21,6 +21,8 @@ "party_name", "book_advance_payments_in_separate_party_account", "reconcile_on_advance_payment_date", + "apply_tds", + "tax_withholding_category", "column_break_11", "bank_account", "party_bank_account", @@ -60,10 +62,6 @@ "taxes_and_charges_section", "purchase_taxes_and_charges_template", "sales_taxes_and_charges_template", - "column_break_55", - "apply_tax_withholding_amount", - "tax_withholding_category", - "section_break_56", "taxes", "section_break_60", "base_total_taxes_and_charges", @@ -71,6 +69,11 @@ "total_taxes_and_charges", "deductions_or_loss_section", "deductions", + "section_tax_withholding_entry", + "tax_withholding_group", + "ignore_tax_withholding_threshold", + "override_tax_withholding_entries", + "tax_withholding_entries", "transaction_references", "reference_no", "column_break_23", @@ -578,14 +581,15 @@ "label": "Custom Remarks" }, { - "depends_on": "eval:doc.apply_tax_withholding_amount", + "depends_on": "eval:doc.apply_tds", "fieldname": "tax_withholding_category", "fieldtype": "Link", "label": "Tax Withholding Category", - "mandatory_depends_on": "eval:doc.apply_tax_withholding_amount", + "mandatory_depends_on": "eval:doc.apply_tds", "options": "Tax Withholding Category" }, { +<<<<<<< HEAD "default": "0", "depends_on": "eval:doc.party_type == 'Supplier'", "fieldname": "apply_tax_withholding_amount", @@ -594,6 +598,8 @@ }, { "collapsible": 1, +======= +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) "fieldname": "taxes_and_charges_section", "fieldtype": "Section Break", "label": "Taxes and Charges" @@ -648,15 +654,6 @@ "options": "Company:company:default_currency", "read_only": 1 }, - { - "fieldname": "column_break_55", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_56", - "fieldtype": "Section Break", - "hide_border": 1 - }, { "depends_on": "eval:doc.received_amount && doc.payment_type != 'Internal Transfer'", "fieldname": "received_amount_after_tax", @@ -753,6 +750,46 @@ "options": "No\nYes", "print_hide": 1, "search_index": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.party_type == 'Supplier'", + "fieldname": "apply_tds", + "fieldtype": "Check", + "label": "Consider for Tax Withholding" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0", + "depends_on": "eval: doc.apply_tds", + "fieldname": "section_tax_withholding_entry", + "fieldtype": "Section Break", + "label": "Tax Withholding Entry" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" + }, + { + "default": "0", + "fieldname": "ignore_tax_withholding_threshold", + "fieldtype": "Check", + "label": "Ignore Tax Withholding Threshold" + }, + { + "fieldname": "tax_withholding_entries", + "fieldtype": "Table", + "label": "Tax Withholding Entries", + "options": "Tax Withholding Entry", + "read_only_depends_on": "eval: !doc.override_tax_withholding_entries" + }, + { + "default": "0", + "fieldname": "override_tax_withholding_entries", + "fieldtype": "Check", + "label": "Edit Tax Withholding Entries" } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 4737e22e91a..03fb098a6ac 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -30,9 +30,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, ) -from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( - get_party_tax_withholding_details, -) +from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PaymentTaxWithholding from erpnext.accounts.general_ledger import ( make_gl_entries, make_reverse_gl_entries, @@ -80,9 +78,10 @@ class PaymentEntry(AccountsController): from erpnext.accounts.doctype.payment_entry_reference.payment_entry_reference import ( PaymentEntryReference, ) + from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry amended_from: DF.Link | None - apply_tax_withholding_amount: DF.Check + apply_tds: DF.Check auto_repeat: DF.Link | None bank: DF.ReadOnly | None bank_account: DF.Link | None @@ -103,11 +102,13 @@ class PaymentEntry(AccountsController): custom_remarks: DF.Check deductions: DF.Table[PaymentEntryDeduction] difference_amount: DF.Currency + ignore_tax_withholding_threshold: DF.Check in_words: DF.SmallText | None is_opening: DF.Literal["No", "Yes"] letter_head: DF.Link | None mode_of_payment: DF.Link | None naming_series: DF.Literal["ACC-PAY-.YYYY.-"] + override_tax_withholding_entries: DF.Check paid_amount: DF.Currency paid_amount_after_tax: DF.Currency paid_from: DF.Link @@ -139,6 +140,8 @@ class PaymentEntry(AccountsController): status: DF.Literal["", "Draft", "Submitted", "Cancelled"] target_exchange_rate: DF.Float tax_withholding_category: DF.Link | None + tax_withholding_entries: DF.Table[TaxWithholdingEntry] + tax_withholding_group: DF.Link | None taxes: DF.Table[AdvanceTaxesandCharges] title: DF.Data | None total_allocated_amount: DF.Currency @@ -189,7 +192,7 @@ class PaymentEntry(AccountsController): self.validate_allocated_amount() self.validate_paid_invoices() self.ensure_supplier_is_not_blocked() - self.set_tax_withholding() + PaymentTaxWithholding(self).on_validate() self.set_status() self.set_total_in_words() @@ -199,6 +202,7 @@ class PaymentEntry(AccountsController): def on_submit(self): if self.difference_amount: frappe.throw(_("Difference Amount must be zero")) + PaymentTaxWithholding(self).on_submit() self.update_payment_requests() self.update_payment_schedule() self.make_gl_entries() @@ -300,8 +304,10 @@ class PaymentEntry(AccountsController): "Unreconcile Payment", "Unreconcile Payment Entries", "Advance Payment Ledger Entry", + "Tax Withholding Entry", ) super().on_cancel() + PaymentTaxWithholding(self).on_cancel() self.update_payment_requests(cancel=True) self.update_payment_schedule(cancel=1) self.make_gl_entries(cancel=1) @@ -937,93 +943,6 @@ class PaymentEntry(AccountsController): self.base_in_words = money_in_words(base_amount, self.company_currency) self.in_words = money_in_words(amount, currency) - def set_tax_withholding(self): - if self.party_type != "Supplier": - return - - if not self.apply_tax_withholding_amount: - return - - net_total = self.calculate_tax_withholding_net_total() - - # Adding args as purchase invoice to get TDS amount - args = frappe._dict( - { - "company": self.company, - "doctype": "Payment Entry", - "supplier": self.party, - "posting_date": self.posting_date, - "net_total": net_total, - } - ) - - tax_withholding_details = get_party_tax_withholding_details(args, self.tax_withholding_category) - - if not tax_withholding_details: - return - - tax_withholding_details.update( - {"cost_center": self.cost_center or erpnext.get_default_cost_center(self.company)} - ) - - accounts = [] - for d in self.taxes: - if d.account_head == tax_withholding_details.get("account_head"): - # Preserve user updated included in paid amount - if d.included_in_paid_amount: - tax_withholding_details.update({"included_in_paid_amount": d.included_in_paid_amount}) - - d.update(tax_withholding_details) - accounts.append(d.account_head) - - if not accounts or tax_withholding_details.get("account_head") not in accounts: - self.append("taxes", tax_withholding_details) - - to_remove = [ - d - for d in self.taxes - if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head") - ] - - for d in to_remove: - self.remove(d) - - def calculate_tax_withholding_net_total(self): - net_total = 0 - order_details = self.get_order_wise_tax_withholding_net_total() - - for d in self.references: - tax_withholding_net_total = order_details.get(d.reference_name) - if not tax_withholding_net_total: - continue - - net_taxable_outstanding = max( - 0, d.outstanding_amount - (d.total_amount - tax_withholding_net_total) - ) - - net_total += min(net_taxable_outstanding, d.allocated_amount) - - net_total += self.unallocated_amount - - return net_total - - def get_order_wise_tax_withholding_net_total(self): - if self.party_type == "Supplier": - doctype = "Purchase Order" - else: - doctype = "Sales Order" - - docnames = [d.reference_name for d in self.references if d.reference_doctype == doctype] - - return frappe._dict( - frappe.db.get_all( - doctype, - filters={"name": ["in", docnames]}, - fields=["name", "base_tax_withholding_net_total"], - as_list=True, - ) - ) - def apply_taxes(self): self.initialize_taxes() self.determine_exclusive_rate() diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 42ed2130890..59a01bc84ef 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -59,14 +59,15 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-05 16:07:47.307971", + "modified": "2025-08-13 06:52:46.130142", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 0cda42a6ca2..0dfb32a32f4 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -223,7 +223,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. }); } - this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1); erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm); } @@ -363,10 +362,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. }, function () { me.apply_pricing_rule(); - me.frm.doc.apply_tds = me.frm.supplier_tds ? 1 : 0; - me.frm.doc.tax_withholding_category = me.frm.supplier_tds; - me.frm.set_df_property("apply_tds", "read_only", me.frm.supplier_tds ? 0 : 1); - me.frm.set_df_property("tax_withholding_category", "hidden", me.frm.supplier_tds ? 0 : 1); + me.frm.doc.apply_tds = + me.frm.tax_withholding_category || me.frm.tax_withholding_group ? 1 : 0; + me.frm.clear_table("tax_withholding_entries"); // while duplicating, don't change payment terms if (me.frm.doc.__run_link_triggers === false) { @@ -379,26 +377,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. apply_tds(frm) { var me = this; - me.frm.set_value("tax_withheld_vouchers", []); - if (!me.frm.doc.apply_tds) { - me.frm.set_value("tax_withholding_category", ""); - me.frm.set_df_property("tax_withholding_category", "hidden", 1); - } else { - me.frm.set_value("tax_withholding_category", me.frm.supplier_tds); - me.frm.set_df_property("tax_withholding_category", "hidden", 0); - } - } - - tax_withholding_category(frm) { - var me = this; - let filtered_taxes = (me.frm.doc.taxes || []).filter((row) => !row.is_tax_withholding_account); - me.frm.clear_table("taxes"); - - filtered_taxes.forEach((row) => { - me.frm.add_child("taxes", row); - }); - - me.frm.refresh_field("taxes"); + me.frm.clear_table("tax_withholding_entries"); } credit_to() { @@ -702,10 +681,7 @@ frappe.ui.form.on("Purchase Invoice", { onload: function (frm) { if (frm.doc.__onload && frm.doc.supplier) { if (frm.is_new()) { - frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0; - } - if (!frm.doc.__onload.supplier_tds) { - frm.set_df_property("apply_tds", "read_only", 1); + frm.doc.apply_tds = frm.doc.__onload.apply_tds ? 1 : 0; } } @@ -714,7 +690,7 @@ frappe.ui.form.on("Purchase Invoice", { }); if (frm.is_new()) { - frm.clear_table("tax_withheld_vouchers"); + frm.clear_table("tax_withholding_entries"); } }, @@ -741,6 +717,7 @@ frappe.ui.form.on("Purchase Invoice", { company: function (frm) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + frm.clear_table("tax_withholding_entries"); if (frm.doc.company) { frappe.call({ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index d2efe60e459..bd86928766c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -27,7 +27,6 @@ "update_billed_amount_in_purchase_order", "update_billed_amount_in_purchase_receipt", "apply_tds", - "tax_withholding_category", "amended_from", "supplier_invoice_details", "bill_no", @@ -68,8 +67,6 @@ "column_break_28", "total", "net_total", - "tax_withholding_net_total", - "base_tax_withholding_net_total", "taxes_section", "tax_category", "taxes_and_charges", @@ -102,14 +99,17 @@ "total_advance", "outstanding_amount", "disable_rounded_total", + "section_tax_withholding_entry", + "tax_withholding_group", + "ignore_tax_withholding_threshold", + "override_tax_withholding_entries", + "tax_withholding_entries", "section_break_44", "apply_discount_on", "base_discount_amount", "column_break_46", "additional_discount_percentage", "discount_amount", - "tax_withheld_vouchers_section", - "tax_withheld_vouchers", "sec_tax_breakup", "other_charges_calculation", "item_wise_tax_details", @@ -130,7 +130,6 @@ "only_include_allocated_payments", "get_advances", "advances", - "advance_tax", "write_off", "write_off_amount", "base_write_off_amount", @@ -286,7 +285,7 @@ "default": "0", "fieldname": "apply_tds", "fieldtype": "Check", - "label": "Apply Tax Withholding Amount", + "label": "Consider for Tax Withholding", "print_hide": 1 }, { @@ -1358,14 +1357,6 @@ "fieldname": "dimension_col_break", "fieldtype": "Column Break" }, - { - "fieldname": "tax_withholding_category", - "fieldtype": "Link", - "hidden": 1, - "label": "Tax Withholding Category", - "options": "Tax Withholding Category", - "print_hide": 1 - }, { "fieldname": "billing_address", "fieldtype": "Link", @@ -1455,14 +1446,6 @@ "fieldname": "column_break_147", "fieldtype": "Column Break" }, - { - "fieldname": "advance_tax", - "fieldtype": "Table", - "hidden": 1, - "label": "Advance Tax", - "options": "Advance Tax", - "read_only": 1 - }, { "fieldname": "subscription", "fieldtype": "Link", @@ -1477,42 +1460,6 @@ "label": "Is Old Subcontracting Flow", "read_only": 1 }, - { - "default": "0", - "depends_on": "apply_tds", - "fieldname": "tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Tax Withholding Net Total", - "no_copy": 1, - "options": "currency", - "read_only": 1 - }, - { - "depends_on": "apply_tds", - "fieldname": "base_tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Base Tax Withholding Net Total", - "no_copy": 1, - "options": "Company:company:default_currency", - "print_hide": 1, - "read_only": 1 - }, - { - "collapsible_depends_on": "tax_withheld_vouchers", - "fieldname": "tax_withheld_vouchers_section", - "fieldtype": "Section Break", - "label": "Tax Withheld Vouchers" - }, - { - "fieldname": "tax_withheld_vouchers", - "fieldtype": "Table", - "label": "Tax Withheld Vouchers", - "no_copy": 1, - "options": "Tax Withheld Vouchers", - "read_only": 1 - }, { "fieldname": "payments_tab", "fieldtype": "Tab Break", @@ -1662,7 +1609,7 @@ "fieldtype": "Data", "is_virtual": 1, "label": "Last Scanned Warehouse" - }, + }, { "fieldname": "claimed_landed_cost_amount", "fieldtype": "Currency", @@ -1679,6 +1626,40 @@ "label": "Item Wise Tax Details", "no_copy": 1, "options": "Item Wise Tax Detail" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0", + "depends_on": "eval: doc.apply_tds", + "fieldname": "section_tax_withholding_entry", + "fieldtype": "Section Break", + "label": "Tax Withholding Entry" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group", + "print_hide": 1 + }, + { + "fieldname": "tax_withholding_entries", + "fieldtype": "Table", + "label": "Tax Withholding Entries", + "options": "Tax Withholding Entry", + "read_only_depends_on": "eval: !doc.override_tax_withholding_entries" + }, + { + "default": "0", + "fieldname": "ignore_tax_withholding_threshold", + "fieldtype": "Check", + "label": "Ignore Tax Withholding Threshold" + }, + { + "default": "0", + "fieldname": "override_tax_withholding_entries", + "fieldtype": "Check", + "label": "Edit Tax Withholding Entries" } ], "grid_page_length": 50, @@ -1686,7 +1667,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2025-08-04 19:19:11.380664", + "modified": "2025-12-15 06:41:38.237728", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index bdd936a520b..30d8f1db73e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -24,9 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( - get_party_tax_withholding_details, -) +from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import PurchaseTaxWithholding from erpnext.accounts.general_ledger import ( get_round_off_account_and_cost_center, make_gl_entries, @@ -61,7 +59,6 @@ class PurchaseInvoice(BuyingController): if TYPE_CHECKING: from frappe.types import DF - from erpnext.accounts.doctype.advance_tax.advance_tax import AdvanceTax from erpnext.accounts.doctype.item_wise_tax_detail.item_wise_tax_detail import ItemWiseTaxDetail from erpnext.accounts.doctype.payment_schedule.payment_schedule import PaymentSchedule from erpnext.accounts.doctype.pricing_rule_detail.pricing_rule_detail import PricingRuleDetail @@ -72,14 +69,13 @@ class PurchaseInvoice(BuyingController): from erpnext.accounts.doctype.purchase_taxes_and_charges.purchase_taxes_and_charges import ( PurchaseTaxesandCharges, ) - from erpnext.accounts.doctype.tax_withheld_vouchers.tax_withheld_vouchers import TaxWithheldVouchers + from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry from erpnext.buying.doctype.purchase_receipt_item_supplied.purchase_receipt_item_supplied import ( PurchaseReceiptItemSupplied, ) additional_discount_percentage: DF.Float address_display: DF.TextEditor | None - advance_tax: DF.Table[AdvanceTax] advances: DF.Table[PurchaseInvoiceAdvance] against_expense_account: DF.SmallText | None allocate_advances_automatically: DF.Check @@ -94,7 +90,6 @@ class PurchaseInvoice(BuyingController): base_paid_amount: DF.Currency base_rounded_total: DF.Currency base_rounding_adjustment: DF.Currency - base_tax_withholding_net_total: DF.Currency base_taxes_and_charges_added: DF.Currency base_taxes_and_charges_deducted: DF.Currency base_total: DF.Currency @@ -128,6 +123,7 @@ class PurchaseInvoice(BuyingController): hold_comment: DF.SmallText | None ignore_default_payment_terms_template: DF.Check ignore_pricing_rule: DF.Check + ignore_tax_withholding_threshold: DF.Check in_words: DF.Data | None incoterm: DF.Link | None inter_company_invoice_reference: DF.Link | None @@ -149,6 +145,7 @@ class PurchaseInvoice(BuyingController): only_include_allocated_payments: DF.Check other_charges_calculation: DF.TextEditor | None outstanding_amount: DF.Currency + override_tax_withholding_entries: DF.Check paid_amount: DF.Currency party_account_currency: DF.Link | None payment_schedule: DF.Table[PaymentSchedule] @@ -198,9 +195,8 @@ class PurchaseInvoice(BuyingController): supplier_warehouse: DF.Link | None tax_category: DF.Link | None tax_id: DF.ReadOnly | None - tax_withheld_vouchers: DF.Table[TaxWithheldVouchers] - tax_withholding_category: DF.Link | None - tax_withholding_net_total: DF.Currency + tax_withholding_entries: DF.Table[TaxWithholdingEntry] + tax_withholding_group: DF.Link | None taxes: DF.Table[PurchaseTaxesandCharges] taxes_and_charges: DF.Link | None taxes_and_charges_added: DF.Currency @@ -245,11 +241,14 @@ class PurchaseInvoice(BuyingController): def onload(self): super().onload() - supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") - self.set_onload("supplier_tds", supplier_tds) + if self.supplier: + tax_withholding_category, tax_withholding_group = frappe.get_cached_value( + "Supplier", self.supplier, ["tax_withholding_category", "tax_withholding_group"] + ) + self.set_onload("apply_tds", tax_withholding_category or tax_withholding_group) if self.is_new(): - self.set("tax_withheld_vouchers", []) + self.set("tax_withholding_entries", []) def before_save(self): if not self.on_hold: @@ -300,6 +299,7 @@ class PurchaseInvoice(BuyingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse") self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") + PurchaseTaxWithholding(self).on_validate() self.set_percentage_received() def set_percentage_received(self): @@ -352,11 +352,13 @@ class PurchaseInvoice(BuyingController): template_name=self.payment_terms_template, ) - tds_category = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") - if tds_category and not for_validate: - self.apply_tds = 1 - self.tax_withholding_category = tds_category - self.set_onload("supplier_tds", tds_category) + if self.supplier: + tax_withholding_category, tax_withholding_group = frappe.get_cached_value( + "Supplier", self.supplier, ["tax_withholding_category", "tax_withholding_group"] + ) + if not for_validate: + if tax_withholding_category or tax_withholding_group: + self.apply_tds = 1 super().set_missing_values(for_validate) @@ -747,6 +749,7 @@ class PurchaseInvoice(BuyingController): def on_submit(self): super().on_submit() + PurchaseTaxWithholding(self).on_submit() self.check_prev_docstatus() @@ -788,7 +791,6 @@ class PurchaseInvoice(BuyingController): self.update_project() update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) - self.update_advance_tax_references() self.process_common_party_accounting() @@ -1672,6 +1674,7 @@ class PurchaseInvoice(BuyingController): check_if_return_invoice_linked_with_payment_entry(self) super().on_cancel() + PurchaseTaxWithholding(self).on_cancel() self.check_on_hold_or_closed_status() @@ -1718,10 +1721,9 @@ class PurchaseInvoice(BuyingController): "Unreconcile Payment", "Unreconcile Payment Entries", "Payment Ledger Entry", - "Tax Withheld Vouchers", "Serial and Batch Bundle", + "Tax Withholding Entry", ) - self.update_advance_tax_references(cancel=1) def update_project(self): projects = frappe._dict() @@ -1844,102 +1846,6 @@ class PurchaseInvoice(BuyingController): self.db_set("on_hold", 0) self.db_set("release_date", None) - def set_tax_withholding(self): - self.set("advance_tax", []) - self.set("tax_withheld_vouchers", []) - - if not self.apply_tds: - return - - if self.apply_tds and not self.get("tax_withholding_category"): - self.tax_withholding_category = frappe.db.get_value( - "Supplier", self.supplier, "tax_withholding_category" - ) - - if not self.tax_withholding_category: - return - - tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details( - self, self.tax_withholding_category - ) - - # Adjust TDS paid on advances - self.allocate_advance_tds(tax_withholding_details, advance_taxes) - - if not tax_withholding_details: - return - - accounts = [] - for d in self.taxes: - if d.account_head == tax_withholding_details.get("account_head"): - d.update(tax_withholding_details) - - accounts.append(d.account_head) - - if not accounts or tax_withholding_details.get("account_head") not in accounts: - self.append("taxes", tax_withholding_details) - - to_remove = [ - d - for d in self.taxes - if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head") - ] - - for d in to_remove: - self.remove(d) - - ## Add pending vouchers on which tax was withheld - for row in voucher_wise_amount: - self.append( - "tax_withheld_vouchers", - { - "voucher_name": row.voucher_name, - "voucher_type": row.voucher_type, - "taxable_amount": row.taxable_amount, - }, - ) - - # calculate totals again after applying TDS - self.calculate_taxes_and_totals() - - def allocate_advance_tds(self, tax_withholding_details, advance_taxes): - for tax in advance_taxes: - allocated_amount = 0 - pending_amount = flt(tax.tax_amount - tax.allocated_amount) - if flt(tax_withholding_details.get("tax_amount")) >= pending_amount: - tax_withholding_details["tax_amount"] -= pending_amount - allocated_amount = pending_amount - elif ( - flt(tax_withholding_details.get("tax_amount")) - and flt(tax_withholding_details.get("tax_amount")) < pending_amount - ): - allocated_amount = tax_withholding_details["tax_amount"] - tax_withholding_details["tax_amount"] = 0 - - self.append( - "advance_tax", - { - "reference_type": "Payment Entry", - "reference_name": tax.parent, - "reference_detail": tax.name, - "account_head": tax.account_head, - "allocated_amount": allocated_amount, - }, - ) - - def update_advance_tax_references(self, cancel=0): - for tax in self.get("advance_tax"): - at = frappe.qb.DocType("Advance Taxes and Charges").as_("at") - - if cancel: - frappe.qb.update(at).set( - at.allocated_amount, at.allocated_amount - tax.allocated_amount - ).where(at.name == tax.reference_detail).run() - else: - frappe.qb.update(at).set( - at.allocated_amount, at.allocated_amount + tax.allocated_amount - ).where(at.name == tax.reference_detail).run() - def set_status(self, update=False, status=None, update_modified=True): if self.is_new(): if self.get("amended_from"): diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index c6324c10373..3bae12b9e1d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1538,7 +1538,7 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): # Create Payment Entry Against the order payment_entry = get_payment_entry(dt="Purchase Order", dn=po.name) payment_entry.paid_from = "Cash - _TC" - payment_entry.apply_tax_withholding_amount = 1 + payment_entry.apply_tds = 1 payment_entry.tax_withholding_category = tax_withholding_category payment_entry.save() payment_entry.submit() @@ -1591,12 +1591,26 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): self.assertEqual(expected_gle[i][1], gle.amount) payment_entry.load_from_db() - self.assertEqual(payment_entry.taxes[0].allocated_amount, 3000) + tax_allocated = sum( + [ + entry.withholding_amount + for entry in payment_entry.get("tax_withholding_entries", []) + if entry.taxable_name + ] + ) + self.assertEqual(tax_allocated, 3000) purchase_invoice.cancel() payment_entry.load_from_db() - self.assertEqual(payment_entry.taxes[0].allocated_amount, 0) + tax_allocated = sum( + [ + entry.withholding_amount + for entry in payment_entry.get("tax_withholding_entries", []) + if entry.taxable_name + ] + ) + self.assertEqual(tax_allocated, 0) def test_purchase_gl_with_tax_withholding_tax(self): company = "_Test Company" @@ -1631,7 +1645,6 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin): do_not_submit=1, ) pi.apply_tds = 1 - pi.tax_withholding_category = tax_withholding_category pi.save() pi.submit() diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 19990719d9e..3e24af7db4d 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -44,6 +44,7 @@ "rate", "amount", "item_tax_template", + "tax_withholding_category", "col_break4", "base_rate", "base_amount", @@ -893,7 +894,7 @@ "default": "1", "fieldname": "apply_tds", "fieldtype": "Check", - "label": "Apply TDS" + "label": "Consider for Tax Withholding" }, { "depends_on": "eval:parent.update_stock == 1 && (doc.use_serial_batch_fields === 0 || doc.docstatus === 1)", @@ -979,6 +980,13 @@ "fieldtype": "Currency", "label": "Distributed Discount Amount", "options": "currency" + }, + { + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "options": "Tax Withholding Category", + "print_hide": 1 } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py index 96db9d66f05..0256e6fbd23 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py @@ -90,6 +90,7 @@ class PurchaseInvoiceItem(Document): stock_qty: DF.Float stock_uom: DF.Link | None stock_uom_rate: DF.Currency + tax_withholding_category: DF.Link | None total_weight: DF.Float uom: DF.Link use_serial_batch_fields: DF.Check diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json index fab5b6f6a21..44c3d2719e6 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json @@ -34,7 +34,8 @@ "base_net_amount", "base_tax_amount", "base_total", - "base_tax_amount_after_discount_amount" + "base_tax_amount_after_discount_amount", + "dont_recompute_tax" ], "fields": [ { @@ -205,11 +206,11 @@ "fieldtype": "Column Break" }, { - "allow_on_submit": 1, - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "allow_on_submit": 1, + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" }, { "default": "0", @@ -262,13 +263,22 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "default": "0", + "fieldname": "dont_recompute_tax", + "fieldtype": "Check", + "hidden": 1, + "label": "Don't Recompute Tax", + "print_hide": 1, + "read_only": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2025-07-24 15:08:44.433022", + "modified": "2025-11-24 18:22:56.886010", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Taxes and Charges", diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py index 4a198e001bc..4e054593455 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.py @@ -32,6 +32,7 @@ class PurchaseTaxesandCharges(Document): ] cost_center: DF.Link | None description: DF.SmallText + dont_recompute_tax: DF.Check included_in_paid_amount: DF.Check included_in_print_rate: DF.Check is_tax_withholding_account: DF.Check @@ -39,6 +40,7 @@ class PurchaseTaxesandCharges(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + project: DF.Link | None rate: DF.Float row_id: DF.Data | None set_by_item_tax_template: DF.Check diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 2c86981a8a4..b88a47ecf43 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -24,6 +24,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( company() { super.company(); erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + this.frm.clear_table("tax_withholding_entries"); } onload() { var me = this; @@ -381,6 +382,9 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( ), }, function () { + me.frm.doc.apply_tds = + me.frm.tax_withholding_category || me.frm.tax_withholding_group ? 1 : 0; + me.frm.clear_table("tax_withholding_entries"); me.apply_pricing_rule(); } ); @@ -597,6 +601,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( this.calculate_taxes_and_totals(); } + + apply_tds(frm) { + this.frm.clear_table("tax_withholding_entries"); + } }; // for backward compatibility: combine new and previous states @@ -817,6 +825,16 @@ frappe.ui.form.on("Sales Invoice", { }, onload: function (frm) { frm.redemption_conversion_factor = null; + + if (frm.doc.__onload && frm.doc.customer) { + if (frm.is_new()) { + frm.doc.apply_tds = frm.doc.__onload.apply_tds ? 1 : 0; + } + } + + if (frm.is_new()) { + frm.clear_table("tax_withholding_entries"); + } }, update_stock: function (frm, dt, dn) { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index a889ca3c2ed..ebf2d2f4b00 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -28,6 +28,7 @@ "update_billed_amount_in_sales_order", "update_billed_amount_in_delivery_note", "is_debit_note", + "apply_tds", "amended_from", "is_created_using_pos", "pos_closing_entry", @@ -90,6 +91,11 @@ "total_advance", "outstanding_amount", "disable_rounded_total", + "section_tax_withholding_entry", + "tax_withholding_group", + "ignore_tax_withholding_threshold", + "override_tax_withholding_entries", + "tax_withholding_entries", "section_break_49", "apply_discount_on", "base_discount_amount", @@ -2247,6 +2253,46 @@ "label": "Item Wise Tax Details", "no_copy": 1, "options": "Item Wise Tax Detail" + }, + { + "default": "0", + "fieldname": "apply_tds", + "fieldtype": "Check", + "label": "Consider for Tax Withholding", + "print_hide": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.apply_tds && doc.docstatus == 0", + "depends_on": "eval: doc.apply_tds", + "fieldname": "section_tax_withholding_entry", + "fieldtype": "Section Break", + "label": "Tax Withholding Entry" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" + }, + { + "fieldname": "tax_withholding_entries", + "fieldtype": "Table", + "label": "Tax Withholding Entries", + "options": "Tax Withholding Entry", + "read_only_depends_on": "eval: !doc.override_tax_withholding_entries" + }, + { + "default": "0", + "fieldname": "ignore_tax_withholding_threshold", + "fieldtype": "Check", + "label": "Ignore Tax Withholding Threshold" + }, + { + "default": "0", + "fieldname": "override_tax_withholding_entries", + "fieldtype": "Check", + "label": "Edit Tax Withholding Entries" } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index fdea1b2d29e..3608c436e67 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -26,9 +26,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, ) -from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( - get_party_tax_withholding_details, -) +from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import get_due_date, get_party_account, get_party_details from erpnext.accounts.utils import ( @@ -77,6 +75,7 @@ class SalesInvoice(SellingController): from erpnext.accounts.doctype.sales_taxes_and_charges.sales_taxes_and_charges import ( SalesTaxesandCharges, ) + from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import TaxWithholdingEntry from erpnext.selling.doctype.sales_team.sales_team import SalesTeam from erpnext.stock.doctype.packed_item.packed_item import PackedItem @@ -90,6 +89,7 @@ class SalesInvoice(SellingController): amended_from: DF.Link | None amount_eligible_for_commission: DF.Currency apply_discount_on: DF.Literal["", "Grand Total", "Net Total"] + apply_tds: DF.Check auto_repeat: DF.Link | None base_change_amount: DF.Currency base_discount_amount: DF.Currency @@ -135,6 +135,7 @@ class SalesInvoice(SellingController): has_subcontracted: DF.Check ignore_default_payment_terms_template: DF.Check ignore_pricing_rule: DF.Check + ignore_tax_withholding_threshold: DF.Check in_words: DF.SmallText | None incoterm: DF.Link | None inter_company_invoice_reference: DF.Link | None @@ -162,6 +163,7 @@ class SalesInvoice(SellingController): only_include_allocated_payments: DF.Check other_charges_calculation: DF.TextEditor | None outstanding_amount: DF.Currency + override_tax_withholding_entries: DF.Check packed_items: DF.Table[PackedItem] paid_amount: DF.Currency party_account_currency: DF.Link | None @@ -214,6 +216,8 @@ class SalesInvoice(SellingController): subscription: DF.Link | None tax_category: DF.Link | None tax_id: DF.Data | None + tax_withholding_entries: DF.Table[TaxWithholdingEntry] + tax_withholding_group: DF.Link | None taxes: DF.Table[SalesTaxesandCharges] taxes_and_charges: DF.Link | None tc_name: DF.Link | None @@ -282,6 +286,7 @@ class SalesInvoice(SellingController): self.indicator_color = "green" self.indicator_title = _("Paid") +<<<<<<< HEAD def before_print(self, settings=None): from frappe.contacts.doctype.address.address import get_address_display_list @@ -334,6 +339,15 @@ class SalesInvoice(SellingController): }, user=frappe.session.user, ) +======= + def onload(self): + super().onload() + if self.customer: + tax_withholding_category, tax_withholding_group = frappe.get_cached_value( + "Customer", self.customer, ["tax_withholding_category", "tax_withholding_group"] + ) + self.set_onload("apply_tds", tax_withholding_category or tax_withholding_group) +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) def validate(self): self.validate_auto_set_posting_time() @@ -344,7 +358,7 @@ class SalesInvoice(SellingController): if not (self.is_pos or self.is_debit_note): self.so_dn_required() - self.set_tax_withholding() + SalesTaxWithholding(self).on_validate() self.validate_proj_cust() self.validate_pos_return() @@ -466,38 +480,6 @@ class SalesInvoice(SellingController): for item in self.get("items"): validate_account_head(item.idx, item.income_account, self.company, _("Income")) - def set_tax_withholding(self): - if self.get("is_opening") == "Yes": - return - - tax_withholding_details = get_party_tax_withholding_details(self) - - if not tax_withholding_details: - return - - accounts = [] - tax_withholding_account = tax_withholding_details.get("account_head") - - for d in self.taxes: - if d.account_head == tax_withholding_account: - d.update(tax_withholding_details) - accounts.append(d.account_head) - - if not accounts or tax_withholding_account not in accounts: - self.append("taxes", tax_withholding_details) - - to_remove = [ - d - for d in self.taxes - if not d.tax_amount and d.charge_type == "Actual" and d.account_head == tax_withholding_account - ] - - for d in to_remove: - self.remove(d) - - # calculate totals again after applying TDS - self.calculate_taxes_and_totals() - def before_save(self): self.set_account_for_mode_of_payment() self.set_paid_amount() @@ -519,6 +501,8 @@ class SalesInvoice(SellingController): # NOTE status updating bypassed for is_return self.status_updater = [] + SalesTaxWithholding(self).on_submit() + self.update_status_updater_args() self.update_prevdoc_status() @@ -658,6 +642,7 @@ class SalesInvoice(SellingController): # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO + SalesTaxWithholding(self).on_cancel() if self.update_stock == 1: self.update_stock_ledger() @@ -699,6 +684,7 @@ class SalesInvoice(SellingController): "Unreconcile Payment Entries", "Payment Ledger Entry", "Serial and Batch Bundle", + "Tax Withholding Entry", ) self.delete_auto_created_batches() diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index e5283ea8103..7608ea87de8 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -43,12 +43,14 @@ "rate", "amount", "item_tax_template", + "tax_withholding_category", "col_break3", "base_rate", "base_amount", "pricing_rules", "stock_uom_rate", "is_free_item", + "apply_tds", "grant_commission", "section_break_21", "net_rate", @@ -986,6 +988,21 @@ "hidden": 1, "label": "SCIO Detail", "read_only": 1 + }, + { + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "options": "Tax Withholding Category", + "print_hide": 1 + }, + { + "default": "1", + "fieldname": "apply_tds", + "fieldtype": "Check", + "label": "Consider for Tax Withholding", + "print_hide": 1, + "read_only": 1 } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 3d3e92f3fd4..2b444d760b7 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -24,6 +24,7 @@ class SalesInvoiceItem(Document): actual_qty: DF.Float allow_zero_valuation_rate: DF.Check amount: DF.Currency + apply_tds: DF.Check asset: DF.Link | None barcode: DF.Data | None base_amount: DF.Currency @@ -95,6 +96,7 @@ class SalesInvoiceItem(Document): stock_uom: DF.Link | None stock_uom_rate: DF.Currency target_warehouse: DF.Link | None + tax_withholding_category: DF.Link | None total_weight: DF.Float uom: DF.Link use_serial_batch_fields: DF.Check diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json index 429bf759743..f499963d92e 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json @@ -14,6 +14,7 @@ "included_in_print_rate", "included_in_paid_amount", "set_by_item_tax_template", + "is_tax_withholding_account", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -202,7 +203,7 @@ "fieldname": "dont_recompute_tax", "fieldtype": "Check", "hidden": 1, - "label": "Dont Recompute tax", + "label": "Don't Recompute Tax", "print_hide": 1, "read_only": 1 }, @@ -241,6 +242,13 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "default": "0", + "fieldname": "is_tax_withholding_account", + "fieldtype": "Check", + "label": "Is Tax Withholding Account", + "read_only": 1 } ], "idx": 1, diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py index 329511c2651..c9382b036b8 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.py @@ -33,6 +33,8 @@ class SalesTaxesandCharges(Document): dont_recompute_tax: DF.Check included_in_paid_amount: DF.Check included_in_print_rate: DF.Check + is_tax_withholding_account: DF.Check + item_wise_tax_detail: DF.Code | None net_amount: DF.Currency parent: DF.Data parentfield: DF.Data diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index c96a95ec1e7..e2cd4127049 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -411,7 +411,10 @@ class Subscription(Document): invoice.customer = self.party else: invoice.supplier = self.party - if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): + tax_withholding_category, tax_withholding_group = frappe.get_cached_value( + "Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"] + ) + if tax_withholding_category or tax_withholding_group: invoice.apply_tds = 1 # Add currency to invoice diff --git a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json deleted file mode 100644 index 667da42bb8a..00000000000 --- a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "actions": [], - "autoname": "hash", - "creation": "2022-09-13 16:18:59.404842", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "voucher_type", - "voucher_name", - "taxable_amount" - ], - "fields": [ - { - "fieldname": "voucher_type", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Voucher Type" - }, - { - "fieldname": "voucher_name", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Voucher Name" - }, - { - "fieldname": "taxable_amount", - "fieldtype": "Currency", - "in_list_view": 1, - "label": "Taxable Amount", - "options": "Company:company:default_currency" - } - ], - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2025-02-05 16:39:14.863698", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Tax Withheld Vouchers", - "naming_rule": "Random", - "owner": "Administrator", - "permissions": [], - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.js b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.js index 042831c8080..83a2348f792 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.js +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.js @@ -16,4 +16,54 @@ frappe.ui.form.on("Tax Withholding Category", { } }); }, + + refresh: function (frm) { + update_rates_read_only_state(frm); + }, + + disable_cumulative_threshold: function (frm) { + toggle_threshold_settings(frm, "disable_cumulative_threshold"); + if (frm.doc.disable_cumulative_threshold) { + reset_rates_column(frm, "cumulative_threshold"); + } + update_rates_read_only_state(frm); + }, + + disable_transaction_threshold: function (frm) { + toggle_threshold_settings(frm, "disable_transaction_threshold"); + if (frm.doc.disable_transaction_threshold) { + reset_rates_column(frm, "single_threshold"); + } + update_rates_read_only_state(frm); + }, }); + +function toggle_threshold_settings(frm, field_name) { + if (frm.doc[field_name]) { + const other_field = + field_name === "disable_cumulative_threshold" + ? "disable_transaction_threshold" + : "disable_cumulative_threshold"; + frm.set_value(other_field, 0); + } +} + +function update_rates_read_only_state(frm) { + frm.fields_dict["rates"].grid.update_docfield_property( + "cumulative_threshold", + "read_only", + frm.doc.disable_cumulative_threshold + ); + frm.fields_dict["rates"].grid.update_docfield_property( + "single_threshold", + "read_only", + frm.doc.disable_transaction_threshold + ); +} + +function reset_rates_column(frm, field_name) { + $.each(frm.doc.rates || [], function (i, row) { + row[field_name] = 0; + }); + frm.refresh_field("rates"); +} diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.json b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.json index 7463e07db30..cff8fea5858 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.json +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.json @@ -10,10 +10,12 @@ "field_order": [ "category_details_section", "category_name", - "round_off_tax_amount", + "tax_deduction_basis", "column_break_2", - "consider_party_ledger_amount", + "round_off_tax_amount", "tax_on_excess_amount", + "disable_cumulative_threshold", + "disable_transaction_threshold", "section_break_8", "rates", "section_break_7", @@ -61,14 +63,7 @@ }, { "default": "0", - "description": "Only payment entries with apply tax withholding unchecked will be considered for checking cumulative threshold breach", - "fieldname": "consider_party_ledger_amount", - "fieldtype": "Check", - "label": "Consider Entire Party Ledger Amount" - }, - { - "default": "0", - "description": "Tax will be withheld only for amount exceeding the cumulative threshold", + "description": "Tax withheld only for amount exceeding cumulative threshold", "fieldname": "tax_on_excess_amount", "fieldtype": "Check", "label": "Only Deduct Tax On Excess Amount " @@ -79,6 +74,28 @@ "fieldname": "round_off_tax_amount", "fieldtype": "Check", "label": "Round Off Tax Amount" + }, + { + "default": "Net Total", + "fieldname": "tax_deduction_basis", + "fieldtype": "Select", + "label": "Deduct Tax On Basis", + "options": "\nGross Total\nNet Total", + "reqd": 1 + }, + { + "default": "0", + "description": "When checked, only transaction threshold will be applied for transaction individually", + "fieldname": "disable_cumulative_threshold", + "fieldtype": "Check", + "label": "Disable Cumulative Threshold" + }, + { + "default": "0", + "description": "When checked, only cumulative threshold will be applied", + "fieldname": "disable_transaction_threshold", + "fieldtype": "Check", + "label": "Disable Transaction Threshold" } ], "index_web_pages_for_search": 1, 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..6cea90d3401 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -1,14 +1,15 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import defaultdict import frappe -from frappe import _, qb +from frappe import _ from frappe.model.document import Document -from frappe.query_builder import Criterion -from frappe.query_builder.functions import Abs, Sum -from frappe.utils import cint, flt, getdate +from frappe.query_builder.functions import Sum +from frappe.utils import getdate +from erpnext import allow_regional from erpnext.controllers.accounts_controller import validate_account_head @@ -28,30 +29,41 @@ class TaxWithholdingCategory(Document): accounts: DF.Table[TaxWithholdingAccount] category_name: DF.Data | None - consider_party_ledger_amount: DF.Check + disable_cumulative_threshold: DF.Check + disable_transaction_threshold: DF.Check rates: DF.Table[TaxWithholdingRate] round_off_tax_amount: DF.Check + tax_deduction_basis: DF.Literal["", "Gross Total", "Net Total"] tax_on_excess_amount: DF.Check # end: auto-generated types def validate(self): + # TODO: Disable single threshold if tax on excess is enabled self.validate_dates() self.validate_companies_and_accounts() self.validate_thresholds() def validate_dates(self): - last_to_date = None - rates = sorted(self.get("rates"), key=lambda d: getdate(d.from_date)) - - for d in rates: + group_rates = defaultdict(list) + for d in self.get("rates"): if getdate(d.from_date) >= getdate(d.to_date): frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx)) + group_rates[d.tax_withholding_group].append(d) - # validate overlapping of dates - if last_to_date and getdate(d.from_date) < getdate(last_to_date): - frappe.throw(_("Row #{0}: Dates overlapping with other row").format(d.idx)) + # Validate overlapping dates within each group + for group, rates in group_rates.items(): + rates = sorted(rates, key=lambda d: getdate(d.from_date)) + last_to_date = None - last_to_date = d.to_date + for d in rates: + if last_to_date and getdate(d.from_date) < getdate(last_to_date): + frappe.throw( + _("Row #{0}: Dates overlapping with other row in group {1}").format( + d.idx, group or "Default" + ) + ) + + last_to_date = d.to_date def validate_companies_and_accounts(self): existing_accounts = set() @@ -78,74 +90,32 @@ class TaxWithholdingCategory(Document): ).format(d.idx) ) + def get_applicable_tax_row(self, posting_date, tax_withholding_group): + for row in self.rates: + if ( + getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) + and row.tax_withholding_group == tax_withholding_group + ): + return row -def get_party_details(inv): - party_type, party = "", "" + frappe.throw(_("No Tax Withholding data found for the current posting date.")) +<<<<<<< HEAD if inv.doctype == "Sales Invoice": party_type = "Customer" party = inv.customer else: party_type = "Supplier" party = inv.supplier +======= + def get_company_account(self, company): + for row in self.accounts: + if company == row.company: + return row.account +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) - if not party: - frappe.throw(_("Please select {0} first").format(party_type)) - - return party_type, party - - -def get_party_tax_withholding_details(inv, tax_withholding_category=None): - if inv.doctype == "Payment Entry": - inv.tax_withholding_net_total = inv.net_total - inv.base_tax_withholding_net_total = inv.net_total - - pan_no = "" - parties = [] - party_type, party = get_party_details(inv) - has_pan_field = frappe.get_meta(party_type).has_field("pan") - - if not tax_withholding_category: - if has_pan_field: - fields = ["tax_withholding_category", "pan"] - else: - fields = ["tax_withholding_category"] - - tax_withholding_details = frappe.db.get_value(party_type, party, fields, as_dict=1) - - tax_withholding_category = tax_withholding_details.get("tax_withholding_category") - pan_no = tax_withholding_details.get("pan") - - if not tax_withholding_category: - return - - # if tax_withholding_category passed as an argument but not pan_no - if not pan_no and has_pan_field: - pan_no = frappe.db.get_value(party_type, party, "pan") - - # Get others suppliers with the same PAN No - if pan_no: - parties = frappe.get_all(party_type, filters={"pan": pan_no}, pluck="name") - - if not parties: - parties.append(party) - - posting_date = inv.get("posting_date") or inv.get("transaction_date") - tax_details = get_tax_withholding_details(tax_withholding_category, posting_date, inv.company) - - if not tax_details: - frappe.msgprint( - _( - "Skipping Tax Withholding Category {0} as there is no associated account set for Company {1} in it." - ).format(tax_withholding_category, inv.company) - ) - if inv.doctype == "Purchase Invoice": - return {}, [], {} - return {} - - if party_type == "Customer" and not tax_details.cumulative_threshold: - # TCS is only chargeable on sum of invoiced value frappe.throw( +<<<<<<< HEAD _( "Tax Withholding Category {} against Company {} for Customer {} should have Cumulative Threshold value." ).format(tax_withholding_category, inv.company, party) @@ -404,244 +374,155 @@ def get_invoice_vouchers(parties, tax_details, company, party_type="Supplier"): if doctype != "Sales Invoice": filters.update( {"apply_tds": 1, "tax_withholding_category": tax_details.get("tax_withholding_category")} +======= + _("No Tax withholding account set for Company {0} in Tax Withholding Category {1}.").format( + frappe.bold(company), frappe.bold(self.name) + ) +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) ) - invoices_details = frappe.get_all(doctype, filters=filters, fields=field) - for d in invoices_details: - d = frappe._dict( - { - "voucher_name": d.name, - "voucher_type": doctype, - "taxable_amount": d.base_net_total, - "grand_total": d.grand_total, - "posting_date": d.posting_date, - } - ) +class TaxWithholdingDetails: + def __init__( + self, + tax_withholding_categories: list[str], + tax_withholding_group: str, + posting_date: str, + party_type: str, + party: str, + company: str, + ): + self.tax_withholding_categories = tax_withholding_categories + self.tax_withholding_group = tax_withholding_group + self.posting_date = posting_date + self.party_type = party_type + self.party = party + self.company = company - if ldc := [x for x in ldcs if d.posting_date >= x.valid_from and d.posting_date <= x.valid_upto]: - if ldc[0].supplier in parties and ldc[0].rate == 0: - d.update({"taxable_amount": 0}) - - vouchers.append(d.voucher_name) - voucher_wise_amount.append(d) - - journal_entries_details = frappe.db.sql( + def get(self) -> list: """ - SELECT j.name, ja.credit - ja.debit AS amount, ja.reference_type - FROM `tabJournal Entry` j, `tabJournal Entry Account` ja - WHERE - j.name = ja.parent - AND j.docstatus = 1 - AND j.is_opening = 'No' - AND j.posting_date between %s and %s - AND ja.party in %s - AND j.apply_tds = 1 - AND j.tax_withholding_category = %s - AND j.company = %s - """, - ( - tax_details.from_date, - tax_details.to_date, - tuple(parties), - tax_details.get("tax_withholding_category"), - company, - ), - as_dict=1, - ) + Fetches tax withholding categories based on the provided parameters. + """ + category_details = frappe._dict() + if not self.tax_withholding_categories: + return category_details - for d in journal_entries_details: - vouchers.append(d.name) - voucher_wise_amount.append( - frappe._dict( - { - "voucher_name": d.name, - "voucher_type": "Journal Entry", - "taxable_amount": d.amount, - "reference_type": d.reference_type, - } + ldc_details = self.get_ldc_details() + + for category_name in self.tax_withholding_categories: + doc: TaxWithholdingCategory = frappe.get_cached_doc("Tax Withholding Category", category_name) + row = doc.get_applicable_tax_row(self.posting_date, self.tax_withholding_group) + account_head = doc.get_company_account(self.company) + + category_detail = frappe._dict( + name=category_name, + description=doc.category_name, + account_head=account_head, + # rates + tax_rate=row.tax_withholding_rate, + from_date=row.from_date, + to_date=row.to_date, + single_threshold=row.single_threshold, + cumulative_threshold=row.cumulative_threshold, + # settings + tax_deduction_basis=doc.tax_deduction_basis, + round_off_tax_amount=doc.round_off_tax_amount, + tax_on_excess_amount=doc.tax_on_excess_amount, + disable_cumulative_threshold=doc.disable_cumulative_threshold, + disable_transaction_threshold=doc.disable_transaction_threshold, + taxable_amount=0, + ) + + # ldc (only if valid based on posting date) + if ldc_detail := ldc_details.get(category_name): + category_detail.update(ldc_detail) + + category_details[category_name] = category_detail + + return category_details + + def get_ldc_details(self): + """ + Fetches the Lower Deduction Certificate (LDC) details for the given party. + Assumes that only one LDC per category can be valid at a time. + """ + ldc_details = {} + + if self.party_type != "Supplier": + return ldc_details + + # NOTE: This can be a configurable option + # To check if filter by tax_id is needed + tax_id = get_tax_id_for_party(self.party_type, self.party) + + # ldc details + ldc_records = self.get_valid_ldc_records(tax_id) + if not ldc_records: + return ldc_details + + ldc_names = [ldc.name for ldc in ldc_records] + ldc_utilization_map = self.get_ldc_utilization_by_category(ldc_names, tax_id) + + # map + for ldc in ldc_records: + category_name = ldc.tax_withholding_category + + unutilized_amount = ldc.certificate_limit - (ldc_utilization_map.get(ldc.name) or 0) + if not unutilized_amount: + continue + + ldc_details[category_name] = dict( + ldc_certificate=ldc.name, + ldc_unutilized_amount=unutilized_amount, + ldc_rate=ldc.rate, + ) + + return ldc_details + + def get_valid_ldc_records(self, tax_id): + ldc = frappe.qb.DocType("Lower Deduction Certificate") + query = ( + frappe.qb.from_(ldc) + .select( + ldc.name, + ldc.tax_withholding_category, + ldc.rate, + ldc.certificate_limit, + ) + .where( + (ldc.valid_from <= self.posting_date) + & (ldc.valid_upto >= self.posting_date) + & (ldc.company == self.company) + & ldc.tax_withholding_category.isin(self.tax_withholding_categories) ) ) - return vouchers, voucher_wise_amount + query = query.where(ldc.pan_no == tax_id) if tax_id else query.where(ldc.supplier == self.party) + return query.run(as_dict=True) -def get_payment_entry_vouchers(parties, tax_details, company, party_type="Supplier"): - payment_entry_filters = { - "party_type": party_type, - "party": ("in", parties), - "docstatus": 1, - "apply_tax_withholding_amount": 1, - "posting_date": ["between", (tax_details.from_date, tax_details.to_date)], - "tax_withholding_category": tax_details.get("tax_withholding_category"), - "company": company, - } - - return frappe.db.get_all("Payment Entry", filters=payment_entry_filters, pluck="name") - - -def get_advance_vouchers(parties, company=None, from_date=None, to_date=None, party_type="Supplier"): - """ - Use Payment Ledger to fetch unallocated Advance Payments - """ - - if party_type == "Supplier": - return [] - - ple = qb.DocType("Payment Ledger Entry") - - conditions = [] - - conditions.append(ple.amount.lt(0)) - conditions.append(ple.delinked == 0) - conditions.append(ple.party_type == party_type) - conditions.append(ple.party.isin(parties)) - conditions.append(ple.voucher_no == ple.against_voucher_no) - - if company: - conditions.append(ple.company == company) - - if from_date and to_date: - conditions.append(ple.posting_date[from_date:to_date]) - - advances = qb.from_(ple).select(ple.voucher_no).distinct().where(Criterion.all(conditions)).run(as_list=1) - if advances: - advances = [x[0] for x in advances] - - return advances - - -def get_taxes_deducted_on_advances_allocated(inv, tax_details): - tax_info = [] - - if inv.get("advances"): - advances = [d.reference_name for d in inv.get("advances")] - - if advances: - pe = frappe.qb.DocType("Payment Entry").as_("pe") - at = frappe.qb.DocType("Advance Taxes and Charges").as_("at") - - tax_info = ( - frappe.qb.from_(at) - .inner_join(pe) - .on(pe.name == at.parent) - .select(pe.posting_date, at.parent, at.name, at.tax_amount, at.allocated_amount) - .where(pe.tax_withholding_category == tax_details.get("tax_withholding_category")) - .where(at.parent.isin(advances)) - .where(at.account_head == tax_details.account_head) - .run(as_dict=True) - ) - - return tax_info - - -def get_deducted_tax(taxable_vouchers, tax_details): - # check if TDS / TCS account is already charged on taxable vouchers - filters = { - "is_cancelled": 0, - "credit": [">", 0], - "posting_date": ["between", (tax_details.from_date, tax_details.to_date)], - "account": tax_details.account_head, - "voucher_no": ["in", taxable_vouchers], - } - field = "credit" - - entries = frappe.db.get_all("GL Entry", filters, pluck=field) - return sum(entries) - - -def get_advance_tax_across_fiscal_year(tax_deducted_on_advances, tax_details): - """ - Only applies for Taxes deducted on Advance Payments - """ - advance_tax_from_across_fiscal_year = sum( - [adv.tax_amount for adv in tax_deducted_on_advances if adv.posting_date < tax_details.from_date] - ) - return advance_tax_from_across_fiscal_year - - -def get_tds_amount(ldc, parties, inv, tax_details, voucher_wise_amount): - tds_amount = 0 - - pi_grand_total = 0 - pi_base_net_total = 0 - jv_credit_amt = 0 - pe_credit_amt = 0 - - for row in voucher_wise_amount: - if row.voucher_type == "Purchase Invoice": - pi_grand_total += row.get("grand_total", 0) - pi_base_net_total += row.get("taxable_amount", 0) - - if row.voucher_type == "Journal Entry" and row.reference_type != "Purchase Invoice": - jv_credit_amt += row.get("taxable_amount", 0) - - ## for TDS to be deducted on advances - pe_filters = { - "party_type": "Supplier", - "party": ("in", parties), - "docstatus": 1, - "apply_tax_withholding_amount": 1, - "unallocated_amount": (">", 0), - "posting_date": ["between", (tax_details.from_date, tax_details.to_date)], - "tax_withholding_category": tax_details.get("tax_withholding_category"), - "company": inv.company, - } - - consider_party_ledger_amt = cint(tax_details.consider_party_ledger_amount) - - if consider_party_ledger_amt: - pe_filters.pop("apply_tax_withholding_amount", None) - pe_filters.pop("tax_withholding_category", None) - - # Get Amount via payment entry - payment_entries = frappe.db.get_all( - "Payment Entry", - filters=pe_filters, - fields=["name", "unallocated_amount as taxable_amount", "payment_type"], - ) - - for row in payment_entries: - value = row.taxable_amount if row.payment_type == "Pay" else -1 * row.taxable_amount - pe_credit_amt += value - voucher_wise_amount.append( - frappe._dict( - { - "voucher_name": row.name, - "voucher_type": "Payment Entry", - "taxable_amount": value, - } + def get_ldc_utilization_by_category(self, ldc_names, tax_id): + twe = frappe.qb.DocType("Tax Withholding Entry") + query = ( + frappe.qb.from_(twe) + .select(twe.lower_deduction_certificate, Sum(twe.taxable_amount).as_("limit_consumed")) + .where( + (twe.company == self.company) + & (twe.party_type == self.party_type) + & (twe.tax_withholding_category.isin(self.tax_withholding_categories)) + & (twe.lower_deduction_certificate.isin(ldc_names)) + & (twe.docstatus == 1) + & (twe.status.isin(["Settled", "Over Withheld"])) ) + .groupby(twe.lower_deduction_certificate) ) - threshold = tax_details.get("threshold", 0) - cumulative_threshold = tax_details.get("cumulative_threshold", 0) - supp_credit_amt = jv_credit_amt + pe_credit_amt + inv.get("tax_withholding_net_total", 0) - tax_withholding_net_total = inv.get("base_tax_withholding_net_total", 0) + query = query.where(twe.tax_id == tax_id) if tax_id else query.where(twe.party == self.party) - # if consider_party_ledger_amount is checked, then threshold will be based on grand total - amt_for_threshold = pi_grand_total if consider_party_ledger_amt else pi_base_net_total - - cumulative_threshold_breached = ( - cumulative_threshold and (supp_credit_amt + amt_for_threshold) >= cumulative_threshold - ) - - if (threshold and tax_withholding_net_total >= threshold) or (cumulative_threshold_breached): - supp_credit_amt += pi_base_net_total - - if cumulative_threshold_breached and cint(tax_details.tax_on_excess_amount): - supp_credit_amt = pi_base_net_total + tax_withholding_net_total - cumulative_threshold - - if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0): - tds_amount = get_lower_deduction_amount( - supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details - ) - else: - tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0 - - return tds_amount + return frappe._dict(query.run()) +<<<<<<< HEAD def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers): tcs_amount = 0 ple = qb.DocType("Payment Ledger Entry") @@ -774,3 +655,8 @@ def normal_round(number): number = int(number) + decimal_part return number +======= +@allow_regional +def get_tax_id_for_party(party_type, party): + return None +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) 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 4aa0dccc65f..8d49ab4282c 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 @@ -2,7 +2,6 @@ # See license.txt import datetime -import unittest import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields @@ -28,10 +27,240 @@ class TestTaxWithholdingCategory(IntegrationTestCase): def tearDown(self): frappe.db.rollback() + def validate_tax_withholding_entries(self, doctype, docname, expected_entries): + """Validate tax withholding entries for a document""" + entries = frappe.get_all( + "Tax Withholding Entry", + filters={"parenttype": doctype, "parent": docname}, + fields=[ + "tax_withholding_category", + "party_type", + "party", + "tax_rate", + "withholding_amount", + "taxable_amount", + "status", + "taxable_doctype", + "taxable_name", + "withholding_doctype", + "withholding_name", + "under_withheld_reason", + "lower_deduction_certificate", + ], + ) + + self.assertEqual(len(entries), len(expected_entries), "Number of entries mismatch") + + # Sort both actual and expected entries for consistent comparison + def sort_key(entry): + return ( + entry.get("taxable_doctype", ""), + entry.get("taxable_name", ""), + entry.get("withholding_doctype", ""), + entry.get("withholding_name", ""), + entry.get("tax_withholding_category", ""), + entry.get("taxable_amount", 0), + entry.get("withholding_amount", 0), + entry.get("under_withheld_reason", "") or "", + entry.get("lower_deduction_certificate", "") or "", + ) + + sorted_entries = sorted(entries, key=sort_key) + sorted_expected = sorted(expected_entries, key=sort_key) + + # Normalize empty strings and None values for comparison + def normalize_entry(entry): + normalized = entry.copy() + # Convert None to empty string and empty string to None for consistent comparison + for field in ["under_withheld_reason", "lower_deduction_certificate"]: + if field in normalized: + if normalized[field] == "" or normalized[field] is None: + normalized[field] = None + return normalized + + normalized_entries = [normalize_entry(entry) for entry in sorted_entries] + normalized_expected = [normalize_entry(entry) for entry in sorted_expected] + + self.assertEqual( + normalized_entries, normalized_expected, "Tax withholding entries do not match expected values" + ) + + def get_tax_withholding_entry(self, **kwargs): + """ + Create a tax withholding entry with consistent field ordering + """ + entry = { + "tax_withholding_category": kwargs.get("tax_withholding_category"), + "party_type": kwargs.get("party_type"), + "party": kwargs.get("party"), + "tax_rate": kwargs.get("tax_rate") or 0.0, + "withholding_amount": kwargs.get("withholding_amount") or 0.0, + "taxable_amount": kwargs.get("taxable_amount") or 0.0, + "status": kwargs.get("status"), + "taxable_doctype": kwargs.get("taxable_doctype") or "", + "taxable_name": kwargs.get("taxable_name") or "", + "withholding_doctype": kwargs.get("withholding_doctype") or "", + "withholding_name": kwargs.get("withholding_name") or "", + "under_withheld_reason": kwargs.get("under_withheld_reason"), + "lower_deduction_certificate": kwargs.get("lower_deduction_certificate"), + } + return entry + + def setup_party_with_category(self, party_type, party_name, category_name): + """Setup party with tax withholding category""" + frappe.db.set_value( + party_type, + party_name, + "tax_withholding_category", + category_name, + ) + + def validate_tax_deduction(self, invoice, expected_amount): + """Validate invoice tax deduction and grand total""" + actual_amount = sum([d.base_tax_amount for d in invoice.taxes if d.is_tax_withholding_account]) + self.assertEqual( + actual_amount, expected_amount, f"Expected TCS charged: {expected_amount}, got: {actual_amount}" + ) + + def cleanup_invoices(self, invoice_list): + """Clean up invoices in reverse order to avoid dependency issues""" + for invoice in reversed(invoice_list): + invoice.reload() + if invoice.docstatus == 1: + invoice.cancel() + def test_cumulative_threshold_tds(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS" - ) + "Tax withholding entries for cumulative threshold TDS with Tax on excess without single threshold" + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + invoices = [] + + # First invoice - should be under withheld + pi1 = create_purchase_invoice(supplier="Test TDS Supplier") + pi1.submit() + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Under Withheld", + withholding_doctype=None, + withholding_name=None, + under_withheld_reason=None, + ) + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi1.name, expected_entries) + + # Second invoice - should also be under withheld + pi2 = create_purchase_invoice(supplier="Test TDS Supplier") + pi2.submit() + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Under Withheld", + withholding_doctype=None, + withholding_name=None, + under_withheld_reason=None, + ) + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_entries) + + # Third invoice - surpasses cumulative threshold, all should be settled + pi3 = create_purchase_invoice(supplier="Test TDS Supplier") + pi3.submit() + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_amount=1000.0, + tax_rate=10.0, + taxable_amount=10000.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason=None, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_amount=1000.0, + tax_rate=10.0, + taxable_amount=10000.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason=None, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi3.name, + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason=None, + ), + ] + + # Validate invoice totals and tax withholding entries + self.validate_tax_deduction(pi3, 3000) + self.validate_tax_withholding_entries("Purchase Invoice", pi3.name, expected_entries) + invoices.append(pi3) + + # Fourth invoice - TDS deducted on every invoice from now on + pi4 = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000) + pi4.submit() + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + taxable_doctype="Purchase Invoice", + taxable_name=pi4.name, + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + withholding_doctype="Purchase Invoice", + withholding_name=pi4.name, + under_withheld_reason=None, + ) + ] + + # Validate invoice totals and tax withholding entries + self.validate_tax_deduction(pi4, 500) + self.validate_tax_withholding_entries("Purchase Invoice", pi4.name, expected_entries) + invoices.append(pi4) + + def test_cumulative_threshold_tds_with_account_change(self): + "Cumulative threshold TDS without tax_on_excess, with account change in the middle of the year" + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Multi Account TDS Category") invoices = [] # create invoices for lower than single threshold tax rate @@ -41,7 +270,7 @@ class TestTaxWithholdingCategory(IntegrationTestCase): invoices.append(pi) # create another invoice whose total when added to previously created invoice, - # surpasses cumulative threshhold + # surpasses cumulative threshold pi = create_purchase_invoice(supplier="Test TDS Supplier") pi.submit() @@ -50,42 +279,7 @@ class TestTaxWithholdingCategory(IntegrationTestCase): self.assertEqual(pi.grand_total, 7000) invoices.append(pi) - # TDS is already deducted, so from onward system will deduct the TDS on every invoice - pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000) - pi.submit() - - # assert equal tax deduction on total invoice amount until now - self.assertEqual(pi.taxes_and_charges_deducted, 500) - invoices.append(pi) - - # delete invoices to avoid clashing - for d in reversed(invoices): - d.cancel() - - def test_tds_with_account_changed(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier", "tax_withholding_category", "Multi Account TDS Category" - ) - invoices = [] - - # create invoices for lower than single threshold tax rate - for _ in range(2): - pi = create_purchase_invoice(supplier="Test TDS Supplier") - pi.submit() - invoices.append(pi) - - # create another invoice whose total when added to previously created invoice, - # surpasses cumulative threshhold - pi = create_purchase_invoice(supplier="Test TDS Supplier") - pi.submit() - - # assert equal tax deduction on total invoice amount until now - self.assertEqual(pi.taxes_and_charges_deducted, 3000) - self.assertEqual(pi.grand_total, 7000) - invoices.append(pi) - - # account changed - + # Change account in the middle of the year frappe.db.set_value( "Tax Withholding Account", {"parent": "Multi Account TDS Category"}, @@ -101,9 +295,8 @@ class TestTaxWithholdingCategory(IntegrationTestCase): self.assertEqual(pi.taxes_and_charges_deducted, 500) invoices.append(pi) - # delete invoices to avoid clashing - for d in reversed(invoices): - d.cancel() + # Clean up invoices to avoid clashing + self.cleanup_invoices(invoices) def test_single_threshold_tds(self): invoices = [] @@ -143,13 +336,11 @@ class TestTaxWithholdingCategory(IntegrationTestCase): # TDS amount is 1000 because in previous invoices it's already deducted self.assertEqual(pi.taxes_and_charges_deducted, 1000) - # delete invoices to avoid clashing - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) def test_tax_withholding_category_checks(self): invoices = [] - frappe.db.set_value("Supplier", "Test TDS Supplier3", "tax_withholding_category", "New TDS Category") + self.setup_party_with_category("Supplier", "Test TDS Supplier3", "New TDS Category") # First Invoice with no tds check pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=20000, do_not_save=True) @@ -168,14 +359,11 @@ class TestTaxWithholdingCategory(IntegrationTestCase): # Second didn't breach, no TDS should be applied self.assertEqual(pi1.taxes, []) - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) def test_cumulative_threshold_with_party_ledger_amount_on_net_total(self): invoices = [] - frappe.db.set_value( - "Supplier", "Test TDS Supplier3", "tax_withholding_category", "Advance TDS Category" - ) + self.setup_party_with_category("Supplier", "Test TDS Supplier3", "Advance TDS Category") # Invoice with tax and without exceeding single and cumulative thresholds for _ in range(2): @@ -208,12 +396,11 @@ class TestTaxWithholdingCategory(IntegrationTestCase): # Threshold calculation should be only on the third invoice self.assertEqual(pi1.taxes[0].tax_amount, 800) - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) def test_cumulative_threshold_with_tax_on_excess_amount(self): invoices = [] - frappe.db.set_value("Supplier", "Test TDS Supplier3", "tax_withholding_category", "New TDS Category") + self.setup_party_with_category("Supplier", "Test TDS Supplier3", "New TDS Category") # Invoice with tax and without exceeding single and cumulative thresholds for _ in range(2): @@ -235,126 +422,209 @@ class TestTaxWithholdingCategory(IntegrationTestCase): pi.submit() invoices.append(pi) - # Third Invoice exceeds single threshold and not exceeding cumulative threshold + # Validate tax withholding entry for each invoice (should be settled with exemption reason) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason="Threshold Exemption", + ) + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + + # Third Invoice breaches cumulative threshold pi1 = create_purchase_invoice(supplier="Test TDS Supplier3", rate=20000) pi1.apply_tds = 1 pi1.save() pi1.submit() invoices.append(pi1) + # Validate tax withholding entries for current invoice only + # For amount before threshold (first 10000): TDS entry with amount zero + # For amount above threshold (next 10000): TDS entry with TDS applied + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi1.name, + under_withheld_reason="Threshold Exemption", + ), + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi1.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi1.name, expected_entries) + # Cumulative threshold is 10,000 # Threshold calculation should be only on the third invoice self.assertTrue(len(pi1.taxes) > 0) self.assertEqual(pi1.taxes[0].tax_amount, 1000) - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) - def test_cumulative_threshold_tcs(self): - frappe.db.set_value( - "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" - ) + def test_cumulative_threshold_tcs_on_gross_amount(self): + self.setup_party_with_category("Customer", "Test TCS Customer", "Cumulative Threshold TCS") invoices = [] - # create invoices for lower than single threshold tax rate + # First two invoices - below threshold, should be settled with zero TCS for _ in range(2): si = create_sales_invoice(customer="Test TCS Customer") + si.append( + "taxes", + { + "category": "Total", + "charge_type": "Actual", + "account_head": "TCS - _TC", + "cost_center": "Main - _TC", + "tax_amount": 200, + "description": "Test Gross Tax", + "add_deduct_tax": "Add", + }, + ) + si.save() si.submit() invoices.append(si) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=10200.0, # including vat amount + withholding_amount=0.0, + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Sales Invoice", + withholding_name=si.name, + under_withheld_reason="Threshold Exemption", + ) + ] + self.validate_tax_withholding_entries("Sales Invoice", si.name, expected_entries) - # create another invoice whose total when added to previously created invoice, - # surpasses cumulative threshold + # Third invoice - breaches threshold, TCS applied only on excess si = create_sales_invoice(customer="Test TCS Customer", rate=12000) + si.append( + "taxes", + { + "category": "Total", + "charge_type": "Actual", + "account_head": "TCS - _TC", + "cost_center": "Main - _TC", + "tax_amount": 400, + "description": "Test Gross Tax", + "add_deduct_tax": "Add", + }, + ) + si.save() si.submit() - - # assert tax collection on total invoice amount created until now - tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == "TCS - _TC"]) - self.assertEqual(tcs_charged, 200) - self.assertEqual(si.grand_total, 12200) invoices.append(si) + # For amount before threshold (first 8000 + VAT): TCS entry with amount zero + # For amount above threshold (next 4000): TCS entry with TCS applied + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=9600.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Sales Invoice", + withholding_name=si.name, + under_withheld_reason="Threshold Exemption", + ), + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=2800.0, + withholding_amount=280.0, + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Sales Invoice", + withholding_name=si.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Sales Invoice", si.name, expected_entries) + self.validate_tax_deduction(si, 280) + self.assertEqual(si.grand_total, 12680) - # TCS is already collected once, so going forward system will collect TCS on every invoice + # Fourth invoice - TCS applied on full amount si = create_sales_invoice(customer="Test TCS Customer", rate=5000) + si.append( + "taxes", + { + "category": "Total", + "charge_type": "Actual", + "account_head": "_Test Account VAT - _TC", + "cost_center": "Main - _TC", + "tax_amount": 500, + "description": "VAT added to test TDS calculation on gross amount", + "add_deduct_tax": "Add", + }, + ) + si.save() si.submit() - - tcs_charged = sum(d.base_tax_amount for d in si.taxes if d.account_head == "TCS - _TC") - self.assertEqual(tcs_charged, 500) invoices.append(si) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=5500.0, + withholding_amount=550.0, + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Sales Invoice", + withholding_name=si.name, + under_withheld_reason=None, + ) + ] + self.validate_tax_withholding_entries("Sales Invoice", si.name, expected_entries) + self.validate_tax_deduction(si, 550) + self.assertEqual(si.grand_total, 6050) # cancel invoices to avoid clashing - for d in reversed(invoices): - d.cancel() - - def test_tcs_on_unallocated_advance_payments(self): - frappe.db.set_value( - "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" - ) - fiscal_year = get_fiscal_year(today(), company="_Test Company") - - vouchers = [] - - # create advance payment - pe1 = create_payment_entry( - payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=20000 - ) - pe1.paid_from = "Debtors - _TC" - pe1.paid_to = "Cash - _TC" - pe1.submit() - vouchers.append(pe1) - - # create invoice - si1 = create_sales_invoice(customer="Test TCS Customer", rate=5000) - si1.submit() - vouchers.append(si1) - - # reconcile - pr = frappe.get_doc("Payment Reconciliation") - pr.company = "_Test Company" - pr.party_type = "Customer" - pr.party = "Test TCS Customer" - pr.receivable_payable_account = "Debtors - _TC" - pr.get_unreconciled_entries() - invoices = [x.as_dict() for x in pr.get("invoices")] - payments = [x.as_dict() for x in pr.get("payments")] - pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) - pr.reconcile() - - # make another invoice - # sum of unallocated amount from payment entry and this sales invoice will breach cumulative threashold - # TDS should be calculated - - # this payment should not be considered for TCS calculation as it is outside of fiscal year - pe2 = create_payment_entry( - payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=10000 - ) - pe2.paid_from = "Debtors - _TC" - pe2.paid_to = "Cash - _TC" - pe2.posting_date = add_days(fiscal_year[1], -10) - pe2.submit() - vouchers.append(pe2) - - si2 = create_sales_invoice(customer="Test TCS Customer", rate=15000) - si2.submit() - vouchers.append(si2) - - si3 = create_sales_invoice(customer="Test TCS Customer", rate=10000) - si3.submit() - vouchers.append(si3) - - # assert tax collection on total invoice amount created until now - tcs_charged = sum([d.base_tax_amount for d in si2.taxes if d.account_head == "TCS - _TC"]) - tcs_charged += sum([d.base_tax_amount for d in si3.taxes if d.account_head == "TCS - _TC"]) - self.assertEqual(tcs_charged, 1500) - - # cancel invoice and payments to avoid clashing - for d in reversed(vouchers): - d.reload() - d.cancel() + self.cleanup_invoices(invoices) def test_tcs_on_allocated_advance_payments(self): - frappe.db.set_value( - "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" - ) + self.setup_party_with_category("Customer", "Test TCS Customer", "Cumulative Threshold TCS") vouchers = [] @@ -364,9 +634,29 @@ class TestTaxWithholdingCategory(IntegrationTestCase): ) pe.paid_from = "Debtors - _TC" pe.paid_to = "Cash - _TC" + pe.apply_tds = 1 + pe.tax_withholding_category = "Cumulative Threshold TCS" pe.submit() vouchers.append(pe) + # Validate payment entry tax withholding entries + payment_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=30000.0, + withholding_amount=3000.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, payment_expected_entries) + si = create_sales_invoice(customer="Test TCS Customer", rate=50000) advances = si.get_advance_entries() si.append( @@ -381,20 +671,347 @@ class TestTaxWithholdingCategory(IntegrationTestCase): si.submit() vouchers.append(si) - # assert tax collection on total invoice ,advance payment adjusted should be excluded. + # Validate TCS charged on Sales Invoice + # Since PE already collected 3000 TCS (over-withheld), and total required is 5000, + # the remaining 2000 is settled from PE's over-withheld amount. + # No new TCS is deducted on SI - the taxes row should be 0. tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == "TCS - _TC"]) - # tcs = (inv amt)50000+(adv amt)30000-(adv adj) 30000 - threshold(30000) * rate 10% - self.assertEqual(tcs_charged, 2000) + self.assertEqual(tcs_charged, 0) - # cancel invoice and payments to avoid clashing - for d in reversed(vouchers): - d.reload() - d.cancel() + # Validate invoice tax withholding entries + invoice_expected_entries = [ + # Main invoice entry + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=30000, # Net amount after advance adjustment (50000-30000) + withholding_amount=0, # Tax on net amount: 30000 * 10% + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Sales Invoice", + withholding_name=si.name, + under_withheld_reason="Threshold Exemption", + ), + # Advance allocation adjustment entry + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=20000.0, # Positive amount that's allocated + withholding_amount=2000.0, # No tax on allocated advance + status="Settled", + taxable_doctype="Sales Invoice", + taxable_name=si.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Sales Invoice", si.name, invoice_expected_entries) + + self.cleanup_invoices(vouchers) + + def test_tds_multiple_payments_adjust_only_linked(self): + """ + Test that when multiple advance payment entries exist for the same supplier, + only the payment entry that is linked/allocated to the invoice is adjusted. + """ + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + + vouchers = [] + + pe1 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier", paid_amount=5000 + ) + pe1.apply_tds = 1 + pe1.tax_withholding_category = "Cumulative Threshold TDS" + pe1.save() + pe1.submit() + vouchers.append(pe1) + + pe1_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe1.name, pe1_expected_entries) + + pe2 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier", paid_amount=3000 + ) + pe2.apply_tds = 1 + pe2.tax_withholding_category = "Cumulative Threshold TDS" + pe2.save() + pe2.submit() + vouchers.append(pe2) + + pe2_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe2.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe2.name, pe2_expected_entries) + + pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=40000) + pi.append( + "advances", + { + "reference_type": pe1.doctype, + "reference_name": pe1.name, + "advance_amount": 5000, + "allocated_amount": 5000, + }, + ) + pi.submit() + vouchers.append(pi) + + invoice_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=35000.0, + withholding_amount=3500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, invoice_expected_entries) + self.cleanup_invoices(vouchers) + + def test_tds_multiple_payments_with_unused_threshold(self): + """ + Test multiple payment entries with unused threshold (tax_on_excess_amount enabled). + Only the linked payment entry should be adjusted, and threshold exemption should apply. + """ + self.setup_party_with_category("Supplier", "Test TDS Supplier3", "New TDS Category") + + vouchers = [] + + pe1 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier3", paid_amount=5000 + ) + pe1.apply_tds = 1 + pe1.tax_withholding_category = "New TDS Category" + pe1.save() + pe1.submit() + vouchers.append(pe1) + + pe1_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe1.name, pe1_expected_entries) + + pe2 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier3", paid_amount=3000 + ) + pe2.apply_tds = 1 + pe2.tax_withholding_category = "New TDS Category" + pe2.save() + pe2.submit() + vouchers.append(pe2) + + pe2_expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe2.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe2.name, pe2_expected_entries) + + pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=40000) + pi.append( + "advances", + { + "reference_type": pe1.doctype, + "reference_name": pe1.name, + "advance_amount": 5000, + "allocated_amount": 5000, + }, + ) + pi.submit() + vouchers.append(pi) + + # Expected entries: + # 1. Threshold Exemption for first 30000 (no TDS) + # 2. Remaining 5000 (40000-30000-5000 from PE1) from invoice + # 3. PE1's 5000 adjusted + invoice_expected_entries = [ + # Threshold exemption for first 30000 + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=30000.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason="Threshold Exemption", + ), + # Remaining 5000 from invoice (40000 - 30000 threshold - 5000 PE1) + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + # PE1's over-withheld adjustment (5000) + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier3", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, invoice_expected_entries) + self.cleanup_invoices(vouchers) + + def test_tds_withholding_group_different_rates(self): + """ + Test that Tax Withholding Group applies different rates for different groups + within the same Tax Withholding Category. + """ + for group_name in ["Individual", "Company"]: + if not frappe.db.exists("Tax Withholding Group", group_name): + frappe.get_doc({"doctype": "Tax Withholding Group", "group_name": group_name}).insert() + + fiscal_year = get_fiscal_year(today(), company="_Test Company") + from_date = fiscal_year[1] + to_date = fiscal_year[2] + + # Single category with BOTH groups at different rates + if not frappe.db.exists("Tax Withholding Category", "TDS Group Rate Category"): + frappe.get_doc( + { + "doctype": "Tax Withholding Category", + "name": "TDS Group Rate Category", + "category_name": "TDS Group Rate Category", + "tax_deduction_basis": "Net Total", + "rates": [ + { + "from_date": from_date, + "to_date": to_date, + "tax_withholding_group": "Individual", + "tax_withholding_rate": 1, # 1% for Individual + "single_threshold": 0, + "cumulative_threshold": 0, + }, + { + "from_date": from_date, + "to_date": to_date, + "tax_withholding_group": "Company", + "tax_withholding_rate": 2, # 2% for Company + "single_threshold": 0, + "cumulative_threshold": 0, + }, + ], + "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], + } + ).insert() + + invoices = [] + + self.setup_party_with_category("Supplier", "Test TDS Supplier5", "TDS Group Rate Category") + frappe.db.set_value("Supplier", "Test TDS Supplier5", "tax_withholding_group", "Individual") + pi1 = create_purchase_invoice(supplier="Test TDS Supplier5", rate=100000) + pi1.submit() + invoices.append(pi1) + + total = sum([d.base_tax_amount for d in pi1.taxes if d.account_head == "TDS - _TC"]) + self.assertEqual(abs(total), 1000, "Individual rate should be 1% (1000 on 100000)") + + self.setup_party_with_category("Supplier", "Test TDS Supplier6", "TDS Group Rate Category") + frappe.db.set_value("Supplier", "Test TDS Supplier6", "tax_withholding_group", "Company") + pi2 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=100000) + pi2.submit() + invoices.append(pi2) + + total = sum([d.base_tax_amount for d in pi2.taxes if d.account_head == "TDS - _TC"]) + self.assertEqual(abs(total), 2000, "Company rate should be 2% (2000 on 100000)") + + self.cleanup_invoices(invoices) def test_tds_calculation_on_net_total(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier4", "tax_withholding_category", "Cumulative Threshold TDS" - ) + self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS") invoices = [] pi = create_purchase_invoice(supplier="Test TDS Supplier4", rate=20000, do_not_save=True) @@ -406,7 +1023,7 @@ class TestTaxWithholdingCategory(IntegrationTestCase): "account_head": "_Test Account VAT - _TC", "cost_center": "Main - _TC", "tax_amount": 1000, - "description": "Test", + "description": "VAT added to test TDS calculation on gross amount", "add_deduct_tax": "Add", }, ) @@ -421,37 +1038,50 @@ class TestTaxWithholdingCategory(IntegrationTestCase): self.assertEqual(pi1.taxes[0].tax_amount, 4000) - # cancel invoices to avoid clashing - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) def test_tds_calculation_on_net_total_partial_tds(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier4", "tax_withholding_category", "Cumulative Threshold TDS" - ) + self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS") invoices = [] - pi = create_purchase_invoice(supplier="Test TDS Supplier4", rate=20000, do_not_save=True) + # Create purchase invoice with 3 items: + # 1. No TDS (apply_tds = 0) + # 2. TDS with Test Service Category (rate 10%, single_threshold=2000, cumulative_threshold=2000, no tax on excess) + # 3. TDS with New TDS Category (rate 10%, cumulative_threshold=30000, tax on excess enabled) + item_code = frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name") + pi = create_purchase_invoice(supplier="Test TDS Supplier4", rate=0, do_not_save=True) + pi.items = [] pi.extend( "items", [ { "doctype": "Purchase Invoice Item", - "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"), + "item_code": item_code, "qty": 1, - "rate": 20000, + "rate": 10000, "cost_center": "Main - _TC", "expense_account": "Stock Received But Not Billed - _TC", - "apply_tds": 0, + "apply_tds": 0, # No TDS for this item }, { "doctype": "Purchase Invoice Item", - "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"), + "item_code": item_code, "qty": 1, - "rate": 35000, + "rate": 5000, # Above single threshold of 2000 for Test Service Category "cost_center": "Main - _TC", "expense_account": "Stock Received But Not Billed - _TC", "apply_tds": 1, + "tax_withholding_category": "Test Service Category", + }, + { + "doctype": "Purchase Invoice Item", + "item_code": item_code, + "qty": 1, + "rate": 35000, # Above cumulative threshold for New TDS Category with tax on excess + "cost_center": "Main - _TC", + "expense_account": "Stock Received But Not Billed - _TC", + "apply_tds": 1, + "tax_withholding_category": "New TDS Category", }, ], ) @@ -459,55 +1089,70 @@ class TestTaxWithholdingCategory(IntegrationTestCase): pi.submit() invoices.append(pi) - self.assertEqual(pi.taxes[0].tax_amount, 5500) + # Expected behavior: + # Item 1: No TDS - no tax withholding entry + # Item 2: Test Service Category - TDS applies as amount (5000) > single threshold (2000) + # Item 3: New TDS Category - TDS applies with tax on excess logic as amount (35000) > cumulative threshold (30000) - # cancel invoices to avoid clashing - for d in reversed(invoices): - d.cancel() + # Validate tax withholding entries + expected_entries = [ + # Item 2: Test Service Category - TDS deducted on full amount since it exceeds single threshold + self.get_tax_withholding_entry( + tax_withholding_category="Test Service Category", + party_type="Supplier", + party="Test TDS Supplier4", # Same supplier for all items + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, # 10% of 5000 + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason=None, + ), + # Item 3: New TDS Category - TDS with tax on excess logic + # First 30000 (threshold) - no TDS + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier4", + tax_rate=10.0, + taxable_amount=30000.0, + withholding_amount=0.0, # No TDS on threshold amount + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason="Threshold Exemption", + ), + # Remaining 5000 (35000-30000) - TDS applies + self.get_tax_withholding_entry( + tax_withholding_category="New TDS Category", + party_type="Supplier", + party="Test TDS Supplier4", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, # 10% of excess amount (5000) + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason=None, + ), + ] - orders = [] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) - po = create_purchase_order(supplier="Test TDS Supplier4", rate=20000, do_not_save=True) - po.extend( - "items", - [ - { - "doctype": "Purchase Order Item", - "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"), - "qty": 1, - "rate": 20000, - "cost_center": "Main - _TC", - "expense_account": "Stock Received But Not Billed - _TC", - "apply_tds": 0, - }, - { - "doctype": "Purchase Order Item", - "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"), - "qty": 1, - "rate": 35000, - "cost_center": "Main - _TC", - "expense_account": "Stock Received But Not Billed - _TC", - "apply_tds": 1, - }, - ], - ) - po.save() - po.submit() - orders.append(po) + self.validate_tax_deduction(pi, 1000) - self.assertEqual(po.taxes[0].tax_amount, 5500) - - # cancel orders to avoid clashing - for d in reversed(orders): - d.cancel() + self.cleanup_invoices(invoices) def test_tds_deduction_for_po_via_payment_entry(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier8", "tax_withholding_category", "Cumulative Threshold TDS" - ) + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Cumulative Threshold TDS") order = create_purchase_order(supplier="Test TDS Supplier8", rate=40000, do_not_save=True) - - # Add some tax on the order order.append( "taxes", { @@ -522,50 +1167,51 @@ class TestTaxWithholdingCategory(IntegrationTestCase): ) order.save() - - order.apply_tds = 1 - order.tax_withholding_category = "Cumulative Threshold TDS" order.submit() - - self.assertEqual(order.taxes[0].tax_amount, 4000) + self.assertEqual(order.taxes[0].tax_amount, 8000) payment = get_payment_entry(order.doctype, order.name) - payment.apply_tax_withholding_amount = 1 + payment.apply_tds = 1 payment.tax_withholding_category = "Cumulative Threshold TDS" payment.save().submit() - self.assertEqual(payment.taxes[0].tax_amount, 4000) + self.assertEqual(payment.taxes[0].tax_amount, 4800) def test_multi_category_single_supplier(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category" - ) + self.setup_party_with_category("Supplier", "Test TDS Supplier5", "Test Service Category") invoices = [] pi = create_purchase_invoice(supplier="Test TDS Supplier5", rate=500, do_not_save=True) - pi.tax_withholding_category = "Test Service Category" pi.save() pi.submit() invoices.append(pi) + self.assertEqual(pi.items[0].tax_withholding_category, "Test Service Category") # Second Invoice will apply TDS checked pi1 = create_purchase_invoice(supplier="Test TDS Supplier5", rate=2500, do_not_save=True) - pi1.tax_withholding_category = "Test Goods Category" + for item in pi1.items: + item.apply_tds = 1 + item.tax_withholding_category = "Test Goods Category" pi1.save() pi1.submit() invoices.append(pi1) self.assertEqual(pi1.taxes[0].tax_amount, 250) - # cancel invoices to avoid clashing - for d in reversed(invoices): - d.cancel() + self.cleanup_invoices(invoices) - def test_tax_withholding_category_voucher_display(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier6", "tax_withholding_category", "Test Multi Invoice Category" - ) + def test_tds_deductions_with_payment_entries(self): + """ + Test tax withholding entries across different voucher types and statuses: + - Purchase Invoice: Regular invoice (Under Withheld - below threshold) + - Return Invoice: Negative amount (Under Withheld - return, no TDS) + - Payment Entry: Over Withheld (always) + - Payment Entry2: Over Withheld (always) + - Final Invoice: Settlement invoice that settles all previous entries (Settled status) + """ + self.setup_party_with_category("Supplier", "Test TDS Supplier6", "Test Multi Invoice Category") invoices = [] + # First invoice - below threshold, should be under withheld pi = create_purchase_invoice(supplier="Test TDS Supplier6", rate=4000, do_not_save=True) pi.apply_tds = 1 pi.tax_withholding_category = "Test Multi Invoice Category" @@ -573,77 +1219,345 @@ class TestTaxWithholdingCategory(IntegrationTestCase): pi.submit() invoices.append(pi) - pi1 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=2000, do_not_save=True) - pi1.apply_tds = 1 - pi1.is_return = 1 - pi1.items[0].qty = -1 - pi1.tax_withholding_category = "Test Multi Invoice Category" - pi1.save() - pi1.submit() - invoices.append(pi1) + # Validate tax withholding entry for first invoice + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=4000.0, + withholding_amount=0.0, + status="Under Withheld", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="", + withholding_name="", + under_withheld_reason=None, + ) + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + + pe1 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=3000 + ) + pe1.apply_tds = 1 + pe1.tax_withholding_category = "Test Multi Invoice Category" + pe1.save() + pe1.submit() + invoices.append(pe1) + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe1.name, expected_entries) + + pe2 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=6000 + ) + pe2.apply_tds = 1 + pe2.tax_withholding_category = "Test Multi Invoice Category" + pe2.save() + pe2.submit() + invoices.append(pe2) + + # Validate tax withholding entry for larger payment entry (over withheld) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe2.name, + under_withheld_reason=None, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe2.name, expected_entries) + + # Final invoice - should breach cumulative threshold and settle all previous entries + pi2 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=12000, do_not_save=True) + pi2.apply_tds = 1 + pi2.tax_withholding_category = "Test Multi Invoice Category" + advances = pi2.get_advance_entries() + pi2.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": advances[0].amount, + }, + ) + pi2.append( + "advances", + { + "reference_type": advances[1].reference_type, + "reference_name": advances[1].reference_name, + "advance_amount": advances[1].amount, + "allocated_amount": advances[1].amount, + }, + ) + pi2.save() + pi2.submit() + invoices.append(pi2) + + # Validate tax withholding entries for final invoice (should settle previous entries) + # Based on actual system behavior, this creates 2 settlement entries: + # 1. Settlement for first invoice (4000, status: Settled) + # 2. Entry for final invoice itself (9000, status: Settled) + expected_entries = [ + # Settlement for first invoice + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=4000.0, + withholding_amount=400.0, # 10% of 4000 + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, # First invoice + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, # Final invoice settles it + under_withheld_reason=None, + ), + # against first payment entry + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=3000.0, # Final invoice amount + withholding_amount=300.0, # TDS on final invoice + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + under_withheld_reason=None, + ), + # against second payment entry + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, # Final invoice amount + withholding_amount=600.0, # TDS on final invoice + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe2.name, + under_withheld_reason=None, + ), + # against second payment entry + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=3000.0, # Final invoice amount + withholding_amount=300.0, # TDS on final invoice + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Final invoice itself + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_entries) + + # validate duplicate entries in Purchase Invoice 1 + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=4000.0, + withholding_amount=400.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + under_withheld_reason=None, + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + # validate duplicate entries in payment entry 1 + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=3000.0, # Final invoice amount + withholding_amount=300.0, # TDS on final invoice + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe1.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe1.name, expected_entries) + + # Validate duplicate entries in payment entry 2 + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, # Final invoice amount + withholding_amount=600.0, # TDS on final invoice + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe2.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe2.name, expected_entries) + + self.cleanup_invoices(invoices) + + def test_tds_deduction_with_partial_payment_adjustment(self): + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier6", "Test Multi Invoice Category") pe = create_payment_entry( - payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=1000 + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=6000 ) - pe.apply_tax_withholding_amount = 1 + pe.apply_tds = 1 pe.tax_withholding_category = "Test Multi Invoice Category" pe.save() pe.submit() invoices.append(pe) - pi2 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=9000, do_not_save=True) - pi2.apply_tds = 1 - pi2.tax_withholding_category = "Test Multi Invoice Category" - pi2.save() - pi2.submit() - invoices.append(pi2) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) - pi2.load_from_db() - - self.assertTrue(pi2.taxes[0].tax_amount, 1100) - - self.assertTrue(pi2.tax_withheld_vouchers[0].voucher_name == pi1.name) - self.assertTrue(pi2.tax_withheld_vouchers[0].taxable_amount == pi1.net_total) - self.assertTrue(pi2.tax_withheld_vouchers[1].voucher_name == pi.name) - self.assertTrue(pi2.tax_withheld_vouchers[1].taxable_amount == pi.net_total) - self.assertTrue(pi2.tax_withheld_vouchers[2].voucher_name == pe.name) - self.assertTrue(pi2.tax_withheld_vouchers[2].taxable_amount == pe.paid_amount) - - # cancel invoices to avoid clashing - for d in reversed(invoices): - d.cancel() - - def test_tax_withholding_via_payment_entry_for_advances(self): - frappe.db.set_value( - "Supplier", "Test TDS Supplier7", "tax_withholding_category", "Advance TDS Category" + pi = create_purchase_invoice(supplier="Test TDS Supplier6", rate=12000, do_not_save=True) + pi.apply_tds = 1 + pi.tax_withholding_category = "Test Multi Invoice Category" + advances = pi.get_advance_entries() + pi.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": 3600, + }, ) + pi.save() + pi.submit() + invoices.append(pi) - # create payment entry - pe1 = create_payment_entry( - payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 - ) - pe1.submit() - - self.assertFalse(pe1.get("taxes")) - - pe2 = create_payment_entry( - payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 - ) - pe2.submit() - - self.assertFalse(pe2.get("taxes")) - - pe3 = create_payment_entry( - payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 - ) - pe3.apply_tax_withholding_amount = 1 - pe3.save() - pe3.submit() - - self.assertEqual(pe3.get("taxes")[0].tax_amount, 1200) - pe1.cancel() - pe2.cancel() - pe3.cancel() + expected_entries = [ + # against first payment entry + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=4000.0, + withholding_amount=400.0, # 600 * 6000/(6000-5400) + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason=None, + ), + # against remaining invoice + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=8000.0, # Final invoice amount + withholding_amount=800.0, # TDS on final invoice + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, # Final invoice itself + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason=None, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + # validate duplicate entries + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=4000.0, + withholding_amount=400.0, # 600 * 6000/(6000-5400) + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, # Final invoice itself + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason=None, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=2000.0, + withholding_amount=200.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) + self.cleanup_invoices(invoices) def test_lower_deduction_certificate_application(self): frappe.db.set_value( @@ -675,9 +1589,7 @@ class TestTaxWithholdingCategory(IntegrationTestCase): pi3.submit() self.assertEqual(pi3.taxes[0].tax_amount, 3500) - pi1.cancel() - pi2.cancel() - pi3.cancel() + self.cleanup_invoices([pi1, pi2, pi3]) def test_ldc_at_0_rate(self): frappe.db.set_value( @@ -718,9 +1630,309 @@ class TestTaxWithholdingCategory(IntegrationTestCase): self.assertEqual(len(pi2.taxes), 1) # pi1 net total shouldn't be included as it lies within LDC at rate of '0' self.assertEqual(pi2.taxes[0].tax_amount, 3500) + self.cleanup_invoices([pi1, pi2]) - pi1.cancel() - pi2.cancel() + def test_payment_entry_with_ldc_and_invoice_adjustment(self): + """ + Test: Payment Entry with LDC, then Invoice, with correct tax adjustment. + - Payment Entry (advance) is made and tax is deducted at LDC rate + - Purchase Invoice is created for a higher amount + - For the portion of invoice covered by advance, tax is adjusted at LDC rate + - For the remaining invoice amount, tax is deducted at normal rate + """ + + invoices = [] + pan = "ABCTY1234D" + supplier = "Test LDC Supplier" + category = "Test Service Category" + ldc_no = "TEST-1" + + frappe.db.set_value( + "Supplier", + supplier, + { + "tax_withholding_category": category, + "pan": pan, + }, + ) + + create_lower_deduction_certificate( + supplier=supplier, + certificate_no=ldc_no, + tax_withholding_category=category, + tax_rate=0, + limit=10000, + ) + + # Payment Entry (advance) with LDC + advance_amount = 6000.0 + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party=supplier, paid_amount=advance_amount + ) + pe.apply_tds = 1 + pe.tax_withholding_category = category + pe.save() + pe.submit() + invoices.append(pe) + + # Validate payment entry tax withholding entries (LDC rate) + expected_pe_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=advance_amount, + withholding_amount=0.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries) + + # Now create and link the invoice (do not update PE after this point) + invoice_amount = 15000 + pi = create_purchase_invoice( + supplier=supplier, + rate=invoice_amount, + ) + + advances = pi.get_advance_entries() + pi.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": advance_amount, + }, + ) + pi.save() + pi.submit() + invoices.append(pi) + + # Validate invoice tax withholding entries + expected_pi_entries = [ + # LDC portion (settled) + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=advance_amount, + withholding_amount=0.0, + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + status="Settled", + ), + # Balance LDC portion (settled) + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=4000.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ), + # Balance LDC portion (settled) + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + under_withheld_reason=None, + lower_deduction_certificate=None, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_pi_entries) + + # validate duplicate entries in payment entry + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=advance_amount, + withholding_amount=0.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ) + ] + + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) + + self.cleanup_invoices(invoices) + + def test_payment_entry_with_ldc_and_partial_invoice_adjustment(self): + """ + Test: Payment Entry with LDC, then Invoice, with correct tax adjustment. + - Payment Entry (advance) is made and tax is deducted at LDC rate + - Purchase Invoice is created for a higher amount + - For the portion of invoice covered by advance, tax is adjusted at LDC rate + - For the remaining invoice amount, tax is deducted at normal rate + """ + + invoices = [] + pan = "ABCTY1234D" + supplier = "Test LDC Supplier" + category = "Test Service Category" + ldc_no = "TEST-1" + + frappe.db.set_value( + "Supplier", + supplier, + { + "tax_withholding_category": category, + "pan": pan, + }, + ) + + create_lower_deduction_certificate( + supplier=supplier, + certificate_no=ldc_no, + tax_withholding_category=category, + tax_rate=0, + limit=15000, + ) + + # Payment Entry (advance) with LDC + advance_amount = 6000.0 + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party=supplier, paid_amount=advance_amount + ) + pe.apply_tds = 1 + pe.tax_withholding_category = category + pe.save() + pe.submit() + invoices.append(pe) + + # Validate payment entry tax withholding entries (LDC rate) + expected_pe_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=advance_amount, + withholding_amount=0.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries) + + # Now create and link the invoice (do not update PE after this point) + invoice_amount = 3000.0 + pi = create_purchase_invoice( + supplier=supplier, + rate=invoice_amount, + ) + + advances = pi.get_advance_entries() + pi.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": invoice_amount, + }, + ) + pi.save() + pi.submit() + invoices.append(pi) + + # Validate invoice tax withholding entries + expected_pi_entries = [ + # LDC portion (settled) + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=invoice_amount, + withholding_amount=0.0, + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + status="Settled", + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_pi_entries) + + # validate duplicate entries in payment entry + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=invoice_amount, # 3000 + withholding_amount=0.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ), + self.get_tax_withholding_entry( + tax_withholding_category=category, + party_type="Supplier", + party=supplier, + tax_rate=0.0, + taxable_amount=advance_amount - invoice_amount, # 6000-3000 = 3000 + withholding_amount=0.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + under_withheld_reason="Lower Deduction Certificate", + lower_deduction_certificate=ldc_no, + ), + ] + + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) + self.cleanup_invoices(invoices) def set_previous_fy_and_tax_category(self): test_company = "_Test Company" @@ -786,15 +1998,13 @@ class TestTaxWithholdingCategory(IntegrationTestCase): po = create_purchase_order(supplier=supplier, qty=10, rate=10000) po.transaction_date = po_and_advance_posting_date po.taxes = [] - po.apply_tds = False - po.tax_withholding_category = None po.save().submit() # Partial advance payment = get_payment_entry(po.doctype, po.name) payment.posting_date = po_and_advance_posting_date payment.paid_amount = 60000 - payment.apply_tax_withholding_amount = 1 + payment.apply_tds = 1 payment.tax_withholding_category = category payment.references = [] payment.taxes = [] @@ -825,7 +2035,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase): payment.reload() self.assertEqual(pi1.taxes, []) self.assertEqual(payment.taxes[0].tax_amount, 6000) - self.assertEqual(payment.taxes[0].allocated_amount, 3000) pi2 = make_purchase_invoice(source_name=po.name) pi2.apply_tds = True @@ -847,28 +2056,1511 @@ class TestTaxWithholdingCategory(IntegrationTestCase): payment.reload() self.assertEqual(pi2.taxes, []) self.assertEqual(payment.taxes[0].tax_amount, 6000) - self.assertEqual(payment.taxes[0].allocated_amount, 6000) + @IntegrationTestCase.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) + def test_tds_payment_entry_cancellation(self): + """ + Test payment entry cancellation clears withholding references from matched entries + """ + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier6", "Test Multi Invoice Category") -def cancel_invoices(): - purchase_invoices = frappe.get_all( - "Purchase Invoice", - { - "supplier": ["in", ["Test TDS Supplier", "Test TDS Supplier1", "Test TDS Supplier2"]], - "docstatus": 1, - }, - pluck="name", - ) + # Create payment entry with tax withholding + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier6", paid_amount=6000 + ) + pe.apply_tds = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save() + pe.submit() + invoices.append(pe) - sales_invoices = frappe.get_all( - "Sales Invoice", {"customer": "Test TCS Customer", "docstatus": 1}, pluck="name" - ) + # Verify initial "Over Withheld" entry + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) - for d in purchase_invoices: - frappe.get_doc("Purchase Invoice", d).cancel() + # Create purchase invoice that settles the payment entry + pi = create_purchase_invoice(supplier="Test TDS Supplier6", rate=8000, do_not_save=True) + pi.apply_tds = 1 + pi.tax_withholding_category = "Test Multi Invoice Category" + advances = pi.get_advance_entries() + pi.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": 6000, + }, + ) + pi.save() + pi.submit() + invoices.append(pi) - for d in sales_invoices: - frappe.get_doc("Sales Invoice", d).cancel() + # Verify entries after invoice creation (should have Settled and Duplicate statuses) + expected_pi_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=2000.0, + withholding_amount=200.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_pi_entries) + + # Verify duplicate entry in payment entry + expected_pe_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries) + + # Cancel the payment entry (reload first to avoid timestamp mismatch) + pe.reload() + pe.cancel() + + # After payment entry cancellation, the purchase invoice entries should have: + # - Withholding references cleared (empty doctype and name) + # - Status changed to "Under Withheld" + # - Withholding amounts set to 0 + expected_pi_entries_after_cancel = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=0.0, # Cleared + status="Under Withheld", # Changed + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="", # Cleared + withholding_name="", # Cleared + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=2000.0, + withholding_amount=200.0, # Not cleared (same document) + status="Settled", # Unchanged (same document) + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_pi_entries_after_cancel) + + pi1 = create_purchase_invoice(supplier="Test TDS Supplier6", rate=8000, do_not_save=True) + pi1.apply_tds = 1 + pi1.tax_withholding_category = "Test Multi Invoice Category" + pi1.submit() + invoices.append(pi1) + + expected_entries = [ + # Adjust previous purchase invoice + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=6000.0, + withholding_amount=600, # Cleared + status="Settled", # Changed + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi1.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier6", + tax_rate=10.0, + taxable_amount=8000.0, + withholding_amount=800, # Cleared + status="Settled", # Changed + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi1.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi1.name, expected_entries) + self.cleanup_invoices(invoices) + + @IntegrationTestCase.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) + def test_tds_purchase_invoice_cancellation(self): + """ + Test that after cancellation, new documents get automatically adjusted against remaining entries + """ + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + # Create payment entry with tax withholding + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier8", paid_amount=10000 + ) + pe.apply_tds = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save() + pe.submit() + invoices.append(pe) + + # Create first purchase invoice + pi1 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=10000, do_not_save=True) + pi1.apply_tds = 1 + pi1.tax_withholding_category = "Test Multi Invoice Category" + advances = pi1.get_advance_entries() + pi1.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": 4500, + }, + ) + pi1.save() + pi1.submit() + invoices.append(pi1) + + # Verify entries after first invoice + expected_pe_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries) + + # Cancel the first purchase invoice + pi1.cancel() + + # After cancellation, payment entry should be back to single "Over Withheld" entry + expected_pe_entries_after_cancel = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries_after_cancel) + + # Create new purchase invoice - should automatically adjust against "Over Withheld" entries + pi2 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=7000, do_not_save=True) + pi2.apply_tds = 1 + pi2.tax_withholding_category = "Test Multi Invoice Category" + advances = pi2.get_advance_entries() + pi2.append( + "advances", + { + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": advances[0].amount, + "allocated_amount": 5500, + }, + ) + pi2.save() + pi2.submit() + invoices.append(pi2) + + # Verify automatic adjustment works correctly + expected_pi2_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=2000.0, + withholding_amount=200.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_pi2_entries) + + # Payment entry should now have the remaining amount as "Over Withheld" + expected_pe_entries_after_pi2 = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=2000.0, + withholding_amount=200.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, + withholding_amount=500.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_pe_entries_after_pi2) + + self.cleanup_invoices(invoices) + + def test_tds_deduction_in_purchase_return(self): + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + + pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=40000) + pi.submit() + + self.assertEqual(pi.taxes_and_charges_deducted, 4000) + + pi_return = create_purchase_invoice(supplier="Test TDS Supplier", is_return=1, qty=-1, rate=40000) + pi_return.return_against = pi.name + pi_return.save() + pi_return.submit() + + self.assertEqual(pi_return.taxes_and_charges_deducted, -4000) + self.cleanup_invoices([pi, pi_return]) + + def test_tds_purchase_invoice_cancellation_and_adjustment(self): + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + pi1 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=3000, do_not_save=True) + pi1.apply_tds = 1 + pi1.tax_withholding_category = "Test Multi Invoice Category" + pi1.save() + pi1.submit() + invoices.append(pi1) + + pi2 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=10000, do_not_save=True) + pi2.apply_tds = 1 + pi2.tax_withholding_category = "Test Multi Invoice Category" + pi2.save() + pi2.submit() + invoices.append(pi2) + + pi1.reload() + pi1.cancel() + + pi3 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=3000, do_not_save=True) + pi3.apply_tds = 1 + pi3.tax_withholding_category = "Test Multi Invoice Category" + pi3.save() + pi3.submit() + invoices.append(pi3) + + # Over-Withheld amount in pi2 will get adjusted + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi3.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + ) + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi3.name, expected_entries) + self.cleanup_invoices(invoices) + + def test_tds_for_return_invoices(self): + """Test TDS handling for return invoices with 3-entry cancellation approach""" + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + # Create return invoice + pi1 = create_purchase_invoice( + supplier="Test TDS Supplier8", rate=3000, is_return=1, qty=-1, do_not_save=True + ) + pi1.apply_tds = 1 + pi1.tax_withholding_category = "Test Multi Invoice Category" + pi1.save() + pi1.submit() + invoices.append(pi1) + + # Create regular invoice that breaches threshold + pi2 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=10000, do_not_save=True) + pi2.apply_tds = 1 + pi2.tax_withholding_category = "Test Multi Invoice Category" + pi2.save() + pi2.submit() + invoices.append(pi2) + + # Before cancellation: 2 entries (cross-referenced settlement) + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=-3000.0, + withholding_amount=-300.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, # pi2 settles against itself after adjustment + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_entries) + + # Duplicate Entries in P1 before cancellation + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=-3000.0, + withholding_amount=-300.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi1.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + ) + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi1.name, expected_entries) + + pi1.reload() + pi1.cancel() + + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=-3000.0, + withholding_amount=-300.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + under_withheld_reason=None, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=0.0, + status="Under Withheld", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Points to withholding document, not cancelled return + withholding_doctype="", + withholding_name="", + under_withheld_reason="", + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_entries) + + # Test future invoice adjustment against the under withheld credit + pi3 = create_purchase_invoice(supplier="Test TDS Supplier8", rate=5000, do_not_save=True) + pi3.apply_tds = 1 + pi3.tax_withholding_category = "Test Multi Invoice Category" + pi3.save() + pi3.submit() + invoices.append(pi3) + + # pi3 should adjust against the under withheld entry from pi1 cancellation + expected_entries = [ + # Settlement of the cancelled return invoice credit + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # References the source of under withheld + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + ), + # Remaining amount with normal tax + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=5000.0, # 5000 - 3000 already adjusted + withholding_amount=500.0, # 2000 * 10% + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi3.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi3.name, expected_entries) + + # expected entries in pi2 + expected_entries = [ + # Original pi2 entry (unchanged) + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=10000.0, + withholding_amount=1000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + ), + # Entry 1: Original entry from pi1 made settled (self-settle) + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=-3000.0, + withholding_amount=-300.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi2.name, + under_withheld_reason=None, + ), + # Entry 2: Under withheld entry for future adjustment (taxable fields point to pi2) + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=3000.0, + withholding_amount=300.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi2.name, # Points to withholding document, not cancelled return + withholding_doctype="Purchase Invoice", + withholding_name=pi3.name, + under_withheld_reason="", + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi2.name, expected_entries) + + self.cleanup_invoices(invoices) + + def test_manual_tax_withholding_validation(self): + """Test validation when user manually overrides tax withholding entries with incorrect amounts""" + self.setup_party_with_category("Supplier", "Test TDS Supplier6", "Test Multi Invoice Category") + + # Create purchase invoice with manual override + pi = create_purchase_invoice(supplier="Test TDS Supplier6", rate=20000, do_not_save=True) + pi.apply_tds = 1 + pi.tax_withholding_category = "Test Multi Invoice Category" + pi.ignore_tax_withholding_threshold = 1 + pi.save() + + pi.override_tax_withholding_entries = 1 # Enable manual override + pi.tax_withholding_entries[0].withholding_amount = 1500 # incorrect tax withheld + self.assertRaisesRegex( + frappe.ValidationError, + r"Row #\d+: Withholding Amount \d+(\.\d+)? does not match calculated amount \d+(\.\d+)?", + pi.save, + ) + + pi.reload() + pi.tax_withholding_entries[0].taxable_amount = 15000 # correct taxable amount + pi.save() + + def test_manual_tax_adjustment_with_partial_adjustment_and_rate_change(self): + """Test manual tax adjustment where tax rate is changed during adjustment between payment and invoice""" + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + # Step 1: Create a Payment Entry with over withheld amount at 10% rate + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier8", paid_amount=150000 + ) + pe.apply_tds = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save().submit() + + # Step 2: Create Purchase Invoice with partial adjustment and manual rate change + pi = create_purchase_invoice(supplier="Test TDS Supplier8", rate=80000, do_not_save=True) + pi.tax_withholding_category = "Test Multi Invoice Category" + pi.override_tax_withholding_entries = 1 # Enable manual override + pi.tax_withholding_entries = [] + + # Entry 1: Partial adjustment with new tax rate (12% instead of 10%) + pi.append( + "tax_withholding_entries", + { + "tax_withholding_category": "Test Multi Invoice Category", + "party_type": "Supplier", + "party": "Test TDS Supplier8", + "tax_rate": 12.0, # Changed rate from 10% to 12% + "taxable_amount": 50000.0, # Partial taxable amount + "withholding_amount": 6000.0, # 50000 * 12% = 6000 + "taxable_doctype": "Purchase Invoice", + "taxable_name": pi.name, + "withholding_doctype": "Payment Entry", + "withholding_name": pe.name, + "conversion_rate": 1.0, + }, + ) + + # Entry 2: Remaining taxable amount under withheld + pi.append( + "tax_withholding_entries", + { + "tax_withholding_category": "Test Multi Invoice Category", + "party_type": "Supplier", + "party": "Test TDS Supplier8", + "tax_rate": 12.0, # Same new rate + "taxable_amount": 30000.0, # Remaining taxable amount (80000 - 50000) + "withholding_amount": 3600.0, # 30000 * 12% = 3600 + "taxable_doctype": "Purchase Invoice", + "taxable_name": pi.name, + "withholding_doctype": "Purchase Invoice", + "withholding_name": pi.name, + "conversion_rate": 1.0, + }, + ) + + pi.save() + pi.submit() + + # Step 3: Verify the tax withholding entries + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=12.0, # Updated rate + taxable_amount=50000.0, + withholding_amount=6000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=12.0, # Updated rate + taxable_amount=30000.0, + withholding_amount=3600.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + + # expected_entries in pe + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=12.0, # Updated rate + taxable_amount=50000.0, + withholding_amount=6000.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, # Updated rate + taxable_amount=90000.0, + withholding_amount=9000.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) + + def test_manual_tax_adjustment_with_rate_change(self): + """Test manual tax adjustment where tax rate is changed during adjustment between payment and invoice""" + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + # Step 1: Create a Payment Entry with over withheld amount at 10% rate + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier8", paid_amount=150000 + ) + pe.apply_tds = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save().submit() + + # Step 2: Create Purchase Invoice with partial adjustment and manual rate change + pi = create_purchase_invoice(supplier="Test TDS Supplier8", rate=80000, do_not_save=True) + pi.tax_withholding_category = "Test Multi Invoice Category" + pi.override_tax_withholding_entries = 1 # Enable manual override + pi.tax_withholding_entries = [] + + # Entry 1: Partial adjustment with new tax rate (12% instead of 10%) + pi.append( + "tax_withholding_entries", + { + "tax_withholding_category": "Test Multi Invoice Category", + "party_type": "Supplier", + "party": "Test TDS Supplier8", + "tax_rate": 30.0, # Changed rate from 10% to 12% + "taxable_amount": 50000.0, # Partial taxable amount + "withholding_amount": 15000.0, # 50000 * 12% = 6000 + "taxable_doctype": "Purchase Invoice", + "taxable_name": pi.name, + "withholding_doctype": "Payment Entry", + "withholding_name": pe.name, + "conversion_rate": 1.0, + }, + ) + + # Entry 2: Remaining taxable amount under withheld + pi.append( + "tax_withholding_entries", + { + "tax_withholding_category": "Test Multi Invoice Category", + "party_type": "Supplier", + "party": "Test TDS Supplier8", + "tax_rate": 12.0, # Same new rate + "taxable_amount": 30000.0, # Remaining taxable amount (80000 - 50000) + "withholding_amount": 3600.0, # 30000 * 12% = 3600 + "taxable_doctype": "Purchase Invoice", + "taxable_name": pi.name, + "withholding_doctype": "Purchase Invoice", + "withholding_name": pi.name, + "conversion_rate": 1.0, + }, + ) + + pi.save() + pi.submit() + + # Step 3: Verify the tax withholding entries + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=30.0, # Updated rate + taxable_amount=50000.0, + withholding_amount=15000.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=12.0, # Updated rate + taxable_amount=30000.0, + withholding_amount=3600.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + ] + + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, expected_entries) + + # expected_entries in pe + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=30.0, # Updated rate + taxable_amount=50000.0, + withholding_amount=15000.0, + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + + self.validate_tax_withholding_entries("Payment Entry", pe.name, expected_entries) + + def test_manual_tax_adjustment_with_zero_rate(self): + """Test manual tax adjustment where tax rate is changed to zero during adjustment""" + self.setup_party_with_category("Supplier", "Test TDS Supplier8", "Test Multi Invoice Category") + + pe = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier8", paid_amount=100000 + ) + pe.apply_tds = 1 + pe.tax_withholding_category = "Test Multi Invoice Category" + pe.save().submit() + + pe_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, + taxable_amount=100000.0, + withholding_amount=10000.0, + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, pe_expected) + + pi = create_purchase_invoice(supplier="Test TDS Supplier8", rate=50000, do_not_save=True) + pi.tax_withholding_category = "Test Multi Invoice Category" + pi.override_tax_withholding_entries = 1 + pi.tax_withholding_entries = [] + + pi.append( + "tax_withholding_entries", + { + "tax_withholding_category": "Test Multi Invoice Category", + "party_type": "Supplier", + "party": "Test TDS Supplier8", + "tax_rate": 0.0, # Zero rate + "taxable_amount": 50000.0, + "withholding_amount": 0.0, # No withholding at zero rate + "taxable_doctype": "Purchase Invoice", + "taxable_name": pi.name, + "withholding_doctype": "Payment Entry", + "withholding_name": pe.name, + "conversion_rate": 1.0, + }, + ) + + pi.save() + pi.submit() + + # Step 3: Verify the tax withholding entries on invoice + pi_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=0.0, + taxable_amount=50000.0, + withholding_amount=0.0, + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, pi_expected) + + # Verify Payment Entry entries after adjustment + # PE should have: + # 1. Duplicate entry (adjusted 50000 portion with zero rate) + # 2. Over Withheld entry (remaining 50000 portion at 10%) + pe_expected_after = [ + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=0.0, # Updated to zero rate + taxable_amount=50000.0, # Preserved from manual entry + withholding_amount=0.0, # Zero because rate is zero + status="Duplicate", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Test Multi Invoice Category", + party_type="Supplier", + party="Test TDS Supplier8", + tax_rate=10.0, # Original rate + taxable_amount=50000.0, # Adjusted taxable + withholding_amount=10000.0, # Original amount (not split) + status="Over Withheld", # Still over withheld + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, pe_expected_after) + + self.cleanup_invoices([pe, pi]) + +<<<<<<< HEAD +======= + def test_tds_on_journal_entry_for_supplier(self): + """Test TDS deduction for Supplier in Debit Note""" + self.setup_party_with_category("Supplier", "Test TDS Supplier", "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.get("is_tax_withholding_account"): + tds_row = row + elif row.party_type == "Supplier": + supplier_row = row + + self.assertIsNotNone(tds_row, "TDS account row should be created") + self.assertIsNotNone(supplier_row, "Supplier account row should exist") + + # TDS should be credited (liability to government) + self.assertEqual(tds_row.credit, 5000) + self.assertEqual(tds_row.debit, 0) + + # Supplier credit should be reduced by TDS amount + self.assertEqual(supplier_row.credit, 45000) # 50000 - 5000 + + # Validate tax withholding entries + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=50000.0, + withholding_amount=5000.0, + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv.name, + withholding_doctype="Journal Entry", + withholding_name=jv.name, + ) + ] + self.validate_tax_withholding_entries("Journal Entry", jv.name, expected_entries) + + def test_tcs_on_journal_entry_for_customer(self): + """Test TCS collection for Customer in Credit Note""" + self.setup_party_with_category("Customer", "Test TCS Customer", "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.get("is_tax_withholding_account"): + tcs_row = row + elif row.party_type == "Customer": + customer_row = row + + self.assertIsNotNone(tcs_row, "TCS account row should be created") + self.assertIsNotNone(customer_row, "Customer account row should exist") + + # TCS should be credited (liability to government) + self.assertEqual(tcs_row.credit, 2000) # (50000 - 30000) * 10% + self.assertEqual(tcs_row.debit, 0) + + # Customer debit should be increased by TCS amount + self.assertEqual(customer_row.debit, 52000) # 50000 + 2000 + + # Validate tax withholding entries - system creates two entries for threshold processing + expected_entries = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=20000.0, # Excess amount above threshold (50000 - 30000) + withholding_amount=2000.0, # 10% of 20000 + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv.name, + withholding_doctype="Journal Entry", + withholding_name=jv.name, + ), + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TCS", + party_type="Customer", + party="Test TCS Customer", + tax_rate=10.0, + taxable_amount=30000.0, # Threshold exemption amount + withholding_amount=0.0, # No tax on threshold portion + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv.name, + withholding_doctype="Journal Entry", + withholding_name=jv.name, + under_withheld_reason="Threshold Exemption", + ), + ] + self.validate_tax_withholding_entries("Journal Entry", jv.name, expected_entries) +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) + + def test_tds_with_multi_currency_invoice(self): + """Test TDS calculation with multi-currency purchase invoice and payment""" + invoices = [] + + self.setup_party_with_category("Supplier", "_Test Supplier USD", "Cumulative Threshold TDS") + + pe = frappe.get_doc( + { + "doctype": "Payment Entry", + "posting_date": today(), + "payment_type": "Pay", + "party_type": "Supplier", + "party": "_Test Supplier USD", + "company": "_Test Company", + "paid_from": "Cash - _TC", + "paid_to": "_Test Payable USD - _TC", + "paid_amount": 40000, # INR + "received_amount": 500, # USD + "source_exchange_rate": 1, + "target_exchange_rate": 80, + "reference_no": "USD-TDS-001", + "reference_date": today(), + "paid_from_account_currency": "INR", + "paid_to_account_currency": "USD", + "apply_tds": 1, + "tax_withholding_category": "Cumulative Threshold TDS", + } + ) + pe.save() + pe.submit() + invoices.append(pe) + + pe_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="_Test Supplier USD", + tax_rate=10.0, + taxable_amount=40000.0, # Base currency: 500 USD * 80 = 40000 INR + withholding_amount=4000.0, # 10% of 40000 INR + status="Over Withheld", + taxable_doctype="", + taxable_name="", + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ) + ] + self.validate_tax_withholding_entries("Payment Entry", pe.name, pe_expected) + + pi = frappe.get_doc( + { + "doctype": "Purchase Invoice", + "supplier": "_Test Supplier USD", + "company": "_Test Company", + "apply_tds": 1, + "currency": "USD", + "conversion_rate": 80, + "credit_to": "_Test Payable USD - _TC", + "taxes": [], + "items": [ + { + "doctype": "Purchase Invoice Item", + "item_code": frappe.db.get_value("Item", {"item_name": "TDS Item"}, "name"), + "qty": 1, + "rate": 500, # 500 USD = 40000 INR + "cost_center": "Main - _TC", + "expense_account": "_Test Account Cost for Goods Sold - _TC", + } + ], + "advances": [ + { + "doctype": "Purchase Invoice Advance", + "reference_type": "Payment Entry", + "reference_name": pe.name, + "advance_amount": 500, # USD + "allocated_amount": 500, # USD (full allocation) + "ref_exchange_rate": 80, + } + ], + } + ) + pi.save() + pi.submit() + invoices.append(pi) + + pi_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="_Test Supplier USD", + tax_rate=10.0, + taxable_amount=40000.0, # Base currency: 500 USD * 80 = 40000 INR + withholding_amount=4000.0, # 10% of 40000 INR (settled from PE) + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Payment Entry", + withholding_name=pe.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, pi_expected) + self.cleanup_invoices(invoices) + frappe.db.set_value("Supplier", "_Test Supplier USD", "tax_withholding_category", "") + + def test_journal_entry_with_adjustment_in_invoice(self): + """Test Journal Entry with amount below threshold creates Under Withheld entry + and gets settled when a new Purchase Invoice crosses the threshold""" + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + + # Create Debit Note with amount below threshold (30000) + jv = make_journal_entry_with_tax_withholding( + party_type="Supplier", + party="Test TDS Supplier", + voucher_type="Debit Note", + amount=20000, # Below cumulative threshold of 30000 + save=False, + ) + jv.apply_tds = 1 + jv.tax_withholding_category = "Cumulative Threshold TDS" + jv.save() + jv.submit() + invoices.append(jv) + + # Validate tax withholding entries - should have Under Withheld status + jv_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=20000.0, + withholding_amount=0.0, # No tax withheld + status="Under Withheld", + taxable_doctype="Journal Entry", + taxable_name=jv.name, + withholding_doctype="", + withholding_name="", + ) + ] + self.validate_tax_withholding_entries("Journal Entry", jv.name, jv_expected) + + pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=20000) + pi.submit() + invoices.append(pi) + + pi_expected = [ + # Entry for JV's under-withheld amount (now settled via PI) + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=20000.0, # JV's taxable amount + withholding_amount=2000.0, # TDS on JV's amount + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + # Entry for PI's own amount + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=20000.0, # PI's taxable amount + withholding_amount=2000.0, # TDS on PI's amount + status="Settled", + taxable_doctype="Purchase Invoice", + taxable_name=pi.name, + withholding_doctype="Purchase Invoice", + withholding_name=pi.name, + ), + ] + self.validate_tax_withholding_entries("Purchase Invoice", pi.name, pi_expected) + + self.cleanup_invoices(invoices) + + def test_journal_entry_negative_amount_debit_note(self): + """Test Journal Entry with negative amount (reversal of Debit Note)""" + invoices = [] + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + + # First create a regular Debit Note to cross threshold + jv1 = make_journal_entry_with_tax_withholding( + party_type="Supplier", + party="Test TDS Supplier", + voucher_type="Debit Note", + amount=50000, + save=False, + ) + jv1.apply_tds = 1 + jv1.tax_withholding_category = "Cumulative Threshold TDS" + jv1.save() + jv1.submit() + invoices.append(jv1) + + # Validate first JV entries + jv1_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=50000.0, + withholding_amount=5000.0, + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv1.name, + withholding_doctype="Journal Entry", + withholding_name=jv1.name, + ) + ] + self.validate_tax_withholding_entries("Journal Entry", jv1.name, jv1_expected) + + jv2 = frappe.new_doc("Journal Entry") + jv2.posting_date = today() + jv2.company = "_Test Company" + jv2.voucher_type = "Debit Note" + jv2.multi_currency = 0 + jv2.apply_tds = 1 + jv2.tax_withholding_category = "Cumulative Threshold TDS" + + jv2.append( + "accounts", + { + "account": "Stock Received But Not Billed - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": 50000, # Credit (reversal of expense) + "exchange_rate": 1, + }, + ) + + # Supplier account: Debit 50000 (instead of normal Credit) + # This reduces supplier liability (refund/reversal) + jv2.append( + "accounts", + { + "account": "Creditors - _TC", + "party_type": "Supplier", + "party": "Test TDS Supplier", + "cost_center": "_Test Cost Center - _TC", + "debit_in_account_currency": 50000, # Debit (reversal) + "exchange_rate": 1, + }, + ) + + jv2.save() + jv2.submit() + invoices.append(jv2) + + jv2_expected = [ + self.get_tax_withholding_entry( + tax_withholding_category="Cumulative Threshold TDS", + party_type="Supplier", + party="Test TDS Supplier", + tax_rate=10.0, + taxable_amount=-50000.0, # Negative taxable amount + withholding_amount=-5000.0, # Negative withholding (reversal) + status="Settled", + taxable_doctype="Journal Entry", + taxable_name=jv2.name, + withholding_doctype="Journal Entry", + withholding_name=jv2.name, + ) + ] + self.validate_tax_withholding_entries("Journal Entry", jv2.name, jv2_expected) + self.cleanup_invoices(invoices) + + def test_delete_draft_pi_with_tax_withholding_entries(self): + """ + Test that draft Purchase Invoice with Tax Withholding Entries can be deleted. + """ + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + + pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000, do_not_save=True) + pi.save() + + self.assertTrue(len(pi.tax_withholding_entries) > 0) + pi.delete() + + def test_tds_rounding_with_decimal_amounts(self): + """Test TDS rounding when round_off_tax_amount is enabled in category""" + self.setup_party_with_category("Supplier", "Test TDS Supplier3", "New TDS Category") + + pi = create_purchase_invoice(supplier="Test TDS Supplier3", rate=35555) + pi.submit() + + tds_row = next(e for e in pi.tax_withholding_entries if e.withholding_amount > 0) + self.assertEqual(tds_row.withholding_amount, 556) + + self.cleanup_invoices([pi]) + + def test_tax_withholding_entry_status_determination(self): + """Test that Tax Withholding Entry status is correctly determined""" + from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import ( + TaxWithholdingEntry, + ) + + # Entry with only taxable fields (Under Withheld) + entry = frappe._dict( + docstatus=1, + withholding_name="", + under_withheld_reason="", + taxable_name="PI-001", + ) + self.assertEqual(TaxWithholdingEntry.get_status(entry), "Under Withheld") + + # Entry with withholding but no taxable (Over Withheld) + entry = frappe._dict( + docstatus=1, + withholding_name="PE-001", + under_withheld_reason="", + taxable_name="", + ) + self.assertEqual(TaxWithholdingEntry.get_status(entry), "Over Withheld") + + # Entry with both (Settled) + entry = frappe._dict( + docstatus=1, + withholding_name="PE-001", + under_withheld_reason="", + taxable_name="PI-001", + ) + self.assertEqual(TaxWithholdingEntry.get_status(entry), "Settled") + + # Entry with under withheld reason (considered matched/settled) + entry = frappe._dict( + docstatus=1, + withholding_name="", + under_withheld_reason="Threshold Exemption", + taxable_name="PI-001", + ) + self.assertEqual(TaxWithholdingEntry.get_status(entry), "Settled") + + # Cancelled entry + entry = frappe._dict(docstatus=2, withholding_name="", under_withheld_reason="", taxable_name="") + self.assertEqual(TaxWithholdingEntry.get_status(entry), "Cancelled") + + def test_invalid_withholding_amount_validation(self): + """Test that mismatched withholding amounts throw validation error on save""" + self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS") + pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000) + + self.assertTrue(len(pi.tax_withholding_entries) > 0) + pi.override_tax_withholding_entries = 1 + + entry = pi.tax_withholding_entries[0] + entry.withholding_amount = 5001 # Should be 5000 (10% of 50000) + self.assertRaisesRegex(frappe.ValidationError, "Withholding Amount.*does not match", pi.save) def create_purchase_invoice(**args): @@ -882,6 +3574,7 @@ def create_purchase_invoice(**args): "set_posting_time": args.set_posting_time or False, "posting_date": args.posting_date or today(), "apply_tds": 0 if args.do_not_apply_tds else 1, + "is_return": args.is_return or 0, "supplier": args.supplier, "company": "_Test Company", "taxes_and_charges": "", @@ -949,6 +3642,7 @@ def create_sales_invoice(**args): "posting_date": today(), "customer": args.customer, "company": "_Test Company", + "apply_tds": 0 if args.do_not_apply_tds else 1, "taxes_and_charges": "", "currency": "INR", "debit_to": "Debtors - _TC", @@ -1094,6 +3788,7 @@ def create_tax_withholding_category_records(): account="TDS - _TC", single_threshold=0, cumulative_threshold=30000.00, + disable_transaction_threshold=1, ) # Category for TCS @@ -1105,6 +3800,9 @@ def create_tax_withholding_category_records(): account="TCS - _TC", single_threshold=0, cumulative_threshold=30000.00, + disable_transaction_threshold=1, + tax_deduction_basis="Gross Total", + tax_on_excess_amount=1, ) # Single threshold @@ -1127,7 +3825,6 @@ def create_tax_withholding_category_records(): single_threshold=0, cumulative_threshold=30000, round_off_tax_amount=1, - consider_party_ledger_amount=1, tax_on_excess_amount=1, ) @@ -1169,7 +3866,6 @@ def create_tax_withholding_category_records(): account="TDS - _TC", single_threshold=5000, cumulative_threshold=10000, - consider_party_ledger_amount=1, ) create_tax_withholding_category( @@ -1180,6 +3876,7 @@ def create_tax_withholding_category_records(): account="TDS - _TC", single_threshold=0, cumulative_threshold=30000, + disable_transaction_threshold=1, ) @@ -1192,8 +3889,9 @@ def create_tax_withholding_category( single_threshold=0, cumulative_threshold=0, round_off_tax_amount=0, - consider_party_ledger_amount=0, tax_on_excess_amount=0, + disable_transaction_threshold=0, + tax_deduction_basis="Net Total", ): if not frappe.db.exists("Tax Withholding Category", category_name): frappe.get_doc( @@ -1202,8 +3900,9 @@ def create_tax_withholding_category( "name": category_name, "category_name": category_name, "round_off_tax_amount": round_off_tax_amount, - "consider_party_ledger_amount": consider_party_ledger_amount, "tax_on_excess_amount": tax_on_excess_amount, + "disable_transaction_threshold": disable_transaction_threshold, + "tax_deduction_basis": tax_deduction_basis, "rates": [ { "from_date": from_date, diff --git a/erpnext/accounts/doctype/advance_tax/__init__.py b/erpnext/accounts/doctype/tax_withholding_entry/__init__.py similarity index 100% rename from erpnext/accounts/doctype/advance_tax/__init__.py rename to erpnext/accounts/doctype/tax_withholding_entry/__init__.py diff --git a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.json b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.json new file mode 100644 index 00000000000..76109669216 --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.json @@ -0,0 +1,237 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-06-20 04:55:28.583171", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_krko", + "company", + "party_type", + "party", + "tax_id", + "column_break_egzm", + "tax_withholding_category", + "tax_withholding_group", + "taxable_amount", + "tax_rate", + "withholding_amount", + "target_section", + "taxable_doctype", + "taxable_name", + "taxable_date", + "currency", + "conversion_rate", + "column_break_fqoe", + "under_withheld_reason", + "lower_deduction_certificate", + "source_section", + "withholding_doctype", + "withholding_name", + "withholding_date", + "column_break_dahw", + "section_break_ggna", + "status", + "column_break_jfjf", + "created_by_migration" + ], + "fields": [ + { + "fieldname": "section_break_krko", + "fieldtype": "Section Break" + }, + { + "fieldname": "party_type", + "fieldtype": "Link", + "label": "Party Type", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "party", + "fieldtype": "Dynamic Link", + "label": "Party", + "options": "party_type", + "read_only": 1 + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "label": "Tax ID", + "read_only": 1 + }, + { + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax Withholding Category", + "options": "Tax Withholding Category", + "read_only": 1 + }, + { + "fieldname": "column_break_egzm", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fieldname": "tax_rate", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Tax Rate" + }, + { + "columns": 1, + "fieldname": "taxable_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Base Taxable Amount", + "options": "Company:company:default_currency" + }, + { + "description": "Transaction from which tax is withheld", + "fieldname": "source_section", + "fieldtype": "Section Break", + "label": "Deducted From" + }, + { + "fieldname": "column_break_dahw", + "fieldtype": "Column Break" + }, + { + "description": "Transaction for which tax is withheld", + "fieldname": "target_section", + "fieldtype": "Section Break", + "label": "Applicable For" + }, + { + "fieldname": "column_break_fqoe", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_ggna", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_jfjf", + "fieldtype": "Column Break" + }, + { + "fieldname": "lower_deduction_certificate", + "fieldtype": "Link", + "label": "Lower Deduction Certificate", + "options": "Lower Deduction Certificate", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nSettled\nUnder Withheld\nOver Withheld\nDuplicate\nCancelled", + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "read_only": 1 + }, + { + "fieldname": "conversion_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "precision": "9", + "read_only": 1 + }, + { + "fieldname": "withholding_doctype", + "fieldtype": "Link", + "label": "Withholding Document Type", + "options": "DocType" + }, + { + "fieldname": "withholding_name", + "fieldtype": "Dynamic Link", + "label": "Withholding Document Name", + "options": "withholding_doctype" + }, + { + "fieldname": "taxable_doctype", + "fieldtype": "Link", + "label": "Taxable Document Type", + "options": "DocType" + }, + { + "fieldname": "taxable_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Taxable Document Name", + "options": "taxable_doctype" + }, + { + "fieldname": "taxable_date", + "fieldtype": "Date", + "label": "Taxable Date", + "read_only": 1 + }, + { + "fieldname": "withholding_date", + "fieldtype": "Date", + "label": "Withholding Date", + "read_only": 1 + }, + { + "fieldname": "under_withheld_reason", + "fieldtype": "Select", + "label": "Under Withheld Reason", + "options": "\nThreshold Exemption\nLower Deduction Certificate", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "withholding_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Base Tax Withheld", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax Withholding Group", + "options": "Tax Withholding Group", + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "default": "0", + "fieldname": "created_by_migration", + "fieldtype": "Check", + "hidden": 1, + "label": "Created By Migration", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-12-22 09:07:26.701207", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Tax Withholding Entry", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py new file mode 100644 index 00000000000..96a2768e68c --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_entry/tax_withholding_entry.py @@ -0,0 +1,1460 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from collections import defaultdict, deque +from math import inf + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.query_builder.functions import IfNull, Sum +from frappe.utils import flt + +import erpnext +from erpnext.accounts.utils import get_advance_payment_doctypes + +DOCTYPE = "Tax Withholding Entry" + + +class TaxWithholdingEntry(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + company: DF.Link | None + conversion_rate: DF.Float + created_by_migration: DF.Check + currency: DF.Link | None + lower_deduction_certificate: DF.Link | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + party: DF.DynamicLink | None + party_type: DF.Link | None + status: DF.Literal["", "Settled", "Under Withheld", "Over Withheld", "Duplicate", "Cancelled"] + tax_id: DF.Data | None + tax_rate: DF.Percent + tax_withholding_category: DF.Link | None + tax_withholding_group: DF.Link | None + taxable_amount: DF.Currency + taxable_date: DF.Date | None + taxable_doctype: DF.Link | None + taxable_name: DF.DynamicLink | None + under_withheld_reason: DF.Literal["", "Threshold Exemption", "Lower Deduction Certificate"] + withholding_amount: DF.Currency + withholding_date: DF.Date | None + withholding_doctype: DF.Link | None + withholding_name: DF.DynamicLink | None + # end: auto-generated types + + def set_status(self, status=None): + if not status: + status = self.get_status() + + self.status = status + + def get_status(self): + if self.docstatus == 2: + return "Cancelled" + + # Reasons are genuine allowed reasons for under deduction. + # Hence if a reason is provided, consider it as matched. + if not self.withholding_name and not self.under_withheld_reason: + return "Under Withheld" + + elif not self.taxable_name: + return "Over Withheld" + + else: + return "Settled" + + def validate_adjustments(self): + if self.is_taxable_different and self.is_withholding_different: + frappe.throw( + _( + "Row #{0}: Cannot create entry with different taxable AND withholding document links." + ).format(self.idx) + ) + + def validate_tax_withheld_amount(self): + if not self.withholding_name or self.under_withheld_reason: + return + + precision = self.precision("withholding_amount") + allowance = 0.5 + + tax_to_withheld = flt(self.taxable_amount * (self.tax_rate / 100), precision) + diff = abs(tax_to_withheld - self.withholding_amount) + if diff > allowance: + frappe.throw( + _("Row #{0}: Withholding Amount {1} does not match calculated amount {2}.").format( + self.idx, self.withholding_amount, tax_to_withheld + ) + ) + + @property + def is_taxable_different(self): + return self.taxable_doctype != self.parenttype or self.taxable_name != self.parent + + @property + def is_withholding_different(self): + return self.withholding_doctype != self.parenttype or self.withholding_name != self.parent + + def _process_tax_withholding_adjustments(self): + if self.status != "Settled": + return + # adjust old taxable (under-withheld) + if self.is_taxable_different: + self._adjust_against_old_entries(field_type="taxable") + + # adjust old withholding (over-withheld) + elif self.is_withholding_different: + self._adjust_against_old_entries(field_type="withholding") + + def _adjust_against_old_entries(self, field_type: str) -> set: + """ + Find old entries that need adjustment and update them. + The logic reads like: "Match up old incomplete entries with this new entry" + + Args: + field_type: Either "taxable" or "withholding" - determines which fields to use + """ + + doctype_field = f"{field_type}_doctype" + docname_field = f"{field_type}_name" + amount_field = f"{field_type}_amount" + status_to_find = "Under Withheld" if field_type == "taxable" else "Over Withheld" + + if not self.tax_rate: + amount_field = "taxable_amount" + + # old entries + old_entries = frappe.get_all( + DOCTYPE, + filters={ + # NOTE: Allow offsetting across different categories + # Change Filters + "tax_withholding_category": self.tax_withholding_category, + "status": status_to_find, + doctype_field: self.get(doctype_field), + docname_field: self.get(docname_field), + "docstatus": 1, + }, + fields="*", + ) + + value_direction = -1 if self.get(amount_field) < 0 else 1 + remaining_amount = abs(self.get(amount_field)) + docs_needing_reindex = set() + precision = self.precision("taxable_amount") + + # update + for old_entry_data in old_entries: + old_entry = frappe.get_doc(DOCTYPE, **old_entry_data) + old_amount = abs(old_entry.get(amount_field)) + + if old_entry.get(amount_field) * value_direction < 0: + # sign of old entry's amount is different + continue + + amount_we_can_match = min(old_amount, remaining_amount) + proportion = amount_we_can_match / old_amount if old_amount else 0 + values_to_update = self._get_values_to_update(old_entry, proportion, field_type) + + if old_amount <= amount_we_can_match: + # complete adjustment + frappe.db.set_value(DOCTYPE, old_entry.name, values_to_update) + + else: + # partial adjustment + # Calculate balance values for both taxable and withholding amounts + balance_amount = (old_amount - amount_we_can_match) * value_direction + + balance_values = self._get_balance_values_to_update(old_entry, proportion, field_type) + balance_values[amount_field] = balance_amount + + frappe.db.set_value(DOCTYPE, old_entry.name, balance_values) + + # new entry + # For partial adjustments, we need to proportionally adjust both taxable and withholding amounts + values_to_update["withholding_amount"] = old_entry.withholding_amount * proportion + values_to_update["taxable_amount"] = old_entry.taxable_amount * proportion + + # If tax rate has changed, recalculate based on new rate + if self.tax_rate != old_entry.tax_rate: + if not self.tax_rate: + # Zero rate means no withholding + values_to_update["withholding_amount"] = 0 + else: + values_to_update["taxable_amount"] = flt( + values_to_update["withholding_amount"] * 100 / self.tax_rate, + precision, + ) + + new_entry = frappe.copy_doc(old_entry) + new_entry.update(values_to_update) + new_entry.insert() + + docs_needing_reindex.add((old_entry.parenttype, old_entry.parent)) + + remaining_amount -= amount_we_can_match + + if remaining_amount <= 0: + break + + else: + frappe.throw( + _("Row #{0}: Could not find enough {1} entries to match. Remaining amount: {2}").format( + self.idx, status_to_find, remaining_amount + ) + ) + _reset_idx(docs_needing_reindex) + + def _get_values_to_update(self, old_entry, proportion: float, field_type: str): + field_to_update = "withholding" if field_type == "taxable" else "taxable" + + values = { + f"{field_to_update}_amount": self.get(f"{field_to_update}_amount") * proportion, + f"{field_to_update}_doctype": self.get(f"{field_to_update}_doctype"), + f"{field_to_update}_name": self.get(f"{field_to_update}_name"), + f"{field_to_update}_date": self.get(f"{field_to_update}_date"), + "tax_rate": self.tax_rate, + "status": "Duplicate", + "under_withheld_reason": None, + } + + if field_to_update == "taxable": + values.update( + currency=self.currency, + conversion_rate=self.conversion_rate, + under_withheld_reason=self.under_withheld_reason, + lower_deduction_certificate=self.lower_deduction_certificate, + ) + + # NOTE: Allow offsetting across different categories + # Update Tax Withholding Category values + + return values + + def _get_balance_values_to_update(self, old_entry, proportion: float, field_type: str): + """Calculate the balance amounts for both taxable and withholding fields for partial adjustments""" + field_to_update = "withholding" if field_type == "taxable" else "taxable" + field = f"{field_to_update}_amount" + proportion = 1 - proportion + + amount = flt(old_entry.get(field) * proportion, self.precision(field)) + + return {field: amount} + + # CANCEL + def _clear_old_references(self): + if self.status not in ["Settled", "Duplicate"]: + return + + filters = { + "tax_withholding_category": self.tax_withholding_category, + "taxable_doctype": self.taxable_doctype, + "taxable_name": self.taxable_name, + "withholding_doctype": self.withholding_doctype, + "withholding_name": self.withholding_name, + "name": ["!=", self.name], + "docstatus": 1, + } + + if self.is_taxable_different: + frappe.db.set_value( + DOCTYPE, + filters, + { + "withholding_name": "", + "withholding_doctype": "", + "withholding_amount": 0, + "withholding_date": None, + "under_withheld_reason": "", + "lower_deduction_certificate": "", + "status": "Under Withheld", + }, + ) + + elif self.is_withholding_different: + if self.taxable_amount < 0: + # Special handling for return invoice cancellation + self._handle_return_invoice_cancellation(filters) + else: + frappe.db.set_value( + DOCTYPE, + filters, + { + "taxable_name": "", + "taxable_doctype": "", + "taxable_date": None, + "status": "Over Withheld", + }, + ) + + def _handle_return_invoice_cancellation(self, filters): + # Get old entries that need adjustment - inspired by _adjust_against_old_entries + old_entries = frappe.get_all( + DOCTYPE, + filters=filters, + fields="*", + ) + + docs_needing_reindex = set() + + for entry in old_entries: + frappe.db.set_value( + DOCTYPE, + entry.name, + { + "taxable_doctype": entry.withholding_doctype, + "taxable_name": entry.withholding_name, + "taxable_date": entry.withholding_date, + }, + ) + + # cases where withholding amount is zero + if entry.withholding_amount == 0: + continue + + new_entry = frappe.copy_doc(frappe.get_doc(DOCTYPE, **entry)) + values_to_update = { + "taxable_amount": abs(entry.taxable_amount), + "withholding_amount": 0, + "status": "Under Withheld", + "under_withheld_reason": "", + "taxable_doctype": entry.withholding_doctype, + "taxable_name": entry.withholding_name, + "taxable_date": entry.withholding_date, + "withholding_doctype": "", + "withholding_name": "", + "withholding_date": None, + } + new_entry.update(values_to_update) + new_entry.insert() + + docs_needing_reindex.add((entry.parenttype, entry.parent)) + + _reset_idx(docs_needing_reindex) + + +from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( + TaxWithholdingDetails, + get_tax_id_for_party, +) + + +class TaxWithholdingController: + def __init__(self, doc): + self.doc = doc + self.entries = [] + self.precision = self.doc.precision("withholding_amount", "tax_withholding_entries") + + def _get_category_details(self): + """Get tax withholding category details for the current document""" + category_names = self._get_category_names() + + return TaxWithholdingDetails( + category_names, + self.doc.tax_withholding_group, + self.doc.posting_date, + self.party_type, + self.party, + self.doc.company, + ).get() + + def _get_category_names(self): + category_names = set( + item.tax_withholding_category + for item in self.doc.items + if item.tax_withholding_category and item.apply_tds + ) + + return category_names + + def calculate(self): + # Always get category details first for account mapping + self.category_details = self._get_category_details() + + if not self.doc.override_tax_withholding_entries: + self._generate_withholding_entries() + + # Final processing - entry status and tax_update + self._process_withholding_entries() + + def _generate_withholding_entries(self): + # Clear existing entries + self.doc.tax_withholding_entries = [] + + # Calculate taxable amounts for each category + self._update_taxable_amounts() + + # Apply threshold rules + self._evaluate_thresholds() + + # Generate entries for each category + for category in self.category_details.values(): + self.entries += self._create_entries_for_category(category) + + # Add all generated entries to the document + self.doc.extend("tax_withholding_entries", self.entries) + + def _create_entries_for_category(self, category): + entries = [] + + if not category.taxable_amount: + return entries + + # Case 1: Threshold not crossed - create under withheld entry + if not category.threshold_crossed: + entries.append(self._create_under_withheld_entry(category)) + category.taxable_amount = 0 + return entries + + # Case 2: Tax on excess amount - handle threshold exemption first + if category.unused_threshold: + entries.append(self._create_threshold_exemption_entry(category)) + if category.taxable_amount <= 0: + return entries + + # Case 3: Process remaining amount with historical entries + open_entries = self._get_open_entries_for_category(category) + under_entries = open_entries["under_withheld"] + over_entries = open_entries["over_withheld"] + + # Case 4: Adjust Under and Over Withheld Entries + entries.extend(self._adjust_under_over_withheld(under_entries, over_entries, category)) + + # Case 4: Lower Deduction Certificate processing + if category.ldc_unutilized_amount: + entries.extend(self._process_ldc_entries(under_entries, over_entries, category)) + + # Case 5: Regular tax withholding processing + entries.extend(self._merge_entries(under_entries, over_entries, category)) + + return entries + + def _create_under_withheld_entry(self, category): + """Create an under withheld entry when threshold is not crossed""" + return { + **self._create_default_entry(category), + "taxable_amount": category.taxable_amount, + "withholding_doctype": "", + "withholding_name": "", + "withholding_date": "", + "withholding_amount": 0, + } + + def _create_threshold_exemption_entry(self, category): + """Create entry for amount below threshold (tax on excess)""" + taxable_amount = min(category.unused_threshold, category.taxable_amount) + category.taxable_amount -= taxable_amount + + return { + **self._create_default_entry(category), + "taxable_amount": taxable_amount, + "under_withheld_reason": "Threshold Exemption", + } + + def _get_open_entries_for_category(self, category): + """Get historical under withheld and over withheld entries for processing""" + entries = self._get_historical_entries(category) + linked_payments = self._get_linked_payments() + + open_entries = {"under_withheld": deque(), "over_withheld": deque()} + + # Process historical entries + self._categorize_historical_entries(entries, linked_payments, open_entries) + + # Add current document as under withheld + current_entry = self._create_default_entry(category) + current_entry.update( + { + "taxable_amount": category.taxable_amount, + "withholding_doctype": "", + "withholding_name": "", + "withholding_date": "", + } + ) + open_entries["under_withheld"].appendleft(current_entry) + + return open_entries + + def _categorize_historical_entries(self, entries, linked_payments, open_entries): + """Categorize historical entries into under withheld and over withheld""" + for entry in entries: + if entry.status == "Under Withheld": + open_entries["under_withheld"].append(entry) + continue + + # Handle over withheld entries + key = (entry.withholding_doctype, entry.withholding_name) + if key in linked_payments: + # Calculate proportion for linked payments + # TODO: whether it should be entry.taxable_amount only or do we need proportion + total_value = entry.taxable_amount - entry.withholding_amount + if not total_value: + continue + proportion = linked_payments[key] / total_value + + # for handling rounding adjustments + proportion = min(proportion, 1) + entry.withholding_amount *= proportion + open_entries["over_withheld"].appendleft(entry) + continue + + # only linked payment entries are allowed + if entry.withholding_doctype in ["Payment Entry", "Journal Entry"]: + continue + + open_entries["over_withheld"].append(entry) + + def _process_ldc_entries(self, under_entries, over_entries, category): + ldc_config = { + "under_withheld_reason": "Lower Deduction Certificate", + "lower_deduction_certificate": category.ldc_certificate, + } + + return self._merge_entries( + under_entries, + over_entries, + category, + tax_rate=category.ldc_rate, + constraint=category.ldc_unutilized_amount, + default_obj=ldc_config, + ) + + def _update_taxable_amounts(self): + if not self.doc.base_net_total: + return + + self._update_amount_for_item() + + def _update_amount_for_item(self): + precision = self.doc.precision("base_net_rate", "items") + self._update_item_wise_tax_amount() + + for item in self.doc.get("items"): + if not (item.apply_tds and item.tax_withholding_category): + continue + + category = self.category_details.get(item.tax_withholding_category) + + if category.tax_deduction_basis != "Gross Total": + taxable_amount = item.base_net_amount + else: + taxable_amount = item.base_net_amount + item._item_total_tax_amount + + taxable_amount = flt(taxable_amount, precision) + + item._base_tax_withholding_net_total = taxable_amount + + category["taxable_amount"] += flt(taxable_amount, precision) + + def _update_item_wise_tax_amount(self): + for item in self.doc.get("items"): + item._item_total_tax_amount = 0 + + precision = self.doc.precision("tax_amount", "taxes") + for row in self.doc.get("_item_wise_tax_details", []): + item = row.item + + if not (item.apply_tds and item.tax_withholding_category): + continue + + if row.tax.is_tax_withholding_account: + continue + + item._item_total_tax_amount = flt(item._item_total_tax_amount + row.amount, precision) + + def _evaluate_thresholds(self): + """ + Evaluate if thresholds are crossed for each category + + Thresholds are crossed when: + - Single transaction threshold is exceeded + - Cumulative threshold is exceeded + - Threshold check is manually overridden + """ + for category in self.category_details.values(): + category.threshold_crossed = self._is_threshold_crossed_for_category(category) + category.unused_threshold = self._get_unused_threshold(category) + + def _is_threshold_crossed_for_category(self, category): + # Manual override - always cross threshold + if self.doc.ignore_tax_withholding_threshold: + return True + + # Transaction threshold only mode + if category.disable_cumulative_threshold: + return category.taxable_amount >= category.single_threshold + + # No cumulative threshold set + if category.cumulative_threshold == 0: + return True + + # Tax on excess amount - always process + if category.tax_on_excess_amount: + return True + + # Standard cumulative/transaction threshold check + return self._check_historical_threshold_status(category) + + def _check_historical_threshold_status(self, category): + entry = frappe.qb.DocType(DOCTYPE) + result = frappe._dict( + self._base_threshold_query(category).where(entry.status.isin(["Settled", "Under Withheld"])).run() + ) + + # NOTE: Once deducted, always deducted. Not checking cumulative threshold again purposefully. + # conservative approach to avoid tax disputes as it can have conflicting views + # https://www.taxtmi.com/forum/issue?id=118627 + + if result.get("Settled", 0) > 0: + return True + + # Check remaining threshold + remaining_threshold = category.cumulative_threshold - result.get("Under Withheld", 0) + + if not category.disable_transaction_threshold: + remaining_threshold = min(remaining_threshold, category.single_threshold) + + return category.taxable_amount >= remaining_threshold + + def _get_unused_threshold(self, category): + """Calculate unused threshold amount for tax on excess scenarios""" + if not category.tax_on_excess_amount: + return 0 + + entry = frappe.qb.DocType(DOCTYPE) + result = frappe._dict( + self._base_threshold_query(category) + .where(IfNull(entry.under_withheld_reason, "") == "Threshold Exemption") + .run() + ) + + return category.cumulative_threshold - result.get("Settled", 0) + + def _base_threshold_query(self, category): + entry = frappe.qb.DocType(DOCTYPE) + query = ( + frappe.qb.from_(entry) + .select(entry.status, Sum(entry.taxable_amount).as_("taxable_amount")) + .where(entry.party_type == self.party_type) + .where(entry.tax_withholding_category == category.name) + .where(entry.company == self.doc.company) + .where(entry.docstatus == 1) + .groupby(entry.status) + ) + + # NOTE: This can be a configurable option + # To check if filter by tax_id is needed + tax_id = get_tax_id_for_party(self.party_type, self.party) + query = query.where(entry.tax_id == tax_id) if tax_id else query.where(entry.party == self.party) + + return query + + def _get_historical_entries(self, category): + entry = frappe.qb.DocType(DOCTYPE) + base_query = ( + frappe.qb.from_(entry) + .select("*") + .where(entry.tax_withholding_category == category.name) + .where(entry.party_type == self.party_type) + .where(entry.party == self.party) + .where(entry.company == self.doc.company) + .where(entry.docstatus == 1) + ) + + over_withheld_query = base_query.where(entry.status == "Over Withheld") + + return ( + base_query.where(entry.status == "Under Withheld") + .where(entry.taxable_date.between(category.from_date, category.to_date)) + .union(over_withheld_query) + .run(as_dict=True) + ) + + def _get_linked_payments(self): + references = frappe._dict() + for ref in self.doc.advances: + key = (ref.reference_type, ref.reference_name) + references[key] = ref.allocated_amount * self.doc.conversion_rate + + return references + + def _create_default_entry(self, category): + return frappe._dict( + { + "company": self.doc.company, + "party_type": self.party_type, + "party": self.party, + "tax_withholding_category": category.name, + "tax_withholding_group": category.tax_withholding_group, + "tax_rate": category.tax_rate, + "conversion_rate": self.get_conversion_rate(), + "taxable_doctype": self.doc.doctype, + "taxable_name": self.doc.name, + "taxable_date": self.doc.posting_date, + "taxable_amount": 0, + "withholding_doctype": self.doc.doctype, + "withholding_name": self.doc.name, + "withholding_date": self.doc.posting_date, + "withholding_amount": 0, # Will be computed later + } + ) + + def update_tax_rows(self): + """Update tax rows in the parent document based on withholding entries""" + account_amount_map = self._calculate_account_wise_amount() + category_withholding_map = self._get_category_withholding_map() + existing_taxes = {row.account_head: row for row in self.doc.taxes if row.is_tax_withholding_account} + precision = self.doc.precision("tax_amount", "taxes") + conversion_rate = self.get_conversion_rate() + + for account_head, base_amount in account_amount_map.items(): + tax_amount = flt(base_amount / conversion_rate, precision) + if not tax_amount: + continue + + # Update existing tax row or create new one + if existing_tax := existing_taxes.get(account_head): + existing_tax.tax_amount = tax_amount + existing_tax.dont_recompute_tax = 1 + tax_row = existing_tax + for_update = True + else: + tax_row = self._create_tax_row(account_head, tax_amount) + for_update = False + + # Set item-wise tax breakup for this tax row + self._set_item_wise_tax_for_tds( + tax_row, account_head, category_withholding_map, for_update=for_update + ) + + self._remove_zero_tax_rows() + self.calculate_taxes_and_totals() + + def _create_tax_row(self, account_head, tax_amount): + cost_center = self.doc.cost_center or erpnext.get_default_cost_center(self.doc.company) + return self.doc.append( + "taxes", + { + "is_tax_withholding_account": 1, + "category": "Total", + "charge_type": "Actual", + "account_head": account_head, + "description": account_head, + "cost_center": cost_center, + "add_deduct_tax": "Deduct", + "tax_amount": tax_amount, + "dont_recompute_tax": 1, + }, + ) + + def _set_item_wise_tax_for_tds(self, tax_row, account_head, category_withholding_map, for_update=False): + # Get all categories for this account (multiple categories can share same account) + categories_for_account = [ + cat for cat in self.category_details.values() if cat.account_head == account_head + ] + + if not categories_for_account: + return + + if not hasattr(self.doc, "_item_wise_tax_details"): + self.doc._item_wise_tax_details = [] + + if for_update: + self.doc._item_wise_tax_details = [ + d for d in self.doc._item_wise_tax_details if d.get("tax") != tax_row + ] + + items = self.doc.get("items") or [] + category_totals = {} + for item in items: + if item.apply_tds and item.tax_withholding_category: + item_taxable = item.get("_base_tax_withholding_net_total", 0) + category_totals[item.tax_withholding_category] = ( + category_totals.get(item.tax_withholding_category, 0) + item_taxable + ) + + precision = self.doc.precision("tax_amount", "taxes") + for item in items: + if not (item.apply_tds and item.tax_withholding_category): + continue + + category = self.category_details.get(item.tax_withholding_category) + if not category or category.account_head != account_head: + continue + + item_base_taxable = item.get("_base_tax_withholding_net_total") or 0 + + if not category.taxable_amount or not item_base_taxable: + continue + + total_taxable_amount = category_totals.get(category.name, 0) + + if category.unused_threshold and total_taxable_amount: + # Proportionately deduct unused threshold from item's base taxable + item_threshold_deduction = ( + item_base_taxable / total_taxable_amount + ) * category.unused_threshold + item_effective_taxable = max(0, item_base_taxable - item_threshold_deduction) + else: + item_effective_taxable = item_base_taxable + + withholding_amount = category_withholding_map.get(category.name, 0) + if withholding_amount and category.taxable_amount: + item_proportion = item_effective_taxable / category.taxable_amount + item_tax_amount = flt(withholding_amount * item_proportion, precision) + else: + item_tax_amount = 0 + + self.doc._item_wise_tax_details.append( + frappe._dict( + item=item, + tax=tax_row, + rate=category.tax_rate, + amount=item_tax_amount * -1, # Negative because it's a deduction + taxable_amount=item_base_taxable, + ) + ) + + def _get_category_withholding_map(self): + category_withholding_map = defaultdict(float) + + for entry in self.doc.tax_withholding_entries: + if entry.withholding_name != self.doc.name: + continue + category_withholding_map[entry.tax_withholding_category] += entry.withholding_amount + + return category_withholding_map + + def _calculate_account_wise_amount(self): + account_amount_map = defaultdict(float) + + for entry in self.doc.tax_withholding_entries: + if entry.withholding_name != self.doc.name: + continue + category = self.category_details.get(entry.tax_withholding_category) + account_amount_map[category.account_head] += entry.withholding_amount + + return account_amount_map + + def _remove_zero_tax_rows(self): + self.doc.taxes = [ + row for row in self.doc.taxes if not (row.is_tax_withholding_account and not row.tax_amount) + ] + + def _adjust_under_over_withheld( + self, + under_entries: deque, + over_entries: deque, + category: dict, + ): + """ + Merge under withheld and over withheld entries based on the tax rate and constraint. + If only under and over entries are available, they will be processed against current document. + """ + if not (under_entries and over_entries): + return [] + + merged_entries = [] + + while under_entries and over_entries: + under = under_entries[0] + over = over_entries[0] + tax_rate = over.tax_rate + + # Calculate tax amount for this taxable amount + tax_amount = self.compute_withheld_amount( + under.taxable_amount, + tax_rate, + round_off_tax_amount=category.round_off_tax_amount, + ) + + tax_amount = flt(min(tax_amount, over.withholding_amount), self.precision) + + if tax_rate == 0: + taxable_amount = min(under.taxable_amount, over.taxable_amount) + else: + taxable_amount = flt(100 / tax_rate * tax_amount, self.precision) + + # Create merged entry + merged_entry = under.copy() + merged_entry.update( + { + "taxable_amount": taxable_amount, + "withholding_amount": tax_amount, + "withholding_doctype": over.withholding_doctype, + "withholding_name": over.withholding_name, + "withholding_date": over.withholding_date, + "under_withheld_reason": over.under_withheld_reason, + "tax_rate": tax_rate, + "lower_deduction_certificate": over.lower_deduction_certificate, + } + ) + + # Consolidate entries by document combination + if self._should_include_entry(merged_entry): + merged_entries.append(merged_entry) + + under.taxable_amount -= taxable_amount + over.withholding_amount -= tax_amount + + if flt(under.taxable_amount, self.precision) <= 0: + under_entries.popleft() + if flt(over.withholding_amount, self.precision) <= 0: + over_entries.popleft() + + return merged_entries + + def _merge_entries( + self, + under_entries: deque, + over_entries: deque, + category: dict, + tax_rate: float | None = None, + constraint: float = inf, + default_obj: dict | None = None, + ): + """ + Merge under withheld and over withheld entries based on the tax rate and constraint. + If only under and over entries are available, they will be processed against current document. + """ + merged_entries = [] + if not ((under_entries or over_entries) and constraint > 0): + return merged_entries + + if tax_rate is None: + tax_rate = category.tax_rate + + # Process remaining under entries + constraint = self._process_under_withheld_entries( + under_entries, category, tax_rate, constraint, default_obj, merged_entries + ) + + # Process remaining over entries + self._process_over_withheld_entries( + over_entries, category, tax_rate, constraint, default_obj, merged_entries + ) + + return merged_entries + + def _process_under_withheld_entries( + self, under_entries, category, tax_rate, constraint, default_obj, merged_entries + ): + """ + Process remaining Under Withheld Entries - adjust against current document + """ + while under_entries and constraint > 0: + entry = under_entries[0] + + value_direction = -1 if entry.taxable_amount < 0 else 1 + amount_to_process = min(entry.taxable_amount, constraint) + + if amount_to_process * value_direction <= 0: + break + + # Create base entry and calculate withholding amount + merged_entry = self._create_base_entry(entry, category, tax_rate, default_obj) + merged_entry.update( + { + "taxable_amount": flt(amount_to_process, self.precision), + "withholding_amount": self.compute_withheld_amount( + amount_to_process, + tax_rate, + round_off_tax_amount=category.round_off_tax_amount, + ), + "withholding_doctype": self.doc.doctype, + "withholding_name": self.doc.name, + "withholding_date": self.doc.posting_date, + } + ) + + # Always include under entries + merged_entries.append(merged_entry) + + # Update entry amounts + entry.taxable_amount -= amount_to_process + if flt(entry.taxable_amount * value_direction, self.precision) <= 0: + under_entries.popleft() + + # Update constraint + constraint -= amount_to_process + + return constraint + + def _process_over_withheld_entries( + self, over_entries, category, tax_rate, constraint, default_obj, merged_entries + ): + """ + Process remaining Over Withheld Entries - adjust existing over-withheld amounts + """ + while over_entries and constraint > 0: + entry = over_entries[0] + + value_direction = -1 if entry.taxable_amount < 0 else 1 + amount_to_process = min(entry.taxable_amount, constraint) + + if amount_to_process * value_direction <= 0: + break + + # Create base entry and calculate withholding amount + merged_entry = self._create_base_entry(entry, category, tax_rate, default_obj) + merged_entry.update( + { + "taxable_amount": flt(amount_to_process, self.precision), + "withholding_amount": self.compute_withheld_amount( + amount_to_process, + tax_rate, + round_off_tax_amount=category.round_off_tax_amount, + ), + "withholding_doctype": entry.withholding_doctype, + "withholding_name": entry.withholding_name, + "withholding_date": entry.withholding_date, + "taxable_doctype": "", + "taxable_name": "", + "taxable_date": "", + "conversion_rate": self.get_conversion_rate(), + } + ) + + # Only include over entries related to current document + if self._should_include_entry(merged_entry): + merged_entries.append(merged_entry) + + # Update entry amounts + entry.taxable_amount -= amount_to_process + if flt(entry.taxable_amount * value_direction, self.precision) <= 0: + over_entries.popleft() + + # Update constraint + constraint -= amount_to_process + + return constraint + + def _create_base_entry(self, source_entry, category, tax_rate, default_obj): + entry = {} + if default_obj: + entry.update(default_obj) + + entry.update( + { + "taxable_doctype": source_entry.taxable_doctype, + "taxable_name": source_entry.taxable_name, + "taxable_date": source_entry.taxable_date, + "tax_withholding_category": category.name, + "tax_rate": tax_rate, + "party_type": self.party_type, + "party": self.party, + "company": self.doc.company, + } + ) + return entry + + def _should_include_entry(self, entry): + return entry.get("taxable_name") == self.doc.name or entry.get("withholding_name") == self.doc.name + + def compute_withheld_amount(self, taxable_amount, tax_rate, round_off_tax_amount=False): + """Calculate the withholding amount based on taxable amount and rate""" + amount = taxable_amount * tax_rate / 100 + if round_off_tax_amount: + return flt(amount, 0) + return flt(amount, self.precision) + + def _process_withholding_entries(self): + """Final processing - update tax rows and validate""" + self.update_tax_rows() + for entry in self.doc.tax_withholding_entries: + entry: TaxWithholdingEntry + entry.set_status(entry.status) + entry.validate_adjustments() + entry.validate_tax_withheld_amount() + + def on_submit(self): + for entry in self.doc.tax_withholding_entries: + entry: TaxWithholdingEntry + entry._process_tax_withholding_adjustments() + + def on_cancel(self): + for entry in self.doc.tax_withholding_entries: + entry: TaxWithholdingEntry + entry._clear_old_references() + entry.set_status() + + def _is_tax_withholding_applicable(self): + # Clear existing tax withholding amounts before recalculation + self._clear_existing_tax_amounts() + + if not self.doc.apply_tds or self.doc.get("is_opening") == "Yes" or not self._get_category_names(): + self.doc.tax_withholding_entries = [] + return False + + return True + + def _clear_existing_tax_amounts(self): + for row in self.doc.taxes: + if row.is_tax_withholding_account and row.tax_amount: + row.tax_amount = 0 + row.base_tax_amount_after_discount_amount = 0 + + def calculate_taxes_and_totals(self): + self.doc.calculate_taxes_and_totals() + + def get_conversion_rate(self): + return self.doc.get("conversion_rate") or 1 + + def on_validate(self): + if self._is_tax_withholding_applicable(): + self.calculate() + + +class PurchaseTaxWithholding(TaxWithholdingController): + """Tax withholding controller for Purchase Invoices""" + + def __init__(self, doc): + super().__init__(doc) + self.party_type = "Supplier" + self.party = doc.supplier + + +class SalesTaxWithholding(TaxWithholdingController): + """Tax withholding controller for Sales Invoices (TCS)""" + + def __init__(self, doc): + super().__init__(doc) + self.party_type = "Customer" + self.party = doc.customer + + +class PaymentTaxWithholding(TaxWithholdingController): + """Tax withholding controller for Payment Entries""" + + def __init__(self, doc): + super().__init__(doc) + self.party_type = doc.party_type + self.party = doc.party + + def _get_category_names(self): + if not self.doc.tax_withholding_category: + return [] + + return [self.doc.tax_withholding_category] + + def _update_taxable_amounts(self): + category = next(iter(self.category_details.values())) + + taxable_amount_in_party_currency = self.doc.unallocated_amount + taxable_amount_in_party_currency += sum( + flt(d.allocated_amount) + for d in self.doc.references + if d.reference_doctype in get_advance_payment_doctypes() + ) + + exchange_rate = self.get_conversion_rate() + taxable_amount = flt(taxable_amount_in_party_currency * exchange_rate, self.precision) + + category["taxable_amount"] = taxable_amount + + def get_conversion_rate(self): + if self.doc.payment_type == "Receive": + return self.doc.source_exchange_rate or 1 + else: + return self.doc.target_exchange_rate or 1 + + def calculate_taxes_and_totals(self): + self.doc.apply_taxes() + + def _get_open_entries_for_category(self, category): + # for payment only over withheld + open_entries = {"under_withheld": deque(), "over_withheld": deque()} + + current_entry = frappe._dict( + { + **self._create_default_entry(category), + "taxable_amount": category.taxable_amount, + "taxable_doctype": "", + "taxable_name": "", + "taxable_date": "", + } + ) + + open_entries["over_withheld"].append(current_entry) + + return open_entries + + def _is_threshold_crossed_for_category(self, category): + """For payment entries if apply_tds is checked, return True""" + return True + + def _get_unused_threshold(self, category): + """Always withhold Tax and whenever tax gets deducted adjust it""" + return 0 + + +class JournalTaxWithholding(TaxWithholdingController): + """Tax withholding controller for Journal Entries""" + + def __init__(self, doc): + super().__init__(doc) + 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 + + self.party_field = None + self.reverse_field = None + + self._setup_party_info() + + def _setup_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) + + if self.party_type: + self._setup_direction_fields() + + 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 _get_category_names(self): + if not self.doc.tax_withholding_category: + return [] + + return [self.doc.tax_withholding_category] + + def _update_taxable_amounts(self): + if not self.category_details: + return + + net_amount = self._calculate_net_total() + category = next(iter(self.category_details.values())) + category["taxable_amount"] = net_amount + + 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_conversion_rate(self): + return self.party_row.get("exchange_rate", 1.0) + + def calculate_taxes_and_totals(self): + self.doc.set_amounts_in_company_currency() + self.doc.set_total_debit_credit() + self.doc.set_against_account() + + def update_tax_rows(self): + 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")) + + account_amount_map = self._calculate_account_wise_amount() + + if not account_amount_map: + return + + self._reset_existing_tds() + + for account_head, tax_amount in account_amount_map.items(): + if not tax_amount: + continue + + self._create_or_update_tds_row(account_head, tax_amount) + self._update_party_amount(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 _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 _create_or_update_tds_row(self, account_head, tax_amount): + from erpnext.accounts.utils import get_account_currency + from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate + + account_currency = get_account_currency(account_head) + 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(tax_amount, self.precision) + tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision) + + tax_row = None + for row in self.doc.get("accounts"): + if row.account == account_head and row.get("is_tax_withholding_account"): + tax_row = row + break + + if not tax_row: + tax_row = self.doc.append( + "accounts", + { + "account": account_head, + "account_currency": account_currency, + "exchange_rate": exchange_rate, + "cost_center": self.doc.get("cost_center") + or erpnext.get_default_cost_center(self.doc.company), + "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() + + def _is_tax_withholding_applicable(self): + if not self._should_apply_tds(): + self.doc.tax_withholding_entries = [] + return False + + if not self.doc.tax_withholding_category: + self.doc.tax_withholding_entries = [] + return False + + return True + + def _get_linked_payments(self): + """Journal Entry doesn't have advances like invoices""" + return frappe._dict() + + +def _reset_idx(docs_to_reset_idx): + updates = {} + for doctype, docname in docs_to_reset_idx: + names = frappe.get_all( + DOCTYPE, + filters={"parent": docname, "parenttype": doctype, "docstatus": 1}, + pluck="name", + ) + + for idx, name in enumerate(names, start=1): + updates[name] = {"idx": idx} + + if updates: + frappe.db.bulk_update(DOCTYPE, updates, update_modified=False) diff --git a/erpnext/accounts/doctype/tax_withholding_entry/test_tax_withholding_entry.py b/erpnext/accounts/doctype/tax_withholding_entry/test_tax_withholding_entry.py new file mode 100644 index 00000000000..8a09f46e079 --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_entry/test_tax_withholding_entry.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestTaxWithholdingEntry(IntegrationTestCase): + """ + Integration tests for TaxWithholdingEntry. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/accounts/doctype/tax_withheld_vouchers/__init__.py b/erpnext/accounts/doctype/tax_withholding_group/__init__.py similarity index 100% rename from erpnext/accounts/doctype/tax_withheld_vouchers/__init__.py rename to erpnext/accounts/doctype/tax_withholding_group/__init__.py diff --git a/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.js b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.js new file mode 100644 index 00000000000..a1b889489bd --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Tax Withholding Group", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.json b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.json new file mode 100644 index 00000000000..d2700f1eaeb --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:group_name", + "creation": "2025-06-29 05:24:51.819891", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "group_name" + ], + "fields": [ + { + "fieldname": "group_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group Name", + "reqd": 1, + "unique": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-06-29 05:25:50.243710", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Tax Withholding Group", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.py similarity index 57% rename from erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py rename to erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.py index dbb69a2e769..2e688fa09d9 100644 --- a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.py +++ b/erpnext/accounts/doctype/tax_withholding_group/tax_withholding_group.py @@ -1,11 +1,11 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt # import frappe from frappe.model.document import Document -class TaxWithheldVouchers(Document): +class TaxWithholdingGroup(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -14,12 +14,7 @@ class TaxWithheldVouchers(Document): if TYPE_CHECKING: from frappe.types import DF - parent: DF.Data - parentfield: DF.Data - parenttype: DF.Data - taxable_amount: DF.Currency - voucher_name: DF.Data | None - voucher_type: DF.Data | None + group_name: DF.Data # end: auto-generated types pass diff --git a/erpnext/accounts/doctype/tax_withholding_group/test_tax_withholding_group.py b/erpnext/accounts/doctype/tax_withholding_group/test_tax_withholding_group.py new file mode 100644 index 00000000000..268febe3d7e --- /dev/null +++ b/erpnext/accounts/doctype/tax_withholding_group/test_tax_withholding_group.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestTaxWithholdingGroup(IntegrationTestCase): + """ + Integration tests for TaxWithholdingGroup. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json index c58d945c5aa..01ba2453f69 100644 --- a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json +++ b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json @@ -7,10 +7,11 @@ "field_order": [ "from_date", "to_date", - "tax_withholding_rate", + "tax_withholding_group", "column_break_3", - "single_threshold", - "cumulative_threshold" + "tax_withholding_rate", + "cumulative_threshold", + "single_threshold" ], "fields": [ { @@ -30,14 +31,14 @@ "fieldname": "single_threshold", "fieldtype": "Float", "in_list_view": 1, - "label": "Single Transaction Threshold" + "label": "Transaction Threshold" }, { "columns": 3, "fieldname": "cumulative_threshold", "fieldtype": "Float", "in_list_view": 1, - "label": "Cumulative Transaction Threshold" + "label": "Cumulative Threshold" }, { "columns": 2, @@ -54,20 +55,28 @@ "in_list_view": 1, "label": "To Date", "reqd": 1 + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:52.708165", + "modified": "2025-06-29 05:31:05.120377", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Withholding Rate", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.py b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.py index 35d4e63ecca..984db8016ae 100644 --- a/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.py +++ b/erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.py @@ -20,6 +20,7 @@ class TaxWithholdingRate(Document): parentfield: DF.Data parenttype: DF.Data single_threshold: DF.Float + tax_withholding_group: DF.Link | None tax_withholding_rate: DF.Float to_date: DF.Date # end: auto-generated types diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index eef82a398b9..83514ecb34a 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -176,10 +176,6 @@ def _get_party_details( for d in party.get("sales_team") ] - # supplier tax withholding category - if party_type == "Supplier" and party: - party_details["supplier_tds"] = frappe.get_value(party_type, party.name, "tax_withholding_category") - if not party_details.get("tax_category") and pos_profile: party_details["tax_category"] = frappe.get_value("POS Profile", pos_profile, "tax_category") @@ -352,10 +348,13 @@ def set_contact_details(party_details, party, party_type): def set_other_values(party_details, party, party_type): # copy + to_copy = ["tax_withholding_category", "tax_withholding_group", "language"] + if party_type == "Customer": - to_copy = ["customer_name", "customer_group", "territory", "language"] + to_copy.extend(["customer_name", "customer_group", "territory"]) else: - to_copy = ["supplier_name", "supplier_group", "language"] + to_copy.extend(["supplier_name", "supplier_group"]) + for f in to_copy: party_details[f] = party.get(f) diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.js b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.js index bdc98ab0ae3..36b8adf764c 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.js +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.js @@ -14,9 +14,8 @@ frappe.query_reports["Tax Withholding Details"] = { fieldname: "party_type", label: __("Party Type"), fieldtype: "Select", - options: ["Supplier", "Customer"], - reqd: 1, - default: "Supplier", + options: ["", "Supplier", "Customer"], + default: "", on_change: function () { frappe.query_report.set_filter_value("party", ""); }, diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 922f2ee6d61..e485dac114b 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -1,194 +1,112 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe from frappe import _ -from frappe.utils import flt, getdate - -from erpnext.accounts.utils import get_currency_precision +from frappe.query_builder.functions import IfNull def execute(filters=None): - if filters.get("party_type") == "Customer": - party_naming_by = frappe.get_single_value("Selling Settings", "cust_master_name") - else: - party_naming_by = frappe.db.get_single_value("Buying Settings", "supp_master_name") - - filters["naming_series"] = party_naming_by - + """Generate Tax Withholding Details report""" validate_filters(filters) - ( - tds_docs, - tds_accounts, - tax_category_map, - journal_entry_party_map, - net_total_map, - ) = get_tds_docs(filters) + # Process and format data + data = get_tax_withholding_data(filters) columns = get_columns(filters) - res = get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map - ) - return columns, res + return columns, data def validate_filters(filters): - """Validate if dates are properly set""" + """Validate report filters""" filters = frappe._dict(filters or {}) + + if not filters.from_date or not filters.to_date: + frappe.throw(_("From Date and To Date are required")) + if filters.from_date > filters.to_date: frappe.throw(_("From Date must be before To Date")) -def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map): - party_map = get_party_pan_map(filters.get("party_type")) - tax_rate_map = get_tax_rate_map(filters) - gle_map = get_gle_map(tds_docs) - precision = get_currency_precision() +def get_tax_withholding_data(filters): + """Process entries into final report format""" + data = [] + entries = get_tax_withholding_entries(filters) + if not entries: + return data - out = [] - entries = {} - for name, details in gle_map.items(): - for entry in details: - tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0 - tax_withholding_category, rate = None, None - bill_no, bill_date = "", "" - party = entry.party or entry.against - posting_date = entry.posting_date - voucher_type = entry.voucher_type + doc_info = get_additional_doc_info(entries) + party_details = get_party_details(entries) - if voucher_type == "Journal Entry": - party_list = journal_entry_party_map.get(name) - if party_list: - party = party_list[0] + for entry in entries: + doc_details = frappe._dict() + if entry.taxable_name: + doc_details = doc_info.get((entry.taxable_doctype, entry.taxable_name), {}) - if entry.account in tds_accounts.keys(): - tax_amount += entry.credit - entry.debit - # infer tax withholding category from the account if it's the single account for this category - tax_withholding_category = tds_accounts.get(entry.account) - # or else the consolidated value from the voucher document - if not tax_withholding_category: - tax_withholding_category = tax_category_map.get((voucher_type, name)) - # or else from the party default - if not tax_withholding_category: - tax_withholding_category = party_map.get(party, {}).get("tax_withholding_category") + party_info = party_details.get((entry.party_type, entry.party), {}) - rate = get_tax_withholding_rates(tax_rate_map.get(tax_withholding_category, []), posting_date) + row = { + "section_code": entry.tax_withholding_category, + "entity_type": party_info.get("entity_type"), + "rate": entry.tax_rate, + "total_amount": entry.taxable_amount, + "grand_total": doc_details.get("grand_total", 0), + "base_total": doc_details.get("base_total", 0), + "tax_amount": entry.withholding_amount, + "transaction_date": entry.withholding_date, + "transaction_type": entry.taxable_doctype, + "ref_no": entry.taxable_name, + "taxable_date": entry.taxable_date, + "supplier_invoice_no": doc_details.get("bill_no"), + "supplier_invoice_date": doc_details.get("bill_date"), + "withholding_doctype": entry.withholding_doctype, + "withholding_name": entry.withholding_name, + "party_name": party_info.get("party_name"), + "tax_id": entry.tax_id, + "party": entry.party, + "party_type": entry.party_type, + } + data.append(row) - values = net_total_map.get((voucher_type, name)) - - if values: - if voucher_type == "Journal Entry" and tax_amount and rate: - # back calculate total amount from rate and tax_amount - base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0]) - total_amount = grand_total = base_total - - else: - if tax_amount and rate: - # back calculate total amount from rate and tax_amount - total_amount = flt((tax_amount * 100) / rate, precision=precision) - else: - total_amount = values[0] - - grand_total = values[1] - base_total = values[2] - - if voucher_type == "Purchase Invoice": - bill_no = values[3] - bill_date = values[4] - else: - total_amount += entry.credit - - if tax_amount: - if party_map.get(party, {}).get("party_type") == "Supplier": - party_name = "supplier_name" - party_type = "supplier_type" - else: - party_name = "customer_name" - party_type = "customer_type" - - row = { - "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id": party_map.get( - party, {} - ).get("pan"), - "party": party_map.get(party, {}).get("name"), - } - - if filters.naming_series == "Naming Series": - row["party_name"] = party_map.get(party, {}).get(party_name) - - row.update( - { - "section_code": tax_withholding_category or "", - "entity_type": party_map.get(party, {}).get(party_type), - "rate": rate, - "total_amount": total_amount, - "grand_total": grand_total, - "base_total": base_total, - "tax_amount": tax_amount, - "transaction_date": posting_date, - "transaction_type": voucher_type, - "ref_no": name, - "supplier_invoice_no": bill_no, - "supplier_invoice_date": bill_date, - } - ) - - key = entry.voucher_no - if key in entries: - entries[key]["tax_amount"] += tax_amount - else: - entries[key] = row - out = list(entries.values()) - out.sort(key=lambda x: (x["section_code"], x["transaction_date"])) - - return out + # Sort by section code and transaction date + data.sort(key=lambda x: (x["section_code"] or "", x["transaction_date"] or "")) + return data -def get_party_pan_map(party_type): +def get_party_details(entries): + """Fetch party details in batch for all entries""" party_map = frappe._dict() + parties_by_type = {"Customer": set(), "Supplier": set()} - fields = ["name", "tax_withholding_category"] - if party_type == "Supplier": - fields += ["supplier_type", "supplier_name"] - else: - fields += ["customer_type", "customer_name"] + # Group parties by type + for entry in entries: + if entry.party_type in parties_by_type and entry.party: + parties_by_type[entry.party_type].add(entry.party) - if frappe.db.has_column(party_type, "pan"): - fields.append("pan") + # Batch fetch for each party type + for party_type, party_set in parties_by_type.items(): + if not party_type or not party_set: + continue - party_details = frappe.db.get_all(party_type, fields=fields) + doctype = frappe.qb.DocType(party_type) + fields = [doctype.name] - for party in party_details: - party.party_type = party_type - party_map[party.name] = party + if party_type == "Supplier": + fields.extend([doctype.supplier_type.as_("entity_type"), doctype.supplier_name.as_("party_name")]) + elif party_type == "Customer": + fields.extend([doctype.customer_type.as_("entity_type"), doctype.customer_name.as_("party_name")]) + + query = frappe.qb.from_(doctype).select(*fields).where(doctype.name.isin(party_set)) + party_details = query.run(as_dict=True) + + for party in party_details: + party_map[(party_type, party.name)] = party return party_map -def get_gle_map(documents): - # create gle_map of the form - # {"purchase_invoice": list of dict of all gle created for this invoice} - gle_map = {} - - gle = frappe.db.get_all( - "GL Entry", - {"voucher_no": ["in", documents], "is_cancelled": 0}, - ["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"], - ) - - for d in gle: - if d.voucher_no not in gle_map: - gle_map[d.voucher_no] = [d] - else: - gle_map[d.voucher_no].append(d) - - return gle_map - - def get_columns(filters): - pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" + """Generate report columns based on filters""" columns = [ { "label": _("Section Code"), @@ -197,286 +115,190 @@ def get_columns(filters): "fieldtype": "Link", "width": 90, }, - {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 60}, + {"label": _("Tax Id"), "fieldname": "tax_id", "fieldtype": "Data", "width": 60}, + { + "label": _(f"{filters.get('party_type', 'Party')} Name"), + "fieldname": "party_name", + "fieldtype": "Data", + "width": 180, + }, + { + "label": _(filters.get("party_type", "Party")), + "fieldname": "party", + "fieldtype": "Dynamic Link", + "options": "party_type", + "width": 180, + }, + { + "label": _("Entity Type"), + "fieldname": "entity_type", + "fieldtype": "Data", + "width": 100, + }, + { + "label": _("Supplier Invoice No"), + "fieldname": "supplier_invoice_no", + "fieldtype": "Data", + "width": 120, + }, + { + "label": _("Supplier Invoice Date"), + "fieldname": "supplier_invoice_date", + "fieldtype": "Date", + "width": 120, + }, + { + "label": _("Tax Rate %"), + "fieldname": "rate", + "fieldtype": "Percent", + "width": 60, + }, + { + "label": _("Total Amount"), + "fieldname": "total_amount", + "fieldtype": "Currency", + "width": 120, + }, + { + "label": _("Base Total"), + "fieldname": "base_total", + "fieldtype": "Currency", + "width": 120, + }, + { + "label": _("Tax Amount"), + "fieldname": "tax_amount", + "fieldtype": "Currency", + "width": 120, + }, + { + "label": _("Grand Total"), + "fieldname": "grand_total", + "fieldtype": "Currency", + "width": 120, + }, + { + "label": _("Reference Date"), + "fieldname": "taxable_date", + "fieldtype": "Date", + "width": 100, + }, + { + "label": _("Transaction Type"), + "fieldname": "transaction_type", + "fieldtype": "Data", + "width": 130, + }, + { + "label": _("Reference No."), + "fieldname": "ref_no", + "fieldtype": "Dynamic Link", + "options": "transaction_type", + "width": 180, + }, + { + "label": _("Date of Transaction"), + "fieldname": "transaction_date", + "fieldtype": "Date", + "width": 100, + }, + { + "label": _("Withholding Document"), + "fieldname": "withholding_name", + "fieldtype": "Dynamic Link", + "options": "withholding_doctype", + "width": 150, + }, ] - if filters.naming_series == "Naming Series": - columns.append( - { - "label": _(filters.party_type + " Name"), - "fieldname": "party_name", - "fieldtype": "Data", - "width": 180, - } - ) - else: - columns.append( - { - "label": _(filters.get("party_type")), - "fieldname": "party", - "fieldtype": "Dynamic Link", - "options": "party_type", - "width": 180, - } - ) - - columns.extend( - [ - {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100}, - ] - ) - if filters.party_type == "Supplier": - columns.extend( - [ - { - "label": _("Supplier Invoice No"), - "fieldname": "supplier_invoice_no", - "fieldtype": "Data", - "width": 120, - }, - { - "label": _("Supplier Invoice Date"), - "fieldname": "supplier_invoice_date", - "fieldtype": "Date", - "width": 120, - }, - ] - ) - - columns.extend( - [ - { - "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), - "fieldname": "rate", - "fieldtype": "Percent", - "width": 60, - }, - { - "label": _("Total Amount"), - "fieldname": "total_amount", - "fieldtype": "Float", - "width": 120, - }, - { - "label": _("Base Total"), - "fieldname": "base_total", - "fieldtype": "Float", - "width": 120, - }, - { - "label": _("TDS Amount") if filters.get("party_type") == "Supplier" else _("TCS Amount"), - "fieldname": "tax_amount", - "fieldtype": "Float", - "width": 120, - }, - { - "label": _("Grand Total"), - "fieldname": "grand_total", - "fieldtype": "Float", - "width": 120, - }, - {"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 130}, - { - "label": _("Reference No."), - "fieldname": "ref_no", - "fieldtype": "Dynamic Link", - "options": "transaction_type", - "width": 180, - }, - { - "label": _("Date of Transaction"), - "fieldname": "transaction_date", - "fieldtype": "Date", - "width": 100, - }, - ] - ) - return columns -def get_tds_docs(filters): - tds_documents = [] - purchase_invoices = [] - sales_invoices = [] - payment_entries = [] - journal_entries = [] - tax_category_map = frappe._dict() - net_total_map = frappe._dict() - journal_entry_party_map = frappe._dict() - bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name") - - _tds_accounts = frappe.get_all( - "Tax Withholding Account", - {"company": filters.get("company")}, - ["account", "parent"], - ) - tds_accounts = {} - for tds_acc in _tds_accounts: - # if it turns out not to be the only tax withholding category, then don't include in the map - if tds_acc["account"] in tds_accounts: - tds_accounts[tds_acc["account"]] = None - else: - tds_accounts[tds_acc["account"]] = tds_acc["parent"] - - tds_docs = get_tds_docs_query(filters, bank_accounts, list(tds_accounts.keys())).run(as_dict=True) - - for d in tds_docs: - if d.voucher_type == "Purchase Invoice": - purchase_invoices.append(d.voucher_no) - if d.voucher_type == "Sales Invoice": - sales_invoices.append(d.voucher_no) - elif d.voucher_type == "Payment Entry": - payment_entries.append(d.voucher_no) - elif d.voucher_type == "Journal Entry": - journal_entries.append(d.voucher_no) - - tds_documents.append(d.voucher_no) - - if purchase_invoices: - get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, net_total_map) - - if sales_invoices: - get_doc_info(sales_invoices, "Sales Invoice", tax_category_map, net_total_map) - - if payment_entries: - get_doc_info(payment_entries, "Payment Entry", tax_category_map, net_total_map) - - if journal_entries: - journal_entry_party_map = get_journal_entry_party_map(journal_entries) - get_doc_info(journal_entries, "Journal Entry", tax_category_map, net_total_map) - - return ( - tds_documents, - tds_accounts, - tax_category_map, - journal_entry_party_map, - net_total_map, - ) - - -def get_tds_docs_query(filters, bank_accounts, tds_accounts): - if not tds_accounts: - frappe.throw( - _("No {0} Accounts found for this company.").format(frappe.bold(_("Tax Withholding"))), - title=_("Accounts Missing Error"), - ) - gle = frappe.qb.DocType("GL Entry") +def get_tax_withholding_entries(filters): + twe = frappe.qb.DocType("Tax Withholding Entry") query = ( - frappe.qb.from_(gle) - .select("voucher_no", "voucher_type", "against", "party") - .where(gle.is_cancelled == 0) + frappe.qb.from_(twe) + .select( + twe.company, + twe.party_type, + twe.party, + IfNull(twe.tax_id, "").as_("tax_id"), + twe.tax_withholding_category, + IfNull(twe.tax_withholding_group, "").as_("tax_withholding_group"), + twe.taxable_amount, + twe.tax_rate, + twe.withholding_amount, + IfNull(twe.taxable_doctype, "").as_("taxable_doctype"), + IfNull(twe.taxable_name, "").as_("taxable_name"), + twe.taxable_date, + IfNull(twe.under_withheld_reason, "").as_("under_withheld_reason"), + IfNull(twe.lower_deduction_certificate, "").as_("lower_deduction_certificate"), + IfNull(twe.withholding_doctype, "").as_("withholding_doctype"), + IfNull(twe.withholding_name, "").as_("withholding_name"), + twe.withholding_date, + twe.status, + ) + .where(twe.docstatus == 1) + .where(twe.withholding_date >= filters.from_date) + .where(twe.withholding_date <= filters.to_date) + .where(IfNull(twe.withholding_name, "") != "") + .where(twe.status != "Duplicate") ) - if filters.get("from_date"): - query = query.where(gle.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - query = query.where(gle.posting_date <= filters.get("to_date")) + if filters.get("company"): + query = query.where(twe.company == filters.get("company")) + + if filters.get("party_type"): + query = query.where(twe.party_type == filters.get("party_type")) if filters.get("party"): - party = [filters.get("party")] - jv_condition = gle.against.isin(party) | ( - (gle.voucher_type == "Journal Entry") & (gle.party == filters.get("party")) - ) - else: - party = frappe.get_all(filters.get("party_type"), pluck="name") - jv_condition = gle.against.isin(party) | ( - (gle.voucher_type == "Journal Entry") - & ((gle.party_type == filters.get("party_type")) | (gle.party_type == "")) - ) + query = query.where(twe.party == filters.get("party")) - query.where((gle.account.isin(tds_accounts) & jv_condition) | gle.party.isin(party)) - if bank_accounts: - query = query.where( - gle.against.notin(bank_accounts) & (gle.account.isin(tds_accounts) & jv_condition) - | gle.party.isin(party) - ) - - return query + return query.run(as_dict=True) -def get_journal_entry_party_map(journal_entries): - journal_entry_party_map = {} - for d in frappe.db.get_all( - "Journal Entry Account", - { - "parent": ("in", journal_entries), - "party_type": ("in", ("Supplier", "Customer")), - "party": ("is", "set"), - }, - ["parent", "party"], - ): - if d.parent not in journal_entry_party_map: - journal_entry_party_map[d.parent] = [] - journal_entry_party_map[d.parent].append(d.party) - - return journal_entry_party_map - - -def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None): - common_fields = ["name"] - fields_dict = { - "Purchase Invoice": [ - "tax_withholding_category", - "base_tax_withholding_net_total", - "grand_total", - "base_total", - "bill_no", - "bill_date", - ], - "Sales Invoice": ["base_net_total", "grand_total", "base_total"], - "Payment Entry": [ - "tax_withholding_category", - "paid_amount", - "paid_amount_after_tax", - "base_paid_amount", - ], - "Journal Entry": ["tax_withholding_category", "total_debit"], +def get_additional_doc_info(entries): + """Fetch additional document information in batch""" + doc_info = {} + docs_by_type = { + "Purchase Invoice": set(), + "Sales Invoice": set(), + "Payment Entry": set(), + "Journal Entry": set(), } - entries = frappe.get_all( - doctype, filters={"name": ("in", vouchers)}, fields=common_fields + fields_dict[doctype] - ) + # Group documents by type + for entry in entries: + if entry.taxable_name and entry.taxable_doctype in docs_by_type: + docs_by_type[entry.taxable_doctype].add(entry.taxable_name) + + for doctype_name, voucher_set in docs_by_type.items(): + if voucher_set: + _fetch_doc_info(doctype_name, voucher_set, doc_info) + + return doc_info + + +def _fetch_doc_info(doctype_name, voucher_set, doc_info): + doctype = frappe.qb.DocType(doctype_name) + fields = [doctype.name] + + # Add doctype-specific fields + if doctype_name == "Purchase Invoice": + fields.extend([doctype.grand_total, doctype.base_total, doctype.bill_no, doctype.bill_date]) + elif doctype_name == "Sales Invoice": + fields.extend([doctype.grand_total, doctype.base_total]) + elif doctype_name == "Payment Entry": + fields.extend( + [doctype.paid_amount_after_tax.as_("grand_total"), doctype.base_paid_amount.as_("base_total")] + ) + elif doctype_name == "Journal Entry": + fields.extend([doctype.total_debit.as_("grand_total"), doctype.total_debit.as_("base_total")]) + else: + return + + query = frappe.qb.from_(doctype).select(*fields).where(doctype.name.isin(voucher_set)) + entries = query.run(as_dict=True) for entry in entries: - tax_category_map[(doctype, entry.name)] = entry.tax_withholding_category - if doctype == "Purchase Invoice": - value = [ - entry.base_tax_withholding_net_total, - entry.grand_total, - entry.base_total, - entry.bill_no, - entry.bill_date, - ] - elif doctype == "Sales Invoice": - value = [entry.base_net_total, entry.grand_total, entry.base_total] - elif doctype == "Payment Entry": - value = [entry.paid_amount, entry.paid_amount_after_tax, entry.base_paid_amount] - else: - value = [entry.total_debit] * 3 - - net_total_map[(doctype, entry.name)] = value - - -def get_tax_rate_map(filters): - rate_map = frappe.get_all( - "Tax Withholding Rate", - filters={"from_date": ("<=", filters.to_date), "to_date": (">=", filters.from_date)}, - fields=["parent", "tax_withholding_rate", "from_date", "to_date"], - ) - - rate_list = frappe._dict() - - for rate in rate_map: - rate_list.setdefault(rate.parent, []).append(frappe._dict(rate)) - - return rate_list - - -def get_tax_withholding_rates(tax_withholding, posting_date): - # returns the row that matches with the fiscal year from posting date - for rate in tax_withholding: - if getdate(rate.from_date) <= getdate(posting_date) <= getdate(rate.to_date): - return rate.tax_withholding_rate - - return 0 + doc_info[(doctype_name, entry.name)] = entry diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 2253b3631c1..49bf556da66 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -2,35 +2,18 @@ import frappe from frappe import _ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import ( - get_result, - get_tds_docs, + get_tax_withholding_data, ) from erpnext.accounts.utils import get_fiscal_year def execute(filters=None): - if filters.get("party_type") == "Customer": - party_naming_by = frappe.get_single_value("Selling Settings", "cust_master_name") - else: - party_naming_by = frappe.db.get_single_value("Buying Settings", "supp_master_name") - - filters.update({"naming_series": party_naming_by}) - validate_filters(filters) + data = get_tax_withholding_data(filters) columns = get_columns(filters) - ( - tds_docs, - tds_accounts, - tax_category_map, - journal_entry_party_map, - invoice_total_map, - ) = get_tds_docs(filters) - res = get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_total_map - ) - final_result = group_by_party_and_category(res, filters) + final_result = group_by_party_and_category(data, filters) return columns, final_result @@ -55,7 +38,6 @@ def group_by_party_and_category(data, filters): party_category_wise_map.setdefault( (row.get("party"), row.get("section_code")), { - "pan": row.get("pan"), "tax_id": row.get("tax_id"), "party": row.get("party"), "party_name": row.get("party_name"), @@ -89,9 +71,8 @@ def get_final_result(party_category_wise_map): def get_columns(filters): - pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ - {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 90}, + {"label": _("Tax Id"), "fieldname": "tax_id", "fieldtype": "Data", "width": 90}, { "label": _(filters.get("party_type")), "fieldname": "party", @@ -99,47 +80,43 @@ def get_columns(filters): "options": "party_type", "width": 180, }, + { + "label": _(f"{filters.get('party_type', 'Party')} Name"), + "fieldname": "party_name", + "fieldtype": "Data", + "width": 180, + }, + { + "label": _("Section Code"), + "options": "Tax Withholding Category", + "fieldname": "section_code", + "fieldtype": "Link", + "width": 180, + }, + { + "label": _("Entity Type"), + "fieldname": "entity_type", + "fieldtype": "Data", + "width": 180, + }, + { + "label": _("Tax Rate %"), + "fieldname": "rate", + "fieldtype": "Percent", + "width": 120, + }, + { + "label": _("Total Amount"), + "fieldname": "total_amount", + "fieldtype": "Float", + "width": 120, + }, + { + "label": _("Tax Amount"), + "fieldname": "tax_amount", + "fieldtype": "Float", + "width": 120, + }, ] - if filters.naming_series == "Naming Series": - columns.append( - { - "label": _(filters.party_type + " Name"), - "fieldname": "party_name", - "fieldtype": "Data", - "width": 180, - } - ) - - columns.extend( - [ - { - "label": _("Section Code"), - "options": "Tax Withholding Category", - "fieldname": "section_code", - "fieldtype": "Link", - "width": 180, - }, - {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180}, - { - "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), - "fieldname": "rate", - "fieldtype": "Percent", - "width": 120, - }, - { - "label": _("Total Amount"), - "fieldname": "total_amount", - "fieldtype": "Float", - "width": 120, - }, - { - "label": _("Tax Amount"), - "fieldname": "tax_amount", - "fieldtype": "Float", - "width": 120, - }, - ] - ) - return columns diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index a1b50202f5b..82b8ee82aa5 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -90,31 +90,6 @@ frappe.ui.form.on("Purchase Order", { prevent_past_schedule_dates(frm); }, - supplier: function (frm) { - // Do not update if inter company reference is there as the details will already be updated - if (frm.updating_party_details || frm.doc.inter_company_invoice_reference) return; - - if (frm.doc.__onload && frm.doc.__onload.load_after_mapping) return; - - erpnext.utils.get_party_details( - frm, - "erpnext.accounts.party.get_party_details", - { - posting_date: frm.doc.transaction_date, - bill_date: frm.doc.bill_date, - party: frm.doc.supplier, - party_type: "Supplier", - account: frm.doc.credit_to, - price_list: frm.doc.buying_price_list, - fetch_payment_terms_template: cint(!frm.doc.ignore_default_payment_terms_template), - }, - function () { - frm.set_df_property("apply_tds", "read_only", frm.supplier_tds ? 0 : 1); - frm.set_df_property("tax_withholding_category", "hidden", frm.supplier_tds ? 0 : 1); - } - ); - }, - get_materials_from_supplier: function (frm) { let po_details = []; @@ -162,15 +137,6 @@ frappe.ui.form.on("Purchase Order", { frm.set_value("transaction_date", frappe.datetime.get_today()); } - if (frm.doc.__onload && frm.doc.supplier) { - if (frm.is_new()) { - frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0; - } - if (!frm.doc.__onload.supplier_tds) { - frm.set_df_property("apply_tds", "read_only", 1); - } - } - erpnext.queries.setup_queries(frm, "Warehouse", function () { return erpnext.queries.warehouse(frm.doc); }); @@ -181,14 +147,6 @@ frappe.ui.form.on("Purchase Order", { } }, - apply_tds: function (frm) { - if (!frm.doc.apply_tds) { - frm.set_value("tax_withholding_category", ""); - } else { - frm.set_value("tax_withholding_category", frm.supplier_tds); - } - }, - get_subcontracting_boms_for_finished_goods: function (fg_item) { return frappe.call({ method: "erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_finished_goods", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 8a8a222da73..be638a68429 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -22,8 +22,6 @@ "schedule_date", "column_break1", "company", - "apply_tds", - "tax_withholding_category", "is_subcontracted", "has_unit_price_items", "supplier_warehouse", @@ -57,8 +55,6 @@ "column_break_26", "total", "net_total", - "tax_withholding_net_total", - "base_tax_withholding_net_total", "section_break_48", "pricing_rules", "raw_material_details", @@ -1134,19 +1130,6 @@ "options": "Company", "read_only": 1 }, - { - "default": "0", - "fieldname": "apply_tds", - "fieldtype": "Check", - "label": "Apply Tax Withholding Amount" - }, - { - "depends_on": "eval: doc.apply_tds", - "fieldname": "tax_withholding_category", - "fieldtype": "Link", - "label": "Tax Withholding Category", - "options": "Tax Withholding Category" - }, { "collapsible": 1, "fieldname": "accounting_dimensions_section", @@ -1224,28 +1207,6 @@ "label": "Additional Info", "oldfieldtype": "Section Break" }, - { - "default": "0", - "depends_on": "apply_tds", - "fieldname": "tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Tax Withholding Net Total", - "no_copy": 1, - "options": "currency", - "read_only": 1 - }, - { - "depends_on": "apply_tds", - "fieldname": "base_tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Base Tax Withholding Net Total", - "no_copy": 1, - "options": "Company:company:default_currency", - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "column_break_99", "fieldtype": "Column Break" diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 5c3e0d92257..6735b079bfe 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -15,9 +15,6 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( - get_party_tax_withholding_details, -) from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController @@ -61,7 +58,6 @@ class PurchaseOrder(BuyingController): advance_payment_status: DF.Literal["Not Initiated", "Initiated", "Partially Paid", "Fully Paid"] amended_from: DF.Link | None apply_discount_on: DF.Literal["", "Grand Total", "Net Total"] - apply_tds: DF.Check auto_repeat: DF.Link | None base_discount_amount: DF.Currency base_grand_total: DF.Currency @@ -69,7 +65,6 @@ class PurchaseOrder(BuyingController): base_net_total: DF.Currency base_rounded_total: DF.Currency base_rounding_adjustment: DF.Currency - base_tax_withholding_net_total: DF.Currency base_taxes_and_charges_added: DF.Currency base_taxes_and_charges_deducted: DF.Currency base_total: DF.Currency @@ -157,8 +152,6 @@ class PurchaseOrder(BuyingController): supplier_name: DF.Data | None supplier_warehouse: DF.Link | None tax_category: DF.Link | None - tax_withholding_category: DF.Link | None - tax_withholding_net_total: DF.Currency taxes: DF.Table[PurchaseTaxesandCharges] taxes_and_charges: DF.Link | None taxes_and_charges_added: DF.Currency @@ -191,8 +184,6 @@ class PurchaseOrder(BuyingController): ] def onload(self): - supplier_tds = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") - self.set_onload("supplier_tds", supplier_tds) self.set_onload("can_update_items", self.can_update_items()) def before_validate(self): @@ -204,9 +195,6 @@ class PurchaseOrder(BuyingController): self.set_status() - # apply tax withholding only if checked and applicable - self.set_tax_withholding() - self.validate_supplier() self.validate_schedule_date() validate_for_items(self) @@ -284,36 +272,6 @@ class PurchaseOrder(BuyingController): [["Supplier Quotation", "supplier_quotation", "supplier_quotation_item"]] ) - def set_tax_withholding(self): - if not self.apply_tds: - return - - tax_withholding_details = get_party_tax_withholding_details(self, self.tax_withholding_category) - - if not tax_withholding_details: - return - - accounts = [] - for d in self.taxes: - if d.account_head == tax_withholding_details.get("account_head"): - d.update(tax_withholding_details) - accounts.append(d.account_head) - - if not accounts or tax_withholding_details.get("account_head") not in accounts: - self.append("taxes", tax_withholding_details) - - to_remove = [ - d - for d in self.taxes - if not d.tax_amount and d.account_head == tax_withholding_details.get("account_head") - ] - - for d in to_remove: - self.remove(d) - - # calculate totals again after applying TDS - self.calculate_taxes_and_totals() - def validate_supplier(self): prevent_po = frappe.db.get_value("Supplier", self.supplier, "prevent_pos") if prevent_po: @@ -695,13 +653,6 @@ class PurchaseOrder(BuyingController): if sco: update_sco_status(sco, "Closed" if self.status == "Closed" else None) - def set_missing_values(self, for_validate=False): - tds_category = frappe.db.get_value("Supplier", self.supplier, "tax_withholding_category") - if tds_category and not for_validate: - self.set_onload("supplier_tds", tds_category) - - super().set_missing_values(for_validate) - @frappe.request_cache def item_last_purchase_rate(name, conversion_rate, item_code, conversion_factor=1.0): @@ -839,10 +790,6 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions target.flags.ignore_permissions = ignore_permissions set_missing_values(source, target) - # set tax_withholding_category from Purchase Order - if source.apply_tds and source.tax_withholding_category and target.apply_tds: - target.tax_withholding_category = source.tax_withholding_category - # Get the advance paid Journal Entries in Purchase Invoice Advance if target.get("allocate_advances_automatically"): target.set_advances() diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 3679e7337e8..5e5eb7fd55b 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -55,7 +55,6 @@ "pricing_rules", "stock_uom_rate", "is_free_item", - "apply_tds", "section_break_29", "net_rate", "net_amount", @@ -899,12 +898,6 @@ "fieldname": "column_break_54", "fieldtype": "Column Break" }, - { - "default": "1", - "fieldname": "apply_tds", - "fieldtype": "Check", - "label": "Apply TDS" - }, { "fieldname": "wip_composite_asset", "fieldtype": "Link", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py index c0747a614bc..25173bc27bf 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py @@ -18,7 +18,6 @@ class PurchaseOrderItem(Document): actual_qty: DF.Float against_blanket_order: DF.Check amount: DF.Currency - apply_tds: DF.Check base_amount: DF.Currency base_net_amount: DF.Currency base_net_rate: DF.Currency diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index 6975bd56571..125a8b6adb1 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -37,9 +37,10 @@ "dashboard_tab", "tax_tab", "tax_id", - "column_break_27", "tax_category", + "column_break_27", "tax_withholding_category", + "tax_withholding_group", "contact_and_address_tab", "address_contacts", "address_html", @@ -480,6 +481,12 @@ "fieldtype": "Table", "label": "Customer Numbers", "options": "Customer Number At Supplier" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" } ], "grid_page_length": 50, @@ -493,7 +500,7 @@ "link_fieldname": "party" } ], - "modified": "2025-04-27 12:07:10.859758", + "modified": "2025-06-29 05:30:50.398653", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index e3aac1b407a..a1f76d0a42d 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -73,6 +73,7 @@ class Supplier(TransactionBase): tax_category: DF.Link | None tax_id: DF.Data | None tax_withholding_category: DF.Link | None + tax_withholding_group: DF.Link | None warn_pos: DF.Check warn_rfqs: DF.Check website: DF.Data | None diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3902b2f7202..667ae9b5f01 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -296,8 +296,6 @@ class AccountsController(TransactionBase): if self.doctype == "Purchase Invoice": self.calculate_paid_amount() - # apply tax withholding only if checked and applicable - self.set_tax_withholding() with temporary_flag("company", self.company): validate_regional(self) @@ -1034,6 +1032,12 @@ class AccountsController(TransactionBase): ): item.set("is_fixed_asset", ret.get("is_fixed_asset", 0)) + if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field( + "tax_withholding_category", + ): + if not item.get("tax_withholding_category") and ret.get("tax_withholding_category"): + item.set("tax_withholding_category", ret.get("tax_withholding_category")) + # Double check for cost center # Items add via promotional scheme may not have cost center set if hasattr(item, "cost_center") and not item.get("cost_center"): diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index a227c12ac05..09a42e79d4a 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -393,12 +393,14 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai elif doctype == "Purchase Invoice": # look for Print Heading "Debit Note" doc.select_print_heading = frappe.get_cached_value("Print Heading", _("Debit Note")) - if source.tax_withholding_category: - doc.set_onload("supplier_tds", source.tax_withholding_category) elif doctype == "Delivery Note": # manual additions to the return should hit the return warehous, too doc.set_warehouse = default_warehouse_for_sales_return + if doc.doctype in ["Sales Invoice", "Purchase Invoice"]: + doc.tax_withholding_group = source.tax_withholding_group + doc.ignore_tax_withholding_threshold = source.ignore_tax_withholding_threshold + for tax in doc.get("taxes") or []: if tax.charge_type == "Actual": tax.tax_amount = -1 * tax.tax_amount @@ -455,6 +457,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai def update_item(source_doc, target_doc, source_parent): target_doc.qty = -1 * source_doc.qty target_doc.pricing_rules = None + if doctype in ["Purchase Receipt", "Subcontracting Receipt"]: returned_qty_map = get_returned_qty_map_for_row( source_parent.name, source_parent.supplier, source_doc.name, doctype @@ -525,6 +528,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai target_doc.po_detail = source_doc.po_detail target_doc.pr_detail = source_doc.pr_detail target_doc.purchase_invoice_item = source_doc.name + target_doc.tax_withholding_category = source_doc.tax_withholding_category + target_doc.apply_tds = source_doc.apply_tds elif doctype == "Delivery Note": returned_qty_map = get_returned_qty_map_for_row( @@ -556,6 +561,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai if doctype == "Sales Invoice": target_doc.sales_invoice_item = source_doc.name + target_doc.tax_withholding_category = source_doc.tax_withholding_category + target_doc.apply_tds = source_doc.apply_tds else: target_doc.pos_invoice_item = source_doc.name diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index ab6cf1222b5..24ccc6c1223 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -29,11 +29,6 @@ class calculate_taxes_and_totals: frappe.flags.round_off_applicable_accounts = [] frappe.flags.round_row_wise_tax = frappe.get_single_value("Accounts Settings", "round_row_wise_tax") - if doc.get("round_off_applicable_accounts_for_tax_withholding"): - frappe.flags.round_off_applicable_accounts.append( - doc.round_off_applicable_accounts_for_tax_withholding - ) - self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) self.calculate() @@ -77,24 +72,11 @@ class calculate_taxes_and_totals: self.initialize_taxes() self.determine_exclusive_rate() self.calculate_net_total() - self.calculate_tax_withholding_net_total() self.calculate_taxes() self.adjust_grand_total_for_inclusive_tax() self.calculate_totals() self.calculate_total_net_weight() - def calculate_tax_withholding_net_total(self): - if hasattr(self.doc, "tax_withholding_net_total"): - sum_net_amount = 0 - sum_base_net_amount = 0 - for item in self._items: - if hasattr(item, "apply_tds") and item.apply_tds: - sum_net_amount += item.net_amount - sum_base_net_amount += item.base_net_amount - - self.doc.tax_withholding_net_total = sum_net_amount - self.doc.base_tax_withholding_net_total = sum_base_net_amount - def validate_item_tax_template(self): if self.doc.get("is_return") and self.doc.get("return_against"): return @@ -577,16 +559,7 @@ 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")) - - if tax.get("is_tax_withholding_account") and item.meta.get_field("apply_tds"): - if not item.get("apply_tds") or not self.doc.tax_withholding_net_total: - current_tax_amount = 0.0 - else: - current_tax_amount = item.net_amount * actual / self.doc.tax_withholding_net_total - else: - current_tax_amount = ( - item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0 - ) + current_tax_amount = item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0 elif tax.charge_type == "On Net Total": if tax.account_head in item_tax_map: diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 9a65062e7c4..ce9ca0e4d9e 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -647,6 +647,10 @@ global_search_doctypes = { ], } +ignore_links_on_delete = [ + "Tax Withholding Entry", +] + additional_timeline_content = {"*": ["erpnext.telephony.doctype.call_log.call_log.get_linked_call_logs"]} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 83dca1839fb..1e457f6d0cd 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -306,7 +306,6 @@ erpnext.patches.v14_0.create_accounting_dimensions_in_subcontracting_doctypes erpnext.patches.v14_0.fix_subcontracting_receipt_gl_entries erpnext.patches.v13_0.drop_unused_sle_index_parts erpnext.patches.v14_0.create_accounting_dimensions_for_asset_capitalization -erpnext.patches.v14_0.update_partial_tds_fields erpnext.patches.v14_0.create_incoterms_and_migrate_shipment erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request erpnext.patches.v14_0.update_entry_type_for_journal_entry @@ -450,3 +449,10 @@ erpnext.patches.v16_0.set_valuation_method_on_companies erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table erpnext.patches.v16_0.migrate_budget_records_to_new_structure erpnext.patches.v16_0.populate_budget_distribution_total +<<<<<<< HEAD +======= +erpnext.patches.v16_0.set_mr_picked_qty +erpnext.patches.v16_0.update_tax_withholding_field_in_payment_entry +erpnext.patches.v16_0.migrate_tax_withholding_data + +>>>>>>> c66f78c784 (feat: Introduce tax withholding entry) diff --git a/erpnext/patches/v14_0/update_partial_tds_fields.py b/erpnext/patches/v14_0/update_partial_tds_fields.py deleted file mode 100644 index 991201395f3..00000000000 --- a/erpnext/patches/v14_0/update_partial_tds_fields.py +++ /dev/null @@ -1,33 +0,0 @@ -import frappe -from frappe.utils import nowdate - -from erpnext.accounts.utils import FiscalYearError, get_fiscal_year - - -def execute(): - # Only do for current fiscal year, no need to repost for all years - for company in frappe.get_all("Company"): - try: - fiscal_year_details = get_fiscal_year(date=nowdate(), company=company.name, as_dict=True) - - purchase_invoice = frappe.qb.DocType("Purchase Invoice") - - frappe.qb.update(purchase_invoice).set( - purchase_invoice.tax_withholding_net_total, purchase_invoice.net_total - ).set(purchase_invoice.base_tax_withholding_net_total, purchase_invoice.base_net_total).where( - purchase_invoice.company == company.name - ).where(purchase_invoice.apply_tds == 1).where( - purchase_invoice.posting_date >= fiscal_year_details.year_start_date - ).where(purchase_invoice.docstatus == 1).run() - - purchase_order = frappe.qb.DocType("Purchase Order") - - frappe.qb.update(purchase_order).set( - purchase_order.tax_withholding_net_total, purchase_order.net_total - ).set(purchase_order.base_tax_withholding_net_total, purchase_order.base_net_total).where( - purchase_order.company == company.name - ).where(purchase_order.apply_tds == 1).where( - purchase_order.transaction_date >= fiscal_year_details.year_start_date - ).where(purchase_order.docstatus == 1).run() - except FiscalYearError: - pass diff --git a/erpnext/patches/v16_0/migrate_tax_withholding_data.py b/erpnext/patches/v16_0/migrate_tax_withholding_data.py new file mode 100644 index 00000000000..5b1a271517c --- /dev/null +++ b/erpnext/patches/v16_0/migrate_tax_withholding_data.py @@ -0,0 +1,1289 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +""" +Migration patch for Tax Withholding Entry data. + +This patch migrates historical TDS/TCS data from the old structure to the new +Tax Withholding Entry child table structure. + +Old Structure: +- Purchase Invoice: taxes table (is_tax_withholding_account), tax_withheld_vouchers, advance_tax +- Sales Invoice: taxes table (TDS amount without is_tax_withholding_account checkbox) +- Payment Entry: advance_taxes_and_charges table with allocated_amount +- Journal Entry: accounts table with is_tax_withholding_account (but not reliable) + +New Structure: +- All doctypes: tax_withholding_entries child table with detailed tracking +""" + +from collections import defaultdict + +import frappe +from frappe.query_builder import Case +from frappe.query_builder.functions import IfNull, Max, Sum +from frappe.utils import flt, now + + +def execute(): + tds_accounts = get_tds_accounts() + if not tds_accounts: + return + + tax_rate_map = get_tax_rate_map() + column_cache = get_column_cache() + party_tax_id_cache = {} + + # Clean up any existing migration entries + frappe.db.delete("Tax Withholding Entry", filters={"created_by_migration": 1}) + + # Migrate data from each document type + # Purchase Invoice migration also handles Payment Entry TDS (allocated and unallocated) + PurchaseInvoiceMigrator(tds_accounts, tax_rate_map, column_cache, party_tax_id_cache).migrate() + migrate_sales_invoices(tds_accounts, tax_rate_map, column_cache, party_tax_id_cache) + migrate_journal_entries(tds_accounts, tax_rate_map, column_cache, party_tax_id_cache) + + # Copy tax_withholding_category to item level + copy_category_to_items_for_purchase(column_cache) + copy_category_to_items_for_sales(column_cache) + + +def get_column_cache(): + return { + "Supplier": { + "pan": frappe.db.has_column("Supplier", "pan"), + "tax_id": frappe.db.has_column("Supplier", "tax_id"), + }, + "Customer": { + "pan": frappe.db.has_column("Customer", "pan"), + "tax_id": frappe.db.has_column("Customer", "tax_id"), + }, + "Purchase Invoice": { + "tax_withholding_category": frappe.db.has_column("Purchase Invoice", "tax_withholding_category"), + }, + "Purchase Invoice Item": { + "tax_withholding_category": frappe.db.has_column( + "Purchase Invoice Item", "tax_withholding_category" + ), + }, + "Sales Invoice": { + "tax_withholding_category": frappe.db.has_column("Sales Invoice", "tax_withholding_category"), + }, + "Sales Invoice Item": { + "tax_withholding_category": frappe.db.has_column( + "Sales Invoice Item", "tax_withholding_category" + ), + }, + } + + +def get_tds_accounts(): + twa = frappe.qb.DocType("Tax Withholding Account") + + result = ( + frappe.qb.from_(twa).select(twa.account, twa.company, twa.parent.as_("category")).run(as_dict=True) + ) + + if not result: + return None + + # Build lookup: {(company, account): category} + # If account has multiple categories, set to None (ambiguous) + account_map = {} + for row in result: + key = (row.company, row.account) + if key in account_map: + # Multiple categories use same account - ambiguous + account_map[key] = None + else: + account_map[key] = row.category + + # Also build account set by company for quick lookup + accounts_by_company = {} + for row in result: + accounts_by_company.setdefault(row.company, set()).add(row.account) + + return {"account_map": account_map, "accounts_by_company": accounts_by_company} + + +def get_tax_rate_map(): + twr = frappe.qb.DocType("Tax Withholding Rate") + twc = frappe.qb.DocType("Tax Withholding Category") + + rates = ( + frappe.qb.from_(twr) + .join(twc) + .on(twr.parent == twc.name) + .select( + twr.parent, + twr.tax_withholding_rate, + twr.from_date, + twr.to_date, + twc.tax_on_excess_amount, + ) + .run(as_dict=True) + ) + + rate_map = {} + for rate in rates: + rate_map.setdefault(rate.parent, []).append(rate) + + return rate_map + + +def get_tax_rate_for_date(tax_rate_map, category, posting_date): + if not category or category not in tax_rate_map or not posting_date: + return 0, False + + for rate in tax_rate_map[category]: + if rate.from_date and rate.to_date and rate.from_date <= posting_date <= rate.to_date: + return (rate.tax_withholding_rate, bool(rate.tax_on_excess_amount)) + + return 0, False + + +def get_party_tax_id(party_type, party, column_cache, party_tax_id_cache): + if not party: + return None + + cache_key = (party_type, party) + if cache_key in party_tax_id_cache: + return party_tax_id_cache[cache_key] + + tax_id = None + doctype_cols = column_cache.get(party_type, {}) + + if doctype_cols.get("pan"): + tax_id = frappe.db.get_value(party_type, party, "pan") + elif doctype_cols.get("tax_id"): + tax_id = frappe.db.get_value(party_type, party, "tax_id") + + party_tax_id_cache[cache_key] = tax_id + return tax_id + + +def determine_status(taxable_name, withholding_name, under_withheld_reason, is_duplicate=False): + """Determine the status of a Tax Withholding Entry.""" + if is_duplicate: + return "Duplicate" + + # If under_withheld_reason is specified, it's settled (legitimate reason for under deduction) + if under_withheld_reason: + return "Settled" + + # If both taxable and withholding are specified, it's settled + if taxable_name and withholding_name: + return "Settled" + + # If only taxable is specified, it's under withheld (tax not yet deducted) + if taxable_name and not withholding_name: + return "Under Withheld" + + # If only withholding is specified, it's over withheld (deducted but no taxable doc) + if withholding_name and not taxable_name: + return "Over Withheld" + + return "" + + +def bulk_insert_entries(all_entries): + """ + Bulk insert Tax Withholding Entries. + all_entries: dict of {(parent_doctype, parent_name): [entries]} + """ + if not all_entries: + return + + # Get existing names to avoid collisions + existing_names = set(frappe.get_all("Tax Withholding Entry", pluck="name")) + + def generate_unique_name(): + while True: + name = frappe.generate_hash(length=10) + if name not in existing_names: + existing_names.add(name) + return name + + # Prepare all entries with proper fields + fields = [ + "name", + "creation", + "modified", + "modified_by", + "owner", + "docstatus", + "parent", + "parentfield", + "parenttype", + "idx", + "company", + "party_type", + "party", + "tax_id", + "tax_withholding_category", + "tax_withholding_group", + "taxable_amount", + "tax_rate", + "withholding_amount", + "taxable_doctype", + "taxable_name", + "taxable_date", + "withholding_doctype", + "withholding_name", + "withholding_date", + "status", + "under_withheld_reason", + "currency", + "conversion_rate", + "created_by_migration", + ] + + current_time = now() + current_user = frappe.session.user + + values = [] + for (parent_doctype, parent_name), entries in all_entries.items(): + for idx, entry in enumerate(entries, start=1): + # Determine status + status = determine_status( + entry.get("taxable_name"), + entry.get("withholding_name"), + entry.get("under_withheld_reason"), + entry.get("is_duplicate", False), + ) + + values.append( + ( + generate_unique_name(), # name + current_time, # creation + current_time, # modified + current_user, # modified_by + current_user, # owner + 1, # docstatus (submitted) + parent_name, # parent + "tax_withholding_entries", # parentfield + parent_doctype, # parenttype + idx, # idx + entry.get("company"), + entry.get("party_type"), + entry.get("party"), + entry.get("tax_id"), + entry.get("tax_withholding_category"), + entry.get("tax_withholding_group", ""), + flt(entry.get("taxable_amount"), 2), + flt(entry.get("tax_rate"), 4), + flt(entry.get("withholding_amount"), 2), + entry.get("taxable_doctype", ""), + entry.get("taxable_name", ""), + entry.get("taxable_date"), + entry.get("withholding_doctype", ""), + entry.get("withholding_name", ""), + entry.get("withholding_date"), + status, + entry.get("under_withheld_reason", ""), + entry.get("currency", ""), + flt(entry.get("conversion_rate"), 9) or 1, + 1, # created_by_migration + ) + ) + + if values: + frappe.db.bulk_insert("Tax Withholding Entry", fields, values, ignore_duplicates=True) + + +# ============================================================================= +# PURCHASE INVOICE MIGRATION +# ============================================================================= + + +class PurchaseInvoiceMigrator: + def __init__(self, tds_accounts, tax_rate_map, column_cache, party_tax_id_cache): + self.tds_accounts = tds_accounts + self.tax_rate_map = tax_rate_map + self.column_cache = column_cache + self.party_tax_id_cache = party_tax_id_cache + + # Build TDS account set + self.all_tds_accounts = set() + for accounts in tds_accounts["accounts_by_company"].values(): + self.all_tds_accounts.update(accounts) + + # Raw query results + self._invoices_with_tds = [] + self._all_withheld_vouchers = [] + self._all_advance_taxes = [] + self._pe_tds_entries = [] + + # Lookups + self.invoice_taxes = {} # {invoice_name: {"info": row, "tax_rows": [], "tds_total": float}} + self.withheld_by_invoice = defaultdict(list) # {parent_invoice: [vouchers]} + self.advance_by_invoice = defaultdict(list) # {parent_invoice: [advance_taxes]} + self.pe_allocated = defaultdict(float) # {pe_name: total_allocated} + self.pe_taxes = {} # {pe_name: {"info": row, "tax_rows": [], "tds_total": float}} + self.invoice_info = {} # {invoice_name: row} + + # Sets for tracking + self.invoices_with_twv = set() + self.invoices_with_advance_tax = set() + self.tds_paid_by_other = {} # {taxable_invoice: withholding_invoice} + + # Date lookups + self.pe_dates = {} + self.pi_dates = {} + self._pi_names_for_dates = set() + self._pe_names_for_dates = set() + + # Output + self.all_entries = {} # {(doctype, name): [entries]} + + def migrate(self): + if not self.all_tds_accounts: + return + + self._fetch_data() + self._build_lookups() + self._fetch_dates() + self._process_invoices() + self._process_pe_overwithheld() + bulk_insert_entries(self.all_entries) + + # ------------------------------------------------------------------------- + # Data Fetching + # ------------------------------------------------------------------------- + + def _fetch_data(self): + pi = frappe.qb.DocType("Purchase Invoice") + ptc = frappe.qb.DocType("Purchase Taxes and Charges") + twv = frappe.qb.DocType("Tax Withheld Vouchers") + at = frappe.qb.DocType("Advance Tax") + pe = frappe.qb.DocType("Payment Entry") + atc = frappe.qb.DocType("Advance Taxes and Charges") + + # Query 1: Invoices with TDS in taxes table + self._invoices_with_tds = ( + frappe.qb.from_(pi) + .join(ptc) + .on(ptc.parent == pi.name) + .select( + pi.name, + pi.supplier, + pi.company, + pi.posting_date, + pi.base_net_total, + pi.base_tax_withholding_net_total, + pi.tax_withholding_category, + pi.is_return, + pi.currency, + pi.conversion_rate, + ptc.account_head, + ptc.base_tax_amount_after_discount_amount, + ) + .where(pi.docstatus == 1) + .where(ptc.account_head.isin(list(self.all_tds_accounts))) + .run(as_dict=True) + ) + + # Query 2: Tax withheld vouchers + self._all_withheld_vouchers = ( + frappe.qb.from_(twv) + # JV / PE withholding possible + .left_join(pi) + .on(twv.parent == pi.name) + .select( + twv.parent, + twv.voucher_type, + twv.voucher_name, + twv.taxable_amount, + pi.supplier, + pi.company, + pi.posting_date, + pi.base_net_total, + pi.base_tax_withholding_net_total, + pi.tax_withholding_category, + pi.is_return, + pi.currency, + pi.conversion_rate, + ) + .where(twv.parenttype == "Purchase Invoice") + .where(pi.docstatus == 1) + .run(as_dict=True) + ) + + # Query 3: Advance taxes with PI info + self._all_advance_taxes = ( + frappe.qb.from_(at) + .join(pi) + .on(at.parent == pi.name) + .select( + at.parent, + at.reference_type, + at.reference_name, + at.account_head, + at.allocated_amount, + pi.supplier, + pi.company, + pi.posting_date, + pi.base_net_total, + pi.base_tax_withholding_net_total, + pi.tax_withholding_category, + pi.is_return, + pi.currency, + pi.conversion_rate, + ) + .where(at.parenttype == "Purchase Invoice") + .where(at.reference_type == "Payment Entry") + .where(pi.docstatus == 1) + .run(as_dict=True) + ) + + # Query 4: Payment Entries with TDS + self._pe_tds_entries = ( + frappe.qb.from_(pe) + .join(atc) + .on(atc.parent == pe.name) + .select( + pe.name.as_("payment_entry"), + pe.party_type, + pe.party, + pe.company, + pe.posting_date, + pe.paid_amount, + pe.tax_withholding_category, + pe.paid_from_account_currency, + pe.paid_to_account_currency, + pe.source_exchange_rate, + pe.target_exchange_rate, + pe.payment_type, + atc.account_head, + atc.base_tax_amount, + atc.add_deduct_tax, + ) + .where(pe.docstatus == 1) + .where(pe.apply_tds == 1) + .where(atc.account_head.isin(list(self.all_tds_accounts))) + .run(as_dict=True) + ) + + # ------------------------------------------------------------------------- + # Lookup Building + # ------------------------------------------------------------------------- + + def _build_lookups(self): + self._build_invoice_taxes_lookup() + self._build_withheld_vouchers_lookup() + self._build_advance_taxes_lookup() + self._build_pe_taxes_lookup() + self._build_invoice_info_lookup() + + def _build_invoice_taxes_lookup(self): + for row in self._invoices_with_tds: + inv_name = row.name + if inv_name not in self.invoice_taxes: + self.invoice_taxes[inv_name] = {"info": row, "tax_rows": [], "tds_total": 0} + + self.invoice_taxes[inv_name]["tax_rows"].append(row) + self.invoice_taxes[inv_name]["tds_total"] += abs(flt(row.base_tax_amount_after_discount_amount)) + + def _build_withheld_vouchers_lookup(self): + for row in self._all_withheld_vouchers: + self.withheld_by_invoice[row.parent].append(row) + self.invoices_with_twv.add(row.parent) + + # Track cross-invoice TDS payment + if row.voucher_name != row.parent and row.parent in self.invoice_taxes: + if row.voucher_name not in self.tds_paid_by_other: + self.tds_paid_by_other[row.voucher_name] = row.parent + + if row.voucher_type != "Purchase Invoice": + continue + + self._pi_names_for_dates.add(row.voucher_name) + + def _build_advance_taxes_lookup(self): + for row in self._all_advance_taxes: + self.advance_by_invoice[row.parent].append(row) + pe_name = row.reference_name + self.pe_allocated[pe_name] += flt(row.allocated_amount) + self._pe_names_for_dates.add(pe_name) + self.invoices_with_advance_tax.add(row.parent) + + def _build_pe_taxes_lookup(self): + for row in self._pe_tds_entries: + pe_name = row.payment_entry + self._pe_names_for_dates.add(pe_name) + + if pe_name not in self.pe_taxes: + self.pe_taxes[pe_name] = {"info": row, "tax_rows": [], "tds_total": 0} + + self.pe_taxes[pe_name]["tax_rows"].append(row) + amount = abs(flt(row.base_tax_amount)) + + if row.add_deduct_tax == "Deduct": + self.pe_taxes[pe_name]["tds_total"] += amount + else: + self.pe_taxes[pe_name]["tds_total"] -= amount + + def _build_invoice_info_lookup(self): + for row in self._invoices_with_tds: + if row.name not in self.invoice_info: + self.invoice_info[row.name] = row + + for row in self._all_withheld_vouchers: + if row.parent not in self.invoice_info: + self.invoice_info[row.parent] = row + + for row in self._all_advance_taxes: + if row.parent not in self.invoice_info: + self.invoice_info[row.parent] = row + + def _fetch_dates(self): + pe = frappe.qb.DocType("Payment Entry") + pi = frappe.qb.DocType("Purchase Invoice") + + if self._pe_names_for_dates: + pe_date_rows = ( + frappe.qb.from_(pe) + .select(pe.name, pe.posting_date) + .where(pe.name.isin(list(self._pe_names_for_dates))) + .run(as_dict=True) + ) + self.pe_dates = {row.name: row.posting_date for row in pe_date_rows} + + if self._pi_names_for_dates: + pi_date_rows = ( + frappe.qb.from_(pi) + .select(pi.name, pi.posting_date) + .where(pi.name.isin(list(self._pi_names_for_dates))) + .run(as_dict=True) + ) + self.pi_dates = {row.name: row.posting_date for row in pi_date_rows} + + # ------------------------------------------------------------------------- + # Invoice Processing + # ------------------------------------------------------------------------- + + def _process_invoices(self): + all_invoice_names = ( + set(self.invoice_taxes.keys()) | self.invoices_with_twv | self.invoices_with_advance_tax + ) + + for invoice_name in all_invoice_names: + self._process_invoice(invoice_name) + + def _process_invoice(self, invoice_name): + info = self.invoice_info.get(invoice_name) + if not info: + return + + # Build context for this invoice + ctx = self._build_invoice_context(invoice_name, info) + entries = [] + + # Process advance tax allocations (PE -> PI) + entries.extend(self._process_advance_taxes(invoice_name, ctx)) + + # Process TDS paid in current invoice + if ctx["total_tds_in_invoice"]: + entries.extend(self._process_invoice_tds(invoice_name, ctx)) + + # Handle under-withheld (TWV exists but no TDS) + elif invoice_name in self.invoices_with_twv: + entry = self._process_underwithheld(invoice_name, ctx) + if entry: + entries.append(entry) + + if entries: + self._add_entries("Purchase Invoice", invoice_name, entries) + + def _build_invoice_context(self, invoice_name, info): + # Get category + category = info.tax_withholding_category + if not category and invoice_name in self.invoice_taxes: + for tax_row in self.invoice_taxes[invoice_name]["tax_rows"]: + account_key = (info.company, tax_row.account_head) + category = self.tds_accounts["account_map"].get(account_key) + if category: + break + + # Get party info + party_type = "Supplier" + party = info.supplier + tax_id = get_party_tax_id(party_type, party, self.column_cache, self.party_tax_id_cache) + tax_rate, tax_on_excess = get_tax_rate_for_date(self.tax_rate_map, category, info.posting_date) + + # Current invoice's taxable amount + current_taxable = abs(info.base_tax_withholding_net_total or info.base_net_total) + + # Get TDS total + has_tds = invoice_name in self.invoice_taxes + total_tds_in_invoice = abs(self.invoice_taxes[invoice_name]["tds_total"]) if has_tds else 0 + + # Get related data + advance_taxes = self.advance_by_invoice.get(invoice_name, []) + withheld_vouchers = self.withheld_by_invoice.get(invoice_name, []) + + # Calculate past taxable from withheld vouchers + past_taxable_total = sum( + flt(v.taxable_amount) for v in withheld_vouchers if v.voucher_name != invoice_name + ) + + return { + "info": info, + "category": category, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_rate": tax_rate, + "tax_on_excess": tax_on_excess, + "current_taxable": current_taxable, + "past_taxable_total": past_taxable_total, + "total_tds_in_invoice": total_tds_in_invoice, + "advance_taxes": advance_taxes, + "withheld_vouchers": withheld_vouchers, + } + + def _process_advance_taxes(self, invoice_name, ctx): + entries = [] + info = ctx["info"] + + for adv_tax in ctx["advance_taxes"]: + pe_name = adv_tax.reference_name + pe_date = self.pe_dates.get(pe_name) + allocated_amount = flt(adv_tax.allocated_amount) + + if allocated_amount <= 0: + continue + + if ctx["tax_rate"]: + taxable_for_allocation = flt(allocated_amount * 100 / ctx["tax_rate"], 2) + else: + taxable_for_allocation = ctx["current_taxable"] + + ctx["current_taxable"] += taxable_for_allocation + + # Entry in Purchase Invoice + entry_in_pi = self._create_entry( + ctx, + taxable_amount=taxable_for_allocation, + withholding_amount=allocated_amount, + taxable_doctype="Purchase Invoice", + taxable_name=invoice_name, + taxable_date=info.posting_date, + withholding_doctype="Payment Entry", + withholding_name=pe_name, + withholding_date=pe_date, + ) + entries.append(entry_in_pi) + + # Duplicate entry in Payment Entry + entry_in_pe = entry_in_pi.copy() + entry_in_pe["is_duplicate"] = True + self._add_entries("Payment Entry", pe_name, [entry_in_pe]) + + return entries + + def _process_invoice_tds(self, invoice_name, ctx): + entries = [] + info = ctx["info"] + tax_rate = ctx["tax_rate"] + + tds_for_past = 0 + tds_for_current_and_past = abs(ctx["total_tds_in_invoice"]) + + # Calculate TDS split between current and past invoices + if not ctx["tax_on_excess"] and ctx["past_taxable_total"] > 0 and tax_rate: + tds_for_past = flt(ctx["past_taxable_total"] * tax_rate / 100, 2) + tds_for_current = max(0, tds_for_current_and_past - tds_for_past) + else: + tds_for_current = tds_for_current_and_past + + # Entry for current invoice + if tds_for_current: + if tax_rate: + calc_taxable = flt(tds_for_current * 100 / tax_rate, 2) + else: + calc_taxable = ctx["current_taxable"] + + # Handle threshold exemption for tax_on_excess categories + if ctx["tax_on_excess"] and calc_taxable < ctx["current_taxable"]: + taxable_exemption_amount = flt(ctx["current_taxable"] - calc_taxable, 2) + + # Create threshold exemption entry (no TDS on threshold amount) + threshold_entry = self._create_entry( + ctx, + taxable_amount=taxable_exemption_amount, + withholding_amount=0, + taxable_doctype="Purchase Invoice", + taxable_name=invoice_name, + taxable_date=info.posting_date, + withholding_doctype="Purchase Invoice", + withholding_name=invoice_name, + withholding_date=info.posting_date, + under_withheld_reason="Threshold Exemption", + ) + entries.append(threshold_entry) + + main_entry = self._create_entry( + ctx, + taxable_amount=calc_taxable, + withholding_amount=tds_for_current, + taxable_doctype="Purchase Invoice", + taxable_name=invoice_name, + taxable_date=info.posting_date, + withholding_doctype="Purchase Invoice", + withholding_name=invoice_name, + withholding_date=info.posting_date, + ) + entries.append(main_entry) + + # Process withheld vouchers (TDS for past invoices paid here) + if tds_for_past: + entries.extend(self._process_withheld_vouchers(invoice_name, ctx)) + + return entries + + def _process_withheld_vouchers(self, invoice_name, ctx): + entries = [] + info = ctx["info"] + tax_rate = ctx["tax_rate"] + + for voucher in ctx["withheld_vouchers"]: + if voucher.voucher_name == invoice_name: + continue + + voucher_date = self.pi_dates.get(voucher.voucher_name) or info.posting_date + voucher_taxable = flt(voucher.taxable_amount) + voucher_tds = flt(voucher_taxable * tax_rate / 100, 2) if tax_rate else 0 + + # Entry in current invoice + entry_in_current = self._create_entry( + ctx, + taxable_amount=voucher_taxable, + withholding_amount=voucher_tds, + taxable_doctype=voucher.voucher_type, + taxable_name=voucher.voucher_name, + taxable_date=voucher_date, + withholding_doctype="Purchase Invoice", + withholding_name=invoice_name, + withholding_date=info.posting_date, + ) + entries.append(entry_in_current) + + # Duplicate entry in past invoice + entry_in_past = entry_in_current.copy() + entry_in_past["is_duplicate"] = True + self._add_entries(voucher.voucher_type, voucher.voucher_name, [entry_in_past]) + + return entries + + def _process_underwithheld(self, invoice_name, ctx): + # Skip if TDS was paid by another invoice + if invoice_name in self.tds_paid_by_other: + return None + + info = ctx["info"] + taxable = info.base_tax_withholding_net_total or info.base_net_total + + tax_on_excess = ctx["tax_on_excess"] + under_withheld_reason = "Threshold Exemption" if tax_on_excess else "" + + return self._create_entry( + ctx, + taxable_amount=taxable, + withholding_amount=0, + taxable_doctype="Purchase Invoice", + taxable_name=invoice_name, + taxable_date=info.posting_date, + withholding_doctype="", + withholding_name="", + withholding_date=None, + under_withheld_reason=under_withheld_reason, + ) + + # ------------------------------------------------------------------------- + # Payment Entry Over-Withheld Processing + # ------------------------------------------------------------------------- + + def _process_pe_overwithheld(self): + for pe_name, data in self.pe_taxes.items(): + info = data["info"] + total_tds = data["tds_total"] + + if not total_tds: + continue + + # Calculate unallocated TDS + total_allocated = self.pe_allocated.get(pe_name, 0) + unallocated_tds = total_tds - total_allocated + + if unallocated_tds <= 0: + continue + + # Get category + category = info.tax_withholding_category + + party_type = info.party_type + party = info.party + tax_id = get_party_tax_id(party_type, party, self.column_cache, self.party_tax_id_cache) + tax_rate, _ = get_tax_rate_for_date(self.tax_rate_map, category, info.posting_date) + + if tax_rate: + unallocated_taxable = flt(unallocated_tds * 100 / tax_rate, 2) + else: + unallocated_taxable = info.paid_amount + + # Get currency and conversion rate based on payment type + if info.payment_type == "Receive": + currency = info.paid_from_account_currency + conversion_rate = info.source_exchange_rate or 1 + else: + currency = info.paid_to_account_currency + conversion_rate = info.target_exchange_rate or 1 + + entry = { + "company": info.company, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_withholding_category": category, + "taxable_amount": unallocated_taxable, + "tax_rate": tax_rate, + "withholding_amount": unallocated_tds, + "taxable_doctype": "", + "taxable_name": "", + "taxable_date": None, + "withholding_doctype": "Payment Entry", + "withholding_name": pe_name, + "withholding_date": info.posting_date, + "currency": currency, + "conversion_rate": conversion_rate, + } + + self._add_entries("Payment Entry", pe_name, [entry]) + + # ------------------------------------------------------------------------- + # Helper Methods + # ------------------------------------------------------------------------- + + def _create_entry(self, ctx, **kwargs): + is_return = ctx["info"].is_return + + if is_return: + if "taxable_amount" in kwargs: + kwargs["taxable_amount"] = -kwargs["taxable_amount"] + if "withholding_amount" in kwargs: + kwargs["withholding_amount"] = -kwargs["withholding_amount"] + + return { + "company": ctx["info"].company, + "party_type": ctx["party_type"], + "party": ctx["party"], + "tax_id": ctx["tax_id"], + "tax_withholding_category": ctx["category"], + "tax_rate": ctx["tax_rate"], + "currency": ctx["info"].currency, + "conversion_rate": ctx["info"].conversion_rate or 1, + **kwargs, + } + + def _add_entries(self, parent_doctype, parent_name, entries): + key = (parent_doctype, parent_name) + if key not in self.all_entries: + self.all_entries[key] = [] + + self.all_entries[key].extend(entries) + + +# ============================================================================= +# SALES INVOICE MIGRATION +# ============================================================================= + + +def migrate_sales_invoices(tds_accounts, tax_rate_map, column_cache, party_tax_id_cache): + """ + Migrate Sales Invoice TCS data. + + Sales Invoice TCS is simpler - only tax on excess amount for current invoice. + No tax_withheld_vouchers or advance_tax tables. + Tax is identified from GL Entry on TCS accounts. + """ + gle = frappe.qb.DocType("GL Entry") + si = frappe.qb.DocType("Sales Invoice") + cust = frappe.qb.DocType("Customer") + + # Build conditions for TCS accounts + all_tcs_accounts = set() + for _company, accounts in tds_accounts["accounts_by_company"].items(): + all_tcs_accounts.update(accounts) + + if not all_tcs_accounts: + return + + # Get Sales Invoices with TCS amounts aggregated + # Use conditional sum to aggregate TCS amounts only from TCS accounts + tcs_accounts_list = list(all_tcs_accounts) + + tcs_entries = ( + frappe.qb.from_(si) + .left_join(gle) + .on((gle.voucher_no == si.name) & (gle.voucher_type == "Sales Invoice") & (gle.is_cancelled == 0)) + .join(cust) + .on(si.customer == cust.name) + .select( + si.name.as_("invoice_name"), + si.posting_date, + si.customer, + si.company, + si.base_grand_total, + si.is_return, + si.currency, + si.conversion_rate, + cust.tax_withholding_category, + Sum(Case().when(gle.account.isin(tcs_accounts_list), gle.credit - gle.debit).else_(0)).as_( + "total_tcs" + ), + Max(Case().when(gle.account.isin(tcs_accounts_list), gle.account).else_(None)).as_("account"), + ) + .where(si.docstatus == 1) + .groupby(si.name) + .run(as_dict=True) + ) + + all_entries = {} + category_wise_invoices = defaultdict(list) + + for row in tcs_entries: + total_tcs = row.total_tcs + net_grand_total = abs(row.base_grand_total - total_tcs) + + # Get category + category = row.tax_withholding_category + if not category: + account_key = (row.company, row.account) + category = tds_accounts["account_map"].get(account_key) + + # Party info + party_type = "Customer" + party = row.customer + tax_id = get_party_tax_id(party_type, party, column_cache, party_tax_id_cache) + + tax_rate, _ = get_tax_rate_for_date(tax_rate_map, category, row.posting_date) + + if not tax_rate: + continue + + # Back-calculate taxable amount + calculated_taxable = 0 + if tax_rate and total_tcs: + calculated_taxable = flt(total_tcs * 100 / tax_rate, 2) + + # For returns, amounts are negative + if row.is_return: + total_tcs = -abs(total_tcs) + calculated_taxable = -abs(calculated_taxable) + + entries = [] + + # Handle threshold exemption for categories + # NOTE: Default tax_on_excess is True for Sales Invoice + if abs(calculated_taxable) < net_grand_total: + taxable_exemption_amount = flt(net_grand_total - abs(calculated_taxable), 2) + + if row.is_return: + taxable_exemption_amount = -abs(taxable_exemption_amount) + + threshold_entry = { + "company": row.company, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_withholding_category": category, + "taxable_amount": taxable_exemption_amount, + "tax_rate": tax_rate, + "withholding_amount": 0, + "taxable_doctype": "Sales Invoice", + "taxable_name": row.invoice_name, + "taxable_date": row.posting_date, + "withholding_doctype": "Sales Invoice", + "withholding_name": row.invoice_name, + "withholding_date": row.posting_date, + "under_withheld_reason": "Threshold Exemption", + "currency": row.currency, + "conversion_rate": row.conversion_rate or 1, + } + entries.append(threshold_entry) + + entry = { + "company": row.company, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_withholding_category": category, + "taxable_amount": calculated_taxable, + "tax_rate": tax_rate, + "withholding_amount": total_tcs, + "taxable_doctype": "Sales Invoice", + "taxable_name": row.invoice_name, + "taxable_date": row.posting_date, + "withholding_doctype": "Sales Invoice", + "withholding_name": row.invoice_name, + "withholding_date": row.posting_date, + "currency": row.currency, + "conversion_rate": row.conversion_rate or 1, + } + entries.append(entry) + + all_entries[("Sales Invoice", row.invoice_name)] = entries + category_wise_invoices[category].append(row.invoice_name) + + bulk_insert_entries(all_entries) + + # Update Sales Invoice and Sales Invoice Item + for category, invoices in category_wise_invoices.items(): + frappe.db.set_value("Sales Invoice", {"name": ("in", invoices)}, {"apply_tds": 1}) + frappe.db.set_value( + "Sales Invoice Item", + {"parent": ("in", invoices), "parenttype": "Sales Invoice"}, + {"tax_withholding_category": category, "apply_tds": 1}, + ) + + +# ============================================================================= +# JOURNAL ENTRY MIGRATION +# ============================================================================= + + +def migrate_journal_entries(tds_accounts, tax_rate_map, column_cache, party_tax_id_cache): + """ + Migrate Journal Entry TDS data. + + For Journal Entry, we rely on GL Entry to identify TDS transactions. + Party info is obtained from JE Account rows that are NOT TDS accounts. + """ + gle = frappe.qb.DocType("GL Entry") + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") + + # Build conditions for TDS accounts + all_tds_accounts = set() + for _company, accounts in tds_accounts["accounts_by_company"].items(): + all_tds_accounts.update(accounts) + + if not all_tds_accounts: + return + + # Get Journal Entries with TDS entries in GL + tds_gl_entries = ( + frappe.qb.from_(gle) + .join(je) + .on(gle.voucher_no == je.name) + .select( + gle.voucher_no.as_("journal_entry"), + gle.account, + gle.credit, + gle.debit, + gle.posting_date, + je.company, + je.tax_withholding_category, + je.voucher_type, + je.total_debit, + ) + .where(gle.voucher_type == "Journal Entry") + .where(gle.is_cancelled == 0) + .where(je.docstatus == 1) + .where(gle.account.isin(list(all_tds_accounts))) + .run(as_dict=True) + ) + + # Get all JE parties in bulk - only from non-TDS account rows + je_names = list({row.journal_entry for row in tds_gl_entries}) + je_parties = {} + if je_names: + # Get party from JE Account rows that are NOT TDS accounts + jea_party_rows = ( + frappe.qb.from_(jea) + .select( + jea.parent, + jea.party_type, + jea.party, + jea.account, + jea.account_currency, + jea.exchange_rate, + ) + .where(jea.parent.isin(je_names)) + .where(jea.party_type.isnotnull()) + .where(jea.party_type != "") + .where(jea.party.isnotnull()) + .where(jea.party != "") + .where(jea.account.notin(list(all_tds_accounts))) # Exclude TDS account rows + .run(as_dict=True) + ) + for row in jea_party_rows: + if row.parent not in je_parties: + je_parties[row.parent] = (row.party_type, row.party, row.account_currency, row.exchange_rate) + + # Group by journal entry + je_taxes = {} + for row in tds_gl_entries: + if row.journal_entry not in je_taxes: + je_taxes[row.journal_entry] = {"info": row, "gl_rows": []} + je_taxes[row.journal_entry]["gl_rows"].append(row) + + all_entries = {} + category_wise_jes = defaultdict(set) + + for je_name, data in je_taxes.items(): + info = data["info"] + + # Assume TCS not allowed in Journal Entry + # Calculate total TDS (credit - debit) + total_tds = sum(flt(row.credit) - flt(row.debit) for row in data["gl_rows"]) + + if total_tds <= 0: + # Ignore TDS payment entries + continue + + # Get category + category = info.tax_withholding_category + if not category: + for gl_row in data["gl_rows"]: + account_key = (info.company, gl_row.account) + category = tds_accounts["account_map"].get(account_key) + if category: + break + + # Get party from JE accounts (non-TDS rows) + party_type = None + party = None + account_currency = "" + exchange_rate = 1 + + if je_name in je_parties: + party_type, party, account_currency, exchange_rate = je_parties[je_name] + + tax_id = get_party_tax_id(party_type, party, column_cache, party_tax_id_cache) if party else None + tax_rate, tax_on_excess = get_tax_rate_for_date(tax_rate_map, category, info.posting_date) + + # Back-calculate taxable amount + calculated_taxable = 0 + if tax_rate and total_tds: + calculated_taxable = min(flt(total_tds * 100 / tax_rate, 2), info.total_debit) + + entries = [] + + # Handle threshold exemption for tax_on_excess categories + if tax_on_excess and calculated_taxable < info.total_debit: + taxable_exemption_amount = flt(info.total_debit - abs(calculated_taxable), 2) + threshold_entry = { + "company": info.company, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_withholding_category": category, + "taxable_amount": taxable_exemption_amount, + "tax_rate": tax_rate, + "withholding_amount": 0, + "taxable_doctype": "Journal Entry", + "taxable_name": je_name, + "taxable_date": info.posting_date, + "withholding_doctype": "Journal Entry", + "withholding_name": je_name, + "withholding_date": info.posting_date, + "under_withheld_reason": "Threshold Exemption", + "currency": account_currency or "", + "conversion_rate": flt(exchange_rate, 9) or 1, + } + entries.append(threshold_entry) + + entry = { + "company": info.company, + "party_type": party_type, + "party": party, + "tax_id": tax_id, + "tax_withholding_category": category, + "taxable_amount": calculated_taxable, + "tax_rate": tax_rate, + "withholding_amount": total_tds, + "taxable_doctype": "Journal Entry", + "taxable_name": je_name, + "taxable_date": info.posting_date, + "withholding_doctype": "Journal Entry", + "withholding_name": je_name, + "withholding_date": info.posting_date, + "currency": account_currency or "", + "conversion_rate": flt(exchange_rate, 9) or 1, + } + entries.append(entry) + all_entries[("Journal Entry", je_name)] = entries + + category_wise_jes[category].add(je_name) + + bulk_insert_entries(all_entries) + + for category, je_names in category_wise_jes.items(): + frappe.db.set_value( + "Journal Entry", + {"name": ("in", list(je_names))}, + {"apply_tds": 1, "tax_withholding_category": category}, + ) + + +# ============================================================================= +# ITEM LEVEL CATEGORY COPY +# ============================================================================= + + +def copy_category_to_items_for_purchase(column_cache): + parent_doctype = "Purchase Invoice" + item_doctype = "Purchase Invoice Item" + + parent_cols = column_cache.get(parent_doctype, {}) + item_cols = column_cache.get(item_doctype, {}) + + if not parent_cols.get("tax_withholding_category"): + return + + if not item_cols.get("tax_withholding_category"): + return + + parent = frappe.qb.DocType(parent_doctype) + item = frappe.qb.DocType(item_doctype, alias="item") + + ( + frappe.qb.update(item) + .join(parent) + .on(item.parent == parent.name) + .set(item.tax_withholding_category, parent.tax_withholding_category) + .where(parent.tax_withholding_category.isnotnull()) + .where(parent.tax_withholding_category != "") + .where(item.apply_tds == 1) + .where(IfNull(item.tax_withholding_category, "") == "") + .run() + ) + + +def copy_category_to_items_for_sales(column_cache): + parent_doctype = "Sales Invoice" + item_doctype = "Sales Invoice Item" + + item_cols = column_cache.get(item_doctype, {}) + + if not item_cols.get("tax_withholding_category"): + return + + parent = frappe.qb.DocType(parent_doctype) + item = frappe.qb.DocType(item_doctype, alias="item") + customer = frappe.qb.DocType("Customer", alias="customer") + + ( + frappe.qb.update(item) + .join(parent) + .on(item.parent == parent.name) + .join(customer) + .on(parent.customer == customer.name) + .set(item.tax_withholding_category, customer.tax_withholding_category) + .where(customer.tax_withholding_category.isnotnull()) + .where(customer.tax_withholding_category != "") + .where(IfNull(item.tax_withholding_category, "") == "") + .run() + ) diff --git a/erpnext/patches/v16_0/update_tax_withholding_field_in_payment_entry.py b/erpnext/patches/v16_0/update_tax_withholding_field_in_payment_entry.py new file mode 100644 index 00000000000..b6b606eb025 --- /dev/null +++ b/erpnext/patches/v16_0/update_tax_withholding_field_in_payment_entry.py @@ -0,0 +1,10 @@ +import frappe +from frappe.query_builder import DocType + + +def execute(): + if not frappe.db.has_column("Payment Entry", "apply_tax_withholding_amount"): + return + + pe = DocType("Payment Entry") + (frappe.qb.update(pe).set(pe.apply_tds, pe.apply_tax_withholding_amount)).run() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 42fad3cbdcc..9d26fe8da30 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -131,6 +131,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe frm.cscript.calculate_taxes_and_totals(); }); + // Tax Withholding Entries - Auto calculate withholding amount when taxable amount or tax rate changes + frappe.ui.form.on("Tax Withholding Entry", "taxable_amount", function (frm, cdt, cdn) { + me.calculate_withholding_amount(frm, cdt, cdn); + }); + + frappe.ui.form.on("Tax Withholding Entry", "tax_rate", function (frm, cdt, cdn) { + me.calculate_withholding_amount(frm, cdt, cdn); + }); + frappe.ui.form.on(this.frm.doctype + " Item", { items_add: function (frm, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); @@ -583,6 +592,18 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + calculate_withholding_amount(frm, cdt, cdn) { + // Calculate withholding amount: taxable_amount * tax_rate / 100 + let row = frappe.get_doc(cdt, cdn); + let withholding_amount = flt( + (row.taxable_amount * row.tax_rate) / 100, + precision("withholding_amount", row) + ); + + // Set the calculated withholding amount + frappe.model.set_value(cdt, cdn, "withholding_amount", withholding_amount); + } + send_sms() { var sms_man = new erpnext.SMSManager(this.frm.doc); } diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 958defa32c7..3c3c6e63798 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -108,7 +108,8 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { args: args, callback: function (r) { if (r.message) { - frm.supplier_tds = r.message.supplier_tds; + frm.tax_withholding_category = r.message.tax_withholding_category; + frm.tax_withholding_group = r.message.tax_withholding_group; frm.updating_party_details = true; frappe.run_serially([ () => frm.set_value(r.message), diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 33ddc6d316d..5cafe47e28d 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -62,9 +62,10 @@ "tax_tab", "taxation_section", "tax_id", - "column_break_21", "tax_category", + "column_break_21", "tax_withholding_category", + "tax_withholding_group", "accounting_tab", "credit_limit_section", "payment_terms", @@ -605,6 +606,12 @@ "fieldtype": "Table", "label": "Supplier Numbers", "options": "Supplier Number At Customer" + }, + { + "fieldname": "tax_withholding_group", + "fieldtype": "Link", + "label": "Tax Withholding Group", + "options": "Tax Withholding Group" } ], "icon": "fa fa-user", diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8b5d6a21604..6a720b64ca3 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -89,6 +89,7 @@ class Customer(TransactionBase): tax_category: DF.Link | None tax_id: DF.Data | None tax_withholding_category: DF.Link | None + tax_withholding_group: DF.Link | None territory: DF.Link | None website: DF.Data | None # end: auto-generated types diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 2d6fb2c3a58..1ab1e1fe73f 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -105,7 +105,13 @@ "customer_details", "customer_items", "item_tax_section_break", + "section_break_oilf", + "column_break_aytr", "taxes", + "section_break_fxqz", + "purchase_tax_withholding_category", + "column_break_ltlb", + "sales_tax_withholding_category", "quality_tab", "inspection_required_before_purchase", "quality_inspection_template", @@ -910,6 +916,37 @@ { "fieldname": "column_break_wugd", "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_oilf", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_aytr", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_fxqz", + "fieldtype": "Section Break" + }, + { + "fieldname": "purchase_tax_withholding_category", + "fieldtype": "Link", + "label": "Purchase Tax Withholding Category", + "depends_on": "is_purchase_item", + "options": "Tax Withholding Category" + }, + { + "fieldname": "column_break_ltlb", + "fieldtype": "Column Break" + }, + { + "fieldname": "sales_tax_withholding_category", + "fieldtype": "Link", + "label": "Sales Tax Withholding Category", + "depends_on": "is_sales_item", + "options": "Tax Withholding Category" } ], "icon": "fa fa-tag", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index fc973ac2449..d1b67178839 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -128,11 +128,13 @@ class Item(Document): over_billing_allowance: DF.Float over_delivery_receipt_allowance: DF.Float production_capacity: DF.Int + purchase_tax_withholding_category: DF.Link | None purchase_uom: DF.Link | None quality_inspection_template: DF.Link | None reorder_levels: DF.Table[ItemReorder] retain_sample: DF.Check safety_stock: DF.Float + sales_tax_withholding_category: DF.Link | None sales_uom: DF.Link | None sample_quantity: DF.Int serial_no_series: DF.Data | None diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 1c8fccf4849..44e934274e4 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -59,8 +59,6 @@ "column_break_27", "total", "net_total", - "tax_withholding_net_total", - "base_tax_withholding_net_total", "taxes_charges_section", "tax_category", "taxes_and_charges", @@ -1256,24 +1254,6 @@ "options": "Subcontracting Receipt", "search_index": 1 }, - { - "fieldname": "tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Tax Withholding Net Total", - "no_copy": 1, - "options": "currency", - "read_only": 1 - }, - { - "fieldname": "base_tax_withholding_net_total", - "fieldtype": "Currency", - "hidden": 1, - "label": "Base Tax Withholding Net Total", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "dispatch_address", "fieldtype": "Link", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 238f0ea0590..093a57ef111 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -55,7 +55,6 @@ class PurchaseReceipt(BuyingController): base_net_total: DF.Currency base_rounded_total: DF.Currency base_rounding_adjustment: DF.Currency - base_tax_withholding_net_total: DF.Currency base_taxes_and_charges_added: DF.Currency base_taxes_and_charges_deducted: DF.Currency base_total: DF.Currency @@ -138,7 +137,6 @@ class PurchaseReceipt(BuyingController): supplier_name: DF.Data | None supplier_warehouse: DF.Link | None tax_category: DF.Link | None - tax_withholding_net_total: DF.Currency taxes: DF.Table[PurchaseTaxesandCharges] taxes_and_charges: DF.Link | None taxes_and_charges_added: DF.Currency diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index b7605cc9276..70ca56e286f 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -59,7 +59,6 @@ "pricing_rules", "stock_uom_rate", "is_free_item", - "apply_tds", "section_break_29", "net_rate", "net_amount", @@ -1107,14 +1106,6 @@ "fieldtype": "Check", "label": "Use Serial No / Batch Fields" }, - { - "default": "1", - "fieldname": "apply_tds", - "fieldtype": "Check", - "hidden": 1, - "label": "Apply TDS", - "read_only": 1 - }, { "default": "0", "fieldname": "return_qty_from_rejected_warehouse", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index 9f65ae56a83..34190f560c7 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -17,7 +17,6 @@ class PurchaseReceiptItem(Document): allow_zero_valuation_rate: DF.Check amount: DF.Currency amount_difference_with_purchase_invoice: DF.Currency - apply_tds: DF.Check asset_category: DF.Link | None asset_location: DF.Link | None barcode: DF.Data | None diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index e88f621f090..62aceedcb32 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -106,6 +106,9 @@ def get_item_details( get_party_item_code(ctx, item, out) + if ctx.doctype in ["Sales Invoice", "Purchase Invoice"]: + get_tax_withholding_category(ctx, item, out) + if ctx.doctype in ["Sales Order", "Quotation"]: set_valuation_rate(out, ctx) @@ -1309,6 +1312,32 @@ def get_party_item_code(ctx: ItemDetailsCtx, item_doc, out: ItemDetails): out.supplier_part_no = item_supplier[0].supplier_part_no if item_supplier else None +def get_tax_withholding_category(ctx: ItemDetailsCtx, item_doc, out: ItemDetails): + """ + Get tax withholding category for the item based on the transaction type and party. + """ + + tax_withholding_category = None + field = ( + "sales_tax_withholding_category" + if ctx.transaction_type == "selling" + else "purchase_tax_withholding_category" + ) + + if item_doc.get(field): + tax_withholding_category = item_doc.get(field) + elif ctx.transaction_type == "buying" and ctx.supplier: + tax_withholding_category = frappe.get_cached_value( + "Supplier", ctx.supplier, "tax_withholding_category" + ) + elif ctx.transaction_type == "selling" and ctx.customer: + tax_withholding_category = frappe.get_cached_value( + "Customer", ctx.customer, "tax_withholding_category" + ) + + out.tax_withholding_category = tax_withholding_category + + from erpnext.deprecation_dumpster import get_pos_profile_item_details diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index a622b1fb9c4..637afe02613 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -968,7 +968,6 @@ def make_purchase_receipt(source_name, target_doc=None, save=False, submit=False "Purchase Taxes and Charges": { "doctype": "Purchase Taxes and Charges", "reset_value": True, - "condition": lambda doc: not doc.is_tax_withholding_account, }, }, postprocess=post_process,