Handling Multi-Currency FX Gain and Loss in the Ledger
When you bill a customer in euros but settle in dollars, two exchange rates enter the picture — the rate implied when you captured the charge and the rate the processor actually applied when it paid you out — and the gap between them is a real gain or loss that must land somewhere in your books. Left unmodeled, it shows up as an unexplained few cents of drift on every cross-currency payout that reconciliation flags forever. This page extends Reconciliation & Double-Entry Ledger and the presentment-versus-settlement model from Multi-Currency Checkout & Localization: where FX gain and loss comes from, and the ledger line that absorbs it so the books still balance to zero.
You hit this the first time a EUR-settled-to-USD payout reconciles to within a few cents but not exactly — that residual is not a bug, it is FX movement, and it needs a home account.
Trade-offs
The choice is which exchange rate you book revenue at, and when you recognize the FX difference. Booking at settlement rate is simplest but delays revenue; booking at capture rate recognizes revenue immediately and posts the FX difference as a separate realized line. The map contrasts them.
For accurate revenue recognition, book at the capture rate and post the settlement-versus-capture difference to a realized FX account. This keeps revenue on the right date and quarantines currency movement in one auditable place.
Which rate the capture leg should carry
There is a second, quieter decision hiding inside “book at the capture rate”: which capture rate. A processor charging a card in EUR and settling to your USD account exposes at least three candidate numbers — the mid-market rate at the instant of authorization, the processor’s own quoted rate (mid-market plus a spread, often 1 to 2 percent), and the rate that eventually appears on the settlement report. Book the receivable at the processor’s quoted rate, not the mid-market rate, because that is the number the customer’s charge_id actually resolves to; if you book at mid-market, the processor’s spread leaks into your fx_loss account and contaminates a line that is supposed to measure only timing movement between capture and payout. The spread is a cost of acceptance, closer in nature to processing fees than to currency risk, and it belongs alongside the gateway fee rather than in the realized FX total that your treasury team reads as an exposure signal.
The capture rate also has to be pinned to a timestamp you can defend. Rates move intraday, so “the EUR/USD rate on 2026-07-22” is not specific enough to reproduce a ledger_entries row two quarters later during an audit. Store the rate, its source (an identifier like ecb_reference or stripe_balance_transaction), and the exact rate_observed_at instant on the entry. When a dispute arrives asking why invoice_id 88213 recognized revenue of 11000 minor units rather than 10940, the answer is a single row that names the rate, where it came from, and when it was read — not a re-derivation from a rate table that has since been overwritten.
Why booking at settlement rate is not merely “delayed”
The settlement-rate approach is often described as simpler, but its real cost is subtler than a late recognition date. If you wait for the payout to learn the rate, then a charge captured on the last day of a quarter and settled three business days into the next quarter has no defensible revenue figure at close — you would either accrue at an estimated rate (reintroducing exactly the FX line you were trying to avoid) or leave the revenue unrecognized and understate the period. Booking at capture rate sidesteps the period-boundary problem entirely: revenue is fixed on the capture date from a rate you already hold, and the later settlement only ever adjusts the FX account, never the revenue account. That separation is what makes the capture-rate model auditable across period boundaries rather than merely earlier.
Step-by-Step Implementation
The five steps record the presentment amount at capture, capture the settlement rate at payout, compute the difference, post it to an FX account, and reconcile per currency. The balancing diagram shows how the realized FX line makes an otherwise-unbalanced cross-currency transaction sum to zero.
1. Record presentment amount and currency at capture
At capture, book the receivable in the presentment currency and its home-currency value at the capture rate. Store the rate itself; it is part of the financial record.
INSERT INTO ledger_entries (invoice_id, account, direction, amount, currency, fx_rate, external_ref)
VALUES
(:invoice_id, 'accounts_receivable', 'debit', 11000, 'USD', 1.10, :charge_id), -- €100 @ 1.10
(:invoice_id, 'revenue', 'credit', 11000, 'USD', 1.10, :charge_id);
2. Capture the settlement rate at payout, post the FX residual
When the payout lands, the processor reports the settlement amount and the rate it applied. The difference between capture-value and settlement-value is realized FX.
from decimal import Decimal
def post_fx_on_settlement(capture_home_minor: int, settled_home_minor: int) -> dict:
diff = settled_home_minor - capture_home_minor # negative = loss, positive = gain
if diff == 0:
return {"fx": 0}
account = "fx_gain" if diff > 0 else "fx_loss"
# ✅ the residual balances cash against the receivable booked at capture rate
return {"account": account, "amount_minor": abs(diff), "direction": "credit" if diff > 0 else "debit"}
3. Reconcile FX accounts per currency at close
At period close, the FX gain and loss accounts summarize currency exposure. Reconcile per settlement currency so each currency’s exposure is visible.
SELECT currency,
SUM(amount) FILTER (WHERE account = 'fx_gain') AS realized_gain,
SUM(amount) FILTER (WHERE account = 'fx_loss') AS realized_loss
FROM ledger_entries
WHERE account IN ('fx_gain','fx_loss')
AND posted_at >= :period_start AND posted_at < :period_end
GROUP BY currency;
Batched payouts and per-charge attribution
The formula above assumes a clean one-charge-to-one-payout mapping, but processors settle in batches: a single USD payout might bundle forty EUR charges, six GBP charges, and a handful of refunds, reporting one aggregate settlement amount per currency rather than a rate per charge. You cannot post a single FX line against the batch and call it done, because reconciliation works at the charge_id grain and a batch-level residual cannot be attributed back to the invoices that produced it. Instead, pull the processor’s per-charge_id balance-transaction detail — Stripe exposes exchange_rate and the net settled minor units on each balance transaction — and post one FX line per charge, then assert that the sum of your per-charge settled amounts equals the aggregate payout total. If those two numbers disagree, the discrepancy is a fee, a rounding artifact, or a missing transaction, and you want to catch it before it disappears into an averaged FX figure.
Refunds and the round-trip rate
A refund on a cross-currency charge is not the mirror image of the original capture, and treating it as one is a common source of small permanent imbalances. The original charge captured at 1.10 and settled at 1.08; the refund weeks later settles at whatever rate is current then — say 1.11 — so returning a customer’s 10000 minor units of EUR costs you a different USD amount than you originally received. The revenue reversal must unwind at the original capture rate so that net revenue for the invoice returns exactly to zero, while the difference between the original settlement and the refund settlement posts as a fresh realized FX line. Reversing the revenue at the refund-date rate instead leaves a stub in the revenue account that no amount of reconciliation will clear, because there is no corresponding real-world flow to match it against.
Verification & Testing
The tests prove the FX line balances the transaction, the sign is correct, and cross-currency payouts reconcile once FX is posted. Seed a EUR charge captured at 1.10 and settled at 1.08 and assert a $2 loss line makes the transaction sum to zero. Flip the settlement rate to 1.12 and assert a gain line. Assert a same-currency payout posts no FX line at all. The panel lists them.
Beyond the three worked examples, the most valuable FX test is a property-based one: generate a random capture rate, a random settlement rate, and a random presentment amount in minor units, then assert two things unconditionally — that cash + fx_line == receivable to the exact minor unit, and that the sign of the FX line matches the sign of settlement_rate - capture_rate. Running that property across ten thousand randomized cases surfaces the rounding bugs that hand-picked examples miss, because the failures cluster at rates that produce a half-minor-unit residual, exactly where a naive round() breaks the balance by one cent. A single unbalanced case out of ten thousand is not noise to be tolerated; it is a rounding rule that will eventually mis-post a real payout.
Testing the reconciliation, not just the posting
Posting the FX line correctly is necessary but not sufficient — the reason the line exists is so a payout reconciles, so the test that matters most compares the ledger against a simulated processor report. Seed a batch payout with three charges at different capture rates, feed in a settlement report whose per-charge_id totals you control, run the reconciliation, and assert that every charge matches to zero and the FX accounts hold exactly the residuals you engineered. Then perturb one input — drop a charge from the report, or shift one settled amount by a single minor unit — and assert the reconciliation flags precisely that charge rather than silently absorbing the difference into an FX line. A reconciliation that never fails when fed bad data is not validating anything; the negative test is what proves the tripwire is armed.
Gotchas & Production Pitfalls
The pitfalls are about which rate you use, unrealized versus realized movement, and keeping FX out of revenue. The map groups them.
- Recomputing history with today’s rate. The FX difference is fixed the moment the payout settles. Snapshot both the capture rate and the settlement rate on the ledger entries; never recompute a past FX line from a current rate.
- Conflating unrealized and realized FX. A held foreign balance that has not settled has only unrealized FX movement. Only post realized gain/loss when the payout actually settles, or your income statement swings on paper movement.
- Folding FX into revenue. FX gain or loss is not sales. Post it to a dedicated
fx_gain/fx_lossaccount so revenue reflects what customers paid, not currency movement. - Float rate math. Multiplying minor units by a floating rate accumulates sub-cent drift. Carry the rate as a
Decimal, compute in integer minor units, and round once at the boundary.
Zero-decimal and three-decimal currencies break the naive scale
The minor-unit assumption that keeps the arithmetic clean quietly assumes every currency has two decimal places, and several do not. JPY and KRW are zero-decimal — 1000 yen is 1000 minor units, not 100000 — while BHD and KWD carry three decimals, so a charge_id for 1.500 dinar is 1500 minor units. If your FX math hard-codes a division by 100 to reach the presentment amount, a JPY-to-USD payout will be wrong by two orders of magnitude and the FX line will absorb a residual that looks catastrophic rather than a rounding cent. Read the scale from a currency table keyed on the ISO code, apply it per leg, and never let the settlement currency’s scale bleed into the presentment currency’s arithmetic. A EUR-to-JPY payout has two different scales in the same transaction, and the balancing FX line has to respect both.
Rounding direction has to be a policy, not an accident
Once rates and minor units meet, some transactions produce a genuinely fractional home-currency value, and the direction you round is a decision with money attached. If you always round the receivable down and always round cash up, every cross-currency charge posts a systematic one-cent FX loss that accumulates into a real, auditor-visible number over millions of transactions. Pick banker’s rounding (round-half-to-even) for the conversion and apply it identically on both the capture and settlement legs so the bias cancels rather than compounds. Document the rule next to the code, because the day someone “fixes” the rounding to match a spreadsheet, every historical FX line becomes non-reproducible and the reconciliation that depended on the old rule starts flagging phantom drift.
FX gain and loss is a treasury signal, not just bookkeeping
The reason to keep realized FX in its own account per currency is not only that revenue stays clean — it is that the fx_gain/fx_loss totals, read per settlement currency at close, are the raw material for a hedging decision. A business settling large EUR volumes into USD that watches its EUR fx_loss line grow month over month is looking at directional currency exposure that a forward contract or a EUR-denominated bank account could neutralize. Fold that signal into revenue and it becomes invisible; isolate it per currency and the ledger doubles as an exposure report that treasury can act on. The per-currency GROUP BY at close is therefore not a reconciliation nicety but the interface between the billing ledger and the finance team’s risk model.
Frequently Asked Questions
Where does an FX gain or loss actually arise? Between the rate used when the receivable was recorded and the rate at which the cash actually settled. It is a real economic result, not a rounding artefact.
Should it be posted per transaction or in aggregate? Per transaction, then reported in aggregate. Posting only a monthly total makes it impossible to attribute a difference to the payout or customer that caused it.
Does an unrealised gain need recording? For balances held at period end in a foreign currency, generally yes, and the treatment is a finance decision. The system’s job is to make the underlying balances and rates available.
How does this interact with the payout reconciliation? Directly — the payout is where the settlement rate becomes known. Reconciling payouts and recording FX differences are usually the same job rather than two.