Multi-Currency Accounting in ERP: Rates, FX Gains, IDR Rounding

Photo by Polina Tankilevitch

Photo by Polina Tankilevitch
The first time a client asked me to add USD invoices to an IDR-based ERP, I estimated two weeks. The schema change took two days. The other three months of calendar time went into everything around it: which exchange rate applies on which date, what happens when a USD invoice is paid at a different rate, how month-end revaluation works, and why the AR aging report suddenly showed a customer owing minus 37 rupiah. Multi-currency is not a field on a form — it is an accounting subsystem.
This post is the design I wish I had on day one, shaped by building AP/AR for ANCoraPRO on NestJS and PostgreSQL: the dual-currency data model, the exchange rate pipeline, realized versus unrealized gains, and the rounding realities of a functional currency that has no decimal places in practice.
The non-negotiable rule from IAS 21, the accounting standard governing foreign exchange effects: an entity keeps its books in its functional currency — for an Indonesian company, almost always IDR — and foreign currency transactions are translated into it at the rate on the transaction date. Your schema must reflect that duality on every monetary line: the document amount in transaction currency, and the booked amount in functional currency, converted once at posting time and then frozen.
Frozen is the key word. The biggest multi-currency bug class I have seen in custom ERPs is storing only the foreign amount and converting on read, using whatever rate is current. Reports then change retroactively every time the rate table updates, and two prints of the same ledger disagree. Store the rate you used, store the converted amount, and never recompute either:
-- Every monetary line stores BOTH currencies, always
CREATE TABLE journal_line (
id BIGSERIAL PRIMARY KEY,
journal_entry_id BIGINT NOT NULL REFERENCES journal_entry(id),
account_id BIGINT NOT NULL REFERENCES account(id),
-- transaction (document) currency
currency_code CHAR(3) NOT NULL, -- 'USD', 'SGD', ...
amount_txn NUMERIC(18,2) NOT NULL, -- signed
-- functional currency (IDR), converted at posting time
exchange_rate NUMERIC(18,6) NOT NULL, -- rate USED, frozen
amount_functional NUMERIC(18,0) NOT NULL -- IDR has no cents
);
-- The rate table keeps history; never overwrite a rate
CREATE TABLE exchange_rate (
currency_code CHAR(3) NOT NULL,
rate_date DATE NOT NULL,
rate NUMERIC(18,6) NOT NULL, -- 1 unit -> IDR
source TEXT NOT NULL, -- 'BI_JISDOR' | 'MANUAL'
PRIMARY KEY (currency_code, rate_date)
);Notice amount_functional is NUMERIC with zero decimal places. Modern IDR has no usable subunit — sen exist in law but not in payments — so booking functional amounts in whole rupiah from the start saves you from phantom fractional balances later. The transaction currency side keeps two decimals because USD, SGD, and EUR genuinely use them.
The second design decision is which rate applies when. In Indonesian practice you will deal with up to three rate families, and your rate table needs to know which is which:
Operationally I treat rates as append-only reference data with a source column: an automated daily fetch writes BI_JISDOR rows, finance can insert MANUAL overrides for currencies the feed misses, and nothing ever updates an existing row. If a rate was wrong, you insert a corrected rate and re-post affected documents deliberately — silent retroactive fixes are how books stop reconciling.
Pro tip: make the posting service fail loudly when no rate exists for the document date, instead of falling back to the latest available rate. The fallback feels helpful in development and then quietly books a Friday invoice at Wednesday's rate in production. Finance teams forgive a blocked posting; they do not forgive wrong numbers found three months later.
Exchange differences come in exactly two flavors, and conflating them is the most common conceptual bug in homegrown multi-currency modules:
Realized gain or loss
Happens at settlement. The invoice was booked at one rate, the payment converts at another, and the difference is real money that hits the profit and loss statement the moment payment clears.
Unrealized gain or loss
Happens at period close. Open foreign-currency balances are revalued at the closing rate. No cash moved — it is a paper adjustment, and it reverses or adjusts again next period.
The settlement logic deserves a worked example, because the subledger invariant it protects is what keeps AR and AP honest:
Worked example — realized FX gain on a USD invoice
Day 1 Invoice issued USD 10,000 @ 16,200 = IDR 162,000,000
DR Accounts Receivable 162,000,000
CR Revenue 162,000,000
Day 35 Payment received USD 10,000 @ 16,450 = IDR 164,500,000
DR Bank 164,500,000
CR Accounts Receivable 162,000,000
CR FX Gain (realized) 2,500,000
The AR subledger must close to EXACTLY zero in BOTH currencies:
USD 10,000 - 10,000 = 0 and IDR 162,000,000 - 162,000,000 = 0
The 2,500,000 difference NEVER touches AR — it goes to FX gain.That invariant — open items must close to zero in both the transaction currency and the functional currency — is the thing to test relentlessly. Every partial payment, every credit note, every write-off must preserve it. Odoo's accounting engine does the same thing with its automatic exchange difference journal: the difference posts to a dedicated FX account, never to the receivable itself.
Unrealized FX is a batch job, and the cleanest way I have found to run it in a custom ERP is as a four-step, fully reversible procedure:
Use the reversing-entry pattern even if your accountant says the ERP they used before adjusted balances in place. Reversal keeps the revaluation strictly separate from transactional history, which means you can re-run a botched close by reversing one journal instead of untangling adjusted open items one by one.
Rounding sounds like a footnote until your trial balance is off by 1 rupiah and an auditor wants to know why. The rules I now build in from the start:
None of this is sophisticated computer science. It is just deciding the policy explicitly, encoding it in one place — the posting service — and refusing to let individual screens do their own arithmetic.
Multi-currency accounting in an ERP stands on four legs: dual-currency storage with frozen rates, a disciplined append-only rate pipeline, strict separation of realized and unrealized differences, and explicit rounding policy enforced at the posting layer. Get those right and the rest — reports, dashboards, aging — falls out naturally, because the underlying numbers can be trusted.
And before writing any code, sit with the client's accountant for one hour and agree which rates apply to which documents. That conversation is cheaper than any refactor you will ever do.