Skip to main content

FX as of the transaction date

What it guarantees

A foreign-currency transaction is converted at the rate that was in effect on its own transaction date, not at the latest rate in the table. If no such rate exists, the posting fails and the transaction is quarantined. It is never valued at par, and never valued at today’s guess.

Why this is a control

This is audit finding #4. Every conversion in the running floor picked the latest rate row:
With a single seeded snapshot, that is as-of-now FX applied to all of history. A transaction from three months ago gets revalued at today’s guess, every time anyone reads it, and the book’s reported value of a past event changes because an unrelated rate was loaded. A book of record must value a historical transaction at its own date’s rate. That is not a preference; it is what makes a prior period’s numbers stable.

The rule

One clause changed, and it is the whole fix: as_of_date <= txn_date. The most recent rate that was in effect on or before the transaction date. Same-currency conversion is identity and needs no row at all.

Quarantine, not par

When no rate exists on or before the date, resolve_rate raises RateUnavailableError:
This is the honest suspense posture. The alternatives are all worse:
Posting at par is the dangerous one precisely because it balances. Both legs get the same wrong rate, so debits still equal credits, and every GL invariant passes. Nothing downstream can tell you the number is wrong. That is why this refusal lives in the rate resolver rather than in a later reconciliation.

Where FX sits in a posting

The resolved base amounts are stored on the line, not recomputed on read. That is what makes a past entry’s book value stable even if the rate table is later corrected: the correction changes future postings and the revaluation sub-ledger, never what a closed period reported.

Where it is enforced

The test that would fail if it broke

backend/tests/test_fx.py covers the as-of-date selection against a table with several dated rows, and the RateUnavailableError path. backend/tests/test_fx_rate_loader.py and langgraph_chat/devcenter/tests/test_fx_rates_api.py cover exact replay, conflicts, audit evidence and authorization. backend/tools/live_acceptance.py re-runs the as-of-date assertion against the live database, inserting one rate row and rolling it back, then asserting the table count is unchanged.

An absent currency was an assertion, not a gap

The engine could always do this correctly. For a while it was simply never handed a currency. _prepare_line reads line.currency or base_currency, so a line that omits its currency is not saying “unknown”, it is saying “this amount is already in the base currency”. That is how a 1,000 USD bill posted 1,000 dong: balanced, hash-chained, in an open period, and tying, because the AP obligation was derived from the same wrong number. The document posting path now states a currency on every line - the coded leg, the input-VAT leg and the control leg alike, and on a base-currency line as much as a foreign one - and states no rate, because the rate is the engine’s to resolve inside the posting transaction.
Schema-bound Typewriter create, revise and staged-review paths require one of the 307 exact uppercase codes from greatbook.currency-code/v1. The compatibility normalisation below applies only to legacy or non-schema callers; it does not loosen the document schema.
A bare $ is deliberately not a recognised spelling. USD, SGD, AUD, CAD and HKD all print it, so reading it as USD is a guess - and a guess that is right most of the time is the worst kind on a money path: a 1,000 SGD invoice extracted as $ would post as 1,000 USD, balanced, chained, and tying, and about 35% wrong. Recognise, or refuse. Never guess.
Two directions of case-sensitivity matter, which is why the normalisation is not simply “upper-case it”: the rate table is keyed on the upper-case code, while a code that is the base currency must match the org’s own spelling byte for byte, or the engine treats the org’s base currency as foreign to itself and quarantines an entry that needed no rate at all. The paired half is the sub-ledger: a GL balance is always base, so the control-account ties compare base against base, and a foreign obligation that states no base-currency amount is refused rather than written with a native number in the column the tie reads as base.

Loading an authoritative rate

The supported write boundary is the service-token-only operator endpoint POST /fx/rates. A browser session cannot use it, and direct SQL is not the procedure. The request names an organization so GreatBook can require the quote currency to equal that organization’s configured base currency. The rate is an exact decimal string, the effective date is explicit, and source is required evidence. The canonical pair/date row remains shared reference data. Created and conflicting attempts append attributable before/after facts, reason and operator-gate evidence to the existing correction log in the same transaction. Exact retries reuse deterministic audit identity rather than duplicating evidence. To release a missing-rate quarantine, an operator loads an authoritative rate effective on or before the document date, then an eligible posting checker approves the still-active proposal again. The Bookkeeper re-runs the same canonical as-of lookup during posting; it never accepts a rate from the document.

Capability and MCP status

The capability registry records greatbook.fx.rate.load@1 as a current application outcome because the audited operator service is merged. It is not invocable through the merged MCP transport contract and is not in the staged-workspace adapter set. Discovery may explain its role, authorization and evidence contract, but cannot turn it into a ChatGPT action or substitute a browser identity for the service-token operator boundary.

What is still open

A partly-settled foreign obligation. Its remainder is converted at the rate it was booked at, and a settlement made at a different rate is a realised FX difference the book has no machinery for - so the tie shows it rather than hiding it. The VAT register’s native/base evidence gap is closed: new rows bind to the exact immutable journal line and carry native currency plus posted base amount. Historical rows without exact evidence remain explicitly partial or inconclusive rather than being backfilled at par or at the latest rate.