9 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
kurogeek 68f0817125 fix: gross discount layout for VAT-inclusive invoices
With inclusive pricing and a discount on Grand Total the Total line is
the tax-inclusive item total and the discount is printed as entered,
followed by Total After Discount, which the Net Total and charge lines
then break down. Other cases keep the net layout: Total (net before
discount), the discount's net share, Net Total, charges. Thai label for
Total After Discount.
2026-09-17 10:30:43 +00:00
kurogeek 4a19c60ba2 fix: print the posted discount and tax amounts
ERPNext takes an Additional Discount off the items' net amounts and,
for a discount on Grand Total, splits it between net and taxes: each
charge is booked at tax_amount_after_discount_amount and Total Taxes
and Charges sums those. The print showed the discount as entered and
the pre-discount tax_amount, so on a Grand Total discount the VAT line
disagreed with the invoice.

The totals includes now print the discount's net share (sum of
item.distributed_discount_amount), Net Total, and each charge after
discount, so the lines add up to Grand Total whichever total the
discount applies on and with inclusive or exclusive tax. A discount
that is not distributed (cash / non-trade) stays after the charges.
2026-09-17 10:19:30 +00:00
kurogeek 71eda0bec9 fix: print Total, Additional Discount, Net Total in that order
Default Standard Sales Invoice and Tax Invoice/Receipt printed the
Additional Discount Amount among the charges, after the Total line but
with no total after the discount. App copies of ERPNext's totals
includes are swapped in via doc.print_templates:

- taxes.html: a discount on Net Total is followed by the Net Total line
  ahead of the charges; a discount on Grand Total stays after them.
- total.html: with inclusive tax, "Total (Without Tax)" is the total
  before the discount (net_total + discount_amount); upstream printed
  net_total, which already has the discount taken off, so the same
  figure appeared before and after the discount line.
2026-09-17 10:02:23 +00:00
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
19 changed files with 794 additions and 55 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
}
File diff suppressed because one or more lines are too long
@@ -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": [
{
+26 -22
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,9 +143,17 @@ fixtures = [
# Installation
# ------------
# before_install = "default_thai_company.install.before_install"
# Fixtures are synced before this runs; VAT schemes for companies that already exist.
after_install = "default_thai_company.vat.setup_companies"
# 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"
@@ -156,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
# -------------------
@@ -191,6 +205,7 @@ after_migrate = "default_thai_company.assets.rebuild_locations"
override_doctype_class = {
"Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice",
"Payment Entry": "default_thai_company.tax_withholding.ThaiPaymentEntry",
"Shipping Rule": "default_thai_company.shipping.ThaiShippingRule",
}
# Document Events
@@ -213,6 +228,8 @@ doc_events = {
"default_thai_company.tax_withholding.setup_company",
"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": {
@@ -223,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
# -------
+2
View File
@@ -6,3 +6,5 @@
# 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
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()
@@ -0,0 +1,5 @@
from default_thai_company.shipping 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"));
});
},
});
+66
View File
@@ -0,0 +1,66 @@
import erpnext
import frappe
from erpnext.accounts.doctype.shipping_rule.shipping_rule import ShippingRule
from default_thai_company.tax_withholding import company_ready, get_or_create_account, thai_companies
# Shipping Rule is named by its label (site-wide unique) but bound to one company,
# so each Thai company gets "<LABEL> - <abbr>", the way ERPNext names accounts and
# tax templates. The label doubles as the charge description on the transaction.
LABEL = "Shipping Charges"
# Shipping billed to customers is revenue; the courier's bill stays an expense.
SHIPPING_ACCOUNT = "Shipping Charges"
SHIPPING_ACCOUNT_GROUPS = ("Direct Income", "Income")
class ThaiShippingRule(ShippingRule):
def add_shipping_rule_to_tax_table(self, doc, shipping_amount):
"""A Fixed rule without an amount only seeds the charge row; the amount is
entered on the transaction. ERPNext re-applies the rule on every
recalculation, which would otherwise reset the row to 0."""
manual = self.calculate_based_on == "Fixed" and not self.shipping_amount
entered = [(row, row.tax_amount) for row in doc.get("taxes")] if manual else ()
super().add_shipping_rule_to_tax_table(doc, shipping_amount)
for row, amount in entered:
row.tax_amount = amount
def shipping_rule_name(company):
abbr = frappe.get_cached_value("Company", company, "abbr")
return f"{LABEL} - {abbr}"
def ensure_shipping_rule(company):
"""Selling Shipping Rule for `company` with the amount entered per
transaction (see ThaiShippingRule). No-op when the rule exists."""
name = shipping_rule_name(company)
if frappe.db.exists("Shipping Rule", name):
return
frappe.get_doc(
{
"doctype": "Shipping Rule",
"label": name,
"company": company,
"shipping_rule_type": "Selling",
"calculate_based_on": "Fixed",
"shipping_amount": 0,
"account": get_or_create_account(
company, SHIPPING_ACCOUNT, "Income", "Income Account", SHIPPING_ACCOUNT_GROUPS
),
"cost_center": erpnext.get_default_cost_center(company),
}
).insert(ignore_permissions=True)
def setup_company(doc, method=None):
"""Company.on_update: default Shipping Rule for a Thai company."""
if company_ready(doc):
ensure_shipping_rule(doc.name)
def setup_companies():
"""after_install and the create_shipping_rules patch: companies that exist
before this code did never pass through `setup_company`."""
for company in thai_companies():
ensure_shipping_rule(company)
+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,50 @@
{#- ERPNext's templates/print_formats/includes/taxes.html, printing the amounts that are posted.
ERPNext takes an Additional Discount off the items' net amounts (item.distributed_discount_amount;
a discount on Grand Total is split between net and taxes) and books each charge at
tax_amount_after_discount_amount, while upstream prints the discount as entered and the
pre-discount tax_amount, so the lines do not add up to Grand Total.
Inclusive tax with a discount on Grand Total: the Total line is the tax-inclusive item total
and the discount is printed as entered; Total After Discount is then broken down into
Net Total and the charges. Otherwise the Total line is the net before the discount, so the
discount is its net share, followed by Net Total and the charges. A discount that is not
distributed (cash / non-trade) comes off the grand total only and stays after the charges,
as upstream. -#}
{%- macro amount_row(label, value) -%}
<div class="row">
<div class="col-xs-5 {%- if doc.align_labels_right %} text-right{%- endif -%}">
<label>{{ label }}</label>
</div>
<div class="col-xs-7 text-right">
{{ value }}
</div>
</div>
{%- endmacro -%}
{%- macro currency(value) -%}
{{ frappe.format_value(value, {"fieldtype": "Currency", "options": "currency"}, doc) }}
{%- endmacro -%}
{%- set net_discount = doc.get("items")|map(attribute="distributed_discount_amount")|select|sum -%}
{%- set gross_discount = net_discount and doc.apply_discount_on == "Grand Total" and doc.flags.show_inclusive_tax_in_print -%}
<div class="row">
<div class="col-xs-6"></div>
<div class="col-xs-6">
{%- if gross_discount -%}
{{ amount_row(_(doc.meta.get_label("discount_amount")), "- " ~ doc.get_formatted("discount_amount", doc)) }}
{{ amount_row(_("Total After Discount"), currency(doc.total - doc.discount_amount)) }}
{{ amount_row(_(doc.meta.get_label("net_total")), doc.get_formatted("net_total", doc)) }}
{%- elif net_discount -%}
{{ amount_row(_(doc.meta.get_label("discount_amount")), "- " ~ currency(net_discount)) }}
{{ amount_row(_(doc.meta.get_label("net_total")), doc.get_formatted("net_total", doc)) }}
{%- endif -%}
{%- for charge in data -%}
{%- if (charge.tax_amount or print_settings.print_taxes_with_zero_amount) and (not charge.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) -%}
{{ amount_row(charge.get_formatted("description"), charge.get_formatted("tax_amount_after_discount_amount", doc)) }}
{%- endif -%}
{%- endfor -%}
{%- if doc.discount_amount and not net_discount -%}
{{ amount_row(_(doc.meta.get_label("discount_amount")), "- " ~ doc.get_formatted("discount_amount", doc)) }}
{%- endif -%}
</div>
</div>
@@ -0,0 +1,22 @@
{#- ERPNext's templates/print_formats/includes/total.html, paired with the taxes include:
with inclusive tax and a discount on Grand Total the Total line is the tax-inclusive item
total (the discount is printed as entered below it); with inclusive tax otherwise it is
the net before the Additional Discount, since net_total already has the discount's net
share (item.distributed_discount_amount) taken off. -#}
{%- set net_discount = doc.get("items")|map(attribute="distributed_discount_amount")|select|sum -%}
{%- set gross_discount = net_discount and doc.apply_discount_on == "Grand Total" and doc.flags.show_inclusive_tax_in_print -%}
<div class="row {% if df.bold %}important{% endif %} data-field">
{% if doc.flags.show_inclusive_tax_in_print and not gross_discount %}
<div class="col-xs-5 {%- if doc.align_labels_right %} text-right{%- endif -%}">
<label>{{ _("Total (Without Tax)") }}</label></div>
<div class="col-xs-7 text-right value">
{{ frappe.format_value(doc.net_total + net_discount, {"fieldtype": "Currency", "options": "currency"}, doc) }}
</div>
{% else %}
<div class="col-xs-5 {%- if doc.align_labels_right %} text-right{%- endif -%}">
<label>{{ _(df.label) }}</label></div>
<div class="col-xs-7 text-right value">
{{ doc.get_formatted("total", doc) }}
</div>
{% endif %}
</div>
@@ -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",
@@ -177,6 +236,39 @@ class TestTaxWithholding(FrappeTestCase):
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(
{
@@ -297,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)"
)
+9
View File
@@ -50,6 +50,7 @@ Amount,จำนวนเงิน
Total,รวม
Total (Without Tax),รวมก่อนภาษี
Net Total,ยอดรวมสุทธิ
Total After Discount,ยอดรวมหลังหักส่วนลด
Total Taxes and Charges,รวมภาษีและค่าธรรมเนียม
Grand Total,ยอดรวมทั้งสิ้น
Rounded Total,ยอดรวมปัดเศษ
@@ -59,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 ยอดชำระสุทธิหลังหักภาษี ณ ที่จ่าย
50 Total รวม
51 Total (Without Tax) รวมก่อนภาษี
52 Net Total ยอดรวมสุทธิ
53 Total After Discount ยอดรวมหลังหักส่วนลด
54 Total Taxes and Charges รวมภาษีและค่าธรรมเนียม
55 Grand Total ยอดรวมทั้งสิ้น
56 Rounded Total ยอดรวมปัดเศษ
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: