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

> Designing multi-currency accounting in a custom ERP: dual-currency journal lines, realized vs unrealized FX gains, and the IDR rounding rules nobody warns you about.

URL: https://www.matthewswong.com/en/blog/erp-multi-currency-accounting/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-04-09
- Updated: 2026-04-09
- Category: ERP

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 Data Model: Every Line Carries Two Currencies

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:

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.

## Exchange Rates: One Pipeline, Three Rate Types

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:

Transaction rate — the rate on the document date, used to book invoices and bills. Most clients use Bank Indonesia's JISDOR reference rate for USD; it is published per business day and is the least arguable choice in an audit.

Tax rate (kurs pajak) — the Ministry of Finance publishes a weekly rate that must be used for tax documents. Your invoice can legitimately carry one rate for accounting and another for the VAT calculation; design for both fields, do not assume they are equal.

Closing rate — the rate on the balance sheet date, used to revalue open monetary balances at month-end per IAS 21. This one drives unrealized gains and losses.

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.

## Realized vs Unrealized FX Gains: Two Different Events

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:

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.

## Month-End Revaluation Without Tears

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:

1. Select all open monetary items in foreign currency: unpaid AR and AP lines, foreign-currency bank accounts, advances and deposits.
2. Revalue each at the closing rate for the period end date and compute the delta against its current functional-currency book value.
3. Post one revaluation journal entry per currency, dated the last day of the period, against the unrealized FX gain and loss accounts.
4. Post the automatic reversal dated the first day of the next period. Settlements then compute realized differences against original booking rates, untouched by revaluation noise.

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.

## IDR Rounding Realities

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:

Round at the line level, once, at posting time — half-up to whole rupiah for functional amounts. Summing rounded lines is stable; rounding sums is not reproducible from the parts.

Force journal balance after rounding: if debits and credits differ by a few rupiah from per-line rounding, post the difference to a dedicated rounding account automatically. Never let the posting fail or, worse, fudge a real line.

Keep rates at six decimals. JISDOR-style quotes carry more precision than two decimals, and truncating rates is a silent source of cumulative drift on high-volume accounts.

Settle tiny residuals automatically: a payment that leaves an open balance under a configurable threshold, say 100 rupiah, should auto-write-off to the rounding account instead of haunting AR aging forever.

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.

## The Takeaway

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.

## Sources and further reading

- IFRS Foundation — IAS 21: The Effects of Changes in Foreign Exchange Rates
- Odoo 18 Documentation — Multi-Currency System in Accounting
- Bank Indonesia — JISDOR Reference Rate Data

> **Key takeaway:** Adding USD invoices to an IDR-based ERP took two days of schema change and three months of everything around it: which exchange rate applies on which date, how an invoice paid at a different rate is reconciled, and how Rupiah rounding is handled so the ledger still balances.

## Source links

- https://www.ifrs.org/issued-standards/list-of-standards/ias-21-the-effects-of-changes-in-foreign-exchange-rates/
- https://www.odoo.com/documentation/18.0/applications/finance/accounting/get_started/multi_currency.html
- https://www.bi.go.id/en/statistik/informasi-kurs/jisdor/Default.aspx

## FAQ

### Why must the converted functional-currency amount be frozen at posting time rather than recalculated on read?

Storing only the foreign amount and converting on read means reports change retroactively every time the rate table is updated, so two prints of the same ledger can disagree. The post's rule is to store the rate used and the converted amount together, then never recompute either. This keeps the ledger stable and auditable.

### What are the three rate types used in Indonesian ERP practice, and why does each matter?

The transaction rate (JISDOR, used on the document date) books invoices and bills; the Ministry of Finance's weekly tax rate (kurs pajak) must be used for VAT calculations and can legally differ from the accounting rate; and the closing rate revalues open foreign-currency balances at month-end to generate unrealized FX differences. Designing for all three fields from the start avoids the common mistake of assuming every document uses the same rate.

### What is the difference between a realized and an unrealized FX gain or loss?

A realized gain or loss occurs at settlement: an invoice was booked at one rate and the payment converts at another, so the difference is real cash that hits the profit and loss statement immediately. An unrealized gain or loss occurs at period close: open foreign-currency balances are revalued at the closing rate as a paper adjustment, then reversed or adjusted again the following period with no cash changing hands.

### Why does the post recommend the reversing-entry pattern for month-end revaluation instead of adjusting balances in place?

Posting the revaluation journal on the last day of the period and its automatic reversal on the first day of the next period keeps revaluation strictly separate from transactional history. This means a botched close can be corrected by reversing one journal entry rather than untangling individually adjusted open items, making the process fully repeatable and auditable.

### What IDR rounding rules does the post enforce at the posting layer?

Functional amounts are rounded half-up to whole rupiah at the line level at posting time — not after summing — because rounding sums is not reproducible from the parts. Any residual debit-credit imbalance from per-line rounding posts automatically to a dedicated rounding account, exchange rates are kept at six decimal places to avoid cumulative drift, and open balances below a configurable threshold (the post cites 100 rupiah) are auto-written off to the same rounding account.

---

- HTML version: https://www.matthewswong.com/en/blog/erp-multi-currency-accounting/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/erp-multi-currency-accounting/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
