From 6819242521dcf6180e5e071190ad60e8149ebb2b Mon Sep 17 00:00:00 2001 From: kurogeek Date: Fri, 11 Sep 2026 10:00:24 +0000 Subject: [PATCH] feat: customer-side withholding tax on Payment Entry Thai customers withhold tax at payment; ERPNext's customer-side handling is Indian TCS which adds tax on top of the Sales Invoice. Override SalesInvoice.set_tax_withholding to no-op for Thai companies, expose "Apply Tax Withholding Amount" on Customer/Receive Payment Entries, and on validate compute rate x pre-VAT amount of each allocated reference (prorated, single threshold honoured) as a deduction to "Withholding Tax Receivable". Client script fetches the Customer's category when the box is ticked. --- .../fixtures/property_setter.json | 16 ++++ default_thai_company/hooks.py | 22 ++++- .../public/js/payment_entry.js | 10 ++ default_thai_company/tax_withholding.py | 96 +++++++++++++++++++ 4 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 default_thai_company/public/js/payment_entry.js diff --git a/default_thai_company/fixtures/property_setter.json b/default_thai_company/fixtures/property_setter.json index 3fe2e50..5f8fdef 100644 --- a/default_thai_company/fixtures/property_setter.json +++ b/default_thai_company/fixtures/property_setter.json @@ -14,5 +14,21 @@ "property_type": "Data", "row_name": null, "value": "Default Standard Sales Invoice" + }, + { + "default_value": null, + "doc_type": "Payment Entry", + "docstatus": 0, + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "field_name": "apply_tax_withholding_amount", + "is_system_generated": 0, + "modified": "2026-09-11 12:00:00.000000", + "module": null, + "name": "Payment Entry-apply_tax_withholding_amount-depends_on", + "property": "depends_on", + "property_type": "Data", + "row_name": null, + "value": "eval:doc.party_type == 'Supplier' || (doc.party_type == 'Customer' && doc.payment_type == 'Receive')" } ] \ No newline at end of file diff --git a/default_thai_company/hooks.py b/default_thai_company/hooks.py index 37b17c4..599b214 100644 --- a/default_thai_company/hooks.py +++ b/default_thai_company/hooks.py @@ -43,7 +43,7 @@ required_apps = ["erpnext"] # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = {"Payment Entry": "public/js/payment_entry.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -90,7 +90,16 @@ fixtures = [ {"doctype": "Print Format", "filters": [["name", "in", ["Default Standard Sales Invoice"]]]}, { "doctype": "Property Setter", - "filters": [["name", "in", ["Sales Invoice-main-default_print_format"]]], + "filters": [ + [ + "name", + "in", + [ + "Sales Invoice-main-default_print_format", + "Payment Entry-apply_tax_withholding_amount-depends_on", + ], + ] + ], }, {"doctype": "Tax Withholding Category", "filters": [["name", "like", "WHT %"]]}, ] @@ -145,9 +154,9 @@ fixtures = [ # --------------- # Override standard doctype classes -# override_doctype_class = { -# "ToDo": "custom_app.overrides.CustomToDo" -# } +override_doctype_class = { + "Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice", +} # Document Events # --------------- @@ -164,6 +173,9 @@ doc_events = { "Company": { "on_update": "default_thai_company.tax_withholding.setup_company", }, + "Payment Entry": { + "validate": "default_thai_company.tax_withholding.set_customer_withholding", + }, } # Scheduled Tasks diff --git a/default_thai_company/public/js/payment_entry.js b/default_thai_company/public/js/payment_entry.js new file mode 100644 index 0000000..19c7540 --- /dev/null +++ b/default_thai_company/public/js/payment_entry.js @@ -0,0 +1,10 @@ +frappe.ui.form.on("Payment Entry", { + apply_tax_withholding_amount(frm) { + if (frm.doc.party_type !== "Customer" || !frm.doc.apply_tax_withholding_amount) return; + // ERPNext's handler looks the category up on Supplier and clears it for a + // Customer; wait for that request to settle, then set the Customer's value. + frappe.db.get_value("Customer", frm.doc.party, "tax_withholding_category").then(({ message }) => { + frappe.after_ajax(() => frm.set_value("tax_withholding_category", message.tax_withholding_category)); + }); + }, +}); diff --git a/default_thai_company/tax_withholding.py b/default_thai_company/tax_withholding.py index 93311f3..18a2bb7 100644 --- a/default_thai_company/tax_withholding.py +++ b/default_thai_company/tax_withholding.py @@ -1,4 +1,12 @@ +import erpnext import frappe +from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice +from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( + get_tax_withholding_details, + normal_round, +) +from frappe import _ +from frappe.utils import cint, flt # Liability: tax we withhold from suppliers and remit on P.N.D.3/53 (linked on every category). # Asset: tax customers withhold from us, creditable against corporate income tax. @@ -102,3 +110,91 @@ def setup_company(doc, method=None): category = frappe.get_doc("Tax Withholding Category", name) if link_company(category, doc.name, account): category.save(ignore_permissions=True) + + +def is_thai_company(company): + return frappe.get_cached_value("Company", company, "country") == "Thailand" + + +class ThaiSalesInvoice(SalesInvoice): + def set_tax_withholding(self): + """Thai customers withhold at payment (see `set_customer_withholding`); + ERPNext's customer-side handling is Indian TCS, which adds tax on top of + the invoice. Skip it for Thai companies.""" + if is_thai_company(self.company): + return + super().set_tax_withholding() + + +def set_customer_withholding(doc, method=None): + """Payment Entry.validate: tax withheld by the customer on a Receive entry + becomes a deduction to the company's withholding receivable account. + + Withholding is rate x pre-VAT amount of each allocated reference, prorated + by the allocation. `paid_amount` is the cash actually received, so the + deduction closes the difference against the gross allocation. Runs after + the controller's validate (exchange rates and allocations are final), then + re-derives the two amounts that depend on deductions. + """ + if doc.party_type != "Customer" or doc.payment_type != "Receive" or not doc.apply_tax_withholding_amount: + return + + if not doc.tax_withholding_category: + doc.tax_withholding_category = frappe.db.get_value("Customer", doc.party, "tax_withholding_category") + if not doc.tax_withholding_category: + frappe.throw(_("Please set Tax Withholding Category for Customer {0}").format(doc.party)) + + details = get_tax_withholding_details(doc.tax_withholding_category, doc.posting_date, doc.company) + if not details: + frappe.throw( + _("Tax Withholding Category {0} has no account for Company {1}").format( + doc.tax_withholding_category, doc.company + ) + ) + + account = frappe.db.get_value( + "Account", + {"company": doc.company, "account_name": ASSET_ACCOUNT, "root_type": "Asset", "is_group": 0}, + ) + if not account: + frappe.throw(_("Account {0} not found for Company {1}").format(ASSET_ACCOUNT, doc.company)) + + amount = get_customer_withholding_amount(doc, details) + row = next((d for d in doc.deductions if d.account == account), None) + + if not amount: + if row: + doc.remove(row) + else: + if not row: + row = doc.append("deductions", {"account": account}) + row.amount = amount + row.description = details.description + row.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company) + + doc.set_unallocated_amount() + doc.set_difference_amount() + + +def get_customer_withholding_amount(doc, details): + total = 0.0 + for ref in doc.references: + if ref.reference_doctype not in ("Sales Invoice", "Sales Order") or not ref.allocated_amount: + continue + + net_total, grand_total = frappe.db.get_value( + ref.reference_doctype, ref.reference_name, ["net_total", "grand_total"] + ) + if not grand_total: + continue + + taxable = ( + flt(ref.allocated_amount) * flt(net_total) / flt(grand_total) * flt(doc.source_exchange_rate) + ) + if details.threshold and taxable < details.threshold: + continue + total += taxable * flt(details.rate) / 100 + + if cint(details.round_off_tax_amount): + return normal_round(total) + return flt(total, doc.precision("difference_amount"))