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:The rule
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:
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.
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 endpointPOST /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 recordsgreatbook.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.Related
- Money is Decimal, always - the other half of amount handling
- The 18 GL invariants - invariants 6 and 7
- Posting rules: the map - where the currency decision is made
- Consolidation, intercompany and FX revaluation - restating open items at close
- Why a ledger, not a spreadsheet - finding #4 as a worked example