"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.
333 lines
12 KiB
Python
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)])
|