feat: show expected withholding tax on Sales Invoice

Custom fields Tax Withholding Category (fetched from Customer),
Withholding Tax and Net Payable After Withholding Tax on Sales Invoice;
computed in the Thai set_tax_withholding override from the category
rate and single threshold, informational only (totals/GL unchanged),
printed under Rounded Total by the standard layout. Payment Entry now
prefers each invoice's category over the customer's.
This commit is contained in:
2026-09-11 10:20:03 +00:00
parent 4767d4bb46
commit ed4062590a
4 changed files with 157 additions and 31 deletions
+73 -29
View File
@@ -116,14 +116,45 @@ def is_thai_company(company):
return frappe.get_cached_value("Company", company, "country") == "Thailand"
def get_withholding_details(category, posting_date, company):
details = get_tax_withholding_details(category, posting_date, company)
if not details:
frappe.throw(
_("Tax Withholding Category {0} has no account for Company {1}").format(category, company)
)
return details
def withholding_on(details, taxable, base_taxable, precision):
"""Withholding for a taxable amount; the single threshold is checked in company currency."""
if details.threshold and flt(base_taxable) < flt(details.threshold):
return 0.0
amount = flt(taxable) * flt(details.rate) / 100
return normal_round(amount) if cint(details.round_off_tax_amount) else flt(amount, precision)
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):
the invoice. For Thai companies only show the expected withholding and
the net payable; totals and GL are untouched."""
if not is_thai_company(self.company):
return super().set_tax_withholding()
self.withholding_tax_amount = self.amount_after_withholding = 0
if not self.tax_withholding_category:
return
super().set_tax_withholding()
details = get_withholding_details(self.tax_withholding_category, self.posting_date, self.company)
self.withholding_tax_amount = withholding_on(
details, self.net_total, self.base_net_total, self.precision("withholding_tax_amount")
)
if self.withholding_tax_amount:
self.amount_after_withholding = flt(
(self.rounded_total or self.grand_total) - self.withholding_tax_amount,
self.precision("amount_after_withholding"),
)
def set_customer_withholding(doc, method=None):
@@ -141,16 +172,6 @@ def set_customer_withholding(doc, method=None):
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",
@@ -159,7 +180,7 @@ def set_customer_withholding(doc, method=None):
if not account:
frappe.throw(_("Account {0} not found for Company {1}").format(ASSET_ACCOUNT, doc.company))
amount = get_customer_withholding_amount(doc, details)
amount, description = get_customer_withholding(doc)
row = next((d for d in doc.deductions if d.account == account), None)
if not amount:
@@ -169,32 +190,55 @@ def set_customer_withholding(doc, method=None):
if not row:
row = doc.append("deductions", {"account": account})
row.amount = amount
row.description = details.description
row.description = 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):
def get_customer_withholding(doc):
"""Total withheld across allocated references, in company currency.
A Sales Invoice's own category wins over the Payment Entry's; Sales Orders
use the Payment Entry's. Withholding is prorated by allocated / grand total
on the pre-VAT amount, per reference.
"""
total = 0.0
details_by_category = {}
precision = doc.precision("difference_amount")
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:
fields = ["net_total", "grand_total"]
if ref.reference_doctype == "Sales Invoice":
fields.append("tax_withholding_category")
values = frappe.db.get_value(ref.reference_doctype, ref.reference_name, fields, as_dict=True)
if not values.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
category = values.get("tax_withholding_category") or doc.tax_withholding_category
if not category:
frappe.throw(
_("Please set Tax Withholding Category on {0} {1} or on this Payment Entry").format(
_(ref.reference_doctype), ref.reference_name
)
)
if category not in details_by_category:
details_by_category[category] = get_withholding_details(category, doc.posting_date, doc.company)
if cint(details.round_off_tax_amount):
return normal_round(total)
return flt(total, doc.precision("difference_amount"))
base_taxable = (
flt(ref.allocated_amount)
* flt(values.net_total)
/ flt(values.grand_total)
* flt(doc.source_exchange_rate)
)
total += withholding_on(details_by_category[category], base_taxable, base_taxable, precision)
if len(details_by_category) == 1:
description = next(iter(details_by_category.values())).description
else:
description = _("Withholding tax deducted by customer")
return flt(total, precision), description