> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# FX as of the transaction date

> A historical transaction is valued at its own date's rate. When no rate exists, the transaction is quarantined rather than posted at par.

# 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:

```sql theme={null}
select rate from fx_rates where from_currency = ... order by as_of_date desc limit 1
```

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

```sql theme={null}
select rate from fx_rates
where from_currency = %s and to_currency = %s and as_of_date <= %s
order by as_of_date desc
limit 1
```

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`:

```
no USD->VND rate on or before 2026-04-12;
transaction quarantined rather than valued at par
```

This is the honest suspense posture.
The alternatives are all worse:

| Alternative                | What it produces                                                                            |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| post at par (rate 1.0)     | 4,000 USD booked as 4,000 VND. Balanced, plausible-looking, and wrong by a factor of 25,000 |
| use the nearest later rate | a transaction valued at a rate that did not exist when it happened                          |
| use today's rate           | audit finding #4, exactly                                                                   |
| **raise, and quarantine**  | a transaction a human has to resolve, and a book that never held a value nobody chose       |

<Warning>
  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.
</Warning>

## Where FX sits in a posting

```mermaid theme={null}
flowchart TD
  L["EntryLine<br/>amount · currency · exchange_rate?"] --> Q{"currency == base?"}
  Q -- yes --> ONE["rate = 1"]
  Q -- no --> E{"exchange_rate supplied<br/>by the caller?"}
  E -- yes --> V{"rate > 0?"}
  V -- no --> CE["CurrencyError"]
  V -- yes --> USE["use it"]
  E -- no --> R["resolve_rate(conn, ccy, base, entry_date)"]
  R --> F{"a rate on or before<br/>entry_date exists?"}
  F -- no --> RU["RateUnavailableError<br/>nothing posted"]
  F -- yes --> USE
  ONE --> B["debit_base / credit_base<br/>= money.base_amount(amount, rate)"]
  USE --> B
  B --> S["stored on the line"]
```

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

| Concern                                                           | Code                                                                                 |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| the as-of-date rule, and the refusal                              | `backend/app/logics/finance/fx.py`                                                   |
| the audited rate writer                                           | `backend.app.logics.finance.fx.load_rate`                                            |
| the service-token-only operator boundary                          | `langgraph_chat/devcenter/fx_api.py`                                                 |
| where the writer calls it                                         | `backend/app/services/ledger_service.py`, `_prepare_line`                            |
| rate coercion, which rejects `float` like every other money value | `money.to_money_rate`                                                                |
| period-end restatement of open foreign-currency items             | [Consolidation and FX revaluation](/greatbook/capabilities/subledgers/consolidation) |

## 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.

<Note>
  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.
</Note>

| What the document's currency field says         | What is posted                                             |
| ----------------------------------------------- | ---------------------------------------------------------- |
| nothing                                         | the org's base currency, in the org's own spelling         |
| a known localised spelling - `₫`, `VNĐ`, `đồng` | `VND`, accent- and case-blind                              |
| an ISO-4217-shaped code                         | that code, upper-cased for the rate lookup                 |
| a code that **is** the base currency            | the **org's** spelling of it, never a re-cased one         |
| anything else                                   | `UnrecognisedCurrencyError` at propose time; nothing posts |

<Warning>
  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.
</Warning>

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.

| Outcome     | Meaning                                                                       |
| ----------- | ----------------------------------------------------------------------------- |
| `created`   | the pair/date row and its audit evidence were inserted                        |
| `unchanged` | the exact rate and source already exist; the retry is a no-op                 |
| `rejected`  | the natural key already holds a different rate or source; nothing is replaced |

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.

## Related

* [Money is Decimal, always](/greatbook/capabilities/money) - the other half of amount handling
* [The 18 GL invariants](/greatbook/capabilities/gl-invariants) - invariants 6 and 7
* [Posting rules: the map](/greatbook/gl/posting-rules) - where the currency decision is made
* [Consolidation, intercompany and FX revaluation](/greatbook/capabilities/subledgers/consolidation) - restating open items at close
* [Why a ledger, not a spreadsheet](/greatbook/why-a-ledger) - finding #4 as a worked example
