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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import erpnext
|
import erpnext
|
||||||
import frappe
|
import frappe
|
||||||
|
from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry
|
||||||
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,15 +158,31 @@ 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
|
||||||
|
|
||||||
|
|
||||||
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" or not doc.apply_tax_withholding_amount:
|
||||||
return
|
return
|
||||||
@@ -181,20 +198,22 @@ def set_customer_withholding(doc, method=None):
|
|||||||
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, description = get_customer_withholding(doc)
|
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.taxes if d.account_head == 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):
|
||||||
|
|||||||
@@ -71,7 +71,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 +87,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 +95,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 +107,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 +120,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 +186,55 @@ 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)
|
||||||
self.assertEqual(pe.deductions, [])
|
self.assertEqual(pe.taxes, [])
|
||||||
self.assertEqual(pe.difference_amount, 0)
|
self.assertEqual(pe.received_amount_after_tax, 856.0)
|
||||||
|
|
||||||
def test_unchecked_receipt_is_untouched(self):
|
def test_unchecked_receipt_is_untouched(self):
|
||||||
si = self.make_invoice()
|
si = self.make_invoice()
|
||||||
pe = self.make_receipt(si, allocated=10700, paid=10700, apply=0)
|
pe = self.make_receipt(si, allocated=10700, apply=0)
|
||||||
self.assertEqual(pe.deductions, [])
|
self.assertEqual(pe.taxes, [])
|
||||||
self.assertEqual(pe.difference_amount, 0)
|
self.assertEqual(pe.received_amount_after_tax, 10700.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user