diff --git a/default_thai_company/fixtures/print_format.json b/default_thai_company/fixtures/print_format.json index 7f7b793..ac29304 100644 --- a/default_thai_company/fixtures/print_format.json +++ b/default_thai_company/fixtures/print_format.json @@ -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" } -] \ No newline at end of file +] diff --git a/default_thai_company/hooks.py b/default_thai_company/hooks.py index 832b8c3..f03d515 100644 --- a/default_thai_company/hooks.py +++ b/default_thai_company/hooks.py @@ -79,6 +79,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 +101,7 @@ fixtures = [ "Default Standard Sales Invoice", "Default Standard Tax Invoice/Receipt", "Default Standard Quotation", + "Withholding Tax Certificate", ], ] ], diff --git a/default_thai_company/public/images/withholding_tax_certificate.png b/default_thai_company/public/images/withholding_tax_certificate.png new file mode 100644 index 0000000..fd2b898 Binary files /dev/null and b/default_thai_company/public/images/withholding_tax_certificate.png differ diff --git a/default_thai_company/tax_withholding.py b/default_thai_company/tax_withholding.py index 63896bd..21828f1 100644 --- a/default_thai_company/tax_withholding.py +++ b/default_thai_company/tax_withholding.py @@ -11,8 +11,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category normal_round, ) from frappe import _ +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 +from frappe.utils import cint, flt, fmt_money, getdate from default_thai_company.utils import money_in_words @@ -389,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, + ), + ) diff --git a/default_thai_company/templates/print_formats/withholding_tax_certificate.html b/default_thai_company/templates/print_formats/withholding_tax_certificate.html new file mode 100644 index 0000000..a03c44c --- /dev/null +++ b/default_thai_company/templates/print_formats/withholding_tax_certificate.html @@ -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) -%} +
+
+ {#- 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") }}
+