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:
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user