Zum Inhalt springen
Zurück zur Übersicht
ERPNext

Customizing sales tax calculation in ERPNext

This guide shows developers how to customize ERPNext's sales tax behaviour.

Veröffentlicht am April 29, 2026

Titelbild zu Customizing sales tax calculation in ERPNext

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)

  1. Data — Tax Templates, Item Tax Templates, Tax Categories, Tax Rules. See Configuring Sales Taxes.
  2. Settings — flags on Accounts Settings and Company. See Configuring Sales Taxes.
  3. Custom Fields — extend DocTypes without touching core schema.
  4. Doc events — hook into validate / submit / cancel lifecycle.
  5. regional_overrides — country-scoped function substitution.
  6. 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_rate and tax_amount hidden 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

  1. Create apps/<your_app>/regional/<country_slug>/ with __init__.py, setup.py, utils.py.

  2. Implement update_regional_tax_settings(country, company) in setup.py — install custom fields, seed Tax Category records, create Tax Rule records.

  3. Implement override functions in utils.py matching the signatures of the four @allow_regional functions you target.

  4. Register in hooks.py:

    regional_overrides = {
        "Your Country": {
            "<full.module.function>": "<full.regional.module.function>",
        },
    }
  5. Optionally add doc_events keyed 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:

  1. 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_rate returns the sentinel). A row with tax_rate = “0” still runs the formula, just with a zero result — this matters for inclusive-tax fraction algebra.

  2. 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.

  3. 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 pushes item.rate up, 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_template on active Subscriptions when upstream rates or rules change.
  • A custom before_validate hook on Sales Invoice that calls get_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_total as base_net_total + base_taxes — always derive from grand_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. Use regional_overrides for 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.py and taxes_and_totals.js if 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

ConcernFile
Calculation engineapps/erpnext/erpnext/controllers/taxes_and_totals.py
Calculation engine (JS mirror)apps/erpnext/erpnext/public/js/controllers/taxes_and_totals.js
Resolution: party + addressapps/erpnext/erpnext/accounts/party.py
Resolution: Tax Rule scoringapps/erpnext/erpnext/accounts/doctype/tax_rule/tax_rule.py
Resolution: Item Tax Templateapps/erpnext/erpnext/stock/get_item_details.py
Auto-append helpersapps/erpnext/erpnext/controllers/accounts_controller.py
@allow_regional decoratorapps/erpnext/erpnext/__init__.py
regional_overrides registryapps/erpnext/erpnext/hooks.py
Country setup modulesapps/erpnext/erpnext/regional/<country_slug>/setup.py
Setup wizard tax loaderapps/erpnext/erpnext/setup/setup_wizard/operations/taxes_setup.py
Seed dataapps/erpnext/erpnext/setup/setup_wizard/data/country_wise_tax.json