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

General Ledger & Multi-Currency Posting Engine

How every transaction becomes a balanced set of ledger postings, and how cross-currency amounts are actually converted at write time

1. Requirements

1.1 Functional requirements

  • Translate any of the four transaction kinds — an invoice, a settlement (an incoming or outgoing payment), a manual journal, or a stock movement with a monetary impact — into one balanced batch of debit/credit lines against ledger accounts, tagged back to the originating voucher.
  • Automatically inject supporting lines the caller never asked for: a per-differing-dimension offsetting line, a cost-center allocation split, and a rounding/balancing line, whenever their respective trigger conditions are met.
  • For every line touching a receivable- or payable-type ledger account, additionally derive a normalized, sign-consistent balance record that answers “what is still outstanding on this voucher” without redoing debit/credit arithmetic across the whole general ledger.
  • Support full reversal (cancellation) of a previously posted voucher, including a narrower partial reversal of one specific allocation leg — e.g. unlinking a single advance’s allocation without cancelling the whole voucher.
  • Support multi-currency posting: a voucher carries one transaction currency and one legal-entity base currency; every ledger account it posts to is constrained to be denominated in one of those two, except for a defined set of voucher types whose individual lines may each carry an independent currency and rate.
  • Maintain a fourth, independent currency view — a configured group reporting currency — on every posted line, converted from the base-currency amount using its own separately looked-up rate.
  • Refuse to post into a disabled, frozen, wrong-currency, wrong-company, or period-locked ledger account, and refuse to leave a voucher’s own books out of balance beyond a small rounding tolerance.

1.2 Non-functional requirements

  • Balance invariant: the sum of every debit must equal the sum of every credit for a voucher’s posting batch, enforced at write time with a tunable tolerance, an automatic round-off correction line for small drifts, and a hard failure above it.
  • Derived, not hand-maintained, balances: the “outstanding amount” on a receivable/payable-tracked voucher is never trusted as independently updatable state — it is recomputed from a live aggregate query over the derived balance ledger whenever a related posting or reversal touches it.
  • Auditability of reversal: cancellation must be attributable and reconstructable — either by marking original rows cancelled and hiding them from every standard report, or, under a stricter mode, by never mutating a stored row and instead always adding a new, dated, opposite-signed row.
  • Multi-currency round-trip correctness: every posted line carries parallel amount columns — base/legal-entity currency, ledger-account currency, voucher transaction currency, and group reporting currency — each independently derived at write time.
  • Idempotent re-posting: a bulk regeneration of history (a “repost”) must replay the same funnel without re-triggering validations meant only for a live, user-initiated post.

1.3 Constraints

  • A single, append-only ledger-posting table is the sole source powering every accounting report (trial balance, balance sheet, party statements); nothing in this module writes accounting facts anywhere else.
  • Only ledger accounts of type Receivable or Payable ever get a derived balance-ledger row; every other account type (bank, cash, income, expense, stock, tax, temporary) is visible only through the raw general ledger.
  • Every ledger account is scoped to exactly one legal entity and carries exactly one fixed currency, which is locked in permanently the moment any posting exists against it.
  • A voucher may only post to a ledger account whose currency equals either the legal entity’s base currency or the voucher’s own transaction currency — never an arbitrary third currency — except for journals, settlements, purchase-side documents, and stock movements, which are explicitly exempted from that single-rate constraint because their own lines can each carry an independent rate.
  • Cancellation is never a delete. A posted voucher is reversed by mirrored, sign-flipped rows, and the exact mechanics of that reversal depend on whether an instance-wide “immutable ledger” switch is enabled.
  • Posting is blocked outright on or before a frozen “accounts frozen till” date, inside a closed accounting period, or on or before the close date of an already-finalized period-close voucher — unless the acting user holds an explicitly configured override role, and even the system administrator account is not exempt from the frozen-date check.

2. High-Level Design

2.1 Component diagram

2.2 Order of operations (why this is not a simple “insert N rows”)

This is a numbered procedure with real branching, so it is written as annotated steps rather than forced into a diagram:

  1. Budget validation runs by default — the condition is a negation: it fires whenever an older “legacy budget controller” setting is switched off (today’s default path), and is skipped on cancellation, on a period-close voucher, or when that legacy setting is on.
  2. Dimension offset injection. For each custom accounting dimension flagged to auto-balance, if the batch’s lines don’t all share one value for it, one extra offsetting line is appended per existing line into the dimension’s offsetting account, party fields cleared — growing the line count before anything downstream sees the batch.
  3. Accounting-period lock check, then disabled-account check — both reject outright. Frozen-date and finalized-period-close are not checked yet — those live later, in the write stage.
  4. Cost-center allocation split. Any line whose cost center has an active percentage-split rule effective as of the posting date is exploded into one line per sub-cost-center, scaled by its percentage at base-currency field precision.
  5. Merge similar lines. Lines sharing account, cost center, party, voucher-detail line, against-voucher reference, project, finance book, voucher, and active dimensions are summed into one; a merged line whose net debit and credit both round to zero is dropped — except a system-generated currency-revaluation journal, which may keep zero-value lines.
  6. Negative-amount flip. Any line left with a negative debit or credit moves the amount to the correct column so both stay non-negative.
  7. Minimum-row check. Fewer than two surviving lines aborts the post — a batch must resolve to at least one debit and one credit.
  8. Balance-ledger derivation happens here — before any posting row is written — except for a period-close voucher, which never gets one. For every other voucher type, the fully processed batch is what the rows are derived and submitted from. Easy to get backwards: the balance ledger exists in the database before the postings it was computed from do.
  9. Write stage begins with a capital-work-in-progress guard (journal entries only, skipped on repost): rejects a journal line targeting a CWIP-flagged account while CWIP accounting is enabled anywhere.
  10. Balance check and round-off. Totals are re-summed independently of anything computed earlier; a mismatch beyond tolerance fails outright, and a small drift inserts one round-off line before re-checking.
  11. Frozen-date check, then finalized-period-close check (skipped for a period-close voucher itself) — both run only now, after the balance-ledger rows already exist, not alongside step 3’s early guards.
  12. Per-line write. Each line becomes its own persisted document, submitted individually rather than bulk-inserted, re-running full validation on top of the batch checks. Immediately after each line’s submit — not once at the end — budget usage is re-validated for it, skipped for a repost, a period-close voucher, or a cancelled line (except a cancelled journal-entry line, still re-validated).

2.3 Data flow — one posting batch, four parallel currency columns


3. Deep Dive

3.1 Data model

Legal Entity (reused) — the company/branch whose books are being posted to. Owns its own chart of accounts, base currency, round-off account and cost center (plus a separate one for opening entries), and the frozen-date / role-override settings that gate posting.

Ledger Account (reused) — a node in a per-legal-entity, hierarchical chart of accounts. Only leaf (“non-group”) accounts can receive postings. Key attributes: account_type (Receivable, Payable, Bank, Cash, Income, Expense, Stock, Tax, Round Off, and about twenty others — this single field decides whether a line needs a party, whether it participates in the derived balance ledger, and whether a cost center is mandatory), a fixed account_currency (falls back to the base currency if blank), report_type (Balance Sheet vs. Profit and Loss — the latter requires a cost center on every posting), balance_must_be (an optional running-balance sign guard, independent of the per-voucher check), and freeze_account / disabled flags.

Posting Batch (coined) — the in-memory list of proposed lines for one voucher, built by the voucher’s own code and handed to the funnel. Not a persisted record; every line shares the same voucher type, voucher number, company, and posting date until the funnel’s transformations rewrite that.

Ledger Posting (coined) — the persisted, atomic unit of the general ledger: one row per debit or credit leg. Beyond account and amount, each row carries an optional party reference (required when the account is Receivable/Payable), an against-voucher reference, a voucher-detail-line reference, cost center, project, finance book, fiscal year, is_opening, and is_cancelled. Critically, it carries four parallel amount pairs — base currency, ledger-account currency, voucher transaction currency, and group reporting currency — each with its own conversion rate stored alongside it.

Party Balance Entry (coined) — a second, purpose-built table populated only from Ledger Posting rows whose account is Receivable or Payable. Where a Ledger Posting keeps separate debit and credit columns, a Party Balance Entry collapses that into one signed amount and an explicit against-voucher link, so “what is still outstanding” becomes a cheap grouped sum instead of a debit-minus-credit scan of the whole general ledger. A delinked flag (not is_cancelled) marks a reversed row. A related, separately-typed row is created alongside it for advance-tied lines — a parallel mechanism this document does not detail further.

Round-Off Posting (coined) — the automatic balancing Ledger Posting inserted when a batch’s debits and credits don’t perfectly agree within tolerance. If an existing line already targets the configured round-off account, the remainder is folded into it (removed entirely if it nets to nothing); otherwise a new line is appended.

Dimension Offset Posting (coined) — the automatic extra Ledger Posting(s) injected per configured custom accounting dimension whenever a batch’s lines don’t all carry the same value for it, so a dimension-scoped report stays self-balancing. Party fields are stripped from these lines.

Immutable Ledger Mode (coined) — a single, instance-wide switch (not per legal entity) that changes how cancellation behaves for every voucher, described below.

3.2 Algorithm — multi-currency conversion at posting time

This is the part most likely to be mis-described from intuition rather than the actual code, so the mechanics below are stated precisely.

Every voucher fixes one conversion_rate before posting (base_amount = transaction_amount × conversion_rate). When currency equals base currency the rate is forced silently to 1.0, an overwrite, not a guard. Sales/purchase invoices throw on a zero rate or a base-currency rate that isn’t 1.00 — usually redundant with the above, a belt-and-braces guard, not the primary control.

When the shared posting-line builder assembles a line, the caller is expected to have already expressed debit/credit in the legal entity’s base currency — that convention is what makes the next step correct:

There are three distinct, not identical, exclusion lists gating three different pieces of this logic — the asymmetry between them is the real story:

  • List A (6 voucher types exempt: journal, period-close, settlement, purchase receipt, purchase invoice, stock movement) gates the account-currency-must-be-base-or-transaction validation. Everyone else must keep every account they touch in {base currency, transaction currency}.
  • List B (only 3 exempt: journal, period-close, settlement) gates the account-currency back-calculation itself — the step that fills debit_in_account_currency/credit_in_account_currency. Purchase invoices, purchase receipts, and stock movements are not in this list, so they do get the automatic back-calculation, even though List A already excused them from the very safety check meant to make that back-calculation correct.
  • List C (4 exempt: purchase invoice, sales invoice, journal, settlement) gates the transaction-currency fallback block. These four compute and supply that block themselves instead of trusting the generic fallback.
build_line(line_args, account_currency=None):
    line = header_defaults + accounting_dimension_defaults + line_args   # caller's values win
    account_currency = account_currency or lookup_currency(line.account)

    if voucher_type not in list_A_exempt:          # 6 types
        assert account_currency in {legal_entity.base_currency, voucher.currency}

    if voucher_type not in list_B_exempt:          # 3 types — a DIFFERENT, smaller list
        rate = line_args.get("transaction_exchange_rate") or voucher.conversion_rate
        if not rate and account_currency != base_currency:
            raise Error("account currency requires a rate")   # no silent 1:1 fallback here either

        # fallback only — fills a column just once, never overwrites a caller-supplied value;
        # result is rounded to a hard-coded 2 decimal places, not the currency's own precision
        if line.debit and not line.debit_in_account_currency:
            line.debit_in_account_currency = (line.debit if account_currency == base_currency
                                               else round(line.debit / rate, 2))
        if line.credit and not line.credit_in_account_currency:
            line.credit_in_account_currency = (line.credit if account_currency == base_currency
                                                else round(line.credit / rate, 2))

    if voucher_type not in list_C_exempt:           # 4 types — yet another list
        line.transaction_currency = voucher.currency or base_currency
        line.debit_in_transaction_currency  = (line.debit_in_account_currency
                                                if account_currency == voucher.currency
                                                else line.debit / voucher.conversion_rate)
        # credit mirrors debit

    return line

Two consequences are easy to get wrong:

  • It is a fallback, not an assignment, and never independently rate-looked-up. The back-calculation only fires when the caller left the account-currency column at zero — a caller-supplied figure is never overwritten — and it always uses a rate already on hand (a per-line transaction_exchange_rate if supplied, otherwise the voucher’s own conversion rate), never a fresh lookup against the account’s own currency. The result is rounded to a fixed 2 decimal places regardless of the currency’s configured precision, and it throws if no rate at all is available while the account currency isn’t the base currency.
  • List B is a strict subset of List A, so purchase invoices, purchase receipts, and stock movements sit in the gap between them: List A doesn’t force their accounts into {base, transaction} currency, yet List B still back-calculates as if that constraint held.

A fourth, wholly independent conversion happens later: when each Ledger Posting row is itself validated (on the row as it is about to be written, not in the voucher’s own code), it looks up a rate from the base currency to a separately configured group reporting currency, keyed off the transaction date, and multiplies the already-determined base-currency debit/credit by it — mandatory, with no silent fallback.

A related, narrower effect surfaces at the invoice level: if an invoice has advance allocations posted at a different exchange rate than its own conversion rate (and the party account’s currency matches the invoice’s transaction currency), the rate difference applied to the allocated amount is computed as an exchange gain or loss per allocation row. This document only notes that the difference is computed here; how it is subsequently booked is a revaluation concern handled elsewhere.

3.3 Contract every voucher type must fulfill

There is no adapter registry here (all voucher types share one code path), but there is an implicit contract every voucher controller must satisfy to plug into the funnel correctly:

Per line, the caller supplies at minimum:
  account                      (required)
  debit  OR  credit             — in the legal entity's BASE currency
  party_type + party            — required if the account is Receivable or Payable
  against_voucher_type/against_voucher  — optional; defaults to the voucher's own identity
  transaction_exchange_rate     — optional per-line rate override

The shared builder fills in company/posting_date/fiscal_year/voucher identity
and dimension defaults unconditionally, then applies Lists A/B/C from §3.2 —
each voucher type is gated independently by all three, not by one combined rule.

Cancellation follows an analogous but reversed contract: given a voucher’s active Ledger Posting rows, the funnel derives reversed balance-ledger rows first, signs already flipped, before touching a single Ledger Posting row. Under the default (legacy) mode, matching original balance-ledger rows are marked delinked and the original Ledger Posting rows marked cancelled via a direct update — hidden from every standard report but never deleted. Under Immutable Ledger Mode, no stored row is ever mutated: the derivation still runs, but instead of delinking it posts new rows dated at the cancellation’s own posting date, and the mirrored, sign-flipped Ledger Posting rows are inserted with is_cancelled = 0 — both original and reversal stay permanently visible and net to zero by construction, rather than by one side being hidden. A narrower partial-cancel path exists purely for unlinking one advance-allocation leg by its exact voucher-detail key, without touching the rest of the voucher.

3.4 Error handling

  • An out-of-balance batch beyond tolerance throws with the exact debit/credit difference named — except a system-generated currency-revaluation journal, exempt entirely.
  • A List A voucher type targeting an account outside {base currency, transaction currency} throws naming both currencies as the only valid choices — a distinct, earlier error from the one below.
  • The List B back-calculation throws separately if no rate at all is available (no per-line override, no voucher-level rate) and the account’s currency isn’t the base currency — no silent 1 fallback here either.
  • A ledger account’s currency is locked the instant any posting exists against it — changing it thereafter throws unconditionally.
  • A per-account running-balance sign guard (balance_must_be) is checked independently of the per-voucher balance check, and can reject an otherwise-balanced voucher.
  • Frozen-account, frozen-date, closed-period, and finalized-period-close checks each carry a distinct error and override role. The frozen-till-date check treats the administrator account more strictly than an ordinary user: a user holding the override role passes; the administrator is rejected outright, precisely because it holds every role and would otherwise use that same loophole to bypass the freeze.
  • A missing group-reporting-currency rate is a hard failure with its own named error — no silent 1 fallback.
  • Disabled, group-type, or wrong-company ledger accounts are rejected twice: a batch-level pre-check, and again inside each line’s own validation on submit — deliberate redundancy.

4. Scale and Reliability

  • Write pattern: posting is synchronous and per-line, not bulk. Every surviving line becomes its own submitted document with its own full validation pass, so a large voucher pays one write-and-validate cost per resulting line — the merge step exists specifically to keep that count down first.
  • Outstanding-amount reads scale with postings-per-voucher, not ledger size: the balance ledger answers “what’s outstanding” via a self-referencing grouped aggregate (a voucher’s own lines vs. everything referencing it as an against-voucher), so cost tracks postings-per-voucher, not the whole ledger. The cached outstanding_amount on the source voucher is a snapshot, not the source of truth — always re-derivable.
  • A separate, simpler update path exists for order-type documents: documents tracking “total advance paid” instead of “outstanding amount” are refreshed through a distinct, lighter method — a fork worth knowing before assuming one universal path.
  • Guard checks are reads, not locks — and they run at two different stages: the accounting-period lock is checked early, before the batch is split or merged; frozen-date and finalized-period-close run later, after the balance-ledger rows already exist. Both are plain reads, not locks — a race window exists at each point under true concurrency (see Trade-offs).
  • Two structural levers correct drift after the fact: round-off tolerance/auto-correction absorbs small drift into a designated account automatically; and Immutable Ledger Mode makes every past posting permanently reconstructable — the property later repost/integrity tooling would depend on.
  • Reposting is a distinct, narrower code path: a from_repost flag skips one class of account-level validation and one layer of budget re-validation.

5. Trade-off Analysis

Decision Trade-off
Derive the balance ledger from ledger postings instead of a running per-voucher balance column Always-consistent, self-healing balances (a repost regenerates them for free) at the cost of every outstanding check being a live aggregate query, not a field read — mitigated by a cached snapshot that is itself not authoritative.
Two distinct “refresh outstanding” code paths (balance-ledger aggregate vs. a lighter method for order-type documents) Lets each document type use the mechanism suited to what it tracks, at the cost of no single place to reason about “how outstanding gets updated.”
A back-calculated currency figure for most voucher types, gated by three separate, not-identical exclusion lists rather than one Keeps the common case simple to validate, at the cost of real asymmetry: purchase invoices, purchase receipts, and stock movements get the back-calculation (List B) without the currency-choice validation (List A) meant to guard it.
Writing the derived balance ledger before the ledger postings it was computed from Guarantees it reflects the fully-processed batch rather than raw draft lines, at the cost of a brief window where it exists but the general ledger it mirrors does not yet.
Legacy cancellation (mutate + hide) vs. Immutable Ledger Mode (never mutate, always append a dated reversal) Immutable mode gives a tamper-evident trail at the cost of unbounded growth and reports needing to net pairs; legacy mode is cheaper but relies on an is_cancelled filter being applied correctly everywhere, forever.
A mandatory, independently-looked-up reporting-currency conversion on every line, no silent fallback Keeps consolidation-ready figures present at write time, at the cost of postability depending on a rate lookup unrelated to the voucher’s own transaction.
Per-line document submission instead of a single bulk insert per posting batch Full, independent field-level validation per line with no special-casing, at the cost of one round trip per line.

6. What to Revisit as the System Grows

  • Unify the two outstanding-update pathways, or at minimum document the fork clearly, before a third document type needs its own variant.
  • Row-level locking or a database constraint for the closed-period/frozen-date checks, rather than plain reads, once posting concurrency within a legal entity grows past occasional overlap.
  • Batch or bulk-insert the per-line write once voucher sizes grow large enough that one submit-and-validate per line becomes the dominant cost.
  • Decouple the reporting-currency lookup from the critical posting path — a transient failure to resolve a group-consolidation rate can currently block an otherwise unrelated, purely local posting.
  • Make Immutable Ledger Mode selectable per legal entity rather than instance-wide, once a deployment needs different audit-strictness postures for different books.
  • Re-examine zero-value line dropping during merge against the balance re-check — confirm it can never mask a genuine sub-precision drift as currency precision and volume grow.

Was this page helpful?