This guide assumes familiarity with the two-phase model (resolution → calculation) and the data-level setup walked through in the companion post Configuring Sales Taxes in ERPNext.
The guiding principle: prefer data over code. ERPNext’s tax engine is country-blind by design. The five charge_type values plus included_in_print_rate and add_deduct_tax cover most real-world VAT/GST/sales-tax patterns. Reach for code-level overrides only when arithmetic, presentation, or lifecycle behaviour cannot be expressed declaratively.
Customization ladder (least to most invasive)
- Data — Tax Templates, Item Tax Templates, Tax Categories, Tax Rules. See Configuring Sales Taxes.
- Settings — flags on Accounts Settings and Company. See Configuring Sales Taxes.
- Custom Fields — extend DocTypes without touching core schema.
- Doc events — hook into validate / submit / cancel lifecycle.
regional_overrides— country-scoped function substitution.- Client / server scripts — last resort for one-off transactional tweaks.
Climb only as high as the requirement forces you to. Steps 1–2 cover the bulk of real-world tax requirements; everything below assumes you have already ruled them out.
1. Extending DocTypes with Custom Fields
Custom fields are the right tool for adding country-specific data that the engine should ignore but downstream consumers (print formats, reports, external integrations) must see.
Examples already in the codebase:
- UAE adds Tax Registration Number, Reverse Charge, VAT Emirate, Tourist Tax Return on Sales Invoice, Purchase Invoice, and Address.
- Italy adds Fiscal Regime, Fiscal Code, VAT Collectability on Company, plus per-item
tax_rateandtax_amounthidden fields on Sales Invoice Item.
Pattern: install custom fields via frappe.custom.doctype.custom_field.custom_field.create_custom_fields(fields_dict) from your country setup module’s update_regional_tax_settings(country, company) function.
2. Doc event handlers
Use doc events for lifecycle behaviour that must not run in the calculation hot path. Wire them in hooks.py:
doc_events = {
"Sales Invoice": {
"validate": "your_app.tax.validate_special_case",
"on_submit": "your_app.tax.post_external_filing",
},
}
Common patterns:
- Validate: enforce country-specific constraints (e.g. require VAT Emirate for UAE companies, or block submission if reverse-charge math doesn’t net to zero).
- On submit: trigger e-invoice XML generation, statutory reporting, or external authority filings (Italy’s e-invoice flow is the canonical example).
- Before save: stamp computed compliance fields (e.g. Sequential Invoice Number for German DATEV-style numbering, see
apps/erpnext_germany/).
Doc events are filtered by the runtime get_region() lookup — handlers should early-return if frappe.get_cached_value("Company", company, "country") doesn’t match the target country.
3. regional_overrides — country-scoped function substitution
For arithmetic or presentation logic that genuinely differs by country, register a per-country override in hooks.py:
regional_overrides = {
"Your Country": {
"erpnext.controllers.taxes_and_totals.update_itemised_tax_data":
"your_app.regional.your_country.utils.update_itemised_tax_data",
},
}
The @erpnext.allow_regional decorator on the target function checks the calling company’s country and substitutes the body if a registered override exists.
Currently override-able tax functions (all in taxes_and_totals.py):
get_regional_round_off_accounts(company, account_list)— patch the round-off account list.update_itemised_tax_data(doc)— re-shape per-item tax fields for serialisation.get_itemised_tax_breakup_header(item_doctype, tax_accounts)— print-format header.get_itemised_tax_breakup_data(doc)— print-format body.
These hooks are intentionally narrow: they let you alter presentation and round-off destinations without touching the calculation engine. If your country needs alternative arithmetic, that’s a strong signal you should re-express the requirement as data (Tax Templates, Item Tax Templates, paired Add/Deduct rows, inclusive-rate flags) before reaching for a core override.
Adding a new country
-
Create
apps/<your_app>/regional/<country_slug>/with__init__.py,setup.py,utils.py. -
Implement
update_regional_tax_settings(country, company)insetup.py— install custom fields, seed Tax Category records, create Tax Rule records. -
Implement override functions in
utils.pymatching the signatures of the four@allow_regionalfunctions you target. -
Register in
hooks.py:regional_overrides = { "Your Country": { "<full.module.function>": "<full.regional.module.function>", }, } -
Optionally add
doc_eventskeyed on the same country for lifecycle behaviour.
4. Item Tax Template behaviour you should know
The Item Tax Template is the most subtle moving part for developers. Three behaviours catch people out:
-
not_applicable≠ rate of zero. A row with Not Applicable = “1” emits the sentinel “N/A”, which short-circuits the tax row entirely for that item (_get_tax_ratereturns the sentinel). A row withtax_rate= “0” still runs the formula, just with a zero result — this matters for inclusive-tax fraction algebra. -
The header template defines which accounts; the Item Tax Template defines which rate per account. Example: a German invoice with
account_head= “Umsatzsteuer 19 %” at “19” in the header template and an item bound to Item Tax Template “7 %” — the engine uses rate “0” for the 19 % row and rate “7” for the 7 % row on that item. The header rate is overridden, not the account. -
Pricing Rule does not touch
item_tax_template. That field is stamped before pricing rules run. Pricing Rules touch only price / discount / margin. The taxable amount may shift indirectly when margin pushesitem.rateup, but the rate map is fixed.
5. The dont_recompute_tax flag
Set dont_recompute_tax = “1” on a tax row when its tax_amount and base_tax_amount must be preserved verbatim through subsequent recalculations. Typical setters:
- POS Invoice Merge Log consolidation —
pos_invoice_merge_log.py. - Tax Withholding Category (TDS / 1099 / VAT WHT) — statutory amounts must not drift.
- Generic invoice consolidation in
accounts_controller.py.
What freezes: tax_amount, base_tax_amount, the per-item breakup JSON.
What still recomputes: tax_amount_after_discount_amount, totals, base_* derivations.
Invariant: rows with dont_recompute_tax = “1” almost always also have charge_type = “Actual” — recomputation is semantically trivial because the amount is the literal value.
6. Subscription tax handling — building your own re-resolution
Subscription snapshots its sales_tax_template once and copies it onto each generated Sales Invoice without re-running Tax Rule resolution. The implication for developers: if your billing flow needs address-aware or rate-aware re-resolution per cycle, you must build it yourself.
Two approaches:
- A scheduled job that updates
sales_tax_templateon active Subscriptions when upstream rates or rules change. - A custom
before_validatehook on Sales Invoice that callsget_party_details()and overrides the snapshotted template per generated invoice.
Subscription Plan has no tax fields — taxes live on the parent Subscription only, so any custom logic should target that DocType.
7. Multi-currency: do not double-round
When extending tax logic for multi-currency documents, follow the existing pattern:
- Compute in transaction currency at full precision.
- Derive base values via
_set_in_company_currency(doc, fields)— it does a two-step rounding (flt(value, txn_precision) × conversion_rate → flt(result, base_precision)) to prevent intermediate-precision loss. - Never reconstruct
base_grand_totalasbase_net_total + base_taxes— always derive fromgrand_total × conversion_rate.
For per-item base contributions, use error diffusion: track _running_txn_tax_total and _running_base_tax_total, emit each item’s contribution as the delta. This guarantees the per-item base values sum exactly to the row total even on extreme conversion rates (the apps/erpnext/erpnext/controllers/tests/test_item_wise_tax_details.py USD→KRW test illustrates this).
Common pitfalls
- Don’t monkey-patch
taxes_and_totals.py. Useregional_overridesfor the four exposed functions or wrap the call site via doc events. Direct patches break on upgrade. - Don’t compute taxes on the JS side and stuff them into the doc. The Python engine re-runs at validate time as the source of truth and will overwrite your client-side numbers. Mirror any change in both
taxes_and_totals.pyandtaxes_and_totals.jsif you must. - Don’t set
dont_recompute_tax= “1” “just to be safe”. It freezes the breakup; if downstream code expects fresh values (e.g. after a quantity edit), the freeze causes silent staleness.
Where to look in the source
| Concern | File |
|---|---|
| Calculation engine | apps/erpnext/erpnext/controllers/taxes_and_totals.py |
| Calculation engine (JS mirror) | apps/erpnext/erpnext/public/js/controllers/taxes_and_totals.js |
| Resolution: party + address | apps/erpnext/erpnext/accounts/party.py |
| Resolution: Tax Rule scoring | apps/erpnext/erpnext/accounts/doctype/tax_rule/tax_rule.py |
| Resolution: Item Tax Template | apps/erpnext/erpnext/stock/get_item_details.py |
| Auto-append helpers | apps/erpnext/erpnext/controllers/accounts_controller.py |
@allow_regional decorator | apps/erpnext/erpnext/__init__.py |
regional_overrides registry | apps/erpnext/erpnext/hooks.py |
| Country setup modules | apps/erpnext/erpnext/regional/<country_slug>/setup.py |
| Setup wizard tax loader | apps/erpnext/erpnext/setup/setup_wizard/operations/taxes_setup.py |
| Seed data | apps/erpnext/erpnext/setup/setup_wizard/data/country_wise_tax.json |