2 Commits
Author SHA1 Message Date
kurogeek 04735ca813 feat: Thai VAT schemes as Sales/Purchase Taxes and Charges Templates
Per Thai company: Thailand VAT 7%, Thailand VAT 7% (Included) for
VAT-inclusive pricing, Thailand VAT 0% for zero-rated supplies. Sales
schemes post to Output VAT, purchase schemes to Input VAT (category
Total, so recoverable VAT stays out of valuation). The 7% scheme is the
default when the company has none. Created on Company save, after
install, and by a patch for existing companies.
2026-09-15 09:35:15 +00:00
kurogeek 381aab2c83 feat: Output VAT and Input VAT accounts for every Thai company
Thai VAT is filed on P.P.30 as output tax (ภาษีขาย, collected on sales)
net of input tax (ภาษีซื้อ, paid on purchases); ERPNext's wizard makes a
single VAT account. Both join the per-company tax accounts created on
Company save and fixture import; a patch adds them to existing companies.
Account names are translated for the chart of accounts tree.
2026-09-15 09:19:43 +00:00
9 changed files with 172 additions and 28 deletions
+6 -2
View File
@@ -103,7 +103,8 @@ fixtures = [
# ------------ # ------------
# before_install = "default_thai_company.install.before_install" # before_install = "default_thai_company.install.before_install"
# after_install = "default_thai_company.install.after_install" # Fixtures are synced before this runs; VAT schemes for companies that already exist.
after_install = "default_thai_company.vat.setup_companies"
# Uninstallation # Uninstallation
# ------------ # ------------
@@ -167,7 +168,10 @@ doc_events = {
"before_import": "default_thai_company.tax_withholding.prepare_fixture_accounts", "before_import": "default_thai_company.tax_withholding.prepare_fixture_accounts",
}, },
"Company": { "Company": {
"on_update": "default_thai_company.tax_withholding.setup_company", "on_update": [
"default_thai_company.tax_withholding.setup_company",
"default_thai_company.vat.setup_company",
],
}, },
"Payment Entry": { "Payment Entry": {
"validate": "default_thai_company.tax_withholding.set_customer_withholding", "validate": "default_thai_company.tax_withholding.set_customer_withholding",
+3 -1
View File
@@ -3,4 +3,6 @@
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync] [post_model_sync]
# Patches added in this section will be executed after doctypes are migrated # Patches added in this section will be executed after doctypes are migrated
default_thai_company.patches.create_vat_accounts
default_thai_company.patches.create_vat_templates
@@ -0,0 +1,8 @@
from default_thai_company.tax_withholding import ensure_company_accounts, thai_companies
def execute():
"""Output VAT / Input VAT were added to COMPANY_ACCOUNTS; Company.on_update
only runs on save, so give existing Thai companies the pair."""
for company in thai_companies():
ensure_company_accounts(company)
@@ -0,0 +1,5 @@
from default_thai_company.vat import setup_companies
def execute():
setup_companies()
+26 -7
View File
@@ -12,10 +12,23 @@ from frappe.utils import cint, flt
from default_thai_company.utils import money_in_words from default_thai_company.utils import money_in_words
# Liability: tax we withhold from suppliers and remit on P.N.D.3/53 (linked on every category). # Tax accounts every Thai company gets (Company.on_update, fixture import, and the
# Asset: tax customers withhold from us, creditable against corporate income tax. # create_vat_accounts patch for companies that predate the VAT pair).
# Withholding: liability = tax we withhold from suppliers, remitted on P.N.D.3/53
# (linked on every category); asset = tax customers withhold from us, creditable
# against corporate income tax.
# VAT: output (ภาษีขาย) collected on sales is payable, input (ภาษีซื้อ) paid on
# purchases is recoverable; the P.P.30 return nets the two, so they are kept apart.
LIABILITY_ACCOUNT = "Withholding Tax Payable" LIABILITY_ACCOUNT = "Withholding Tax Payable"
ASSET_ACCOUNT = "Withholding Tax Receivable" ASSET_ACCOUNT = "Withholding Tax Receivable"
OUTPUT_VAT_ACCOUNT = "Output VAT"
INPUT_VAT_ACCOUNT = "Input VAT"
COMPANY_ACCOUNTS = (
(LIABILITY_ACCOUNT, "Liability"),
(ASSET_ACCOUNT, "Asset"),
(OUTPUT_VAT_ACCOUNT, "Liability"),
(INPUT_VAT_ACCOUNT, "Asset"),
)
# Preferred parent groups by root type; first match wins, else the root account. # Preferred parent groups by root type; first match wins, else the root account.
PARENT_GROUPS = { PARENT_GROUPS = {
@@ -67,9 +80,10 @@ def find_parent_group(company, root_type):
def ensure_company_accounts(company): def ensure_company_accounts(company):
"""Create both withholding accounts for `company`; return the liability account name.""" """Create every account in COMPANY_ACCOUNTS for `company`; return the
get_or_create_account(company, ASSET_ACCOUNT, "Asset") withholding liability account name (the one Tax Withholding Categories link)."""
return get_or_create_account(company, LIABILITY_ACCOUNT, "Liability") accounts = {name: get_or_create_account(company, name, root_type) for name, root_type in COMPANY_ACCOUNTS}
return accounts[LIABILITY_ACCOUNT]
def link_company(category, company, account): def link_company(category, company, account):
@@ -104,9 +118,14 @@ def prepare_fixture_accounts(doc, method=None):
link_company(doc, company, ensure_company_accounts(company)) link_company(doc, company, ensure_company_accounts(company))
def company_ready(doc):
"""A Thai company whose chart of accounts exists."""
return doc.country == "Thailand" and frappe.db.exists("Account", {"company": doc.name})
def setup_company(doc, method=None): def setup_company(doc, method=None):
"""Company.on_update: create withholding accounts and link every WHT category.""" """Company.on_update: create the tax accounts and link every WHT category."""
if doc.country != "Thailand" or not frappe.db.exists("Account", {"company": doc.name}): if not company_ready(doc):
return return
account = ensure_company_accounts(doc.name) account = ensure_company_accounts(doc.name)
@@ -8,7 +8,9 @@ from frappe.utils import nowdate
from default_thai_company.tax_withholding import ( from default_thai_company.tax_withholding import (
ASSET_ACCOUNT, ASSET_ACCOUNT,
INPUT_VAT_ACCOUNT,
LIABILITY_ACCOUNT, LIABILITY_ACCOUNT,
OUTPUT_VAT_ACCOUNT,
get_payment_entry, get_payment_entry,
thai_companies, thai_companies,
) )
@@ -44,16 +46,7 @@ class TestTaxWithholding(FrappeTestCase):
).insert() ).insert()
cls.payable = f"{LIABILITY_ACCOUNT} - {ABBR}" cls.payable = f"{LIABILITY_ACCOUNT} - {ABBR}"
cls.receivable = f"{ASSET_ACCOUNT} - {ABBR}" cls.receivable = f"{ASSET_ACCOUNT} - {ABBR}"
cls.vat = frappe.get_doc("Account", f"{OUTPUT_VAT_ACCOUNT} - {ABBR}")
cls.vat = frappe.get_doc(
{
"doctype": "Account",
"company": COMPANY,
"account_name": "Output VAT",
"parent_account": f"Duties and Taxes - {ABBR}",
"account_type": "Tax",
}
).insert()
frappe.get_doc( frappe.get_doc(
{ {
@@ -137,14 +130,17 @@ class TestTaxWithholding(FrappeTestCase):
def test_company_creation_adds_accounts_and_links_categories(self): def test_company_creation_adds_accounts_and_links_categories(self):
self.assertIn(COMPANY, thai_companies()) self.assertIn(COMPANY, thai_companies())
self.assertEqual( for account, expected in (
frappe.db.get_value("Account", self.payable, ["root_type", "parent_account"]), (self.payable, ("Liability", f"Duties and Taxes - {ABBR}", "Tax")),
("Liability", f"Duties and Taxes - {ABBR}"), (self.receivable, ("Asset", f"Tax Assets - {ABBR}", "Tax")),
) (f"{OUTPUT_VAT_ACCOUNT} - {ABBR}", ("Liability", f"Duties and Taxes - {ABBR}", "Tax")),
self.assertEqual( (f"{INPUT_VAT_ACCOUNT} - {ABBR}", ("Asset", f"Tax Assets - {ABBR}", "Tax")),
frappe.db.get_value("Account", self.receivable, ["root_type", "parent_account"]), ):
("Asset", f"Tax Assets - {ABBR}"), self.assertEqual(
) frappe.db.get_value("Account", account, ["root_type", "parent_account", "account_type"]),
expected,
account,
)
categories = frappe.get_all( categories = frappe.get_all(
"Tax Withholding Category", filters={"name": ("like", "WHT %")}, pluck="name" "Tax Withholding Category", filters={"name": ("like", "WHT %")}, pluck="name"
@@ -153,6 +149,34 @@ class TestTaxWithholding(FrappeTestCase):
for name in categories: for name in categories:
self.assertEqual(self.category_account(name), self.payable, name) 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_fixture_reimport_keeps_site_account_and_relinks(self): def test_fixture_reimport_keeps_site_account_and_relinks(self):
alt = frappe.get_doc( alt = frappe.get_doc(
{ {
+4
View File
@@ -53,3 +53,7 @@ Grand Total,ยอดรวมทั้งสิ้น
Rounded Total,ยอดรวมปัดเศษ Rounded Total,ยอดรวมปัดเศษ
Terms and Conditions Details,รายละเอียดข้อตกลงและเงื่อนไข Terms and Conditions Details,รายละเอียดข้อตกลงและเงื่อนไข
Page {0} of {1},หน้า {0} จาก {1} Page {0} of {1},หน้า {0} จาก {1}
Withholding Tax Payable,ภาษีหัก ณ ที่จ่ายค้างจ่าย
Withholding Tax Receivable,ภาษีถูกหัก ณ ที่จ่าย
Output VAT,ภาษีขาย
Input VAT,ภาษีซื้อ
1 Net Payable After Withholding Tax ยอดชำระสุทธิหลังหักภาษี ณ ที่จ่าย
53 Rounded Total ยอดรวมปัดเศษ
54 Terms and Conditions Details รายละเอียดข้อตกลงและเงื่อนไข
55 Page {0} of {1} หน้า {0} จาก {1}
56 Withholding Tax Payable ภาษีหัก ณ ที่จ่ายค้างจ่าย
57 Withholding Tax Receivable ภาษีถูกหัก ณ ที่จ่าย
58 Output VAT ภาษีขาย
59 Input VAT ภาษีซื้อ
+78
View File
@@ -0,0 +1,78 @@
import erpnext
import frappe
from default_thai_company.tax_withholding import (
INPUT_VAT_ACCOUNT,
OUTPUT_VAT_ACCOUNT,
company_ready,
get_or_create_account,
thai_companies,
)
RATE = 7.0
# title, rate, included_in_print_rate. Exempt supplies take no template.
SCHEMES = (
("Thailand VAT 7%", RATE, 0),
("Thailand VAT 7% (Included)", RATE, 1), # VAT-inclusive prices, the retail norm
("Thailand VAT 0%", 0.0, 0), # zero-rated: exports, international transport
)
DEFAULT_SCHEME = "Thailand VAT 7%"
# doctype, account, root type, extra row fields. Input VAT is recoverable, so on
# purchases it is "Total" (added to the bill, kept out of item valuation).
TEMPLATES = (
("Sales Taxes and Charges Template", OUTPUT_VAT_ACCOUNT, "Liability", {}),
(
"Purchase Taxes and Charges Template",
INPUT_VAT_ACCOUNT,
"Asset",
{"category": "Total", "add_deduct_tax": "Add"},
),
)
def ensure_vat_templates(company):
"""Create the VAT schemes for `company`, skipping titles that exist. The 7%
scheme becomes the default when the company has no default template."""
cost_center = erpnext.get_default_cost_center(company)
for doctype, account_name, root_type, extra in TEMPLATES:
account = get_or_create_account(company, account_name, root_type)
has_default = frappe.db.exists(doctype, {"company": company, "is_default": 1})
for title, rate, included in SCHEMES:
if frappe.db.exists(doctype, {"company": company, "title": title}):
continue
is_default = title == DEFAULT_SCHEME and not has_default
frappe.get_doc(
{
"doctype": doctype,
"title": title,
"company": company,
"is_default": int(is_default),
"taxes": [
{
"charge_type": "On Net Total",
"account_head": account,
"rate": rate,
"description": f"VAT {rate:g}%",
"included_in_print_rate": included,
"cost_center": cost_center,
**extra,
}
],
}
).insert(ignore_permissions=True)
has_default = has_default or is_default
def setup_company(doc, method=None):
"""Company.on_update: VAT schemes for a Thai company."""
if company_ready(doc):
ensure_vat_templates(doc.name)
def setup_companies():
"""after_install and the create_vat_templates patch: companies that exist
before this code did never pass through `setup_company`."""
for company in thai_companies():
ensure_vat_templates(company)