5 Commits
Author SHA1 Message Date
kurogeek baff8de0d3 fix: certificate button only on paid Purchase Invoices
The certificate is issued when the tax is withheld, i.e. on payment;
show the button once the submitted invoice's status is Paid.
2026-09-22 03:58:04 +00:00
kurogeek 2ee381f369 feat: Withholding Tax Certificate button on Purchase Invoice
Shown while Apply Tax Withholding Amount is ticked on a saved, not
cancelled invoice; downloads the certificate PDF in Thai.
2026-09-22 03:29:54 +00:00
kurogeek a1ca8439fc feat: Withholding Tax Certificate print format for Purchase Invoice
The Revenue Department's Sec. 50 bis form (approve_wh3_081156.pdf) as
the page background, with the invoice's data positioned in the form's
fields; two copies per certificate. Its AcroForm fields carry no Thai
font, so the PDF is not filled directly.

get_withholding_certificate(doc) resolves the payer and payee (13-digit
tax ID, one-line address), the certificate row from the category's
income_type, the P.N.D. return from the supplier type and income, and
the amounts in company currency with the tax in Thai words. Dates are
Buddhist Era.
2026-09-22 03:29:54 +00:00
kurogeek 42857b1377 feat: Type of Income Paid on Tax Withholding Category
Custom Select income_type: the row of the withholding tax certificate
(50 tawi) a payment under the category is reported on, set for every
shipped category.

Frappe syncs fixtures before customizations on install and migrate, so a
fixture value for a Custom Field that does not exist yet is dropped;
before_install / before_migrate create the category fields first.
2026-09-22 03:29:39 +00:00
kurogeek 270e44c080 feat: default calendar-year Payroll Period per Thai company 2026-09-18 03:52:35 +00:00
15 changed files with 606 additions and 51 deletions
+11
View File
@@ -20,6 +20,17 @@ file overrides the upstream Thai for the standard labels the print formats show.
print formats embed Sarabun (`public/fonts`, OFL) so PDFs render Thai on servers
without a Thai system font. Switch a user or the print language to `th` to use it.
### Withholding tax certificate
A paid Purchase Invoice with *Apply Tax Withholding Amount* gets a **Withholding Tax Certificate**
button that downloads the Revenue Department's Sec. 50 bis form (หนังสือรับรองการหักภาษี ณ ที่จ่าย,
two copies) as a PDF: the *Withholding Tax Certificate* print format lays the invoice's data
over the official form (`public/images`). The row the payment is reported on comes from
*Type of Income Paid* on the Tax Withholding Category (set for the shipped categories; pick it
for your own), and the ภ.ง.ด. return is ticked from the Supplier's type: ภ.ง.ด.53 for juristic
payees, ภ.ง.ด.1ก / 2 / 3 by income type for individuals. เล่มที่ / เลขที่ (certificate book
numbers) and ลำดับที่ (the line in the return) are left for the accountant.
### Contributing
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
@@ -0,0 +1,73 @@
{
"custom_fields": [
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2026-09-22 09:00:00.000000",
"default": null,
"depends_on": null,
"description": "Row of the withholding tax certificate (50 Tawi) the payment is reported on; Other prints the category name in row 6.",
"docstatus": 0,
"dt": "Tax Withholding Category",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "income_type",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 2,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "category_name",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Type of Income Paid",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2026-09-22 09:00:00.000000",
"modified_by": "Administrator",
"module": null,
"name": "Tax Withholding Category-income_type",
"no_copy": 0,
"non_negative": 0,
"options": "\nSalary and Wages - Sec. 40(1)\nFees and Commissions - Sec. 40(2)\nRoyalties - Sec. 40(3)\nInterest - Sec. 40(4)(a)\nDividends - Sec. 40(4)(b)\nSec. 3 Tera (Services, Rent, Contract Work etc.)\nOther",
"owner": "Administrator",
"permlevel": 0,
"placeholder": null,
"precision": null,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 0,
"unique": 0,
"width": null
}
],
"custom_perms": [],
"doctype": "Tax Withholding Category",
"links": [],
"property_setters": [],
"sync_on_migrate": 1
}
@@ -100,5 +100,39 @@
"report": null,
"show_section_headings": 0,
"standard": "No"
},
{
"absolute_value": 0,
"align_labels_right": 0,
"css": "/* Thai text needs a font with Thai glyphs; ship Sarabun as in Default Standard Sales Invoice. */\n@font-face {\n\tfont-family: \"Sarabun\";\n\tfont-weight: 400;\n\tsrc: url(\"/assets/default_thai_company/fonts/Sarabun-Regular.ttf\");\n}\n@font-face {\n\tfont-family: \"Sarabun\";\n\tfont-weight: 700;\n\tsrc: url(\"/assets/default_thai_company/fonts/Sarabun-Bold.ttf\");\n}\n\n/* The Revenue Department form fills the whole A4 sheet: no page margins. frappe.utils.pdf reads\n these .print-format properties as the wkhtmltopdf page options. */\n.print-format {\n\tmargin-top: 0mm;\n\tmargin-bottom: 0mm;\n\tmargin-left: 0mm;\n\tmargin-right: 0mm;\n\tpage-size: A4;\n}\n/* On screen the standard sheet padding would clip the 595pt form. */\n@media screen {\n\t.print-format {\n\t\tpadding: 0;\n\t\tmax-width: 595pt;\n\t\tmin-height: 0;\n\t}\n}\n\n/* One sheet per copy; values are positioned in pt against the form's PDF coordinates. */\n.wht-page {\n\tposition: relative;\n\twidth: 595pt;\n\theight: 841pt;\n\toverflow: hidden;\n\tpage-break-after: always;\n\tfont-family: Sarabun, sans-serif;\n\tfont-size: 10pt;\n\tcolor: #000;\n}\n.wht-page:last-child {\n\tpage-break-after: auto;\n}\n.wht-form {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\twidth: 100%;\n\theight: 100%;\n}\n.wht-page .f {\n\tposition: absolute;\n\twhite-space: nowrap;\n\toverflow: hidden;\n}\n/* Check mark drawn in CSS: the shipped font has no U+2713 and the PDF renderer sees no system fonts. */\n.wht-page .tick {\n\tposition: absolute;\n}\n.wht-page .tick:after {\n\tcontent: \"\";\n\tposition: absolute;\n\tleft: 4pt;\n\ttop: 0;\n\twidth: 4pt;\n\theight: 8pt;\n\tborder: solid #000;\n\tborder-width: 0 1.5pt 1.5pt 0;\n\t-webkit-transform: rotate(45deg);\n\ttransform: rotate(45deg);\n}",
"custom_format": 1,
"default_print_language": null,
"disabled": 0,
"doc_type": "Purchase Invoice",
"docstatus": 0,
"doctype": "Print Format",
"font": null,
"font_size": 10,
"format_data": null,
"html": "{% include \"default_thai_company/templates/print_formats/withholding_tax_certificate.html\" %}",
"line_breaks": 0,
"margin_bottom": 0.0,
"margin_left": 0.0,
"margin_right": 0.0,
"margin_top": 0.0,
"modified": "2026-09-22 09:00:00.000000",
"module": "Default Thai Company",
"name": "Withholding Tax Certificate",
"page_number": "Hide",
"pdf_generator": "wkhtmltopdf",
"print_format_builder": 0,
"print_format_builder_beta": 0,
"print_format_for": "DocType",
"print_format_type": "Jinja",
"raw_commands": null,
"raw_printing": 0,
"report": null,
"show_section_headings": 0,
"standard": "No"
}
]
@@ -5,7 +5,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Fees and Commissions - Sec. 40(2)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Commission",
"rates": [
{
@@ -31,7 +32,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Royalties - Sec. 40(3)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Royalty",
"rates": [
{
@@ -57,7 +59,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Interest - Sec. 40(4)(a)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Interest (Company)",
"rates": [
{
@@ -83,7 +86,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Interest - Sec. 40(4)(a)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 15% - Interest (Individual)",
"rates": [
{
@@ -109,7 +113,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Dividends - Sec. 40(4)(b)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 10% - Dividend",
"rates": [
{
@@ -135,7 +140,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 5% - Rent",
"rates": [
{
@@ -161,7 +167,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Ship Rental",
"rates": [
{
@@ -187,7 +194,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Professional Fee",
"rates": [
{
@@ -213,7 +221,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Contract Work",
"rates": [
{
@@ -239,7 +248,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Service",
"rates": [
{
@@ -265,7 +275,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 3% - Sales Promotion",
"rates": [
{
@@ -291,7 +302,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 2% - Advertising",
"rates": [
{
@@ -317,7 +329,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Transportation",
"rates": [
{
@@ -343,7 +356,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Non-Life Insurance",
"rates": [
{
@@ -369,7 +383,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 5% - Prize",
"rates": [
{
@@ -395,7 +410,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 5% - Public Entertainer",
"rates": [
{
@@ -421,7 +437,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 0.75% - Agricultural Produce",
"rates": [
{
@@ -447,7 +464,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Sec. 3 Tera (Services, Rent, Contract Work etc.)",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Aquatic Animals",
"rates": [
{
@@ -473,7 +491,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Other",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 1% - Immovable Property (Company)",
"rates": [
{
@@ -499,7 +518,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Other",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 15% - Non-Resident Individual",
"rates": [
{
@@ -525,7 +545,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Other",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 15% - Foreign Company",
"rates": [
{
@@ -551,7 +572,8 @@
"consider_party_ledger_amount": 0,
"docstatus": 0,
"doctype": "Tax Withholding Category",
"modified": "2026-09-11 09:00:00.000000",
"income_type": "Other",
"modified": "2026-09-22 12:00:00.000000",
"name": "WHT 10% - Foreign Company Dividend",
"rates": [
{
+18 -20
View File
@@ -43,7 +43,10 @@ required_apps = ["erpnext"]
# page_js = {"page" : "public/js/file.js"}
# include js in doctype views
doctype_js = {"Payment Entry": "public/js/payment_entry.js"}
doctype_js = {
"Payment Entry": "public/js/payment_entry.js",
"Purchase Invoice": "public/js/purchase_invoice.js",
}
# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
@@ -79,6 +82,7 @@ jinja = {
"default_thai_company.utils.get_letter_head_company",
"default_thai_company.utils.get_company_bank_account",
"default_thai_company.utils.get_in_words",
"default_thai_company.tax_withholding.get_withholding_certificate",
],
}
@@ -100,6 +104,7 @@ fixtures = [
"Default Standard Sales Invoice",
"Default Standard Tax Invoice/Receipt",
"Default Standard Quotation",
"Withholding Tax Certificate",
],
]
],
@@ -138,12 +143,16 @@ fixtures = [
# Installation
# ------------
# before_install = "default_thai_company.install.before_install"
# Fixtures are synced before Custom Fields on install and migrate; the Tax Withholding
# Category fixtures carry values for the app's Custom Fields, so create those first.
before_install = "default_thai_company.tax_withholding.sync_category_customizations"
before_migrate = "default_thai_company.tax_withholding.sync_category_customizations"
# Fixtures are synced before this runs; VAT schemes and Shipping Rules for companies
# that already exist.
after_install = [
"default_thai_company.vat.setup_companies",
"default_thai_company.shipping.setup_companies",
"default_thai_company.payroll.setup_companies",
]
# Fixture import re-inserts the Location tree; restore lft/rgt for user-added nodes.
after_migrate = "default_thai_company.assets.rebuild_locations"
@@ -160,7 +169,8 @@ after_migrate = "default_thai_company.assets.rebuild_locations"
# Name of the app being installed is passed as an argument
# before_app_install = "default_thai_company.utils.before_app_install"
# after_app_install = "default_thai_company.utils.after_app_install"
# Payroll Period for companies created before hrms was installed.
after_app_install = "default_thai_company.payroll.after_app_install"
# Integration Cleanup
# -------------------
@@ -219,6 +229,7 @@ doc_events = {
"default_thai_company.vat.setup_company",
"default_thai_company.assets.setup_company",
"default_thai_company.shipping.setup_company",
"default_thai_company.payroll.setup_company",
],
},
"Payment Entry": {
@@ -229,23 +240,10 @@ doc_events = {
# Scheduled Tasks
# ---------------
# scheduler_events = {
# "all": [
# "default_thai_company.tasks.all"
# ],
# "daily": [
# "default_thai_company.tasks.daily"
# ],
# "hourly": [
# "default_thai_company.tasks.hourly"
# ],
# "weekly": [
# "default_thai_company.tasks.weekly"
# ],
# "monthly": [
# "default_thai_company.tasks.monthly"
# ],
# }
# Payroll Period is per calendar year; roll the default over on 1 January.
scheduler_events = {
"daily": ["default_thai_company.payroll.setup_companies"],
}
# Testing
# -------
+1
View File
@@ -7,3 +7,4 @@
default_thai_company.patches.create_vat_accounts
default_thai_company.patches.create_vat_templates
default_thai_company.patches.create_shipping_rules
default_thai_company.patches.create_payroll_periods
@@ -0,0 +1,5 @@
from default_thai_company.payroll import setup_companies
def execute():
setup_companies()
+62
View File
@@ -0,0 +1,62 @@
import frappe
from frappe.utils import getdate
from default_thai_company.tax_withholding import thai_companies
# Thai personal income tax (P.N.D.1/91) is assessed per calendar year whatever
# the company's fiscal year, so the Payroll Period is January to December.
# Payroll Period is named by prompt (site-wide unique) but bound to one company,
# so each Thai company gets "<year> - <abbr>", like the Shipping Rule.
def hrms_installed():
return "hrms" in frappe.get_installed_apps()
def payroll_period_name(company, year):
abbr = frappe.get_cached_value("Company", company, "abbr")
return f"{year} - {abbr}"
def ensure_payroll_period(company, year):
"""Calendar-year Payroll Period for `company`. No-op when any period of the
company touches the year: an existing one covers it, or the company runs its
own scheme and a second period would only fail the overlap check."""
start, end = f"{year}-01-01", f"{year}-12-31"
if frappe.db.exists(
"Payroll Period", {"company": company, "start_date": ("<=", end), "end_date": (">=", start)}
):
return
frappe.get_doc(
{
"doctype": "Payroll Period",
"name": payroll_period_name(company, year),
"company": company,
"start_date": start,
"end_date": end,
}
).insert(ignore_permissions=True)
def setup_company(doc, method=None):
"""Company.on_update: this year's Payroll Period for a Thai company."""
if doc.country == "Thailand" and hrms_installed():
ensure_payroll_period(doc.name, getdate().year)
def setup_companies():
"""after_install, the daily scheduler, and the create_payroll_periods patch:
this year's Payroll Period for every Thai company. The scheduler rolls the
default over on 1 January."""
if not hrms_installed():
return
year = getdate().year
for company in thai_companies():
ensure_payroll_period(company, year)
def after_app_install(app_name):
"""Payroll Period only exists once hrms is installed; catch companies that
were created before it."""
if app_name == "hrms":
setup_companies()
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

@@ -0,0 +1,23 @@
frappe.ui.form.on("Purchase Invoice", {
refresh(frm) {
// The certificate is issued once the tax has been withheld, i.e. on payment.
if (!frm.doc.apply_tds || frm.doc.docstatus !== 1 || frm.doc.status !== "Paid") return;
// The Withholding Tax Certificate print format renders the Revenue Department's
// Sec. 50 bis form (fixtures/print_format.json) as a PDF.
frm.add_custom_button(__("Withholding Tax Certificate"), () => {
const params = new URLSearchParams({
doctype: frm.doctype,
name: frm.docname,
format: "Withholding Tax Certificate",
no_letterhead: 1,
_lang: "th",
});
const w = window.open(
frappe.urllib.get_full_url(
`/api/method/frappe.utils.print_format.download_pdf?${params}`
)
);
if (!w) frappe.msgprint(__("Please enable pop-ups"));
});
},
});
+142 -1
View File
@@ -1,3 +1,6 @@
import json
import os
import erpnext
import frappe
from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry
@@ -8,7 +11,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
normal_round,
)
from frappe import _
from frappe.utils import cint, flt
from frappe.contacts.doctype.address.address import get_default_address
from frappe.modules.utils import sync_customizations_for_doctype
from frappe.utils import cint, flt, fmt_money, getdate
from default_thai_company.utils import money_in_words
@@ -122,6 +127,17 @@ def prepare_fixture_accounts(doc, method=None):
link_company(doc, company, ensure_company_accounts(company))
def sync_category_customizations():
"""before_install / before_migrate: create the Tax Withholding Category Custom
Fields (custom/tax_withholding_category.json) ahead of the fixture import.
Frappe syncs fixtures before customizations, and a fixture value for a field
that does not exist yet is dropped."""
folder = frappe.get_app_path("default_thai_company", "default_thai_company", "custom")
filename = "tax_withholding_category.json"
with open(os.path.join(folder, filename)) as f:
sync_customizations_for_doctype(json.load(f), folder, filename)
def company_ready(doc):
"""A Thai company whose chart of accounts exists."""
return doc.country == "Thailand" and frappe.db.exists("Account", {"company": doc.name})
@@ -374,3 +390,128 @@ def get_customer_withholding(doc):
else:
description = _("Withholding tax deducted by customer")
return flt(total, precision), description
# Withholding tax certificate (หนังสือรับรองการหักภาษี ณ ที่จ่าย, Sec. 50 bis): the income
# row the payment is reported on, from Tax Withholding Category.income_type (custom field).
# Rows: 1 = Sec. 40(1), 2 = 40(2), 3 = 40(3), 4a = 40(4)(a), 4b = 40(4)(b) dividends
# paid from profits taxed at the standard 20% rate (form row 4(b)(1)(1.3)), 5 = payments
# withheld under Revenue Department orders issued under Sec. 3 tera, 6 = other (specified).
CERTIFICATE_ROWS = {
"Salary and Wages - Sec. 40(1)": "1",
"Fees and Commissions - Sec. 40(2)": "2",
"Royalties - Sec. 40(3)": "3",
"Interest - Sec. 40(4)(a)": "4a",
"Dividends - Sec. 40(4)(b)": "4b",
"Sec. 3 Tera (Services, Rent, Contract Work etc.)": "5",
}
THAI_MONTHS = (
"มกราคม",
"กุมภาพันธ์",
"มีนาคม",
"เมษายน",
"พฤษภาคม",
"มิถุนายน",
"กรกฎาคม",
"สิงหาคม",
"กันยายน",
"ตุลาคม",
"พฤศจิกายน",
"ธันวาคม",
)
def pnd_form(row, supplier_type):
"""P.N.D. return the certificate row is filed on, as numbered on the form:
1 = ภ.ง.ด.1ก, 2 = ภ.ง.ด.2, 3 = ภ.ง.ด.3, 53 = ภ.ง.ด.53.
Juristic payees (Company; Partnership, taken as registered) file on
ภ.ง.ด.53 whatever the income; individuals by income type: salary on
ภ.ง.ด.1ก, Sec. 40(3)/(4) on ภ.ง.ด.2, the rest on ภ.ง.ด.3.
"""
if supplier_type != "Individual":
return "53"
if row == "1":
return "1"
if row in ("3", "4a", "4b"):
return "2"
return "3"
def one_line_address(address_name):
"""Address in the order of the Thailand Address Template, on one line."""
if not address_name:
return None
address = frappe.get_cached_doc("Address", address_name)
parts = [
address.address_line1,
address.address_line2,
address.county,
address.city,
address.state,
address.pincode,
]
if address.country and address.country != "Thailand":
parts.append(address.country)
return " ".join(part.strip() for part in parts if part and part.strip())
def tax_id_digits(tax_id):
"""The 13 digits of a Thai tax ID for the form's boxes, or None when it is not one."""
digits = "".join(ch for ch in (tax_id or "") if ch.isdigit())
return digits if len(digits) == 13 else None
def get_withholding_certificate(doc):
"""Certificate data for a Purchase Invoice with Apply Tax Withholding Amount.
Amounts are in company currency; the form is Thai, so dates are Buddhist Era
and the tax in words is Thai whatever the print language.
"""
company = frappe.get_cached_doc("Company", doc.company)
currency = erpnext.get_company_currency(doc.company)
precision = doc.precision("base_grand_total")
category = (
frappe.get_cached_doc("Tax Withholding Category", doc.tax_withholding_category)
if doc.tax_withholding_category
else None
)
row = CERTIFICATE_ROWS.get(category.income_type if category else None, "6")
supplier_type = frappe.get_cached_value("Supplier", doc.supplier, "supplier_type")
amount = flt(doc.base_tax_withholding_net_total, precision)
tax = flt(
sum(abs(flt(t.base_tax_amount)) for t in doc.get("taxes") if cint(t.is_tax_withholding_account)),
precision,
)
posting_date = getdate(doc.posting_date)
return frappe._dict(
payer=frappe._dict(
name=company.company_name,
tax_id=company.tax_id,
tax_id_digits=tax_id_digits(company.tax_id),
address=one_line_address(get_default_address("Company", doc.company)),
),
payee=frappe._dict(
name=doc.supplier_name,
tax_id=doc.tax_id,
tax_id_digits=tax_id_digits(doc.tax_id),
address=one_line_address(doc.supplier_address or get_default_address("Supplier", doc.supplier)),
),
row=row,
# row 6 prints what was paid for; translations/th.csv carries the fixture categories
row_note=_(category.category_name) if row == "6" and category else None,
pnd=pnd_form(row, supplier_type),
date=f"{posting_date.day:02d}/{posting_date.month:02d}/{posting_date.year + 543}",
amount=fmt_money(amount, precision),
tax=fmt_money(tax, precision),
tax_in_words=money_in_words(tax, currency, lang="th"),
issued=frappe._dict(
day=posting_date.day,
month=THAI_MONTHS[posting_date.month - 1],
year=posting_date.year + 543,
),
)
@@ -0,0 +1,87 @@
{#- หนังสือรับรองการหักภาษี ณ ที่จ่าย (Sec. 50 bis certificate) for a Purchase Invoice.
The Revenue Department's fillable form (approve_wh3_081156.pdf, A4 = 595 x 842 pt) is the page
background; every value sits in the rectangle of the corresponding AcroForm field, given here as
the PDF's (x0, y0, x1, y1) with the origin at the bottom-left so the numbers can be checked against
the form. Two identical copies are printed: copy 1 goes with the payee's tax return, copy 2 is the
payee's record. -#}
{%- set c = get_withholding_certificate(doc) -%}
{%- set rows = {
"1": {"date": (327, 533, 403, 546), "pay": (411, 533, 490, 547), "tax": (496, 534, 560, 547)},
"2": {"date": (328, 519, 403, 533), "pay": (410, 520, 489, 534), "tax": (496, 519, 560, 532)},
"3": {"date": (328, 504, 403, 518), "pay": (411, 504, 490, 517), "tax": (496, 504, 560, 517)},
"4a": {"date": (328, 490, 403, 505), "pay": (412, 490, 491, 504), "tax": (496, 490, 560, 503)},
"4b": {"date": (328, 402, 404, 416), "pay": (410, 403, 489, 417), "tax": (497, 403, 562, 416)},
"5": {"date": (327, 216, 403, 230), "pay": (409, 216, 489, 230), "tax": (496, 215, 561, 230)},
"6": {"date": (327, 199, 403, 213), "pay": (409, 199, 489, 213), "tax": (496, 198, 561, 213)},
} -%}
{%- set pnd_boxes = {
"1": (209, 603, 222, 615),
"2": (395, 602, 407, 615),
"3": (471, 602, 484, 615),
"53": (395, 584, 407, 597),
} -%}
{#- Left edges of the printed 13-digit ID boxes (12 pt wide, grouped 1-4-5-2-1), measured on the
form; the AcroForm comb field does not line up with them. -#}
{%- set id_cells = (375, 393, 405, 417, 429, 447.5, 459.5, 471.5, 483.5, 495.5, 513.5, 525.5, 545) -%}
{%- macro box(rect, text, align="left", size=10) -%}
<div class="f" style="left: {{ rect[0] }}pt; top: {{ 842 - rect[3] }}pt; width: {{ rect[2] - rect[0] }}pt; height: {{ rect[3] - rect[1] }}pt; line-height: {{ rect[3] - rect[1] }}pt; text-align: {{ align }}; font-size: {{ size }}pt;">{{ text }}</div>
{%- endmacro -%}
{%- macro tick(rect) -%}
<div class="tick" style="left: {{ rect[0] }}pt; top: {{ 842 - rect[3] }}pt; width: {{ rect[2] - rect[0] }}pt; height: {{ rect[3] - rect[1] }}pt;"></div>
{%- endmacro -%}
{%- macro tax_id(rect, party) -%}
{%- if party.tax_id_digits -%}
{%- for digit in party.tax_id_digits -%}
{%- set x = id_cells[loop.index0] -%}
{{ box((x, rect[1], x + 12, rect[3]), digit, "center") }}
{%- endfor -%}
{%- elif party.tax_id -%}
{{ box(rect, party.tax_id, "center") }}
{%- endif -%}
{%- endmacro -%}
{%- macro amount_row(key) -%}
{{ box(rows[key].date, c.date, "center") }}
{{ box(rows[key].pay, c.amount, "right") }}
{{ box(rows[key].tax, c.tax, "right") }}
{%- endmacro -%}
{%- for _ in range(2) -%}
<div class="wht-page">
<img class="wht-form" src="/assets/default_thai_company/images/withholding_tax_certificate.png" alt="">
{#- Invoice reference in the top-right margin; เล่มที่ / เลขที่ are the certificate book's numbers. -#}
{{ box((400, 821, 560, 835), doc.name, "right", 8) }}
{#- ผู้มีหน้าที่หักภาษี ณ ที่จ่าย: the company -#}
{{ tax_id((375, 744, 558, 759), c.payer) }}
{{ box((54, 729, 316, 745), c.payer.name) }}
{{ box((61, 706, 550, 722), c.payer.address or "", "left", 9) }}
{#- ผู้ถูกหักภาษี ณ ที่จ่าย: the supplier -#}
{{ tax_id((375, 676, 558, 690), c.payee) }}
{{ box((53, 658, 315, 671), c.payee.name) }}
{{ box((59, 627, 550, 643), c.payee.address or "", "left", 9) }}
{{ tick(pnd_boxes[c.pnd]) }}
{%- if c.row == "6" %}
{{ box((96, 197, 325, 214), c.row_note or "", "left", 8) }}
{%- endif %}
{{ amount_row(c.row) }}
{{ box((409, 180, 488, 196), c.amount, "right") }}
{{ box((496, 180, 560, 196), c.tax, "right") }}
{{ box((185, 158, 558, 177), c.tax_in_words) }}
{#- ผู้จ่ายเงิน (1) หัก ณ ที่จ่าย -#}
{{ tick((82, 119, 94, 131)) }}
{{ box((342, 72, 366, 87), c.issued.day, "center") }}
{{ box((364, 71, 428, 87), c.issued.month, "center") }}
{{ box((429, 72, 470, 87), c.issued.year, "center") }}
</div>
{%- endfor -%}
@@ -12,12 +12,15 @@ from default_thai_company.tax_withholding import (
LIABILITY_ACCOUNT,
OUTPUT_VAT_ACCOUNT,
get_payment_entry,
get_withholding_certificate,
thai_companies,
)
COMPANY = "_Test WHT Company"
ABBR = "_TWC"
CUSTOMER = "_Test WHT Customer"
SUPPLIER = "_Test WHT Supplier"
INDIVIDUAL = "_Test WHT Individual"
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")
@@ -27,12 +30,13 @@ 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"):
# Custom Fields first: the category fixtures carry income_type. Fixture
# categories must exist before the company is created so the Company hook
# has something to link; force=True re-imports.
for fname in ("tax_withholding_category.json", "sales_invoice.json", "payment_entry.json"):
with open(f"{CUSTOM_DIR}/{fname}") as f:
sync_customizations_for_doctype(json.load(f), CUSTOM_DIR, fname)
import_file_by_path(FIXTURE, force=True, data_import=True)
frappe.get_doc(
{
@@ -42,6 +46,7 @@ class TestTaxWithholding(FrappeTestCase):
"country": "Thailand",
"default_currency": "THB",
"chart_of_accounts": "Standard",
"tax_id": "0105551234567",
}
).insert()
cls.payable = f"{LIABILITY_ACCOUNT} - {ABBR}"
@@ -69,6 +74,35 @@ class TestTaxWithholding(FrappeTestCase):
}
).insert()
supplier_group = frappe.db.get_value("Supplier Group", {"is_group": 0})
for name, supplier_type, tax_id in (
(SUPPLIER, "Company", "0-1234-56789-01-2"),
(INDIVIDUAL, "Individual", "1234567890123"),
):
frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": name,
"supplier_type": supplier_type,
"supplier_group": supplier_group,
"tax_id": tax_id,
}
).insert()
frappe.get_doc(
{
"doctype": "Address",
"address_title": SUPPLIER,
"address_type": "Billing",
"address_line1": "99/9 Moo 5",
"address_line2": "Soi Sukhumvit 24",
"city": "Khlong Toei",
"state": "Bangkok",
"pincode": "10110",
"country": "Thailand",
"links": [{"link_doctype": "Supplier", "link_name": SUPPLIER}],
}
).insert()
def make_invoice(self, rate=10000, category=None, inclusive=False):
si = frappe.get_doc(
{
@@ -121,6 +155,31 @@ class TestTaxWithholding(FrappeTestCase):
def withheld(self, pe):
return [(t.account_head, t.add_deduct_tax, t.tax_amount) for t in pe.taxes]
def make_purchase_invoice(self, supplier, category, rate=100000):
pi = frappe.get_doc(
{
"doctype": "Purchase Invoice",
"company": COMPANY,
"supplier": supplier,
"set_posting_time": 1,
"posting_date": "2026-09-16",
"due_date": "2026-09-16",
"apply_tds": 1,
"tax_withholding_category": category,
"items": [{"item_code": ITEM, "qty": 1, "rate": rate}],
"taxes": [
{
"charge_type": "On Net Total",
"account_head": f"{INPUT_VAT_ACCOUNT} - {ABBR}",
"rate": 7,
"description": "VAT 7%",
}
],
}
)
pi.set_missing_values()
return pi.insert()
def category_account(self, category, company=COMPANY):
return frappe.db.get_value(
"Tax Withholding Account",
@@ -330,3 +389,34 @@ class TestTaxWithholding(FrappeTestCase):
)
self.assertEqual(pe.references[0].allocated_amount, 5350.0)
self.assertEqual(self.withheld(pe), [(self.receivable, "Deduct", 150.0)])
def test_certificate_reports_withholding_on_the_category_row(self):
pi = self.make_purchase_invoice(SUPPLIER, "WHT 3% - Professional Fee")
self.assertEqual(pi.grand_total, 104000.0) # 100,000 + 7% VAT - 3% withheld
c = get_withholding_certificate(pi)
self.assertEqual((c.row, c.row_note, c.pnd), ("5", None, "53"))
self.assertEqual((c.amount, c.tax, c.tax_in_words), ("100,000.00", "3,000.00", "สามพันบาทถ้วน"))
self.assertEqual(
(c.date, dict(c.issued)), ("16/09/2569", {"day": 16, "month": "กันยายน", "year": 2569})
)
self.assertEqual((c.payer.name, c.payer.tax_id_digits), (COMPANY, "0105551234567"))
self.assertEqual(
(c.payee.name, c.payee.tax_id_digits, c.payee.address),
(SUPPLIER, "0123456789012", "99/9 Moo 5 Soi Sukhumvit 24 Khlong Toei Bangkok 10110"),
)
def test_certificate_return_follows_payee_type_and_income(self):
c = get_withholding_certificate(
self.make_purchase_invoice(INDIVIDUAL, "WHT 15% - Interest (Individual)")
)
self.assertEqual((c.row, c.pnd, c.tax), ("4a", "2", "15,000.00"))
self.assertIsNone(c.payee.address)
c = get_withholding_certificate(
self.make_purchase_invoice(INDIVIDUAL, "WHT 15% - Non-Resident Individual")
)
self.assertEqual((c.row, c.pnd), ("6", "3"))
self.assertEqual(
c.row_note, "Sec. 40(2)-(6) income paid to non-resident individuals - Sec. 50(2) (P.N.D.3)"
)
+8
View File
@@ -60,3 +60,11 @@ Withholding Tax Payable,ภาษีหัก ณ ที่จ่ายค้า
Withholding Tax Receivable,ภาษีถูกหัก ณ ที่จ่าย
Output VAT,ภาษีขาย
Input VAT,ภาษีซื้อ
Withholding Tax Certificate,หนังสือรับรองการหักภาษี ณ ที่จ่าย
Type of Income Paid,ประเภทเงินได้พึงประเมินที่จ่าย
Salary and Wages - Sec. 40(1),เงินเดือน ค่าจ้าง ฯลฯ ตามมาตรา 40(1)
Fees and Commissions - Sec. 40(2),ค่าธรรมเนียม ค่านายหน้า ฯลฯ ตามมาตรา 40(2)
Royalties - Sec. 40(3),ค่าแห่งลิขสิทธิ์ ฯลฯ ตามมาตรา 40(3)
Interest - Sec. 40(4)(a),ดอกเบี้ย ฯลฯ ตามมาตรา 40(4)(ก)
Dividends - Sec. 40(4)(b),เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ ตามมาตรา 40(4)(ข)
"Sec. 3 Tera (Services, Rent, Contract Work etc.)",ตามคำสั่งกรมสรรพากรที่ออกตามมาตรา 3 เตรส (ค่าบริการ ค่าเช่า ค่าจ้างทำของ ฯลฯ)
1 Net Payable After Withholding Tax ยอดชำระสุทธิหลังหักภาษี ณ ที่จ่าย
60 Withholding Tax Receivable ภาษีถูกหัก ณ ที่จ่าย
61 Output VAT ภาษีขาย
62 Input VAT ภาษีซื้อ
63 Withholding Tax Certificate หนังสือรับรองการหักภาษี ณ ที่จ่าย
64 Type of Income Paid ประเภทเงินได้พึงประเมินที่จ่าย
65 Salary and Wages - Sec. 40(1) เงินเดือน ค่าจ้าง ฯลฯ ตามมาตรา 40(1)
66 Fees and Commissions - Sec. 40(2) ค่าธรรมเนียม ค่านายหน้า ฯลฯ ตามมาตรา 40(2)
67 Royalties - Sec. 40(3) ค่าแห่งลิขสิทธิ์ ฯลฯ ตามมาตรา 40(3)
68 Interest - Sec. 40(4)(a) ดอกเบี้ย ฯลฯ ตามมาตรา 40(4)(ก)
69 Dividends - Sec. 40(4)(b) เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ ตามมาตรา 40(4)(ข)
70 Sec. 3 Tera (Services, Rent, Contract Work etc.) ตามคำสั่งกรมสรรพากรที่ออกตามมาตรา 3 เตรส (ค่าบริการ ค่าเช่า ค่าจ้างทำของ ฯลฯ)
+3 -3
View File
@@ -5,14 +5,14 @@ from frappe.contacts.doctype.address.address import get_default_address, render_
from num2words import num2words
def money_in_words(amount, currency):
"""Amount in words for the active language.
def money_in_words(amount, currency, lang=None):
"""Amount in words for `lang` (default: the active language).
frappe.utils.money_in_words renders "<currency> <words> only." in every
language; Thai documents write "<words>บาทถ้วน" or "<words>บาท<words>สตางค์".
Currencies num2words cannot spell in Thai keep frappe's wording.
"""
if frappe.local.lang == "th":
if (lang or frappe.local.lang) == "th":
try:
return num2words(amount, lang="th", to="currency", currency=currency)
except NotImplementedError: