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

Custom Accounting Dimensions Framework

How an administrator adds an arbitrary extra segment — location, channel, region — to every transaction and every ledger posting without touching schema by hand

1. Requirements

1.1 Functional requirements

  • Let an administrator declare a new reporting segment (e.g., “Location,” “Sales Channel”) on top of the two segments the system ships with natively — cost center and project — without writing code.
  • Once declared, the segment must appear as a fillable field on every relevant transaction header, every relevant transaction line and tax/charge line, and — critically — on the resulting ledger posting itself, so reports can group or filter by it.
  • The value on a ledger posting must be resolvable from either the transaction header or the specific line that produced it, with the line’s value taking precedence when both are present.
  • A segment must be scopable per legal entity: each legal entity sets its own default value and decides independently whether the segment is mandatory for profit-and-loss postings, mandatory for balance-sheet postings, or optional.
  • A segment must be constrainable per ledger account: an administrator can restrict (or explicitly allow) which values are usable against a specific account, and mark a value mandatory whenever that account is used.
  • A legal entity can configure a segment so that, whenever one posting batch touches more than one value of it, the system automatically inserts an offsetting posting per value so segment-scoped reports still balance to zero on their own.
  • Declaring a segment must not silently collide with a same-named field a transaction record already carries natively; on conflict, the existing field becomes the segment’s source of truth instead of a duplicate being added.
  • Disabling a segment must stop new postings from requiring or offering it without destroying historical values. Removing one must clean up every field and schema-level override it created, everywhere it was added.

1.2 Non-functional requirements

  • Idempotency: re-saving a segment definition, or re-running the field-creation step, must not create duplicate fields or overrides.
  • Low administrative overhead at declaration time: adding a segment touches dozens of transaction record types in one operation, so it runs as a background job with progress reporting rather than blocking the administrator’s save.
  • Backward compatibility: the segment shows up as a plain field with a stable, predictable name so existing reporting code can consume it without per-segment special-casing (with the notable exception in § 3.4).
  • Safety under a stale client: a value’s mandatory/allowed-value check is evaluated at the moment a ledger posting is actually written, not only at form-fill time, so a bypassed client cannot post an invalid value.

1.3 Constraints

  • A segment cannot target the platform’s own core record types, the dimension definition record itself, cost center, project, ledger account, or legal entity — excluded so a segment can’t redefine the primitives it depends on.
  • Only one segment definition may exist per target record type; a second attempt to reuse the same target is rejected.
  • A segment’s field name must be a valid column identifier, and once saved its target record type cannot be changed — only a new segment can be created if the target was wrong.
  • Cost center and project are not implemented through this framework; they are native fields with their own dedicated validation and call sites (§ 3.4). The framework governs everything beyond those two.

2. High-Level Design

2.1 Component diagram

2.2 Definition-time walkthrough — how a new segment gets its fields

This is a short annotated procedure, not a branching graph:

  1. Administrator saves a new Dimension Definition naming the target record type (e.g., “Sales Channel”) and a label; a field name is derived from the label if none was supplied.
  2. Validation rejects the save if the target record type is excluded, already targeted by another segment, or the derived field name is not a valid column identifier.
  3. Fieldname-conflict check: every record type on the target-surface list is inspected for an existing field of the same name. Where one already exists, no duplicate is added — that record type’s existing field becomes the value source instead. This is the one place the framework deliberately does not overwrite what is already there.
  4. Field injection runs as a background job (synchronous only under test execution), walking the target-surface list and adding a new link-type field, pointed at the segment’s target record type, to every record type that doesn’t already have one. Fields alternate insertion position between two anchor points so successive segments don’t all stack in one column.
  5. The Budget Envelope record type is special-cased: instead of a plain link field, the new field is made conditional on the envelope’s own “budget against” selector being set to this segment’s target, and the target is appended to that selector’s list of choices.
  6. Post-submission editability is decided separately, by which record types are currently enrolled in the ledger-repost tool’s allow-list (documented separately) — enrolled types get an editable-after-submit field from the start; others can be promoted later by the same enrollment mechanism.
  7. Disabling a segment makes every field it created read-only, everywhere, without deleting anything. Deleting one removes every field and schema-level override it created across the whole target-surface list, and prunes it from the Budget Envelope’s choice list.

2.3 Posting-time data flow — how a value reaches the ledger


3. Deep Dive

3.1 Data model

Dimension Definition The setup record naming which existing record type becomes a new segment (e.g., “Location,” “Sales Channel,” or an existing organizational master such as a department hierarchy). Holds the target record type, a display label, the derived field name, and a disabled flag, plus a child table of Dimension Company Settings, one row per legal entity that configures the segment.

Dimension Company Setting (child row) Per legal entity: a default value (a fallback used on rounding/balancing postings, § 3.3), mandatory for profit-and-loss, mandatory for balance-sheet, and an automatically post balancing entry flag paired with the ledger account for the balancing leg. A legal entity cannot appear twice for the same segment.

Dimension Access Filter One per (legal entity, segment): an allow-or-restrict switch, a child table of applicable accounts, and a child table of allowed values. Turning off value restriction clears the value list and forces the switch back to “restrict” — an empty restrict-list is a no-op, so “not configured” and “no restriction” behave identically.

Ledger Posting / Party Balance Entry (reused, doc 001) Every declared segment becomes a plain field on both records, populated by the posting funnel (§ 3.3) — these are what every downstream report actually reads.

Budget Envelope (reused, doc 008) Wired differently: its segment field only shows when the envelope’s own target selector is set to this segment’s record type, which is appended to that selector’s choice list rather than the envelope gaining an always-visible field.

3.2 The field-injection algorithm, in full

on Dimension Definition save:
    validate target record type not in {core scaffolding, Dimension Definition,
                                          Cost Center, Project, Legal Entity, Ledger Account, Finance Book}
    validate no other Dimension Definition already targets this record type
    validate derived field name is a legal column identifier
    validate no legal entity appears twice in the company-settings child table

    run (async unless under test):
        target_list = declared target-surface list   # dozens of record types
        for each record_type in target_list:
            if record_type already has a field with this name:
                skip (existing field becomes the value source; conflict is reported once)
            elif record_type == "Budget Envelope":
                add conditional field + extend the envelope's target-selector choice list
            else:
                add a new link field pointing at the segment's target record type,
                editable-after-submit = (record_type is in the ledger-repost allow-list)
        clear cached metadata for every touched record type

3.3 Value-propagation algorithm (the posting funnel’s contribution)

The general-ledger posting engine (doc 001) builds one Ledger Posting at a time from a transaction header and, optionally, the specific line that produced it. For every declared segment it performs exactly this:

value = header.get(segment_fieldname)
if line is not None and line.get(segment_fieldname):
    value = line.get(segment_fieldname)
posting[segment_fieldname] = value

Header value first, line value wins if present — nothing more sophisticated than that. This is the one place in the entire framework where an arbitrary segment’s value actually lands on a ledger row; every other piece exists to define, constrain, or validate that single assignment.

A second, narrower enrichment applies only to the automatic round-off/balancing leg the posting engine appends when a batch’s debits and credits don’t tie out: if the transaction’s own record type carries every declared segment field, the leg copies those values straight from the transaction; if it doesn’t, it falls back to each segment’s configured legal-entity default, but only when that value is itself flagged mandatory for the round-off account’s report type.

3.4 Default dimensions vs. custom dimensions — two paths, not one

Cost center and project are not processed by the field-injection or value-propagation logic above. They are native fields on the ledger posting record with their own dedicated enforcement, and every call site that builds a posting passes them explicitly and by name — line by line, transaction type by transaction type — rather than picking them up through the generic segment loop. Every place elsewhere in the codebase that assembles “the segments to check” for a non-posting purpose (repost change-detection, budget applicability, mandatory-field sweeps) builds it as the dynamic custom-segment list plus a hardcoded pair for cost center and project appended afterward — never as one unified list. The mandatory-for-profit-and-loss rule for cost center is its own dedicated check, running independently alongside the generic mandatory-for-report-type check that governs every custom segment through the Dimension Company Setting table. Cost center additionally participates in a distribution step this framework has no equivalent of: a submitted allocation rule can silently split one posting into several proportional postings across sibling cost centers.

The practical consequence: a custom segment added today is not symmetric with cost center or project. It rides a generic, declarative path (define once, injected everywhere, validated generically); cost center and project ride a hand-authored path baked into each transaction type’s own posting code. Extending the hand-authored behavior of one to the other requires code changes on both sides, not a configuration change.

3.5 Error handling

  • Fieldname collisions are resolved at definition time, not posting time: the conflicting record types simply don’t receive a duplicate field, and a one-time message identifies which ones will use their existing field instead.
  • Missing mandatory value or disallowed value are each caught with a distinct, dedicated error at the moment a Ledger Posting is actually about to be written (per-account Dimension Access Filter; per-legal-entity Dimension Company Setting mandatory-for-P&L/B-S) — deliberately late, after the batch is already assembled, so a stale client-side form cannot post an invalid transaction.
  • Client-side behavior is advisory only: transaction forms fetch the live segment list and each legal entity’s defaults, use them to restrict which values a lookup field offers (querying the same allow/restrict configuration used server-side) and to pre-fill defaults on new documents and rows — but none of this is a hard gate. The two errors above are the only actual enforcement, and both run entirely server-side, inside the posting funnel, independent of whatever the form displayed.

4. Scale and Reliability

  • Load pattern: definition-time field injection is rare and admin-driven (touching dozens of record types in one pass), so it runs as a background job with progress reporting; posting-time value resolution happens on every transaction submission and is a handful of dictionary lookups, not a query.
  • Horizontal scaling: the mandatory/allowed-value checks and the offsetting-entry insertion run inside the same transaction that writes the ledger postings, so they scale with normal posting throughput — no separate service or queue in the loop.
  • Cache invalidation: every record type touched by field injection has its cached metadata cleared explicitly, since stale cached field lists would otherwise hide the newly added segment from that record type’s forms and validations until a restart.
  • Segment growth: every segment adds a real field to every record type on the target-surface list, so the cost of adding segments compounds across dozens of record types at once — the direct trade-off for the “no code” declaration model (§ 5).
  • Repost interaction: post-submission editability of a segment’s field is tied to a separate mechanism tracking which record types are safe to repost, so enabling repost support for one retroactively unlocks post-submission segment edits for it too — a cross-cutting side effect, not a bug.

5. Trade-off Analysis

Decision Trade-off
Generic segment framework layered on top of, rather than merged with, the native cost-center/project fields Fast to extend (definition-time configuration, no code) but produces two structurally different code paths for conceptually similar data — auditing “does this transaction type respect segment X” means checking twice, once in the generic loop and once in the hand-authored call sites.
Field injection walks a large, hardcoded target-surface list rather than discovering applicable record types dynamically Predictable, auditable set of surfaces; but a record type left off that list (a new transaction type added later) silently never gets segment fields unless the list itself is updated — looks like a missing feature rather than a bug.
Existing same-named fields are reused rather than shadowed by a duplicate Avoids confusing double fields and lets an organization “promote” an existing field into a segment; but the framework’s guarantees (mandatory checks, filters) then apply to a field whose other validation rules were written for an unrelated purpose.
Mandatory/allowed-value enforcement happens only at ledger-posting time, not earlier as a blocking submission-time step Cannot be bypassed by a stale client, but failures surface deep in the posting funnel rather than as an early form error — a mid-batch rejection is harder to debug than a front-door validation message.
Client-side value restriction (query filtering + default pre-fill) with no client-side hard gate Simple, reusable UI wiring; but the UI’s “allowed values” and the server’s agree only as long as both read the same configuration — any direct write path bypasses the UI.

6. What to Revisit as the System Grows

  • Unify the default-dimension and custom-dimension paths. As long as cost center and project stay hand-wired into every transaction type’s own posting code while every other segment rides the generic framework, promoting a popular custom segment to default status means touching both mechanisms — a real, recurring cost.
  • Move mandatory/allowed-value validation earlier, to document-submission time rather than only at ledger-write time, turning a mid-batch rejection into an ordinary form validation message — at the cost of duplicating the check in two places.
  • Track the target-surface list’s own drift. Field injection depends on a hardcoded list of record types rather than a dynamic query, so every new transaction-like record type needs a deliberate addition to that list — worth a test asserting the list and the platform’s actual transaction types stay in sync.
  • Reconsider the offsetting-entry design once segment cardinality grows. One offsetting posting per distinct value is fine across a handful of values; a high-cardinality segment could turn one transaction into many offsetting legs, worth capping or aggregating before that becomes common.

Was this page helpful?