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.
This commit is contained in:
2026-09-11 10:00:24 +00:00
parent 8b3cf9af1e
commit 6819242521
4 changed files with 139 additions and 5 deletions
@@ -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')"
}
]
+17 -5
View File
@@ -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
@@ -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));
});
},
});
+96
View File
@@ -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"))