From ed4062590a00d8c8666a79a3956578ad353ea84f Mon Sep 17 00:00:00 2001 From: kurogeek Date: Fri, 11 Sep 2026 10:20:03 +0000 Subject: [PATCH] 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. --- .../fixtures/custom_field.json | 50 +++++++++ default_thai_company/hooks.py | 14 +++ default_thai_company/tax_withholding.py | 102 +++++++++++++----- .../tests/test_tax_withholding.py | 22 +++- 4 files changed, 157 insertions(+), 31 deletions(-) create mode 100644 default_thai_company/fixtures/custom_field.json diff --git a/default_thai_company/fixtures/custom_field.json b/default_thai_company/fixtures/custom_field.json new file mode 100644 index 0000000..0329ce5 --- /dev/null +++ b/default_thai_company/fixtures/custom_field.json @@ -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 + } +] diff --git a/default_thai_company/hooks.py b/default_thai_company/hooks.py index 599b214..4457c44 100644 --- a/default_thai_company/hooks.py +++ b/default_thai_company/hooks.py @@ -102,6 +102,20 @@ fixtures = [ ], }, {"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 diff --git a/default_thai_company/tax_withholding.py b/default_thai_company/tax_withholding.py index 18a2bb7..b995ff7 100644 --- a/default_thai_company/tax_withholding.py +++ b/default_thai_company/tax_withholding.py @@ -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 diff --git a/default_thai_company/tests/test_tax_withholding.py b/default_thai_company/tests/test_tax_withholding.py index 503fad8..e7447e0 100644 --- a/default_thai_company/tests/test_tax_withholding.py +++ b/default_thai_company/tests/test_tax_withholding.py @@ -10,6 +10,7 @@ ABBR = "_TWC" CUSTOMER = "_Test WHT Customer" ITEM = "_Test WHT Service" 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): @@ -19,6 +20,8 @@ class TestTaxWithholding(FrappeTestCase): # Fixture categories must exist before the company is created so the # Company hook has something to link; force=True re-imports. 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( { @@ -64,7 +67,7 @@ class TestTaxWithholding(FrappeTestCase): } ).insert() - def make_invoice(self, rate=10000): + def make_invoice(self, rate=10000, category=None): si = frappe.get_doc( { "doctype": "Sales Invoice", @@ -72,6 +75,7 @@ class TestTaxWithholding(FrappeTestCase): "customer": CUSTOMER, "posting_date": nowdate(), "due_date": nowdate(), + "tax_withholding_category": category, "items": [{"item_code": ITEM, "qty": 1, "rate": rate}], "taxes": [ { @@ -158,10 +162,24 @@ class TestTaxWithholding(FrappeTestCase): 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)]) - def test_sales_invoice_is_not_grossed_up(self): + def test_sales_invoice_shows_withholding_without_grossing_up(self): si = self.make_invoice() 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(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): si = self.make_invoice()