Files
default_thai_company/default_thai_company/tests/test_tax_withholding.py
T
kurogeek 8cba58ba02 feat: default Shipping Rule per Thai company
"Shipping Charges - <abbr>": Selling, Fixed, posting to a "Shipping
Charges" income account under Direct Income (created if the chart lacks
it) with the default cost center. Shipping Rule is named by its label,
so the company abbreviation keeps one rule per company, as ERPNext
names its tax templates. Created on Company save, after install, and by
a patch for existing companies.

The amount is entered on the transaction, not the rule: ERPNext
re-applies the rule on every recalculation and would reset the charge
row to the rule's fixed amount. ThaiShippingRule overrides the doctype
class so a Fixed rule with no amount only seeds the row and leaves the
entered amount alone.
2026-09-17 10:02:23 +00:00

333 lines
12 KiB
Python

import json
import frappe
from frappe.modules.import_file import import_file_by_path
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,
INPUT_VAT_ACCOUNT,
LIABILITY_ACCOUNT,
OUTPUT_VAT_ACCOUNT,
get_payment_entry,
thai_companies,
)
COMPANY = "_Test WHT Company"
ABBR = "_TWC"
CUSTOMER = "_Test WHT Customer"
ITEM = "_Test WHT Service"
FIXTURE = frappe.get_app_path("default_thai_company", "fixtures", "tax_withholding_category.json")
CUSTOM_DIR = frappe.get_app_path("default_thai_company", "default_thai_company", "custom")
class TestTaxWithholding(FrappeTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Fixture categories must exist before the company is created so the
# Company hook has something to link; force=True re-imports.
import_file_by_path(FIXTURE, force=True, data_import=True)
for fname in ("sales_invoice.json", "payment_entry.json"):
with open(f"{CUSTOM_DIR}/{fname}") as f:
sync_customizations_for_doctype(json.load(f), CUSTOM_DIR, fname)
frappe.get_doc(
{
"doctype": "Company",
"company_name": COMPANY,
"abbr": ABBR,
"country": "Thailand",
"default_currency": "THB",
"chart_of_accounts": "Standard",
}
).insert()
cls.payable = f"{LIABILITY_ACCOUNT} - {ABBR}"
cls.receivable = f"{ASSET_ACCOUNT} - {ABBR}"
cls.vat = frappe.get_doc("Account", f"{OUTPUT_VAT_ACCOUNT} - {ABBR}")
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": CUSTOMER,
"customer_type": "Company",
"customer_group": frappe.db.get_value("Customer Group", {"is_group": 0}),
"territory": frappe.db.get_value("Territory", {"is_group": 0}),
"tax_withholding_category": "WHT 3% - Service",
}
).insert()
frappe.get_doc(
{
"doctype": "Item",
"item_code": ITEM,
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}),
"is_stock_item": 0,
"stock_uom": "Nos",
}
).insert()
def make_invoice(self, rate=10000, category=None, inclusive=False):
si = frappe.get_doc(
{
"doctype": "Sales Invoice",
"company": COMPANY,
"customer": CUSTOMER,
"posting_date": nowdate(),
"due_date": nowdate(),
"tax_withholding_category": category,
"items": [{"item_code": ITEM, "qty": 1, "rate": rate}],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": self.vat.name,
"rate": 7,
"description": "VAT 7%",
"included_in_print_rate": int(inclusive),
}
],
}
)
si.set_missing_values()
return si.submit()
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",
"company": COMPANY,
"payment_type": "Receive",
"party_type": "Customer",
"party": CUSTOMER,
"posting_date": nowdate(),
"paid_from": f"Debtors - {ABBR}",
"paid_to": f"Cash - {ABBR}",
"paid_amount": allocated,
"received_amount": allocated,
"apply_tax_withholding_amount": apply,
"references": [
{
"reference_doctype": "Sales Invoice",
"reference_name": invoice.name,
"allocated_amount": allocated,
}
],
}
).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",
{"parent": category, "parenttype": "Tax Withholding Category", "company": company},
"account",
)
def test_company_creation_adds_accounts_and_links_categories(self):
self.assertIn(COMPANY, thai_companies())
for account, expected in (
(self.payable, ("Liability", f"Duties and Taxes - {ABBR}", "Tax")),
(self.receivable, ("Asset", f"Tax Assets - {ABBR}", "Tax")),
(f"{OUTPUT_VAT_ACCOUNT} - {ABBR}", ("Liability", f"Duties and Taxes - {ABBR}", "Tax")),
(f"{INPUT_VAT_ACCOUNT} - {ABBR}", ("Asset", f"Tax Assets - {ABBR}", "Tax")),
):
self.assertEqual(
frappe.db.get_value("Account", account, ["root_type", "parent_account", "account_type"]),
expected,
account,
)
categories = frappe.get_all(
"Tax Withholding Category", filters={"name": ("like", "WHT %")}, pluck="name"
)
self.assertEqual(len(categories), 22)
for name in categories:
self.assertEqual(self.category_account(name), self.payable, name)
def test_company_creation_adds_vat_schemes(self):
for doctype, account in (
("Sales Taxes and Charges Template", f"{OUTPUT_VAT_ACCOUNT} - {ABBR}"),
("Purchase Taxes and Charges Template", f"{INPUT_VAT_ACCOUNT} - {ABBR}"),
):
schemes = {}
for name in frappe.get_all(
doctype, filters={"company": COMPANY, "title": ("like", "Thailand VAT%")}, pluck="name"
):
doc = frappe.get_doc(doctype, name)
(row,) = doc.taxes
schemes[doc.title] = (
doc.is_default,
row.account_head,
row.charge_type,
row.rate,
row.included_in_print_rate,
)
self.assertEqual(
schemes,
{
"Thailand VAT 7%": (1, account, "On Net Total", 7.0, 0),
"Thailand VAT 7% (Included)": (0, account, "On Net Total", 7.0, 1),
"Thailand VAT 0%": (0, account, "On Net Total", 0.0, 0),
},
doctype,
)
def test_company_creation_adds_shipping_rule(self):
rule = frappe.get_doc("Shipping Rule", f"Shipping Charges - {ABBR}")
self.assertEqual(
(rule.company, rule.shipping_rule_type, rule.calculate_based_on, rule.account, rule.cost_center),
(COMPANY, "Selling", "Fixed", f"Shipping Charges - {ABBR}", f"Main - {ABBR}"),
)
self.assertEqual(frappe.db.get_value("Account", rule.account, "root_type"), "Income")
def test_shipping_amount_entered_on_transaction_survives_recalculation(self):
rule = f"Shipping Charges - {ABBR}"
so = frappe.get_doc(
{
"doctype": "Sales Order",
"company": COMPANY,
"customer": CUSTOMER,
"transaction_date": nowdate(),
"delivery_date": nowdate(),
"shipping_rule": rule,
"items": [{"item_code": ITEM, "qty": 1, "rate": 1000}],
}
)
so.set_missing_values()
so.apply_shipping_rule()
(charge,) = so.taxes
self.assertEqual((charge.description, charge.tax_amount), (rule, 0))
charge.tax_amount = 150
so.insert()
so.apply_shipping_rule()
so.save()
self.assertEqual([t.tax_amount for t in so.taxes], [150])
self.assertEqual(so.grand_total, 1150)
def test_fixture_reimport_keeps_site_account_and_relinks(self):
alt = frappe.get_doc(
{
"doctype": "Account",
"company": COMPANY,
"account_name": "Alternative WHT Payable",
"parent_account": f"Duties and Taxes - {ABBR}",
"account_type": "Tax",
}
).insert()
rent = frappe.get_doc("Tax Withholding Category", "WHT 5% - Rent")
for row in rent.accounts:
if row.company == COMPANY:
row.account = alt.name
rent.save()
import_file_by_path(FIXTURE, force=True, data_import=True)
self.assertEqual(self.category_account("WHT 5% - Rent"), alt.name)
self.assertEqual(self.category_account("WHT 3% - Service"), self.payable)
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)])
def test_sales_invoice_shows_withholding_without_grossing_up(self):
si = self.make_invoice()
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(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)
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)
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))
self.assertEqual((pe.unallocated_amount, pe.difference_amount), (0, 0))
pe.submit()
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)
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, 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_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)])