3 Commits
Author SHA1 Message Date
kurogeek 2d93fe45c2 fix: get_payment_entry override rejected request-only args
frappe.call filters kwargs to the target signature; the *args/**kwargs
wrapper accepted 'cmd' and forwarded it to ERPNext's function. Mirror
the original signature.
2026-09-13 07:59:49 +00:00
kurogeek 9b99be9abd 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.
2026-09-13 07:56:21 +00:00
kurogeek 6a082edc56 fix: post customer withholding as a Deduct tax row, not a deduction
With a deduction row ERPNext treats Paid Amount as cash received and
credits the party for paid + deduction, so a gross Paid Amount (what Get
Outstanding Invoices fills) over-credited Debtors and posted the gross
to the bank. Use the taxes table with add_deduct_tax=Deduct like
supplier TDS: Paid Amount is the gross settlement, bank receives paid
minus withholding, receivable is debited. Override Payment Entry
build_gl_map to flag the bank entry post_net_value so the merged bank
line shows the net amount.
2026-09-13 07:45:11 +00:00
4 changed files with 217 additions and 51 deletions
+6 -5
View File
@@ -145,6 +145,7 @@ fixtures = [
override_doctype_class = { override_doctype_class = {
"Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice", "Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice",
"Payment Entry": "default_thai_company.tax_withholding.ThaiPaymentEntry",
} }
# Document Events # Document Events
@@ -195,11 +196,11 @@ doc_events = {
# Overriding Methods # Overriding Methods
# ------------------------------ # ------------------------------
#
# override_whitelisted_methods = { override_whitelisted_methods = {
# "frappe.desk.doctype.event.event.get_events": "default_thai_company.event.get_events" "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; # each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard, # generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps # along with any modifications made in other Frappe apps
@@ -1,10 +1,29 @@
frappe.ui.form.on("Payment Entry", { frappe.ui.form.on("Payment Entry", {
apply_tax_withholding_amount(frm) { 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 // 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. // 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.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);
},
}); });
+109 -14
View File
@@ -1,5 +1,7 @@
import erpnext import erpnext
import frappe 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.sales_invoice.sales_invoice import SalesInvoice
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_tax_withholding_details, get_tax_withholding_details,
@@ -157,44 +159,137 @@ class ThaiSalesInvoice(SalesInvoice):
) )
class ThaiPaymentEntry(PaymentEntry):
def build_gl_map(self):
"""A "Deduct" tax row on a receipt posts bank Dr gross and bank Cr
withholding; ERPNext merges them into one entry but only nets it when
the bank entry itself carries `post_net_value`. Set it so the bank
ledger shows the amount that actually arrived."""
gl_entries = super().build_gl_map()
if self.payment_type == "Receive" and self.party_type == "Customer" and self.get("taxes"):
for entry in gl_entries:
if entry.account == self.paid_to:
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(
dt,
dn,
party_amount=None,
bank_account=None,
bank_amount=None,
party_type=None,
payment_type=None,
reference_date=None,
created_from_payment_request=False,
):
"""Create > Payment from a Sales Invoice: arrive with withholding applied.
Signature mirrors ERPNext's so `frappe.call` drops request-only args
(`cmd`, ...) instead of forwarding them.
"""
pe = _get_payment_entry(
dt,
dn,
party_amount=party_amount,
bank_account=bank_account,
bank_amount=bank_amount,
party_type=party_type,
payment_type=payment_type,
reference_date=reference_date,
created_from_payment_request=created_from_payment_request,
)
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): def set_customer_withholding(doc, method=None):
"""Payment Entry.validate: tax withheld by the customer on a Receive entry """Payment Entry.validate: tax withheld by the customer on a Receive entry
becomes a deduction to the company's withholding receivable account. is posted like ERPNext's supplier TDS, as a "Deduct" row in the taxes
table against the withholding receivable account.
Withholding is rate x pre-VAT amount of each allocated reference, prorated `paid_amount` is the gross amount settled against the invoices (what
by the allocation. `paid_amount` is the cash actually received, so the "Get Outstanding Invoices" fills in); the bank receives paid minus the
deduction closes the difference against the gross allocation. Runs after withholding (`received_amount_after_tax`). Withholding is rate x pre-VAT
the controller's validate (exchange rates and allocations are final), then amount of each allocated reference, prorated by the allocation. Runs after
re-derives the two amounts that depend on deductions. 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 return
if not doc.tax_withholding_category: if method == "validate":
doc.tax_withholding_category = frappe.db.get_value("Customer", doc.party, "tax_withholding_category") auto_apply_customer_withholding(doc)
account = frappe.db.get_value( account = frappe.db.get_value(
"Account", "Account",
{"company": doc.company, "account_name": ASSET_ACCOUNT, "root_type": "Asset", "is_group": 0}, {"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: 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))
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) amount, description = get_customer_withholding(doc)
row = next((d for d in doc.deductions if d.account == account), None)
if not amount: if not amount:
if row: if row:
doc.remove(row) doc.remove(row)
else: else:
if not row: if not row:
row = doc.append("deductions", {"account": account}) row = doc.append(
row.amount = amount "taxes", {"account_head": account, "charge_type": "Actual", "add_deduct_tax": "Deduct"}
)
row.tax_amount = amount
row.description = 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.apply_taxes()
doc.set_difference_amount() doc.set_amounts_after_tax()
def get_customer_withholding(doc): def get_customer_withholding(doc):
@@ -6,7 +6,12 @@ from frappe.modules.utils import sync_customizations_for_doctype
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate 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" COMPANY = "_Test WHT Company"
ABBR = "_TWC" ABBR = "_TWC"
@@ -71,7 +76,7 @@ class TestTaxWithholding(FrappeTestCase):
} }
).insert() ).insert()
def make_invoice(self, rate=10000, category=None): def make_invoice(self, rate=10000, category=None, inclusive=False):
si = frappe.get_doc( si = frappe.get_doc(
{ {
"doctype": "Sales Invoice", "doctype": "Sales Invoice",
@@ -87,6 +92,7 @@ class TestTaxWithholding(FrappeTestCase):
"account_head": self.vat.name, "account_head": self.vat.name,
"rate": 7, "rate": 7,
"description": "VAT 7%", "description": "VAT 7%",
"included_in_print_rate": int(inclusive),
} }
], ],
} }
@@ -94,7 +100,8 @@ class TestTaxWithholding(FrappeTestCase):
si.set_missing_values() si.set_missing_values()
return si.submit() return si.submit()
def make_receipt(self, invoice, allocated, paid, apply=1): def make_receipt(self, invoice, allocated, apply=1):
"""Paid amount is the gross allocation; withholding reduces what reaches the bank."""
return frappe.get_doc( return frappe.get_doc(
{ {
"doctype": "Payment Entry", "doctype": "Payment Entry",
@@ -105,8 +112,8 @@ class TestTaxWithholding(FrappeTestCase):
"posting_date": nowdate(), "posting_date": nowdate(),
"paid_from": f"Debtors - {ABBR}", "paid_from": f"Debtors - {ABBR}",
"paid_to": f"Cash - {ABBR}", "paid_to": f"Cash - {ABBR}",
"paid_amount": paid, "paid_amount": allocated,
"received_amount": paid, "received_amount": allocated,
"apply_tax_withholding_amount": apply, "apply_tax_withholding_amount": apply,
"references": [ "references": [
{ {
@@ -118,6 +125,9 @@ class TestTaxWithholding(FrappeTestCase):
} }
).insert() ).insert()
def withheld(self, pe):
return [(t.account_head, t.add_deduct_tax, t.tax_amount) for t in pe.taxes]
def category_account(self, category, company=COMPANY): def category_account(self, category, company=COMPANY):
return frappe.db.get_value( return frappe.db.get_value(
"Tax Withholding Account", "Tax Withholding Account",
@@ -181,44 +191,85 @@ class TestTaxWithholding(FrappeTestCase):
def test_receipt_uses_invoice_category_over_customer_category(self): def test_receipt_uses_invoice_category_over_customer_category(self):
si = self.make_invoice(category="WHT 5% - Rent") si = self.make_invoice(category="WHT 5% - Rent")
self.assertEqual(si.withholding_tax_amount, 500.0) self.assertEqual(si.withholding_tax_amount, 500.0)
pe = self.make_receipt(si, allocated=10700, paid=10200) pe = self.make_receipt(si, allocated=10700)
self.assertEqual([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 500.0)]) self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 500.0)])
self.assertEqual(pe.difference_amount, 0) self.assertEqual(pe.received_amount_after_tax, 10200.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()
pe = self.make_receipt(si, allocated=10700, paid=10400) pe = self.make_receipt(si, allocated=10700)
self.assertEqual(pe.tax_withholding_category, "WHT 3% - Service") self.assertEqual(pe.tax_withholding_category, "WHT 3% - Service")
self.assertEqual([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 300.0)]) self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 300.0)])
self.assertEqual(pe.difference_amount, 0) self.assertEqual((pe.paid_amount, pe.received_amount_after_tax), (10700.0, 10400.0))
self.assertEqual((pe.unallocated_amount, pe.difference_amount), (0, 0))
pe.submit() pe.submit()
gl = { gl = {}
g.account: (g.debit, g.credit)
for g in frappe.get_all( for g in frappe.get_all(
"GL Entry", filters={"voucher_no": pe.name}, fields=["account", "debit", "credit"] "GL Entry", filters={"voucher_no": pe.name}, fields=["account", "debit", "credit"]
) ):
} gl.setdefault(g.account, [0, 0])
self.assertEqual(gl[f"Cash - {ABBR}"], (10400.0, 0.0)) gl[g.account][0] += g.debit
self.assertEqual(gl[self.receivable], (300.0, 0.0)) gl[g.account][1] += g.credit
self.assertEqual(gl[f"Debtors - {ABBR}"], (0.0, 10700.0)) self.assertEqual(gl[f"Cash - {ABBR}"], [10400.0, 0.0])
self.assertEqual(gl[self.receivable], [300.0, 0.0])
self.assertEqual(gl[f"Debtors - {ABBR}"], [0.0, 10700.0])
self.assertEqual(len(gl), 3)
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 0) self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 0)
def test_inclusive_vat_invoice_withholds_on_pre_vat_amount(self):
si = self.make_invoice(inclusive=True) # 10,000 incl. 7% VAT -> net 9,345.79
self.assertEqual((si.net_total, si.grand_total), (9345.79, 10000.0))
self.assertEqual(si.withholding_tax_amount, 280.37)
pe = self.make_receipt(si, allocated=10000)
self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 280.37)])
self.assertEqual(pe.received_amount_after_tax, 9719.63)
def test_partial_allocation_prorates_withholding(self): def test_partial_allocation_prorates_withholding(self):
si = self.make_invoice() si = self.make_invoice()
pe = self.make_receipt(si, allocated=5350, paid=5200) pe = self.make_receipt(si, allocated=5350)
self.assertEqual([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 150.0)]) self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 150.0)])
self.assertEqual(pe.difference_amount, 0) self.assertEqual(pe.received_amount_after_tax, 5200.0)
def test_below_single_threshold_has_no_deduction(self): def test_below_single_threshold_has_no_deduction(self):
si = self.make_invoice(rate=800) # net 800 < 1,000 threshold si = self.make_invoice(rate=800) # net 800 < 1,000 threshold
pe = self.make_receipt(si, allocated=856, paid=856) pe = self.make_receipt(si, allocated=856, apply=0)
self.assertEqual(pe.deductions, []) self.assertEqual(pe.apply_tax_withholding_amount, 0) # invoice carries no withholding
self.assertEqual(pe.difference_amount, 0) 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() si = self.make_invoice()
pe = self.make_receipt(si, allocated=10700, paid=10700, apply=0) pe = get_payment_entry("Sales Invoice", si.name, bank_account=f"Cash - {ABBR}")
self.assertEqual(pe.deductions, []) self.assertEqual(pe.apply_tax_withholding_amount, 1)
self.assertEqual(pe.difference_amount, 0) 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)])