4 Commits
Author SHA1 Message Date
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
9 changed files with 188 additions and 4 deletions
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -139,8 +139,12 @@ fixtures = [
# ------------
# 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 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",
]
# Fixture import re-inserts the Location tree; restore lft/rgt for user-added nodes.
after_migrate = "default_thai_company.assets.rebuild_locations"
@@ -191,6 +195,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 +218,7 @@ 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",
],
},
"Payment Entry": {
+1
View File
@@ -6,3 +6,4 @@
# 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
@@ -0,0 +1,5 @@
from default_thai_company.shipping import setup_companies
def execute():
setup_companies()
+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)
@@ -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>
@@ -177,6 +177,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(
{
+1
View File
@@ -50,6 +50,7 @@ Amount,จำนวนเงิน
Total,รวม
Total (Without Tax),รวมก่อนภาษี
Net Total,ยอดรวมสุทธิ
Total After Discount,ยอดรวมหลังหักส่วนลด
Total Taxes and Charges,รวมภาษีและค่าธรรมเนียม
Grand Total,ยอดรวมทั้งสิ้น
Rounded Total,ยอดรวมปัดเศษ
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 ยอดรวมปัดเศษ