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:
@@ -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')"
|
||||
}
|
||||
]
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user