Multi-Currency Ledger Architecture: FX Conversion, Revaluation, Rounding and Minor Units in Double-Entry Systems (2026)

Multi-Currency Ledger Architecture: FX Conversion, Revaluation, Rounding and Minor Units in Double-Entry Systems (2026)

Multi-Currency Ledger Architecture: FX Conversion, Revaluation, Rounding and Minor Units

A single-currency ledger has one invariant: debits equal credits. The moment you add a second currency, that invariant quietly becomes false. A journal that debits EUR 10,000.00 and credits USD 10,875.00 does not balance in any unit, yet it is exactly what a currency conversion looks like. Teams that bolt currencies onto a working ledger usually discover this through a reconciliation break, a rounding cent that never goes away, or a month-end report that no accountant can tie out. A multi-currency ledger has to make the invariant true again, explicitly and per currency.

This matters now because almost every fintech, marketplace and SaaS product sells cross-border by default, and stablecoin and instant-payment rails have shortened the time money sits in a foreign currency to minutes. Errors that used to be absorbed by a quarterly close now surface in near-real-time balances.

You will leave with a reference design: integer minor units keyed to ISO 4217 exponents, conversion journals routed through FX clearing and position accounts, period-end revaluation aligned with IAS 21 concepts, a rounding policy with an auditable residual account, SQL schema, code and a fully worked numeric example.

What this covers: money representation, the per-currency balancing invariant, conversion journals, rate snapshotting, realized versus unrealized FX gains, revaluation jobs, rounding and allocation, failure modes and a build checklist.

Disclaimer: this is a systems-architecture discussion, not accounting, tax or investment advice. Confirm accounting treatment with a qualified accountant for your jurisdiction and reporting framework.

Context and Background

Double-entry bookkeeping is the backbone of every serious money system, and our deep dive on ledger database architecture for double-entry systems covers the single-currency foundations: immutable journal entries, account types, balance derivation and idempotent posting. Most open-source and commercial ledgers since then have added a currency column. Far fewer have thought through what that column does to the rest of the model.

There are three distinct problems hiding behind the phrase “multi-currency”. The first is representation: how to store an amount so that JPY, USD and KWD are all exact, given that they have different numbers of decimal places. The second is conversion: how to record the act of exchanging one currency for another so that every journal still balances. The third is measurement: how to express foreign balances in a single reporting currency, and how to account for the gains and losses that appear when exchange rates move while you hold those balances.

Each problem has an authority. Representation is governed by ISO 4217, the international standard for currency codes, whose maintenance agency (SIX, on behalf of ISO) publishes the minor-unit exponent for every active code. Conversion is a bookkeeping pattern that predates computers; banks have used “position” or “conversion” accounts for centuries. Measurement is governed by accounting standards, chiefly IAS 21 The Effects of Changes in Foreign Exchange Rates under IFRS, with ASC 830 playing the equivalent role under US GAAP.

Incumbent systems take different stances. ERP general ledgers such as SAP and Oracle typically store every line in the transaction currency plus one or more local and group currencies, and run a scheduled foreign-currency valuation program at period end. Purpose-built ledger databases such as TigerBeetle keep amounts as unsigned integers and leave currency semantics to the application by giving each ledger its own identifier, a design compared in our post on TigerBeetle versus Postgres for financial ledgers. Payment processors expose amounts in minor units on their APIs, as Stripe’s documentation does, but leave accounting to you.

The thesis of this post is that a multi-currency ledger should never balance across currencies. It should balance within each currency, always, and express cross-currency relationships only through designated accounts whose job is to hold the imbalance. Once that rule is enforced in the database, conversion, revaluation and rounding all become ordinary journals rather than special cases.

The Multi-Currency Ledger Reference Architecture

Direct answer: A multi-currency ledger stores every amount as an integer count of the currency’s ISO 4217 minor unit, enforces debits equal credits separately for each currency inside every journal, routes conversions through FX clearing accounts that hold the cross-currency imbalance, snapshots the exact rate used, and revalues monetary foreign balances at period end.

Multi-currency ledger reference architecture with currency registry, rate snapshots, journal store and revaluation job

Figure 1: Reference architecture for a multi-currency ledger. Currency metadata and rate snapshots feed an idempotent posting service; revaluation writes ordinary journals back into the same store.

The diagram shows six components. Product and payment APIs never write balances directly; they submit journal requests to an idempotent posting service. That service consults a currency registry for each currency’s exponent and a rate snapshot service for any rate it must apply. Journals land in an append-only store, from which balances are derived per account and per currency. FX clearing and position accounts are ordinary accounts with a special role, and they feed treasury’s exposure view. A period-end revaluation job reads balances and a frozen closing rate, then posts adjusting journals back through the same store. Finally, a GL export produces functional-currency figures for the accounting system.

Money is an integer of minor units plus a currency code

Represent every amount as a pair: a signed 64-bit or 128-bit integer and a three-letter ISO 4217 code. The integer counts minor units, so USD 10.99 is stored as 1099 and JPY 500 as 500. Never use binary floating point. A float cannot represent 0.10 exactly, and repeated addition drifts. Decimal types are exact but invite a subtler bug: they allow amounts like USD 10.995, which no bank account can hold.

The exponent is not always 2. According to the current ISO 4217 list, currencies such as JPY, KRW, VND, CLP, ISK, UGX and the CFA francs XOF and XAF have exponent 0. Seven currencies have exponent 3: BHD, IQD, JOD, KWD, LYD, OMR and TND. Two funds codes, CLF (Chilean Unidad de Fomento) and UYW (Uruguayan Unidad Previsional), have exponent 4. Precious metals and special codes such as XAU and XDR have no defined minor unit at all.

Exponents also change. ISK and UGX are listed with exponent 0, yet Stripe documents that, for backward compatibility, its API still expects them as two-decimal values whose last two digits are always 00. So the exponent your ledger uses and the exponent a counterparty’s API expects can legitimately differ. Store the ledger exponent in a versioned currency registry, and convert at the integration boundary with an explicit per-provider mapping rather than assuming they match.

Balance within each currency, never across

The core invariant is simple to state: for every journal and every currency appearing in it, the sum of debits equals the sum of credits. A journal touching only EUR must balance in EUR. A conversion journal touching EUR and USD must balance in EUR and, separately, in USD. There is no “balance in functional currency” check at the journal level, because that check would require a rate, and rates are opinions that change by the second.

This is what forces the existence of clearing accounts. If a customer converts EUR 10,000.00 into USD 10,875.00, the EUR leg needs a counter-entry in EUR and the USD leg needs a counter-entry in USD. Both counter-entries go to an FX clearing account, which ends up holding EUR 10,000.00 on one side and USD 10,875.00 on the other. That account is the honest record of what the business now owns: a currency position.

Enforce the invariant in the database, not only in application code. A deferred constraint trigger or a check performed in the same transaction as the insert means that no bug in a service, a migration or a manual fix can write an unbalanced journal. The section below shows a Postgres implementation.

Every rate is a snapshot with an identity

Any posting that uses a rate must record which rate it used: the numeric value, the direction (EUR to USD, not “the EURUSD rate”), the source, the timestamp it was observed and a rate type such as spot, executed, closing or average. Give each snapshot a stable identifier and store it on the journal. Later, an auditor or a reconciliation job can recompute every functional-currency figure from first principles.

Sources matter. The European Central Bank publishes euro reference rates for around 29 currencies at about 16:00 CET on working days, based on a concertation among central banks around 14:10 CET, and states explicitly that they are published for information purposes only. That makes them suitable for reporting conversions and closing rates, not for pricing customer trades. Executed trades should use the rate from the liquidity provider’s fill, which our FX pricing engine architecture post covers from the quoting side.

Store rates as exact decimals with generous precision, for example a numeric with 18 significant digits, and never store only the inverse. EURUSD 1.0875 inverted is 0.919540229885…, and rounding the inverse to six places then re-inverting yields a different rate. Store the direction actually quoted and derive the other at full precision when needed.

Functional, transaction and presentation currencies

Three currency roles appear on every line, and a schema should name them. The transaction currency is the currency of the actual money movement. The functional currency is the currency of the primary economic environment of the legal entity, a concept defined in IAS 21. The presentation currency is whatever the group reports in, which may differ again.

In practice each journal line carries the transaction amount in the transaction currency, and, optionally, a functional-currency equivalent computed at the snapshot rate. The transaction amount is the source of truth for balances. The functional equivalent is a derived accounting value that revaluation adjusts later. Keeping these two concepts in separate columns avoids the most common modelling bug: overwriting the real amount with a converted one.

Walk-Through: Schema, Conversion Journals and Revaluation

This section builds the multi-currency ledger end to end: a Postgres schema that enforces per-currency balance, a conversion journal through FX clearing, a worked example from invoice to settlement to conversion, and the revaluation job. The numbers in the example are illustrative rates chosen for easy arithmetic, not market data.

A schema that makes the invariant unbreakable

The schema below is deliberately small. Currencies carry their exponent. Rates are immutable snapshots. Journals are headers, and lines carry a signed amount where positive means debit and negative means credit, which turns “debits equal credits” into “the sum is zero”.

CREATE TABLE currency (
  code        char(3) PRIMARY KEY,          -- ISO 4217 alpha code
  exponent    smallint NOT NULL CHECK (exponent BETWEEN 0 AND 4),
  valid_from  date NOT NULL,
  valid_to    date
);

CREATE TABLE fx_rate_snapshot (
  rate_id       bigserial PRIMARY KEY,
  base_ccy      char(3) NOT NULL REFERENCES currency(code),
  quote_ccy     char(3) NOT NULL REFERENCES currency(code),
  rate          numeric(28,12) NOT NULL CHECK (rate > 0),  -- quote per 1 base
  rate_type     text NOT NULL CHECK (rate_type IN ('spot','executed','closing','average')),
  source        text NOT NULL,                -- e.g. 'lp:bank-a', 'ecb-ref'
  observed_at   timestamptz NOT NULL,
  UNIQUE (base_ccy, quote_ccy, rate_type, source, observed_at)
);

CREATE TABLE account (
  account_id    bigserial PRIMARY KEY,
  entity_id     int NOT NULL,
  code          text NOT NULL,                -- e.g. 'cash.eur', 'fx.clearing'
  role          text NOT NULL CHECK (role IN
                ('asset','liability','equity','income','expense',
                 'fx_clearing','fx_gain_unrealized','fx_gain_realized','rounding')),
  monetary      boolean NOT NULL DEFAULT true -- drives revaluation
);

CREATE TABLE journal (
  journal_id      bigserial PRIMARY KEY,
  idempotency_key text UNIQUE NOT NULL,
  entity_id       int NOT NULL,
  kind            text NOT NULL,              -- 'payment','conversion','revaluation',...
  effective_at    timestamptz NOT NULL,
  posted_at       timestamptz NOT NULL DEFAULT now(),
  reverses        bigint REFERENCES journal(journal_id)
);

CREATE TABLE journal_line (
  journal_id      bigint NOT NULL REFERENCES journal(journal_id),
  line_no         int NOT NULL,
  account_id      bigint NOT NULL REFERENCES account(account_id),
  currency        char(3) NOT NULL REFERENCES currency(code),
  amount_minor    bigint NOT NULL CHECK (amount_minor <> 0), -- +debit / -credit
  functional_minor bigint,                    -- derived, in entity functional ccy
  rate_id         bigint REFERENCES fx_rate_snapshot(rate_id),
  PRIMARY KEY (journal_id, line_no)
);

-- Per-currency balance check, enforced at commit time.
CREATE FUNCTION assert_journal_balanced() RETURNS trigger AS $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM journal_line
    WHERE journal_id = NEW.journal_id
    GROUP BY currency
    HAVING sum(amount_minor) <> 0
  ) THEN
    RAISE EXCEPTION 'journal % unbalanced in at least one currency', NEW.journal_id;
  END IF;
  RETURN NULL;
END $$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER journal_balanced
AFTER INSERT ON journal_line
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_journal_balanced();

Three design decisions in this multi-currency ledger schema deserve a justification. First, amount_minor is a bigint with no decimal scale, so it is physically impossible to store a fraction of a cent. A signed 64-bit integer tops out near 9.22 quintillion minor units, which is ample for fiat at exponent 2 or 3 but can be tight for crypto assets with 18-decimal base units; such assets need numeric(39,0) or a 128-bit type. Second, the trigger is deferred to commit, so a multi-line insert can be briefly unbalanced inside the transaction but can never commit unbalanced. Third, functional_minor is nullable and derived, which signals to every reader that it is an accounting view, not the balance of record.

Two refinements are worth adding in production. Validate on insert that amount_minor respects any per-currency granularity rule your integrations impose, for example a payout provider that only accepts multiples of 100 for a given code. And forbid updates and deletes on journal_line entirely, so corrections happen only through reversing journals, which is the same discipline described in our post on event sourcing and bitemporal audit trails for financial ledgers.

The conversion journal through FX clearing

Sequence diagram of a currency conversion ledger flow from quote to fill to EUR and USD journal legs

Figure 2: A customer conversion in a currency conversion ledger. The rate identifier travels from quote to fill to posting, and both legs settle against FX clearing.

The sequence in Figure 2 has one property that matters more than any other in a multi-currency ledger: the ledger posts using the rate of the confirmed fill, identified by the same rate ID that was quoted, and only after the fill is confirmed. Before the fill, the source funds are reserved with a hold, not debited. If the trade fails, the hold is released and no FX journal ever exists. If it succeeds, a single journal with four lines posts atomically.

For a customer converting EUR 10,000.00 into USD at an executed rate of 1.0875 USD per EUR, the journal looks like this:

Line Account Currency Amount (minor) Meaning
1 Customer EUR wallet (liability) EUR +1,000,000 Debit: we owe the customer less EUR
2 FX clearing EUR -1,000,000 Credit: clearing receives the EUR
3 FX clearing USD +1,087,500 Debit: clearing pays out USD
4 Customer USD wallet (liability) USD -1,087,500 Credit: we owe the customer more USD

EUR sums to zero and USD sums to zero. FX clearing now holds a long EUR position and a short USD position. Those positions are real exposures, and they persist until treasury executes an offsetting trade with a liquidity provider, which posts its own journal against the same clearing account and bank accounts in each currency. If the customer’s rate included a markup, the difference between the customer rate and the treasury execution rate shows up as a residual in FX clearing, and a sweep journal moves it to a revenue account. That makes FX spread revenue measurable to the minor unit rather than inferred from a spreadsheet.

Some teams split the clearing role into two accounts: a per-currency FX position account that treasury manages, and a transient FX clearing account that should net to zero after each matched trade. The split is worth it when customer conversions and treasury hedges happen at different times, because a non-zero transient clearing balance then becomes a clean alert signal for an unmatched trade.

Worked example: invoice, revaluation, settlement and conversion

Consider a company whose functional currency is USD. It invoices a European customer EUR 10,000.00. All rates below are illustrative and expressed as USD per 1 EUR.

Day 1, invoice at spot 1.0850. The ledger debits accounts receivable EUR 1,000,000 minor units and credits revenue EUR 1,000,000. The functional equivalent is 10,000.00 x 1.0850 = USD 10,850.00, stored as functional_minor = 1,085,000 on both lines with the spot rate ID. Revenue is a non-monetary item once recognised and is never revalued.

Month end, closing rate 1.0920. The receivable is a monetary item, because it will be settled in a fixed number of currency units. IAS 21 paragraph 23 requires foreign-currency monetary items to be translated at the closing rate at the end of each reporting period. The new functional value is 10,000.00 x 1.0920 = USD 10,920.00, an increase of USD 70.00. Under paragraph 28, such exchange differences are, as a general rule, recognised in profit or loss. The revaluation journal therefore posts a functional-only adjustment: debit receivable USD-equivalent 7,000 minor units, credit unrealized FX gain 7,000.

Day 40, settlement at spot 1.0880. The customer pays EUR 10,000.00 into the company’s EUR bank account. In EUR, the journal is trivial: debit EUR cash 1,000,000, credit receivable 1,000,000. In functional terms, the cash arrives at 10,000.00 x 1.0880 = USD 10,880.00, while the receivable was carried at USD 10,920.00. The USD 40.00 shortfall is a realized FX loss for the period.

Day 41, conversion at executed rate 1.0875. Treasury sells EUR 10,000.00 for USD 10,875.00. The EUR cash was carried at USD 10,880.00, so the conversion crystallises a further realized loss of USD 5.00, which appears as the functional-currency residual in FX clearing and is swept to realized FX gain or loss.

Now check the economics. Over the life of the invoice, the company recognised revenue of USD 10,850.00 and ended up with USD 10,875.00 in cash. The net FX effect is +USD 25.00. The ledger shows +70.00 unrealized in month one, then -40.00 and -5.00 realized in month two, which sums to +25.00. The numbers tie, and every step is reproducible from the rate snapshots.

Many ledgers reverse the revaluation on day one of the next period instead of carrying it. Under that approach the receivable returns to USD 10,850.00, settlement realizes a gain of USD 30.00, and conversion realizes a loss of USD 5.00. The total is still +25.00. The two approaches differ only in how gains are labelled across periods, so pick one and apply it consistently; mixing them double-counts.

The revaluation job

FX revaluation accounting flow at period close from frozen closing rates to balanced revaluation journals

Figure 3: The period-end FX revaluation accounting job. Closing rates are frozen first; only monetary foreign balances are revalued; output is an ordinary journal batch.

Figure 3 walks the job. The close is triggered for an entity and period. The job freezes a closing-rate snapshot, which becomes the single source of every rate used in that run. It selects balances on accounts flagged as monetary, in currencies other than the entity’s functional currency. For each account and currency pair, it computes the functional value at the closing rate, compares it with the carrying functional value, and posts the difference to unrealized FX gain or loss. Optionally it schedules a reversal for the first day of the next period. A trial-balance check confirms that every journal in the batch balances per currency.

The monetary flag is the subtle part. IAS 21 defines monetary items as units of currency held and assets and liabilities to be received or paid in a fixed or determinable number of units of currency. Cash, receivables, payables, customer wallet balances and loans are monetary. Prepaid expenses, inventory, fixed assets and recognised revenue are not, and non-monetary items carried at historical cost keep the transaction-date rate. A multi-currency ledger that revalues everything in a foreign currency will produce gains and losses that the accounting framework does not recognise.

Here is a compact implementation of the core computation. It uses Python’s decimal module at high precision and rounds exactly once, at the functional currency’s exponent.

from decimal import Decimal, ROUND_HALF_EVEN, getcontext
getcontext().prec = 34  # IEEE 754 decimal128-equivalent precision

EXPONENT = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3, "CLF": 4}

def to_major(minor: int, ccy: str) -> Decimal:
    return Decimal(minor).scaleb(-EXPONENT[ccy])

def to_minor(major: Decimal, ccy: str) -> int:
    q = Decimal(1).scaleb(-EXPONENT[ccy])
    return int(major.quantize(q, rounding=ROUND_HALF_EVEN).scaleb(EXPONENT[ccy]))

def revalue(balances, closing, functional="USD"):
    """balances: list of (account_id, ccy, amount_minor, carrying_functional_minor)
       closing:  dict ccy -> Decimal rate (functional per 1 unit of ccy)"""
    lines = []
    for acct, ccy, amt, carrying in balances:
        if ccy == functional or amt == 0:
            continue
        new_val = to_minor(to_major(amt, ccy) * closing[ccy], functional)
        delta = new_val - carrying
        if delta == 0:
            continue
        # functional-only adjustment: account vs unrealized gain/loss
        lines.append((acct, functional, delta))
        lines.append(("fx_gain_unrealized", functional, -delta))
    assert sum(l[2] for l in lines) == 0
    return lines

print(revalue([(101, "EUR", 1_000_000, 1_085_000)], {"EUR": Decimal("1.0920")}))
# [(101, 'USD', 7000), ('fx_gain_unrealized', 'USD', -7000)]

The function returns functional-currency lines, so the revaluation journal balances in USD on its own. In the schema above, the adjustment against account 101 is recorded as a functional-only line, with amount_minor in the transaction currency left untouched. One clean way to model it is a separate functional_adjustment table keyed by account and period, so balances of record never change when rates move.

Translation into a presentation currency

Revaluation inside an entity is different from translation for consolidation. When a group presents in a currency other than an entity’s functional currency, IAS 21 paragraph 39 has assets and liabilities translated at the closing rate and income and expenses at transaction-date rates, with an average rate permitted as an approximation when rates do not fluctuate significantly. The resulting exchange differences go to other comprehensive income, not profit or loss.

For ledger design, this means translation belongs in a reporting layer that reads the entity ledgers, not in the entity ledgers themselves. Keep each entity’s multi-currency ledger in its functional currency with transaction-currency detail, and generate presentation-currency figures from frozen rate snapshots during consolidation. That separation stops consolidation adjustments from contaminating operational balances that customers and regulators see.

Rounding, Allocation and Residual Accounts

Rounding is where a multi-currency ledger most often leaks money. Every conversion multiplies an exact integer by a rate with many decimal places and must produce another exact integer. That step discards information, and the discarded fractions have to go somewhere auditable.

Rounding pipeline for ISO 4217 minor units with largest remainder allocation and a residual account

Figure 4: A rounding pipeline for ISO 4217 minor units. Compute at full precision, round once, allocate splits with largest remainder, and post any residual to a dedicated account.

Round once, at the boundary, to the target exponent

The rule in Figure 4 is to carry full precision through the whole calculation and round exactly once, when producing a posted amount in the target currency’s minor unit. Chained rounding compounds error. Converting EUR to USD with rounding, then USD to JPY with rounding, gives a different yen amount from converting EUR to JPY directly at the cross rate. Neither is wrong in isolation, but a multi-currency ledger that sometimes does one and sometimes the other will fail reconciliation.

There is a historical precedent for fixing this in regulation. When the euro replaced its legacy national currencies, Council Regulation (EC) No 1103/97 fixed conversion rates to six significant figures, prohibited rounding or truncating those rates and prohibited using inverse rates, and required conversions between two legacy currencies to triangulate through the euro. The lesson generalises: write down the path every conversion takes and the precision at each step, then enforce it in one shared library.

Choose the rounding mode deliberately

The common modes are round half up (ties away from zero), round half even (ties to the nearest even digit, often called banker’s rounding), and truncation. They behave very differently at scale. Python’s decimal module defaults to half even, while PostgreSQL’s round() on the numeric type rounds ties away from zero, so the same calculation in application code and in SQL can disagree on exact ties.

A worked, illustrative number shows why this matters. Suppose a platform converts one million small amounts per day and the discarded fraction per conversion is uniformly distributed. With truncation, every conversion loses on average half a minor unit, so the platform systematically under-credits by about 500,000 cents, roughly USD 5,000, per day. With half even, the expected bias is zero and the random error of the daily sum has a standard deviation of about the square root of n over 12 minor units, around 289 cents or USD 2.89. Same volume, same rates: one choice is a slow leak and the other is noise.

Some products have contractual rounding rules that override your preference. Card scheme settlement, tax regimes and regulated price displays can each specify a mode. Model rounding mode as a property of the conversion context, such as customer conversion, tax computation or scheme settlement, not as a global constant.

Allocate splits with largest remainder

Splitting is a second rounding problem. Dividing USD 100.00 three ways yields 33.333… each, and rounding each share gives 33.33 x 3 = 99.99. The missing cent must be assigned. The largest-remainder method floors every share, then hands the leftover minor units one at a time to the shares with the largest fractional remainders, breaking ties deterministically, for example by line number. The output for this case is 33.34, 33.33 and 33.33, which sums exactly to 100.00.

def allocate(total_minor: int, weights: list[int]) -> list[int]:
    """Split total_minor by integer weights; result sums exactly to total_minor."""
    s = sum(weights)
    raw = [total_minor * w for w in weights]
    shares = [r // s for r in raw]
    rema = [(r % s, -i) for i, r in enumerate(raw)]   # deterministic tie-break
    for _, neg_i in sorted(rema, reverse=True)[: total_minor - sum(shares)]:
        shares[-neg_i] += 1
    return shares

assert allocate(10_000, [1, 1, 1]) == [3334, 3333, 3333]

The function is exact for positive totals and integer weights, and it never touches floating point. Use the same function for marketplace splits, fee allocation, tax apportionment and multi-leg payouts, so every part of the system splits money the same way.

Post residuals to a rounding account

Sometimes a residual is unavoidable. A cross-currency fee computed in the customer’s currency and booked in the merchant’s currency, or a provider that settles a batch total that differs from the sum of individually rounded items, leaves a few minor units that belong to no business line. Post those to a dedicated rounding residual account in the relevant currency, with a reason code and a link to the source journal.

That account should behave like a smoke detector. Its balance per currency should stay small and random-walk around zero under half even rounding. Alert when the absolute balance exceeds a threshold, for instance a few hundred minor units per day per currency, or when it trends in one direction, which usually means a truncation bug or a provider that rounds differently from you. Our reconciliation engine architecture post covers how to match provider totals against itemised ledger lines and classify these breaks automatically.

Minor units are not always the smallest real unit

Minor-unit exponents describe the accounting precision, not the physical coins in circulation or the precision of every downstream system. Some currencies have exponents larger than anything practically used; the ISO list itself notes that a minor unit may exist only in exchange rates. Some providers enforce coarser granularity than ISO; Stripe documents that HUF and TWD manual payouts must be whole units, submitted as multiples of 100 in its API. Cash-rounding rules in several countries round retail cash totals to the nearest five or ten minor units.

Design the multi-currency ledger for this by separating three precisions per currency: the ledger exponent from ISO 4217, the integration granularity per provider, and any display or cash rounding increment. Only the ledger exponent determines what can be stored. The other two are constraints validated at the edges, with residuals booked explicitly.

Trade-offs, Gotchas, and What Goes Wrong

Storing only the converted amount. The most damaging anti-pattern is converting at ingestion and storing only the functional-currency amount. It looks simpler until the first customer asks for their EUR balance, and you discover the ledger can only answer in USD at yesterday’s rate. Store the transaction amount as the balance of record and treat every functional figure as derived.

Silent rate fallbacks. When a rate is missing, code often falls back to the last known rate, an inverted rate or a hard-coded default. Each fallback produces a plausible number and an unexplainable variance later. Fail the posting instead, or post with an explicit rate type such as fallback that a reconciliation job must clear before close.

Revaluing the wrong things. Revaluing non-monetary items, or revaluing customer liabilities in a currency that is actually the entity’s functional currency, creates phantom gains. Drive revaluation from an explicit monetary flag and the entity’s functional currency, both versioned, rather than from a list of account names in a cron job.

Double counting across reversal styles. If one team’s job reverses revaluations on day one and another team’s settlement logic assumes carried revaluations, realized gains get counted twice. Choose one convention and encode it in the posting service, not in report SQL.

Clearing accounts that never clear. An FX clearing account that drifts is a symptom. Persistent non-zero balances in a transient clearing account usually mean an unmatched trade, a fill posted with a different rate ID from the quote, or a sweep job that failed. Monitor balance age, not only balance size.

Exponent changes. ISO 4217 is amended regularly; in recent years ZWG replaced ZWL, XCG replaced ANG and Bulgaria’s lev was replaced by the euro. A redenomination or exponent change is a migration of balances, not a metadata edit. Version the currency registry with validity dates and run the migration as journals so history stays reproducible.

Integer overflow and scale. A bigint is safe for fiat but not for every asset. High-precision tokens and some exotic funds codes need 128-bit or arbitrary-precision integers. Choose the integer width per asset class at design time, because widening a column in a hot ledger table is expensive.

Rates as floats in transit. JSON numbers are often parsed into binary floats by default. Transmit rates and amounts as strings or integers, and parse rates directly into a decimal type at the boundary.

Latency between quote and post. If the ledger posts customer conversions at the quoted rate but treasury hedges later at a different rate, the gap is market risk. That is a business decision, but the ledger must make it visible by keeping the quote rate, the execution rate and the hedge rate as separate snapshots.

Practical Recommendations

Start by making the multi-currency ledger invariant physical. Add per-currency balance enforcement to the database before adding a second currency to production, because retrofitting it onto existing unbalanced data is a forensic exercise. Then introduce the currency registry and rate snapshot store, and make the posting service refuse any line in an unknown currency or without a rate ID where one is needed.

Next, decide accounting conventions with your finance team and write them down as code: functional currency per entity, which accounts are monetary, closing-rate source and cutoff time, whether revaluations reverse, rounding mode per context and the residual-alert thresholds. Treat each as versioned configuration with an effective date, so a change in policy never rewrites history.

Finally, build reconciliation around the multi-currency ledger from day one. Every FX clearing account, rounding account and provider settlement should have an automated tie-out that runs daily, with breaks routed to a queue that humans actually clear. Most multi-currency incidents are not exotic; they are small, unowned residuals that compound.

A short multi-currency ledger build checklist:

  • Store amounts as integers in ISO 4217 minor units with a versioned exponent registry.
  • Enforce debits equal credits per currency in a deferred database constraint.
  • Route every conversion through FX clearing or position accounts in each currency.
  • Snapshot every rate with direction, type, source and timestamp, and store the rate ID on lines.
  • Revalue only monetary items at a frozen closing rate; pick one reversal convention.
  • Round once, with an explicit mode per context; allocate splits with largest remainder.
  • Post residuals to a rounding account and alert on size and drift.
  • Keep presentation-currency translation in the reporting layer, not the entity ledger.

Frequently Asked Questions

How do you handle multiple currencies in double-entry accounting?

Treat each currency as its own balancing universe. Every journal must have debits equal credits separately for each currency it touches. When money moves between currencies, post each leg against an FX clearing or position account in that currency, so the clearing account carries the cross-currency position. Store a functional-currency equivalent on each line for reporting, computed at a recorded rate snapshot. This keeps balances exact in the transaction currency while still producing a single-currency trial balance for accounting and regulators.

What are ISO 4217 minor units?

ISO 4217 assigns each currency a three-letter code, a numeric code and a minor-unit exponent: the number of decimal places used for accounting. Most currencies use 2, so 1 USD equals 100 minor units. JPY, KRW, VND, CLP and the CFA francs use 0, while BHD, IQD, JOD, KWD, LYD, OMR and TND use 3. Funds codes CLF and UYW use 4. Storing amounts as integers of minor units avoids floating-point errors and prevents fractions that no account could hold.

What is the difference between realized and unrealized FX gains?

An unrealized gain or loss arises when a foreign-currency monetary balance, such as a receivable, is remeasured at a new closing rate while still outstanding. A realized gain or loss arises when the item is settled or converted at a rate that differs from its carrying value. Under IAS 21 both are generally recognised in profit or loss for monetary items. A ledger records unrealized amounts through period-end revaluation journals and realized amounts at settlement or conversion.

How often should a multi-currency ledger run FX revaluation?

At minimum, at every reporting period end, because IAS 21 requires monetary items to be translated at the closing rate on each reporting date. Many fintechs also revalue daily for internal management reporting and treasury exposure monitoring, while posting only the period-end run to the statutory general ledger. Daily runs are cheap when revaluation is just a batch of ordinary journals. The constraints are a frozen rate snapshot per run and a clear convention for reversals.

Should you store money as decimal or integer?

Integer minor units are the safest default for balances of record, because they make fractional cents unrepresentable and arithmetic exact. Decimal types are appropriate for rates and for intermediate calculations, where you need many decimal places before a single rounding step. Avoid binary floating point for both. If you support assets with very fine units, such as some crypto tokens, use 128-bit or arbitrary-precision integers rather than switching to decimals for balances.

Which exchange rate should a ledger use for conversion?

Use the rate that actually applied. For customer conversions and treasury trades, that is the executed rate from the fill. For revaluation and reporting, use a documented closing rate from a defined source and cutoff time, for example a central bank reference rate. For translating income and expenses into a presentation currency, an average rate can approximate transaction-date rates when rates are stable. Whatever the choice, record the rate snapshot on every line.

Further Reading

References

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *