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
@@ -0,0 +1,50 @@
[
{
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fetch_from": "customer.tax_withholding_category",
"fetch_if_empty": 1,
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
"insert_after": "taxes_and_charges",
"label": "Tax Withholding Category",
"modified": "2026-09-11 18:00:00.000000",
"name": "Sales Invoice-tax_withholding_category",
"options": "Tax Withholding Category",
"print_hide": 1
},
{
"depends_on": "eval:doc.tax_withholding_category",
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "withholding_tax_amount",
"fieldtype": "Currency",
"insert_after": "rounded_total",
"label": "Withholding Tax",
"modified": "2026-09-11 18:00:00.000000",
"name": "Sales Invoice-withholding_tax_amount",
"no_copy": 1,
"options": "currency",
"print_hide_if_no_value": 1,
"read_only": 1
},
{
"bold": 1,
"depends_on": "eval:doc.tax_withholding_category",
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Sales Invoice",
"fieldname": "amount_after_withholding",
"fieldtype": "Currency",
"insert_after": "withholding_tax_amount",
"label": "Net Payable After Withholding Tax",
"modified": "2026-09-11 18:00:00.000000",
"name": "Sales Invoice-amount_after_withholding",
"no_copy": 1,
"options": "currency",
"print_hide_if_no_value": 1,
"read_only": 1
}
]
+14
View File
@@ -102,6 +102,20 @@ fixtures = [
], ],
}, },
{"doctype": "Tax Withholding Category", "filters": [["name", "like", "WHT %"]]}, {"doctype": "Tax Withholding Category", "filters": [["name", "like", "WHT %"]]},
{
"doctype": "Custom Field",
"filters": [
[
"name",
"in",
[
"Sales Invoice-tax_withholding_category",
"Sales Invoice-withholding_tax_amount",
"Sales Invoice-amount_after_withholding",
],
]
],
},
] ]
# Installation # Installation
+72 -28
View File
@@ -116,14 +116,45 @@ def is_thai_company(company):
return frappe.get_cached_value("Company", company, "country") == "Thailand" 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): class ThaiSalesInvoice(SalesInvoice):
def set_tax_withholding(self): def set_tax_withholding(self):
"""Thai customers withhold at payment (see `set_customer_withholding`); """Thai customers withhold at payment (see `set_customer_withholding`);
ERPNext's customer-side handling is Indian TCS, which adds tax on top of ERPNext's customer-side handling is Indian TCS, which adds tax on top of
the invoice. Skip it for Thai companies.""" the invoice. For Thai companies only show the expected withholding and
if is_thai_company(self.company): 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 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): def set_customer_withholding(doc, method=None):
@@ -141,16 +172,6 @@ def set_customer_withholding(doc, method=None):
if not doc.tax_withholding_category: if not doc.tax_withholding_category:
doc.tax_withholding_category = frappe.db.get_value("Customer", doc.party, "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 = frappe.db.get_value(
"Account", "Account",
@@ -159,7 +180,7 @@ def set_customer_withholding(doc, method=None):
if not account: if not account:
frappe.throw(_("Account {0} not found for Company {1}").format(ASSET_ACCOUNT, doc.company)) 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) row = next((d for d in doc.deductions if d.account == account), None)
if not amount: if not amount:
@@ -169,32 +190,55 @@ def set_customer_withholding(doc, method=None):
if not row: if not row:
row = doc.append("deductions", {"account": account}) row = doc.append("deductions", {"account": account})
row.amount = amount row.amount = amount
row.description = details.description row.description = description
row.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company) row.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company)
doc.set_unallocated_amount() doc.set_unallocated_amount()
doc.set_difference_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 total = 0.0
details_by_category = {}
precision = doc.precision("difference_amount")
for ref in doc.references: for ref in doc.references:
if ref.reference_doctype not in ("Sales Invoice", "Sales Order") or not ref.allocated_amount: if ref.reference_doctype not in ("Sales Invoice", "Sales Order") or not ref.allocated_amount:
continue continue
net_total, grand_total = frappe.db.get_value( fields = ["net_total", "grand_total"]
ref.reference_doctype, ref.reference_name, ["net_total", "grand_total"] if ref.reference_doctype == "Sales Invoice":
) fields.append("tax_withholding_category")
if not grand_total: values = frappe.db.get_value(ref.reference_doctype, ref.reference_name, fields, as_dict=True)
if not values.grand_total:
continue continue
taxable = ( category = values.get("tax_withholding_category") or doc.tax_withholding_category
flt(ref.allocated_amount) * flt(net_total) / flt(grand_total) * flt(doc.source_exchange_rate) 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 details.threshold and taxable < details.threshold: )
continue if category not in details_by_category:
total += taxable * flt(details.rate) / 100 details_by_category[category] = get_withholding_details(category, doc.posting_date, doc.company)
if cint(details.round_off_tax_amount): base_taxable = (
return normal_round(total) flt(ref.allocated_amount)
return flt(total, doc.precision("difference_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
@@ -10,6 +10,7 @@ ABBR = "_TWC"
CUSTOMER = "_Test WHT Customer" CUSTOMER = "_Test WHT Customer"
ITEM = "_Test WHT Service" ITEM = "_Test WHT Service"
FIXTURE = frappe.get_app_path("default_thai_company", "fixtures", "tax_withholding_category.json") FIXTURE = frappe.get_app_path("default_thai_company", "fixtures", "tax_withholding_category.json")
CUSTOM_FIELDS = frappe.get_app_path("default_thai_company", "fixtures", "custom_field.json")
class TestTaxWithholding(FrappeTestCase): class TestTaxWithholding(FrappeTestCase):
@@ -19,6 +20,8 @@ class TestTaxWithholding(FrappeTestCase):
# Fixture categories must exist before the company is created so the # Fixture categories must exist before the company is created so the
# Company hook has something to link; force=True re-imports. # Company hook has something to link; force=True re-imports.
import_file_by_path(FIXTURE, force=True, data_import=True) import_file_by_path(FIXTURE, force=True, data_import=True)
import_file_by_path(CUSTOM_FIELDS, force=True, data_import=True)
frappe.clear_cache(doctype="Sales Invoice")
frappe.get_doc( frappe.get_doc(
{ {
@@ -64,7 +67,7 @@ class TestTaxWithholding(FrappeTestCase):
} }
).insert() ).insert()
def make_invoice(self, rate=10000): def make_invoice(self, rate=10000, category=None):
si = frappe.get_doc( si = frappe.get_doc(
{ {
"doctype": "Sales Invoice", "doctype": "Sales Invoice",
@@ -72,6 +75,7 @@ class TestTaxWithholding(FrappeTestCase):
"customer": CUSTOMER, "customer": CUSTOMER,
"posting_date": nowdate(), "posting_date": nowdate(),
"due_date": nowdate(), "due_date": nowdate(),
"tax_withholding_category": category,
"items": [{"item_code": ITEM, "qty": 1, "rate": rate}], "items": [{"item_code": ITEM, "qty": 1, "rate": rate}],
"taxes": [ "taxes": [
{ {
@@ -158,10 +162,24 @@ class TestTaxWithholding(FrappeTestCase):
rates = frappe.get_doc("Tax Withholding Category", "WHT 5% - Rent").rates rates = frappe.get_doc("Tax Withholding Category", "WHT 5% - Rent").rates
self.assertEqual([(r.tax_withholding_rate, r.single_threshold) for r in rates], [(5.0, 1000.0)]) self.assertEqual([(r.tax_withholding_rate, r.single_threshold) for r in rates], [(5.0, 1000.0)])
def test_sales_invoice_is_not_grossed_up(self): def test_sales_invoice_shows_withholding_without_grossing_up(self):
si = self.make_invoice() si = self.make_invoice()
self.assertEqual((si.net_total, si.grand_total), (10000.0, 10700.0)) self.assertEqual((si.net_total, si.grand_total), (10000.0, 10700.0))
self.assertEqual([t.account_head for t in si.taxes], [self.vat.name]) self.assertEqual([t.account_head for t in si.taxes], [self.vat.name])
self.assertEqual(si.tax_withholding_category, "WHT 3% - Service") # fetched from Customer
self.assertEqual((si.withholding_tax_amount, si.amount_after_withholding), (300.0, 10400.0))
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 10700.0)
def test_sales_invoice_below_threshold_shows_nothing(self):
si = self.make_invoice(rate=800)
self.assertEqual((si.withholding_tax_amount, si.amount_after_withholding), (0.0, 0.0))
def test_receipt_uses_invoice_category_over_customer_category(self):
si = self.make_invoice(category="WHT 5% - Rent")
self.assertEqual(si.withholding_tax_amount, 500.0)
pe = self.make_receipt(si, allocated=10700, paid=10200)
self.assertEqual([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 500.0)])
self.assertEqual(pe.difference_amount, 0)
def test_receipt_deducts_withholding_and_settles_invoice(self): def test_receipt_deducts_withholding_and_settles_invoice(self):
si = self.make_invoice() si = self.make_invoice()