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
+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"))