Skip to content
ERPNext Data Model
Esc
navigateopen⌘Jpreview
On this page

Country-Specific Tax Localization Framework

A design reference for how region-specific behavior attaches to the shared tax computation engine without forking it

1. Requirements

1.1 Functional requirements

  • Let a small, named set of core functions be replaced per call by a country-specific implementation, without editing the core function itself.
  • Resolve “which country” per call from the Legal Entity actually transacting, not a global switch, so one installation serves entities in several countries at once.
  • Let both the platform’s own country modules and a separately installed compliance application register a replacement for the same extension point, through the same mechanism.
  • Let a Legal Entity carry country-specific configuration, chiefly lists of ledger accounts a country’s tax logic must recognize, and provision one-time country-specific setup (custom fields, permissions, report roles) automatically when its country is set.
  • Let country-specific logic also attach as ordinary document lifecycle behavior (validate, submit, cancel) where call-substitution doesn’t fit.

1.2 Non-functional requirements

  • No core forking: additive registration against the shared computation path (cross-referenced below), never a branch inside it.
  • Coexistence: many countries’ logic ship together with zero collision, keyed by country name.
  • Low ceremony: a decorator plus a flat lookup table, not a formal interface class, with graceful fallback to original behavior when nothing is registered.

1.3 Constraints

  • Only functions explicitly wrapped in the core are extensible this way.
  • Exactly one replacement wins per call — no chaining across countries or applications.
  • A target is resolved by string lookup at call time, not validated when declared — a broken entry surfaces only on first real use.

2. High-Level Design

2.1 Component diagram

2.2 Region resolution and the contest rule (per call)

  1. A wrapped function is called during normal document processing.
  2. The wrapper computes its own identity (module path + function name) and asks the Region Resolver for the current region.
  3. It looks that region up in the Region Override Map. No entry, or no matching function key, means the original function body runs — the common, silent, cheap path.
  4. A match is a list, since more than one installed source can declare into the same slot. The wrapper takes the last entry, per an explicit code comment stating priority goes to whichever source was installed last — no merge, no chaining, exactly one replacement runs.

3. Deep Dive

3.1 Data model

Regional Extension Point — a core function marked, at definition, as regionally overridable, a per-function opt-in. Roughly eighteen functions across the accounting, controller, and depreciation code carry this marking.

Region Override Map — a flat table keyed by region name, then by an extension point’s dotted path, valued as a list of replacement paths. Both the platform’s own region modules and a separately installed application declare into this same table.

Region Module — a plain module of ordinary functions per country (or shared across countries, §3.2), matching whatever signature the function it replaces expects. No base class, no formal contract.

Document Event Hook and Country Fixture Installer — two further, independent mechanisms, covered with the Region Settings Records in §3.3.

3.2 How narrow the seam actually is

Of ~18 marked points, the platform’s own map wires only four region entries, three of them production:

Region Wired extension point(s) Target
A jurisdiction with no region module here one entry, a test-only function a module path absent from this source tree — unreachable in practice
United Arab Emirates itemised tax-data computation; reverse-charge ledger-entry generation its own region module
Saudi Arabia itemised tax-data computation the UAE module — reuses the neighboring country’s function outright
Italy itemised tax-data computation; a validation point, wired to an e-invoicing pre-submit check its own region module

Confirmed by search: the test-only entry’s target module does not exist anywhere in this tree — its proven purpose is a self-test of the fallback path, run under a region with no override. Saudi Arabia’s reuse is deliberate, not an oversight.

The remaining ~15 unwired points — including the rounding hook the tax engine reference (cross-referenced below) calls “a pluggable regional hook” — are attachment surface for something outside this codebase: a separately installed compliance application declares its own entries into this map for that hook and others, confirming it is shared infrastructure, not a closed list.

3.3 Other mechanisms, and what the settings records actually do

Two more mechanisms coexist, unrelated to the map: Document Event Hooks wire a Region Module function directly into a lifecycle stage (an Italian invoice gets a filing attachment on submit, removed on cancel; a UAE purchase document gets a reverse-charge adjustment on validate); and a Country Fixture Installer does a one-time, convention-based import of a setup module named after a Legal Entity’s country, installing custom fields and permissions if one exists, silently skipping if not.

Settings records split by mechanism. South Africa’s settings record and child account list are pure configuration, read by neither the map nor an event hook — the only consumer is a standalone audit report. The UAE settings record and child list are read by two mechanisms at once: the same reverse-charge event hook and Regional Extension Point above. A withholding-tax certificate record and an e-invoice bulk-import record, both in scope here, are not part of this seam: the certificate belongs to withholding-tax computation, covered elsewhere (cross-referenced below); the import record parses a country-specific e-invoice format into ordinary purchase documents.

3.4 Error handling

  • No map entry: silent fallback — the common case, not an error.
  • Entry present, target unimportable: not caught by the wrapper; surfaces as an ordinary failure the first time that region/function pair runs. Nothing validates an entry when declared.
  • Fixture Installer: an absent country module is caught and treated as nothing to install; a present module that raises is caught, logged, and re-raised — the one path here not silent by design.
  • Two sources claiming the same slot: not an error at all — the last-declared entry wins with no warning.

4. Scale and Reliability

  • Adding a country to an existing installation is configuration, not redeploy, once its module is installed — resolution runs per call against the acting entity.
  • Last-installed-app-wins is the sharpest gap: two applications overriding the same slot silently overwrite each other in installation order, with no log, warning, or metric.
  • Lazy validation is a live risk, not theoretical: the unreachable test-region entry (§3.2) shows this failure mode already shipping, undetected because nothing exercises that region.

5. Trade-off Analysis

Decision Trade-off
Decorator + flat hook-declared map, no validation until first invocation Minimal ceremony, zero declaration-time cost — but no signature enforcement, and a stale entry (the test-region one) ships silently.
Region resolved per call from the acting entity, not globally One install serves many countries at once, at the cost of a lookup on every marked call, override or not.
Last-declared entry wins, no conflict detection Trivial for one source; two co-installed applications silently contest the same slot, and invisible install order decides.
~18 marked points, only 3 production functions wired by the core itself Leaves the door open for an external application without the core needing the behavior itself, at the cost of ~15 points as dead surface area otherwise.
Behavior spread across three independent mechanisms, not one system Each fits its own problem shape well, but no single place answers “what does this country change.”

6. What to Revisit as the System Grows

  • Add a startup or build-time check that every map entry resolves to an importable target — the unreachable test-region entry shows a broken one can ship indefinitely unnoticed.
  • Surface a warning, not silence, when two installed sources register the same slot.
  • Document the three attachment mechanisms as one seam, since reading only the override map misses lifecycle-hook wiring and fixture provisioning entirely.
  • Track marked-but-unwired points (like the rounding hook a separately installed application fills in) against changes to the functions they wrap — a signature change today breaks silently for whatever depends on it.

Was this page helpful?