From 9b99be9abdb4de9939d4ff189fe4c8ec40d7a680 Mon Sep 17 00:00:00 2001 From: kurogeek Date: Sun, 13 Sep 2026 07:56:21 +0000 Subject: [PATCH] feat: apply customer withholding automatically on new receipts Tick Apply Tax Withholding Amount on unsaved customer receipts whose references include a Sales Invoice carrying withholding: in the get_payment_entry override (Create > Payment), in the allocate_amount_to_references doc method (Get Outstanding Invoices / Paid Amount changes, so the tax row updates live) and on first save. Saved entries keep the user's choice; unticking now removes the row. Client script recomputes the row when the checkbox or category changes. --- default_thai_company/hooks.py | 10 ++-- .../public/js/payment_entry.js | 23 ++++++- default_thai_company/tax_withholding.py | 60 +++++++++++++++++-- .../tests/test_tax_withholding.py | 41 ++++++++++++- 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/default_thai_company/hooks.py b/default_thai_company/hooks.py index d451df4..baa9d5d 100644 --- a/default_thai_company/hooks.py +++ b/default_thai_company/hooks.py @@ -196,11 +196,11 @@ doc_events = { # Overriding Methods # ------------------------------ -# -# override_whitelisted_methods = { -# "frappe.desk.doctype.event.event.get_events": "default_thai_company.event.get_events" -# } -# + +override_whitelisted_methods = { + "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry": "default_thai_company.tax_withholding.get_payment_entry", +} + # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, # along with any modifications made in other Frappe apps diff --git a/default_thai_company/public/js/payment_entry.js b/default_thai_company/public/js/payment_entry.js index 19c7540..5f4a827 100644 --- a/default_thai_company/public/js/payment_entry.js +++ b/default_thai_company/public/js/payment_entry.js @@ -1,10 +1,29 @@ frappe.ui.form.on("Payment Entry", { apply_tax_withholding_amount(frm) { - if (frm.doc.party_type !== "Customer" || !frm.doc.apply_tax_withholding_amount) return; + if (frm.doc.party_type !== "Customer" || frm.doc.payment_type !== "Receive") return; + if (!frm.doc.apply_tax_withholding_amount) { + frm.events.recompute_customer_withholding(frm); + return; + } // ERPNext's handler looks the category up on Supplier and clears it for a // Customer; wait for that request to settle, then set the Customer's value. frappe.db.get_value("Customer", frm.doc.party, "tax_withholding_category").then(({ message }) => { - frappe.after_ajax(() => frm.set_value("tax_withholding_category", message.tax_withholding_category)); + frappe.after_ajax(() => { + frm.set_value("tax_withholding_category", message.tax_withholding_category); + frm.events.recompute_customer_withholding(frm); + }); }); }, + + tax_withholding_category(frm) { + if (frm.doc.party_type === "Customer" && frm.doc.payment_type === "Receive") { + frm.events.recompute_customer_withholding(frm); + } + }, + + recompute_customer_withholding(frm) { + // allocate_amount_to_references is overridden server-side to refresh the withholding row + if (!frm.doc.references || !frm.doc.references.length) return; + frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, false); + }, }); diff --git a/default_thai_company/tax_withholding.py b/default_thai_company/tax_withholding.py index d69cc8c..6afe4ee 100644 --- a/default_thai_company/tax_withholding.py +++ b/default_thai_company/tax_withholding.py @@ -1,6 +1,7 @@ import erpnext import frappe from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry as _get_payment_entry 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, @@ -171,6 +172,46 @@ class ThaiPaymentEntry(PaymentEntry): entry.post_net_value = True return gl_entries + @frappe.whitelist() + def allocate_amount_to_references(self, *args, **kwargs): + """Called by the form after Get Outstanding Invoices and on Paid Amount + changes; keep the withholding row in step with the allocation.""" + super().allocate_amount_to_references(*args, **kwargs) + if self.party_type == "Customer" and self.payment_type == "Receive" and self.source_exchange_rate: + auto_apply_customer_withholding(self) + set_customer_withholding(self) + + +@frappe.whitelist() +def get_payment_entry(*args, **kwargs): + """Create > Payment from a Sales Invoice: arrive with withholding applied.""" + pe = _get_payment_entry(*args, **kwargs) + if auto_apply_customer_withholding(pe) and pe.source_exchange_rate: + set_customer_withholding(pe) + return pe + + +def auto_apply_customer_withholding(doc): + """Tick Apply Tax Withholding Amount on a new customer receipt whose + references include a Sales Invoice that carries withholding. Only for + unsaved entries: after the first save the checkbox is the user's.""" + if ( + doc.party_type != "Customer" + or doc.payment_type != "Receive" + or doc.apply_tax_withholding_amount + or not doc.is_new() + ): + return False + + invoices = [r.reference_name for r in doc.references if r.reference_doctype == "Sales Invoice"] + if not invoices or not frappe.db.exists( + "Sales Invoice", {"name": ("in", invoices), "withholding_tax_amount": (">", 0)} + ): + return False + + doc.apply_tax_withholding_amount = 1 + return True + def set_customer_withholding(doc, method=None): """Payment Entry.validate: tax withheld by the customer on a Receive entry @@ -184,21 +225,32 @@ def set_customer_withholding(doc, method=None): the controller's validate, then re-runs the tax computation so the row's base amounts and totals are final within this save. """ - if doc.party_type != "Customer" or doc.payment_type != "Receive" or not doc.apply_tax_withholding_amount: + if doc.party_type != "Customer" or doc.payment_type != "Receive": return - if not doc.tax_withholding_category: - doc.tax_withholding_category = frappe.db.get_value("Customer", doc.party, "tax_withholding_category") + if method == "validate": + auto_apply_customer_withholding(doc) account = frappe.db.get_value( "Account", {"company": doc.company, "account_name": ASSET_ACCOUNT, "root_type": "Asset", "is_group": 0}, ) + row = next((d for d in doc.taxes if d.account_head == account), None) if account else None + + if not doc.apply_tax_withholding_amount: + if row: + doc.remove(row) + doc.apply_taxes() + doc.set_amounts_after_tax() + return + if not account: frappe.throw(_("Account {0} not found for Company {1}").format(ASSET_ACCOUNT, doc.company)) + if not doc.tax_withholding_category: + doc.tax_withholding_category = frappe.db.get_value("Customer", doc.party, "tax_withholding_category") + amount, description = get_customer_withholding(doc) - row = next((d for d in doc.taxes if d.account_head == account), None) if not amount: if row: diff --git a/default_thai_company/tests/test_tax_withholding.py b/default_thai_company/tests/test_tax_withholding.py index 09db688..f9ad2dc 100644 --- a/default_thai_company/tests/test_tax_withholding.py +++ b/default_thai_company/tests/test_tax_withholding.py @@ -6,7 +6,12 @@ from frappe.modules.utils import sync_customizations_for_doctype from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate -from default_thai_company.tax_withholding import ASSET_ACCOUNT, LIABILITY_ACCOUNT, thai_companies +from default_thai_company.tax_withholding import ( + ASSET_ACCOUNT, + LIABILITY_ACCOUNT, + get_payment_entry, + thai_companies, +) COMPANY = "_Test WHT Company" ABBR = "_TWC" @@ -229,12 +234,42 @@ class TestTaxWithholding(FrappeTestCase): def test_below_single_threshold_has_no_deduction(self): si = self.make_invoice(rate=800) # net 800 < 1,000 threshold - pe = self.make_receipt(si, allocated=856) + pe = self.make_receipt(si, allocated=856, apply=0) + self.assertEqual(pe.apply_tax_withholding_amount, 0) # invoice carries no withholding self.assertEqual(pe.taxes, []) self.assertEqual(pe.received_amount_after_tax, 856.0) - def test_unchecked_receipt_is_untouched(self): + def test_create_payment_from_invoice_applies_withholding(self): + si = self.make_invoice() + pe = get_payment_entry("Sales Invoice", si.name, bank_account=f"Cash - {ABBR}") + self.assertEqual(pe.apply_tax_withholding_amount, 1) + self.assertEqual(pe.tax_withholding_category, "WHT 3% - Service") + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 300.0)]) + self.assertEqual((pe.paid_amount, pe.received_amount_after_tax), (10700.0, 10400.0)) + pe.insert() + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 300.0)]) + + def test_new_receipt_referencing_withheld_invoice_applies_on_save(self): si = self.make_invoice() pe = self.make_receipt(si, allocated=10700, apply=0) + self.assertEqual(pe.apply_tax_withholding_amount, 1) + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 300.0)]) + + def test_unticking_after_save_removes_withholding(self): + si = self.make_invoice() + pe = self.make_receipt(si, allocated=10700) + pe.apply_tax_withholding_amount = 0 + pe.save() + self.assertEqual(pe.apply_tax_withholding_amount, 0) # saved docs keep the user's choice self.assertEqual(pe.taxes, []) self.assertEqual(pe.received_amount_after_tax, 10700.0) + + def test_reallocation_on_form_recomputes_withholding(self): + si = self.make_invoice() + pe = get_payment_entry("Sales Invoice", si.name, bank_account=f"Cash - {ABBR}") + pe.paid_amount = pe.received_amount = 5350 + pe.allocate_amount_to_references( + paid_amount=5350, paid_amount_change=True, allocate_payment_amount=True + ) + self.assertEqual(pe.references[0].allocated_amount, 5350.0) + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 150.0)])