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",
|
"property_type": "Data",
|
||||||
"row_name": null,
|
"row_name": null,
|
||||||
"value": "Default Standard Sales Invoice"
|
"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"}
|
# page_js = {"page" : "public/js/file.js"}
|
||||||
|
|
||||||
# include js in doctype views
|
# 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_list_js = {"doctype" : "public/js/doctype_list.js"}
|
||||||
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
|
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
|
||||||
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.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": "Print Format", "filters": [["name", "in", ["Default Standard Sales Invoice"]]]},
|
||||||
{
|
{
|
||||||
"doctype": "Property Setter",
|
"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 %"]]},
|
{"doctype": "Tax Withholding Category", "filters": [["name", "like", "WHT %"]]},
|
||||||
]
|
]
|
||||||
@@ -145,9 +154,9 @@ fixtures = [
|
|||||||
# ---------------
|
# ---------------
|
||||||
# Override standard doctype classes
|
# Override standard doctype classes
|
||||||
|
|
||||||
# override_doctype_class = {
|
override_doctype_class = {
|
||||||
# "ToDo": "custom_app.overrides.CustomToDo"
|
"Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice",
|
||||||
# }
|
}
|
||||||
|
|
||||||
# Document Events
|
# Document Events
|
||||||
# ---------------
|
# ---------------
|
||||||
@@ -164,6 +173,9 @@ doc_events = {
|
|||||||
"Company": {
|
"Company": {
|
||||||
"on_update": "default_thai_company.tax_withholding.setup_company",
|
"on_update": "default_thai_company.tax_withholding.setup_company",
|
||||||
},
|
},
|
||||||
|
"Payment Entry": {
|
||||||
|
"validate": "default_thai_company.tax_withholding.set_customer_withholding",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scheduled Tasks
|
# 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
|
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).
|
# 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.
|
# 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)
|
category = frappe.get_doc("Tax Withholding Category", name)
|
||||||
if link_company(category, doc.name, account):
|
if link_company(category, doc.name, account):
|
||||||
category.save(ignore_permissions=True)
|
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