diff --git a/default_thai_company/hooks.py b/default_thai_company/hooks.py index eb734b1..d451df4 100644 --- a/default_thai_company/hooks.py +++ b/default_thai_company/hooks.py @@ -145,6 +145,7 @@ fixtures = [ override_doctype_class = { "Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice", + "Payment Entry": "default_thai_company.tax_withholding.ThaiPaymentEntry", } # Document Events diff --git a/default_thai_company/tax_withholding.py b/default_thai_company/tax_withholding.py index b995ff7..d69cc8c 100644 --- a/default_thai_company/tax_withholding.py +++ b/default_thai_company/tax_withholding.py @@ -1,5 +1,6 @@ import erpnext 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.tax_withholding_category.tax_withholding_category import ( 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): """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 - by the allocation. `paid_amount` is the cash actually received, so the - deduction closes the difference against the gross allocation. Runs after - the controller's validate (exchange rates and allocations are final), then - re-derives the two amounts that depend on deductions. + `paid_amount` is the gross amount settled against the invoices (what + "Get Outstanding Invoices" fills in); the bank receives paid minus the + withholding (`received_amount_after_tax`). Withholding is rate x pre-VAT + amount of each allocated reference, prorated by the allocation. Runs after + 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: 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)) 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 row: doc.remove(row) else: if not row: - row = doc.append("deductions", {"account": account}) - row.amount = amount + row = doc.append( + "taxes", {"account_head": account, "charge_type": "Actual", "add_deduct_tax": "Deduct"} + ) + row.tax_amount = amount 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() + doc.apply_taxes() + doc.set_amounts_after_tax() def get_customer_withholding(doc): diff --git a/default_thai_company/tests/test_tax_withholding.py b/default_thai_company/tests/test_tax_withholding.py index 37e270c..09db688 100644 --- a/default_thai_company/tests/test_tax_withholding.py +++ b/default_thai_company/tests/test_tax_withholding.py @@ -71,7 +71,7 @@ class TestTaxWithholding(FrappeTestCase): } ).insert() - def make_invoice(self, rate=10000, category=None): + def make_invoice(self, rate=10000, category=None, inclusive=False): si = frappe.get_doc( { "doctype": "Sales Invoice", @@ -87,6 +87,7 @@ class TestTaxWithholding(FrappeTestCase): "account_head": self.vat.name, "rate": 7, "description": "VAT 7%", + "included_in_print_rate": int(inclusive), } ], } @@ -94,7 +95,8 @@ class TestTaxWithholding(FrappeTestCase): si.set_missing_values() 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( { "doctype": "Payment Entry", @@ -105,8 +107,8 @@ class TestTaxWithholding(FrappeTestCase): "posting_date": nowdate(), "paid_from": f"Debtors - {ABBR}", "paid_to": f"Cash - {ABBR}", - "paid_amount": paid, - "received_amount": paid, + "paid_amount": allocated, + "received_amount": allocated, "apply_tax_withholding_amount": apply, "references": [ { @@ -118,6 +120,9 @@ class TestTaxWithholding(FrappeTestCase): } ).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): return frappe.db.get_value( "Tax Withholding Account", @@ -181,44 +186,55 @@ class TestTaxWithholding(FrappeTestCase): 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) + pe = self.make_receipt(si, allocated=10700) + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 500.0)]) + self.assertEqual(pe.received_amount_after_tax, 10200.0) def test_receipt_deducts_withholding_and_settles_invoice(self): 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([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 300.0)]) - self.assertEqual(pe.difference_amount, 0) + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 300.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() - gl = { - g.account: (g.debit, g.credit) - for g in frappe.get_all( - "GL Entry", filters={"voucher_no": pe.name}, fields=["account", "debit", "credit"] - ) - } - 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)) + gl = {} + for g in frappe.get_all( + "GL Entry", filters={"voucher_no": pe.name}, fields=["account", "debit", "credit"] + ): + gl.setdefault(g.account, [0, 0]) + gl[g.account][0] += g.debit + gl[g.account][1] += g.credit + 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) + 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): si = self.make_invoice() - pe = self.make_receipt(si, allocated=5350, paid=5200) - self.assertEqual([(d.account, d.amount) for d in pe.deductions], [(self.receivable, 150.0)]) - self.assertEqual(pe.difference_amount, 0) + pe = self.make_receipt(si, allocated=5350) + self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 150.0)]) + self.assertEqual(pe.received_amount_after_tax, 5200.0) 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, paid=856) - self.assertEqual(pe.deductions, []) - self.assertEqual(pe.difference_amount, 0) + pe = self.make_receipt(si, allocated=856) + self.assertEqual(pe.taxes, []) + self.assertEqual(pe.received_amount_after_tax, 856.0) def test_unchecked_receipt_is_untouched(self): si = self.make_invoice() - pe = self.make_receipt(si, allocated=10700, paid=10700, apply=0) - self.assertEqual(pe.deductions, []) - self.assertEqual(pe.difference_amount, 0) + pe = self.make_receipt(si, allocated=10700, apply=0) + self.assertEqual(pe.taxes, []) + self.assertEqual(pe.received_amount_after_tax, 10700.0)