What you'll build: the FX revaluation step of a month-end close — the one that takes a
foreign-currency balance sitting in your ledger and restates it into your functional currency at the
period-end rate, booking the difference as an unrealized gain or loss. On one DataK3 bucket, onto
the gl/core masters, you add two tables — fx_rates (rates at DECIMAL(18,6)) and an fx_reval register
— then revalue an open EUR bank balance, post a balanced reval journal, and reverse it next period
so the two periods net to 0.00. Rates and money are exact decimals, so the gain/loss lands to the cent;
every write is INSERT … ON CONFLICT DO UPDATE, so re-running the close never double-posts.
What you'll learn:
- Store FX rates as
DECIMAL(18,6)and prove they read back exact — no binary-float tail on a rate. - Revalue an open foreign-currency balance into the functional currency: re-derive the transaction-date
carrying value from posted lines, recover the foreign units at the rate it was booked at, re-convert at
the period-end rate, and book the difference — rounded to functional cents, in exact
Decimal. - Post a balanced reval journal (FX gain/loss vs the account) and auto-reverse it next period, so the reval leaves no permanent trace once the position settles.
- Make the close idempotent twice over — the write with
ON CONFLICT(a bare re-INSERTis rejected23505), and the computation by excluding the reval's own journals from its base. The second one is the hard one, and skipping it produces books that are silently wrong while still balancing perfectly. - Run every step two ways — by prompting an agent over the MCP, or the
dodil dataCLI.
The problem — and why it matters
Your company sells into Germany, so treasury funds a euro bank account to pay European suppliers out of.
On 2026-08-20 you moved USD 108,240.00 into it and got EUR 100,000 at 1.0824. Six weeks later the euro
has strengthened to 1.1147, and that same EUR 100,000 is worth USD 111,470.00. Nothing about the
account changed — there are still exactly EUR 100,000 in it — but its value in your functional currency
did, and the accounting standards (ASC 830 in the US, IAS 21 under IFRS) are unambiguous: at each
balance-sheet date you must revalue every open foreign-currency monetary balance at the closing rate and
recognize the difference — here an unrealized gain of USD 3,230.00 — in the income statement.
Get it wrong and it is not a rounding nit. The controller who signs the close is attesting that the
balance sheet states foreign assets at the closing rate; a reval that is off by a cent — because someone
stored a rate as a float and 1.1147 came back as 1.1146999… — is a misstatement. And because the gain is
unrealized (you haven't converted the euros back), it must reverse next period so it doesn't
double-count against the actual gain or loss you'll book when they are finally sold. So the reval has
three hard requirements: the rate must be exact, the reval journal must balance, and it must be
cleanly reversible.
There is a fourth requirement that nobody writes on the list, and it is the one that actually bit us: the
reval must be safe to run twice. It posts to the very account it revalues, so a second run that reads the
current balance revalues its own output — and because each run posts a balanced journal, the trial balance
stays at exactly 0.00 while the gain quietly walks away from the truth. Step 3 and What composing the
six components found are about that.
The classic stack does this with a rates feed in one system, the ledger in another, and a spreadsheet in the
middle where the reval is actually computed and then keyed back in by hand. On DataK3 the rate table, the
ledger balance, the reval register, and the journal you post all live in one bucket, over one copy of the
rows — the reval is a single SELECT against the same wire that holds the ledger, with money as
DECIMAL(18,2) and rates as DECIMAL(18,6) so nothing drifts.
| Piece | Lands in | Pillar |
|---|---|---|
| FX rates (prior + period-end, per currency pair) | fx_rates (DECIMAL(18,6)) | SQL |
| The reval register (orig / revalued / gain-loss per account) | fx_reval (DECIMAL(18,2)) | SQL |
| The reval + reversing journals | journals / journal_lines (from gl/core) | SQL |
NOTE
Connect the DODIL MCP once — then every step shows an Ask your agent tab (the default — DODIL is
agent-native) and a CLI tab. Every number in this tutorial was produced live over this MCP on
2026-09-08 (org IHDIASH, bucket gl-suite) — and not by this component on a bucket of its own, but by
all six GL components running together on that one bucket. That distinction is the subject of a whole
section below; four of the five bugs it found are in this component.
Prerequisites
- A DODIL organization with the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent. Headless? Checkauth_statusfirst — an agent can't do the browser login for you. - The
gl/coremasters (accounts,journals,journal_lines,ledger) on your bucket. If you've run the GL core tutorial, you already have them — skip Step 1. If not, Step 1 stubs just the masters this skill reads, so the tutorial stands alone. export BUCKET=gl-suite— one bucket is the whole GL's data plane. (This is thebucketparam, defaultgl-suite; the functional currency isUSD, the period being revalued is2026-09.) The obvious nameglis not available: DataK3 rejects a bucket name shorter than three characters, which is why the whole suite lives ongl-suite.
Step 1 — Stub the masters (standalone — skip if gl/core is present)
FX revaluation reads the gl/core masters and adds its own two tables. Installed without gl/core, it
stubs just the masters it needs, with the locked shapes — money DECIMAL(18,2), every non-key column
nullable — then seeds the scenario: a euro bank account (1400, currency EUR), the Cash account
it was funded from (1100, USD), an Unrealized FX Gain/Loss account (4900), and the treasury journal
20260820 that bought EUR 100,000 for USD 108,240.00 at 1.0824 — the position we'll revalue.
It also stubs journal_audit, which gl/journal-entry owns: every journal in the GL is written by the
one shared posting.write_journal() path, and that path appends the acting user to the audit trail on every
post. Standalone, the table has to exist or the first post fails.
IMPORTANT
ledger.balance is the FUNCTIONAL-currency signed net — always. It is
SUM(debit) − SUM(credit) over the account's posted lines, in the currency the lines were booked in, which
for a US-functional ledger is USD. The ledger.currency column sitting next to it is a different thing:
it marks the account's transaction currency — the EUR flag that tells the reval this balance needs a
rate pair at all. One column pair, two jobs. Reading balance as euros rather than dollars is a real
bug we shipped and caught, and it is bug 3 in the composition section below. The euro face amount is never
stored — it is recovered by dividing the carrying value by the rate it was booked at.
On bucket gl-suite, stub the GL masters I consume from gl/core, then seed an FX scenario. Create merge-keyed tables with money as DECIMAL(18,2) and every non-key column nullable: accounts (key account_id) with name, type, normal_balance, parent_account_id (long), currency, active (boolean); journals (key journal_id) with journal_date (timestamp), period, source, status, memo, reverses_journal_id (long); journal_lines (composite key journal_id, line_no) with account_id (long), debit DECIMAL(18,2), credit DECIMAL(18,2), line_memo; ledger (key account_id) with balance DECIMAL(18,2), currency, as_of (timestamp); journal_audit (key audit_id, string) with journal_id (long), event, event_at (timestamp). Seed three accounts: 1100 Cash, asset, debit, currency USD; 1400 EUR Bank Account, asset, debit, currency EUR; 4900 Unrealized FX Gain/Loss, revenue, credit, currency USD. Post treasury journal 20260820 (period 2026-08, status posted): debit 1400 108240.00, credit 1100 108240.00 — EUR 100000 bought at 1.0824. Then materialize the ledger as SUM(debit) minus SUM(credit) per account, taking each row's currency from accounts.currency.
data_bucket_create→data_table_create→data_table_upsert→data_pgCreated bucket gl-suite + 5 tables (accounts, journals, journal_lines, ledger, journal_audit), seeded 3 accounts, posted journal 20260820 (SUM(debit) = SUM(credit) = 108240.00), and materialized the ledger — account 1400 carries a functional-currency carrying value of 108240.00 with currency EUR, account 1100 carries -108240.00 USD. Ready to revalue.
export BUCKET=gl-suite # NOT "gl" — DataK3 rejects a bucket name under three characters
dodil data bucket create "$BUCKET" --description "General Ledger — FX revaluation scenario"
# the gl/core masters this skill consumes (money DECIMAL(18,2), every non-key column nullable)
dodil data table create accounts -b "$BUCKET" --merge-key account_id \
--columns-json '[
{"name":"account_id","type":"bigint"},
{"name":"name","type":"string","nullable":true},
{"name":"type","type":"string","nullable":true},
{"name":"normal_balance","type":"string","nullable":true},
{"name":"parent_account_id","type":"long","nullable":true},
{"name":"currency","type":"string","nullable":true},
{"name":"active","type":"boolean","nullable":true}
]'
dodil data table create journals -b "$BUCKET" --merge-key journal_id \
--columns-json '[
{"name":"journal_id","type":"bigint"},
{"name":"journal_date","type":"timestamp","nullable":true},
{"name":"period","type":"string","nullable":true},
{"name":"source","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"memo","type":"string","nullable":true},
{"name":"reverses_journal_id","type":"long","nullable":true}
]'
dodil data table create journal_lines -b "$BUCKET" --merge-key journal_id --merge-key line_no \
--columns-json '[
{"name":"journal_id","type":"bigint"},
{"name":"line_no","type":"bigint"},
{"name":"account_id","type":"long","nullable":true},
{"name":"debit","type":"DECIMAL(18,2)","nullable":true},
{"name":"credit","type":"DECIMAL(18,2)","nullable":true},
{"name":"line_memo","type":"string","nullable":true}
]'
dodil data table create ledger -b "$BUCKET" --merge-key account_id \
--columns-json '[
{"name":"account_id","type":"bigint"},
{"name":"balance","type":"DECIMAL(18,2)","nullable":true},
{"name":"currency","type":"string","nullable":true},
{"name":"as_of","type":"timestamp","nullable":true}
]'
# owned by gl/journal-entry — the sole write path appends the acting user here on every post
dodil data table create journal_audit -b "$BUCKET" --merge-key audit_id \
--columns-json '[
{"name":"audit_id","type":"string"},
{"name":"journal_id","type":"long","nullable":true},
{"name":"event","type":"string","nullable":true},
{"name":"event_at","type":"timestamp","nullable":true}
]'
# the FX scenario: the euro bank account, the cash it was funded from, and the FX gain/loss account
dodil data table upsert accounts -b "$BUCKET" \
--row '{"account_id":1100,"name":"Cash","type":"asset","normal_balance":"debit","parent_account_id":1000,"currency":"USD","active":true}' \
--row '{"account_id":1400,"name":"EUR Bank Account","type":"asset","normal_balance":"debit","parent_account_id":1000,"currency":"EUR","active":true}' \
--row '{"account_id":4900,"name":"Unrealized FX Gain/Loss","type":"revenue","normal_balance":"credit","parent_account_id":4000,"currency":"USD","active":true}'
# treasury journal 20260820 — buy EUR 100,000 at 1.0824. Balances: 108240.00 = 108240.00
dodil data table upsert journals -b "$BUCKET" \
--row '{"journal_id":20260820,"journal_date":"2026-08-20 00:00:00","period":"2026-08","source":"treasury","status":"posted","memo":"Fund EUR bank account: EUR 100,000 @ 1.0824","reverses_journal_id":null}'
dodil data table upsert journal_lines -b "$BUCKET" \
--row '{"journal_id":20260820,"line_no":1,"account_id":1400,"debit":108240.00,"credit":0.00,"line_memo":"EUR 100000 at 1.0824"}' \
--row '{"journal_id":20260820,"line_no":2,"account_id":1100,"debit":0.00,"credit":108240.00,"line_memo":"Cash"}'
# materialize the ledger — the FUNCTIONAL-currency signed net per account, with the currency
# marker taken from accounts.currency (never from a caller's default: see bug 3 below)
dodil data pg -b "$BUCKET" \
"INSERT INTO ledger (account_id, balance, currency, as_of)
SELECT jl.account_id, SUM(jl.debit) - SUM(jl.credit),
COALESCE(MAX(a.currency), 'USD'), TIMESTAMP '2026-08-31 00:00:00'
FROM journal_lines jl
JOIN journals j ON j.journal_id = jl.journal_id
LEFT JOIN accounts a ON a.account_id = jl.account_id
WHERE j.status IN ('posted','reversed')
GROUP BY jl.account_id
ON CONFLICT (account_id) DO UPDATE SET balance = EXCLUDED.balance, currency = EXCLUDED.currency, as_of = EXCLUDED.as_of"
# the open FX position to revalue
dodil data sql -b "$BUCKET" \
"SELECT l.account_id, a.name, l.balance, l.currency FROM ledger l JOIN accounts a ON a.account_id = l.account_id ORDER BY l.account_id"
# 1100 Cash -108240.00 USD
# 1400 EUR Bank Account 108240.00 EUR <- USD carrying value; the EUR flag says "revalue me"# models.py — the gl/core masters this skill consumes, as SQLAlchemy models (natural PKs).
# Money is Numeric(18, 2); timestamps are DateTime (never strings). SQLAlchemy + psycopg
# carries Decimal exactly over the pg wire, so the reval math lands to the cent.
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, Boolean, DateTime, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Account(Base):
__tablename__ = "accounts"
account_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # natural id
name: Mapped[str | None] = mapped_column(String, nullable=True)
type: Mapped[str | None] = mapped_column(String, nullable=True) # asset/revenue/…
normal_balance: Mapped[str | None] = mapped_column(String, nullable=True) # debit/credit
parent_account_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
currency: Mapped[str | None] = mapped_column(String, nullable=True) # the account's txn ccy
active: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
class Journal(Base):
__tablename__ = "journals"
journal_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
journal_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
period: Mapped[str | None] = mapped_column(String, nullable=True) # YYYY-MM
source: Mapped[str | None] = mapped_column(String, nullable=True) # fx-reval, …
status: Mapped[str | None] = mapped_column(String, nullable=True) # posted/draft
memo: Mapped[str | None] = mapped_column(String, nullable=True)
# the reversing mirror links back to the journal it reverses (auto_reverse)
reverses_journal_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
class JournalLine(Base):
__tablename__ = "journal_lines"
journal_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # composite PK
line_no: Mapped[int] = mapped_column(BigInteger, primary_key=True)
account_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
debit: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) # money
credit: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) # money
line_memo: Mapped[str | None] = mapped_column(String, nullable=True)
class Ledger(Base):
"""The materialised balance of each account: the FUNCTIONAL-currency signed net
SUM(debit) - SUM(credit) over its posted lines (posting.rederive_ledger). `currency` is a
different thing — the ACCOUNT's transaction currency, the marker that tells the reval this
balance needs a rate pair. The foreign face amount is never stored; it is recovered by
dividing the carrying value by the rate it was booked at."""
__tablename__ = "ledger"
account_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
balance: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) # functional ccy
currency: Mapped[str | None] = mapped_column(String, nullable=True) # the account's txn ccy
as_of: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)Every data step below carries that third ORM tab — the exact class from the package's models.py
(plain SQLAlchemy, natural PKs, money as Numeric(18, 2)), so the same op reads three ways: an agent
prompt, the dodil data CLI, or the model. models.Base.metadata.create_all(db.engine) builds all six at
once (see Get the code).
NOTE
When gl/core is present, skip this step. Core owns accounts / journals / journal_lines /
ledger (and gl/journal-entry owns journal_audit); this skill adds only fx_rates + fx_reval and
reads the masters. Installed standalone, the stub above recreates just those masters with the same locked
shapes — money DECIMAL(18,2), every non-key column nullable — so the tutorial runs end-to-end on its
own. On the shared gl-suite bucket you install gl/core once and every component
reads the same rows: that is the whole reason the suite is one bucket, and — as the composition section
below shows — the only configuration in which some of these bugs are even findable.
Step 2 — Seed the FX rate table (fx_rates, DECIMAL(18,6))
The rate table is the provenance of the whole reval — a controller signing the close traces every rate to
where it came from. It's keyed on the composite (from_ccy, to_ccy, rate_date), so one row is the rate
for a currency pair on a date, and re-seeding a corrected rate is idempotent. Rates are DECIMAL(18,6) —
more scale than money (six places), and, crucially, exact: a rate is never a float.
In gl-suite, create an fx_rates table keyed on the composite (from_ccy, to_ccy, rate_date) with rate DECIMAL(18,6) and source. Seed EUR to USD rates: 2026-08-20 = 1.082400, 2026-08-31 = 1.082400 and 2026-09-30 = 1.114700, source ECB. Also add GBP to USD rates: 2026-08-31 = 1.267845 and 2026-09-30 = 1.291200, to show the full six-decimal precision reads back exact. Then select the rates back.
data_table_create→data_table_upsert→data_sqlCreated fx_rates (pk from_ccy, to_ccy, rate_date; rate DECIMAL(18,6)), upserted 5 rows. Read back exact: EUR->USD 1.082400 (2026-08-20 and 2026-08-31) and 1.114700 (2026-09-30), GBP->USD 1.267845 and 1.291200 — every rate stores and reads at full (18,6) precision with no float tail.
dodil data table create fx_rates -b "$BUCKET" --merge-key from_ccy --merge-key to_ccy --merge-key rate_date \
--columns-json '[
{"name":"from_ccy","type":"string"},
{"name":"to_ccy","type":"string"},
{"name":"rate_date","type":"string"},
{"name":"rate","type":"DECIMAL(18,6)","nullable":true},
{"name":"source","type":"string","nullable":true}
]'
# the transaction-date rate, the prior period-end, and the period-end being revalued,
# quoted from_ccy -> functional (USD)
dodil data table upsert fx_rates -b "$BUCKET" \
--row '{"from_ccy":"EUR","to_ccy":"USD","rate_date":"2026-08-20","rate":1.082400,"source":"ECB"}' \
--row '{"from_ccy":"EUR","to_ccy":"USD","rate_date":"2026-08-31","rate":1.082400,"source":"ECB"}' \
--row '{"from_ccy":"EUR","to_ccy":"USD","rate_date":"2026-09-30","rate":1.114700,"source":"ECB"}' \
--row '{"from_ccy":"GBP","to_ccy":"USD","rate_date":"2026-08-31","rate":1.267845,"source":"ECB"}' \
--row '{"from_ccy":"GBP","to_ccy":"USD","rate_date":"2026-09-30","rate":1.291200,"source":"ECB"}'
dodil data sql -b "$BUCKET" \
"SELECT from_ccy, to_ccy, rate_date, rate, source FROM fx_rates ORDER BY from_ccy, rate_date"
# EUR USD 2026-08-20 1.082400 ECB <- the rate the position was booked at
# EUR USD 2026-08-31 1.082400 ECB <- the prior period-end
# EUR USD 2026-09-30 1.114700 ECB <- the period-end we revalue at
# GBP USD 2026-08-31 1.267845 ECB
# GBP USD 2026-09-30 1.291200 ECB <- full six-decimal precision, exact, no float tail# models.py — the FX rate table this skill owns. Composite natural PK; rate is Numeric(18, 6)
# — more scale than money, still exact (never a float). Quoted from_ccy -> to_ccy (functional).
class FxRate(Base):
__tablename__ = "fx_rates"
from_ccy: Mapped[str] = mapped_column(String, primary_key=True)
to_ccy: Mapped[str] = mapped_column(String, primary_key=True)
rate_date: Mapped[str] = mapped_column(String, primary_key=True) # YYYY-MM-DD
rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True) # exact rate
source: Mapped[str | None] = mapped_column(String, nullable=True) # ECB, treasury, …IMPORTANT
A rate is DECIMAL(18,6), never double. Store 1.1147 as a float and it comes back as
1.1146999999999999 — and 100000 × 1.1146999… is not 111470.00, so the gain/loss is off by a cent
and the balance sheet misstates. DECIMAL(18,6) is exact: 1.267845 reads back as exactly 1.267845.
Step 3 — Revalue the open balance and post the reval journal (fx_reval)
Now the reval itself, in one SELECT against the same bucket. Three functional-currency figures: the
original carrying value, the revalued carrying value at the period-end rate, and the gain/loss
between them, each ROUNDed to functional cents. Write them to fx_reval with INSERT … SELECT … ON CONFLICT so the register write is idempotent.
The subtlety is where the original carrying value comes from, and it is worth slowing down for — it is the difference between a reval you can run twice and one you cannot:
- Not from
ledger.balance. The reval posts to the account it is revaluing, so after the first run the ledger already contains the gain. A second run would revalue its own output. - From the posted lines, excluding the reval's own journals.
SUM(debit) − SUM(credit)over the account'sposted/reversedlines where the journalsourceis notfx-revalorfx-reval-reversal. That is the transaction-date carrying value —108240.00, the dollars actually spent on the euros — and it does not move no matter how many times the reval runs. - The euro face amount is then recovered, not read:
108240.00 ÷ 1.0824 = EUR 100,000, re-converted at the period-end1.1147to111470.00. Gain:3230.00.
In gl-suite, create an fx_reval register keyed on reval_id (account_id long, ccy, orig_balance DECIMAL(18,2), revalued_balance DECIMAL(18,2), gain_loss DECIMAL(18,2), rate DECIMAL(18,6), period). Then revalue the open EUR balance on account 1400. Derive the base as SUM(debit) minus SUM(credit) over its posted or reversed journal lines, EXCLUDING journals whose source is fx-reval or fx-reval-reversal, so the reval never revalues its own output. That base is orig_balance. Recover the euro units by dividing by the 2026-08-31 rate, re-convert at the 2026-09-30 rate for revalued_balance, and set gain_loss = revalued_balance minus orig_balance. Write it as reval_id 1 for period 2026-09 with INSERT SELECT ON CONFLICT (reval_id) DO UPDATE. Show the row.
data_table_create→data_pg→data_sqlCreated fx_reval. Revalued account 1400 (EUR Bank Account): orig_balance 108240.00 — the transaction-date carrying value re-derived from posted lines excluding fx-reval journals — recovered as EUR 100000 at 1.082400, re-converted at 1.114700 to revalued_balance 111470.00, gain_loss = 3230.00. Exact, no float tail.
dodil data table create fx_reval -b "$BUCKET" --merge-key reval_id \
--columns-json '[
{"name":"reval_id","type":"bigint"},
{"name":"account_id","type":"long","nullable":true},
{"name":"ccy","type":"string","nullable":true},
{"name":"orig_balance","type":"DECIMAL(18,2)","nullable":true},
{"name":"revalued_balance","type":"DECIMAL(18,2)","nullable":true},
{"name":"gain_loss","type":"DECIMAL(18,2)","nullable":true},
{"name":"rate","type":"DECIMAL(18,6)","nullable":true},
{"name":"period","type":"string","nullable":true}
]'
# revalue the open foreign-currency position at the period-end rate.
# The base is re-derived from POSTED LINES, excluding the reval's own journals — so running
# this again recomputes the SAME gain instead of revaluing the previous run's result.
dodil data pg -b "$BUCKET" \
"INSERT INTO fx_reval (reval_id, account_id, ccy, orig_balance, revalued_balance, gain_loss, rate, period)
SELECT 1, b.account_id, a.currency,
ROUND(b.carrying, 2) AS orig_balance,
ROUND(b.carrying / pr.rate * nr.rate, 2) AS revalued_balance,
ROUND(b.carrying / pr.rate * nr.rate, 2) - ROUND(b.carrying, 2) AS gain_loss,
nr.rate, '2026-09'
FROM (SELECT jl.account_id, SUM(jl.debit) - SUM(jl.credit) AS carrying
FROM journal_lines jl
JOIN journals j ON j.journal_id = jl.journal_id
WHERE jl.account_id = 1400
AND j.status IN ('posted','reversed')
AND COALESCE(j.source, '') NOT IN ('fx-reval', 'fx-reval-reversal')
GROUP BY jl.account_id) b
JOIN accounts a ON a.account_id = b.account_id
JOIN fx_rates pr ON pr.from_ccy = a.currency AND pr.to_ccy = 'USD' AND pr.rate_date = '2026-08-31'
JOIN fx_rates nr ON nr.from_ccy = a.currency AND nr.to_ccy = 'USD' AND nr.rate_date = '2026-09-30'
ON CONFLICT (reval_id) DO UPDATE
SET orig_balance = EXCLUDED.orig_balance, revalued_balance = EXCLUDED.revalued_balance,
gain_loss = EXCLUDED.gain_loss, rate = EXCLUDED.rate, period = EXCLUDED.period"
dodil data sql -b "$BUCKET" \
"SELECT reval_id, account_id, ccy, orig_balance, revalued_balance, gain_loss, rate, period FROM fx_reval WHERE reval_id = 1"
# reval_id account_id ccy orig_balance revalued_balance gain_loss rate period
# 1 1400 EUR 108240.00 111470.00 3230.00 1.114700 2026-09# models.py — the reval register this skill owns. reval_id is the natural PK, so the
# ON CONFLICT recompute (db.upsert) leaves a re-run of the close unchanged — never double-posts.
class FxReval(Base):
__tablename__ = "fx_reval"
reval_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
account_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
ccy: Mapped[str | None] = mapped_column(String, nullable=True) # the account's txn ccy
orig_balance: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
revalued_balance: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
gain_loss: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True) # period-end
period: Mapped[str | None] = mapped_column(String, nullable=True) # YYYY-MMThe euros are worth more dollars than when they were bought, so we recognize an unrealized gain. The reval journal makes it real to the books: a gain debits the account (its functional carrying value rose) and credits Unrealized FX Gain/Loss — and, like every journal, it must balance before it posts.
Posting it is not a bare upsert. Every journal in the GL — in every one of the six components — goes through
the single guarded write path, posting.write_journal(), which runs the balance gate, checks the target
period is open (an operational posting into a closed period is rejected 409; the full story is in
Period close), writes idempotently, and appends the acting user to
journal_audit. And then, separately, the caller re-derives the ledger — see bug 1 below for what
happens when it doesn't.
In gl-suite, post the reval journal for the 3230.00 unrealized FX gain. Upsert journal 20269001 (period 2026-09, source fx-reval, status posted, memo Unrealized FX revaluation for account 1400 at 1.114700). Then its 2 lines: debit account 1400 3230.00, credit account 4900 3230.00. Then check that SUM(debit) equals SUM(credit), and re-derive the ledger for the two accounts it touched.
data_table_upsert→data_pg→data_sqlJournal 20269001 posted with 2 lines — debit EUR Bank Account (1400) 3230.00, credit Unrealized FX Gain/Loss (4900) 3230.00. SUM(debit) = 3230.00, SUM(credit) = 3230.00, SUM(debit)-SUM(credit) = 0.00. Ledger re-derived in a second transaction: 1400 is now 111470.00 (still currency EUR), 4900 is -3230.00. Trial balance across the bucket still 0.00.
# a GAIN debits the asset (its USD carrying value rose) and credits the FX gain/loss account
dodil data table upsert journals -b "$BUCKET" \
--row '{"journal_id":20269001,"journal_date":"2026-09-30 00:00:00","period":"2026-09","source":"fx-reval","status":"posted","memo":"Unrealized FX revaluation — account 1400 @ 1.114700","reverses_journal_id":null}'
dodil data table upsert journal_lines -b "$BUCKET" \
--row '{"journal_id":20269001,"line_no":1,"account_id":1400,"debit":3230.00,"credit":0.00,"line_memo":"FX reval"}' \
--row '{"journal_id":20269001,"line_no":2,"account_id":4900,"debit":0.00,"credit":3230.00,"line_memo":"Unrealized FX gain/loss"}'
# THE gate: the reval journal may only post if it balances
dodil data sql -b "$BUCKET" \
"SELECT SUM(debit) AS total_debit, SUM(credit) AS total_credit, SUM(debit) - SUM(credit) AS balance
FROM journal_lines WHERE journal_id = 20269001"
# total_debit total_credit balance
# 3230.00 3230.00 0.00 <- balances to the cent
# AND THEN re-derive the ledger — a SECOND, committed statement. `ledger` is a derived table;
# nothing updates it implicitly. Skipping this is bug 1.
dodil data pg -b "$BUCKET" \
"INSERT INTO ledger (account_id, balance, currency, as_of)
SELECT jl.account_id, SUM(jl.debit) - SUM(jl.credit),
COALESCE(MAX(a.currency), 'USD'), TIMESTAMP '2026-09-30 00:00:00'
FROM journal_lines jl
JOIN journals j ON j.journal_id = jl.journal_id
LEFT JOIN accounts a ON a.account_id = jl.account_id
WHERE j.status IN ('posted','reversed') AND jl.account_id IN (1400, 4900)
GROUP BY jl.account_id
ON CONFLICT (account_id) DO UPDATE
SET balance = EXCLUDED.balance, as_of = EXCLUDED.as_of, currency = EXCLUDED.currency"
dodil data sql -b "$BUCKET" \
"SELECT account_id, balance, currency FROM ledger WHERE account_id IN (1400, 4900) ORDER BY account_id"
# 1400 111470.00 EUR <- the revalued carrying value; the EUR marker survived
# 4900 -3230.00 USD <- the unrealized gain, now visible to every reportNow the correctness proof a close depends on: the reval is idempotent — a month-end job that runs twice must revalue once. There are two separate things to prove, and only the first is the familiar one.
The write. A bare re-INSERT of the register row is rejected 23505; ON CONFLICT DO UPDATE is the
safe path. The computation. That is the one the base exclusion above buys, and it is the reason the whole
end-to-end reval was re-run three more times against the live bucket: fx_reval stayed one row,
gain_loss stayed 3230.00, ledger 1400 stayed 111470.00, and the trial balance stayed 0.00.
In gl-suite, prove the reval is idempotent. First try a bare re-INSERT of fx_reval reval_id 1 to show it is rejected. Then re-run the whole reval end to end three more times and confirm fx_reval is still 1 row, gain_loss is still 3230.00, the ledger balance on 1400 is still 111470.00, and the reval journal still has 2 lines summing to 0.00.
data_pg→data_sqlThe bare re-INSERT raised SQLSTATE 23505 (duplicate key fx_reval_pkey (1) already exists) — a re-INSERT is NOT an upsert. Three further end-to-end re-runs left fx_reval = 1 row, gain_loss = 3230.00, ledger 1400 = 111470.00, reval journal lines = 2, journal balance 0.00 — no double-revaluation and no drift. journal_audit shows the repeated posted events against journal 20269001: the runs happened, the journal landed once.
# a bare re-INSERT of a committed PK is REJECTED — never re-INSERT to retry a close
dodil data pg -b "$BUCKET" \
"INSERT INTO fx_reval (reval_id, account_id, ccy, orig_balance, revalued_balance, gain_loss, rate, period)
VALUES (1, 1400, 'EUR', 108240.00, 111470.00, 3230.00, 1.114700, '2026-09')"
# ERROR: duplicate key value violates unique constraint "fx_reval_pkey":
# Key (reval_id)=(1) already exists. (SQLSTATE 23505)
# re-run the WHOLE reval (the Step 3 statement above) three more times, then assert nothing moved
dodil data sql -b "$BUCKET" \
"SELECT (SELECT COUNT(*) FROM fx_reval) AS reval_rows,
(SELECT gain_loss FROM fx_reval WHERE reval_id = 1) AS gain_loss,
(SELECT balance FROM ledger WHERE account_id = 1400) AS ledger_1400,
(SELECT COUNT(*) FROM journal_lines WHERE journal_id = 20269001) AS reval_journal_lines,
(SELECT SUM(debit) - SUM(credit) FROM journal_lines WHERE journal_id = 20269001) AS reval_journal_balance"
# reval_rows gain_loss ledger_1400 reval_journal_lines reval_journal_balance
# 1 3230.00 111470.00 2 0.00 <- no double-revaluation
# the audit trail proves the re-runs really happened — and landed the journal once
dodil data sql -b "$BUCKET" \
"SELECT journal_id, event, event_at FROM journal_audit WHERE journal_id = 20269001 ORDER BY event_at"
# 20269001 posted:dev-controller ... <- one row per run; one journal, one set of linesWARNING
The version of this that we shipped first was idempotent in its writes and wrong anyway. It read
ledger.balance as its base, so run two revalued run one's result: the gain went 3230.00, then
3326.39, then further — and the trial balance read exactly 0.00 at every step, because each run posted
a perfectly balanced journal. INSERT … ON CONFLICT makes a write idempotent. It does nothing for a
computation that reads its own previous output; that is a separate obligation, and double-entry will
not warn you when you miss it.
Step 4 — Reverse the reval into the next period (auto_reverse=true)
The gain is unrealized — you haven't sold the euros. So the reval reverses on the first day of the next
period, and next month's close revalues the fresh position from scratch. The reversal is the mirror
journal: debit and credit swapped, linked back by reverses_journal_id, source set to
fx-reval-reversal so the base-exclusion in Step 3 skips it too. Over the two periods, every account it
touched nets to 0.00 — the reval leaves no permanent trace once the position actually settles.
POST /revalue does this for you when auto_reverse is true (the default); here is the same op by hand, so
you can see the shape. The live gl-suite run left the September reval standing rather than reversing it —
the numbers below are the pattern, not a reading off that bucket:
In gl-suite, reverse the September FX reval into October. Upsert journal 20269002 (journal_date 2026-10-01, period 2026-10, source fx-reval-reversal, status posted, memo Reversal of the September FX reval, reverses_journal_id 20269001) with the mirror lines: credit account 1400 3230.00, debit account 4900 3230.00. Then show, over the two journals, the net debit-minus-credit per account, and confirm the mirror journal itself balances.
data_table_upsert→data_sqlJournal 20269002 posted, reverses_journal_id = 20269001 — the debit/credit mirror. Over the two journals: account 1400 net 0.00, account 4900 net 0.00. The mirror journal balances on its own (SUM(debit)-SUM(credit) = 0.00). The unrealized reval nets to zero across the two periods.
# the reversing mirror — debit<->credit swapped, linked back by reverses_journal_id
dodil data table upsert journals -b "$BUCKET" \
--row '{"journal_id":20269002,"journal_date":"2026-10-01 00:00:00","period":"2026-10","source":"fx-reval-reversal","status":"posted","memo":"Reversal of the September FX reval (journal 20269001)","reverses_journal_id":20269001}'
dodil data table upsert journal_lines -b "$BUCKET" \
--row '{"journal_id":20269002,"line_no":1,"account_id":1400,"debit":0.00,"credit":3230.00,"line_memo":"Reverse FX reval"}' \
--row '{"journal_id":20269002,"line_no":2,"account_id":4900,"debit":3230.00,"credit":0.00,"line_memo":"Reverse unrealized FX gain/loss"}'
# over the reval + its reversal, each account nets to 0.00
dodil data sql -b "$BUCKET" \
"SELECT account_id, SUM(debit) AS d, SUM(credit) AS c, SUM(debit) - SUM(credit) AS net_impact
FROM journal_lines WHERE journal_id IN (20269001, 20269002) GROUP BY account_id ORDER BY account_id"
# account_id d c net_impact
# 1400 3230.00 3230.00 0.00
# 4900 3230.00 3230.00 0.00 <- the unrealized reval leaves no permanent traceTIP
Why the reversal carries its own source. fx-reval-reversal is excluded from the Step 3 base
alongside fx-reval. If it weren't, reversing would drag the base down by the gain and next month's reval
would be computed off a number that no transaction ever produced. The exclusion list and the source
values are one design, not two.
Routes
The download (see Get the code) fronts this bucket with a small FastAPI app,
routes.py — CRUD over the masters plus the period-end reval this skill owns. This is what a close team
deploys (or runs on a schedule as the gl-reval-engine). Every route follows the same finance rules the
package bakes in, so quoting it is documenting them.
The connection and the one write helper live in db.py — byte-identical to every other DODIL suite. A
DataK3 bucket is a Postgres endpoint (db name = the bucket, user = the literal token, password = your
DODIL token), so there's no data connect step in code, just fixed region constants. upsert() is the only
writer every route uses — INSERT … ON CONFLICT (<pk>) DO UPDATE:
# db.py — the idempotent keyed write (SQLAlchemy + psycopg = the pg wire; Decimal carried exact)
def upsert(session, model, rows, key):
keys = [key] if isinstance(key, str) else list(key)
table = model.__table__
# normalise to a uniform column set — a multi-row VALUES needs every row to name the
# same columns; fill any a caller omitted with None.
cols = {c for r in rows for c in r}
rows = [{c: r.get(c) for c in cols} for r in rows]
stmt = pg_insert(table).values(rows)
update_cols = [c.name for c in table.columns if c.name not in keys and c.name in cols]
if update_cols:
stmt = stmt.on_conflict_do_update(
index_elements=keys,
set_={c: getattr(stmt.excluded, c) for c in update_cols},
)
else:
# every column is part of the key (a pure edge/junction row) — nothing to update.
stmt = stmt.on_conflict_do_nothing(index_elements=keys)
session.execute(stmt)Why it matters here: a month-end reval that runs twice — a retry, a shard replay — must revalue once. A
bare re-INSERT of the fx_reval register row raises duplicate-key 23505; upsert makes the recompute
land the row once. And because money is Numeric and the write goes over the pg wire (SQLAlchemy +
psycopg), Decimal is carried exact — the gRPC upsert path would silently drop an integer 0 into a
DECIMAL, so the reval money never leaves the wire.
CRUD + the two gates. A journal is never written by a route directly. It goes through
posting.write_journal(), the GL's single guarded write path, shared byte-identical across all six
components: it runs the balance gate (422 if SUM(debit) ≠ SUM(credit)), the closed-period lock (409),
writes idempotently, and records the acting user in journal_audit. Both gates are write-path guards, not DB
constraints — which is precisely why there is only one write path:
# routes.py — post a journal through the one guarded write path, then re-derive
@router.post("/journals")
def post_journal(j: JournalIn,
user: dict = Depends(require_permission("gl:journal:post")),
s: Session = Depends(db)):
"""Post a journal + its lines through the GL's single guarded write path. Both gates run
inside posting.write_journal(): SUM(debit) must equal SUM(credit) (422 otherwise) and the
target period must be open (409 otherwise). They are write-path guards, not DB
constraints — which is exactly why there is only one write path."""
lines = [{"journal_id": j.journal_id, **ln.model_dump()} for ln in j.lines]
total_debit, total_credit = posting.assert_balanced(lines)
header = {k: v for k, v in j.model_dump().items() if k != "lines"}
header.setdefault("journal_date", posting.now())
header["journal_date"] = header.get("journal_date") or posting.now()
posting.write_journal(s, header, lines, actor=user["sub"])
posting.rederive_ledger([ln.account_id for ln in j.lines], as_of=posting.now())
return {"ok": True, "journal_id": j.journal_id,
"total_debit": str(total_debit), "total_credit": str(total_credit),
"balance": str(total_debit - total_credit)}That last-but-one line — posting.rederive_ledger(...) — is the fix for bug 1, and it is one line, which is
exactly why four routes were missing it.
Workflow op — the period-end reval (POST /revalue). This is the skill's reason to exist, in one
handler: revalue one open foreign-currency ledger balance at the period-end rate, write the register
idempotently, post the balanced reval journal, and auto-reverse it next period. Every figure is exact
Decimal; _round2 is the SQL ROUND(x, 2) in Decimal (never a binary float):
# routes.py — the period-end FX revaluation
@router.post("/revalue")
def revalue(r: RevalueIn,
user: dict = Depends(require_permission("gl:journal:post")),
s: Session = Depends(db)):
led = s.get(Ledger, r.account_id)
if not led or led.balance is None or led.currency is None:
raise HTTPException(404, "no open ledger balance for that account")
prior = s.get(FxRate, {"from_ccy": led.currency, "to_ccy": r.functional_currency,
"rate_date": r.prior_rate_date})
new = s.get(FxRate, {"from_ccy": led.currency, "to_ccy": r.functional_currency,
"rate_date": r.period_end_rate_date})
if not (prior and new and prior.rate is not None and new.rate is not None):
raise HTTPException(422, "missing prior or period-end rate for the currency pair")
# ── the two things this calculation must get right, both found live ─────────────────
#
# (1) WHAT `ledger.balance` IS. This code used to read `led.balance` as a FOREIGN-currency
# amount and convert it twice (balance x prior_rate, balance x new_rate). Every other
# component writes that column as the FUNCTIONAL-currency signed net of the journal
# lines (posting.rederive_ledger). One column, two incompatible readings — invisible
# standalone, because on its own bucket this component was the only writer.
#
# (2) THE BASE MUST EXCLUDE THE REVAL'S OWN JOURNALS. A reval posts to the very account it
# revalues, so reading the CURRENT ledger balance means the second run revalues the
# first run's result: 3,230.00 became 3,326.39 on a re-run, and the trial balance still
# netted to 0.00 the whole time — silently wrong, not loudly broken. The ON CONFLICT
# writes were idempotent; the INPUT was not. So the base is re-derived here from posted
# lines EXCLUDING every fx-reval journal: the transaction-date carrying value, which
# does not move no matter how many times the reval runs.
base = s.execute(text(
"SELECT COALESCE(SUM(jl.debit) - SUM(jl.credit), 0) "
" FROM journal_lines jl JOIN journals j ON j.journal_id = jl.journal_id "
" WHERE jl.account_id = :a AND j.status IN ('posted','reversed') "
" AND COALESCE(j.source, '') NOT IN ('fx-reval', 'fx-reval-reversal')"),
{"a": r.account_id}).scalar()
orig_balance = _round2(Decimal(base)) # transaction-date carrying value
foreign_units = orig_balance / prior.rate # implied FC units (exact Decimal)
revalued_balance = _round2(foreign_units * new.rate) # re-converted at period-end
gain_loss = revalued_balance - orig_balance
# (1) idempotent write of the reval register — INSERT ... ON CONFLICT (reval_id) DO UPDATE
upsert(s, FxReval, [{
"reval_id": r.reval_id, "account_id": r.account_id, "ccy": led.currency,
"orig_balance": orig_balance, "revalued_balance": revalued_balance,
"gain_loss": gain_loss, "rate": new.rate, "period": r.period,
}], key="reval_id")
s.commit() # commit BEFORE we post the journal (no read-your-writes inside a txn)
# (2) post the BALANCED reval journal — a GAIN debits the asset, credits FX gain/loss
# … via posting.write_journal(...), then:
posting.rederive_ledger([debit_acct, credit_acct], as_of=posting.now())
# (3) if auto_reverse: post the debit<->credit mirror linked by reverses_journal_idFour DataK3 rules are load-bearing in that handler. Idempotent writes — the fx_reval write is upsert,
so a re-run recomputes in place (23505 on a bare re-INSERT). Idempotent inputs — the base excludes
the reval's own journals, so the recompute produces the same number rather than a new one. Money over the
pg wire — base, prior.rate, new.rate are all Decimal, divided and multiplied exactly and rounded
with _round2; nothing becomes a float. No read-your-writes inside a txn — the register write is
committed before the journal is posted, and rederive_ledger opens its own session for a second
committed transaction, because a SELECT cannot see rows the same transaction just wrote.
Live-verified on gl-suite: POST /revalue on account 1400 returns gain_loss = "3230.00"
(orig_balance 108240.00, revalued_balance 111470.00, rate 1.114700), posts a reval journal that
balances (0.00), leaves ledger 1400 at 111470.00 still flagged EUR, and the bucket-wide trial
balance still ties:
# routes.py — the invariant that proves the books tie: total debits = total credits
@router.get("/trial-balance")
def trial_balance(user: dict = Depends(current_user), s: Session = Depends(db)):
"""The invariant that proves the books tie: over every posted line, total debits equal
total credits, so the trial balance nets to 0.00 (exact — no float tail)."""
d, c = s.execute(
select(func.coalesce(func.sum(JournalLine.debit), 0),
func.coalesce(func.sum(JournalLine.credit), 0))
).one()
d, c = _round2(Decimal(d)), _round2(Decimal(c))
return {"total_debit": str(d), "total_credit": str(c), "balance": str(_round2(d - c))}On the full gl-suite bucket — all six components, 21 journals — GET /trial-balance returns
total_debit "1286470.00", total_credit "1286470.00", balance "0.00": exact, no float tail, before and
after the reval. Adding a new workflow touches only routes.py (and maybe models.py); the plumbing in
db.py / auth.py / posting.py is fixed and shared (see EXTENDING.md).
What composing the six components found
Every number in this post came from a bucket where all six GL components had run — core,
journal-entry, period-close, subledger-reconciliation, financial-reporting and this one, over the
same accounts / journals / journal_lines / ledger rows. Before that, each component had been
validated on a bucket of its own, and each one passed.
Composing them surfaced five bugs. Four are in this component, and not one of them was findable on a bucket where FX revaluation was the only writer. That is the lesson, and it is worth more than the FX math: a component validated alone proves only that it is self-consistent. Composition is a separate proof, and on DataK3 it needs one bucket — which is the same reason an ERP belongs on one bucket in the first place.
1 · The reval posted a journal and never re-derived the ledger. The 3230.00 gain went into
journal_lines and appeared in no report: not ledger, not the trial balance by account, not the
balance sheet. Standalone this was invisible, because on its own bucket the reval read ledger as an input
and nothing else ever wrote it — the table simply never needed to be current. Composed, the reval journal
landed and every report kept showing the pre-reval balance. The fix is the one line at the end of the
handler: post through posting.write_journal(), then posting.rederive_ledger(...) in a second committed
transaction — second, because DataK3 has no read-your-writes inside an open transaction, so a re-derive in
the same txn would not see the lines it is meant to aggregate.
2 · Three other posting routes had the same omission. Composed, five routes disagreed about whether
ledger was current, and the one you called last decided what the balance sheet said.
Journal entry tells that half of the story, and it is why the re-derive now sits
inside the shared write path's contract rather than in five separate places by convention.
3 · The re-derive silently disarmed the reval. rederive_ledger used to stamp ledger.currency from the
calling component's FUNCTIONAL_CURRENCY default — "USD". So any posting, from any component, anywhere
in the suite, overwrote the EUR marker on account 1400. The reval reads ledger.currency to pick the
rate pair; with the marker gone it had nothing to revalue. A payroll journal could disable FX revaluation,
and nothing anywhere would raise. The fix is a single COALESCE over a LEFT JOIN, and the principle behind
it is worth stating: an account's currency is a property of the account, so a re-derive must not be able to
change it.
# posting.py — rederive_ledger: the currency comes from accounts, never from the caller
where = " AND j.status IN ('posted','reversed') "
if account_ids is not None:
ids = ",".join(str(int(a)) for a in dict.fromkeys(account_ids) if a is not None)
if not ids:
return
where += f" AND jl.account_id IN ({ids}) "
def _write():
with SessionLocal() as s2:
s2.execute(
text("INSERT INTO ledger (account_id, balance, currency, as_of) "
"SELECT jl.account_id, SUM(jl.debit) - SUM(jl.credit), "
" COALESCE(MAX(a.currency), :cur), :as_of "
" FROM journal_lines jl "
" JOIN journals j ON j.journal_id = jl.journal_id "
" LEFT JOIN accounts a ON a.account_id = jl.account_id "
" WHERE 1=1 " + where +
" GROUP BY jl.account_id "
"ON CONFLICT (account_id) DO UPDATE "
" SET balance = EXCLUDED.balance, as_of = EXCLUDED.as_of, "
" currency = EXCLUDED.currency"),
{"cur": currency, "as_of": as_of or now()})
s2.commit()
retry(_write)Two DataK3 details are visible in that fragment and both are load-bearing: the touched ids are inlined as an
IN (…) of validated integers because DuckDB has no = ANY(array), and every value rides in the
SELECT list because DO UPDATE SET accepts only EXCLUDED.<col> references — a literal is rejected
FeatureNotSupported.
4 · ledger.balance had two incompatible readings. This component assumed foreign-currency units;
every other component wrote the functional-currency signed net. One column, two meanings, no error
message — so the reval multiplied an already-converted number by a rate again. Double conversion is a
particularly nasty bug because the result is plausible: still money, still the right order of magnitude,
still balancing. The discipline that ends it is stated once and obeyed everywhere: ledger.balance is
always the functional-currency signed net, and the foreign face amount is recovered by dividing the
carrying value by the rate it was booked at — never by re-reading the column as units.
5 · The reval was not idempotent, and the books balanced anyway. Covered in Step 3, and it is the one to
remember. Re-running walked the gain 3230.00 → 3326.39 → …, and the trial balance read exactly 0.00
throughout, because every run posted a balanced journal. Silently wrong, not loudly broken. The
double-entry invariant — the thing a ledger is for — cannot detect it, because nothing about it is
unbalanced. INSERT … ON CONFLICT makes a write idempotent; making a computation idempotent when it
reads its own previous output is a separate design obligation, and it belongs on the checklist next to "does
it balance".
Auth — config at the edge, a role gate in the app
On Ignite, end-user login is configuration, not code. The GL deploys with the gl-suite
dodil-appid pool attached (user_pool: gl-suite in .dodil/deploy.yaml) and the per-cluster
Ignite gateway runs the entire browser login at the edge — PKCE S256 against the pool issuer, an
AEAD-sealed host-only session cookie, single-flight refresh, EdDSA JWT verification against trust
anchors this app does not hold — then injects the verified identity into every request it forwards:
X-Dodil-User (sub, email, connection, app_roles), X-Dodil-User-Jwt (the raw verified
token, carrying the catalog-expanded permissions claim) and X-Dodil-Auth-Source. Any inbound copy
of those headers is stripped first, so a caller can never forge them.
What survives in the package is a small auth.py that ships no verifier — no JWKS client, no
issuer/audience env, no crypto dependency, and no pyjwt in requirements.txt. It reads the
injected header and keeps the one job the app still owns: role-based gating.
# auth.py — a header-trust reader, not a verifier (the gateway already checked the JWT)
def current_user(request: Request) -> dict:
raw = request.headers.get("x-dodil-user") # {"sub","email","connection","app_roles"}
... # + permissions read off x-dodil-user-jwt
raise HTTPException(401, "end-user login required — no X-Dodil-User from the gateway")
def require_permission(perm: str):
"""Gate a route on a pool permission: Depends(require_permission("gl:journal:post"))."""
def _dep(user: dict = Depends(current_user)) -> dict:
if perm not in user["permissions"] and perm not in user["roles"]:
raise HTTPException(403, f"missing permission: {perm}")
return user
return _depThe GL is the first DODIL module on namespaced permissions — <module>:<object>:<verb>, so one
customer pool can carry every ERP module's roles without collision (gl:journal:post is not
ap:journal:post). Across all six components the audit left exactly three gates standing:
| Permission | Gates | Why this one and not the rest |
|---|---|---|
gl:journal:post | every route that posts a journal | it moves money |
gl:reversal:approve | reversing a posted, audited journal | erasing an audited entry is an approval act, not a posting act — deliberately split out of gl:journal:post so the person who posts is not the person who can un-post |
gl:period:close | closing a period | it freezes a month: after it, operational postings into that period are rejected 409 |
Both write routes here carry gl:journal:post — POST /journals and, less obviously, POST /revalue,
because a revaluation is a journal posting: it debits the foreign-currency account and credits Unrealized
FX Gain/Loss. POST /accounts, POST /fx-rates, GET /fx-reval/… and GET /trial-balance are
current_user only — a published rate is a fact, not a posting.
Everything else takes a signed-in user and nothing more. Reads, ledger re-derives and report materializations move no money and are idempotent, so gating them would be ceremony — and ceremony is exactly what an auditor discounts.
The pool is created once for the whole suite, with the role catalog those gates check — accounting's segregation of duties expressed as permissions:
Create a dodil-appid pool gl-suite with email+password, and set its role catalog: an accountant may post journals; a controller may also approve reversals and close a period.
Pool gl-suite created — issuer https://appid.dodil.io/ihdiash/gl-suite, audience pool:gl-suite, email+password (local) enabled. Catalog set: accountant = gl:journal:post; controller adds gl:reversal:approve and gl:period:close. A user's next token carries app_roles plus the catalog-expanded permissions claim the gates read.
dodil appid pool create gl-suite --with-local
# issuer: https://appid.dodil.io/ihdiash/gl-suite audience: pool:gl-suite
dodil appid roles set gl-suite \
accountant=gl:journal:post \
controller=gl:journal:post,gl:reversal:approve,gl:period:closeThe two-plane rule is unchanged: the pool identifies the user; the app still reaches DataK3
through its own service account (sa_token.py mints and refreshes a client_credentials token
for the pg-wire password) — an app-user is never a bucket principal. Locally, with no gateway in
front of uvicorn routes:app, opt in to a stub identity with DEV_ALLOW_ANON=1; the stub carries
no permissions unless you grant them (DEV_USER_PERMISSIONS=gl:journal:post,…), so the gated
routes stay gated on a laptop too. The full flow — creating the pool, the redirect_uris allowlist,
what the gateway injects, and the off-gateway path where you do verify the pool JWT yourself — is
App authentication; the catalog mechanics are App
roles.
Get the code
The package is a real download — code/gl-multi-currency/v1.tar. This post is
a walkthrough of exactly those files; the tarball is source only, no Dockerfile — the deploy story lives
in the post, and the deployable artifact is the suite app the six components compose into (below):
models.py # SQLAlchemy — accounts/journals/journal_lines/ledger (consumed) + fx_rates + fx_reval (owned)
routes.py # FastAPI — CRUD + POST /revalue (reval → balanced journal → auto-reverse) + /trial-balance
db.py # the engine + the ON CONFLICT upsert helper every route uses
posting.py # THE guarded journal write path: balance gate + closed-period lock (SHARED, byte-identical)
auth.py # gateway header-trust identity — the app ships NO auth code (SHARED, byte-identical)
sa_token.py # mints/refreshes the app's service-account token = the pg-wire password (SHARED)
PLATFORM.md # the platform invariants you COPY (not generate) — identical in every package
.env.example # BUCKET + PG_HOST/PG_PORT + DODIL_TOKEN (or the service-account pair)
requirements.txt # sqlalchemy, psycopg[binary], fastapi, uvicorn, pydantic, httpx
There is no pyjwt and no issuer/audience configuration: with the gateway in front, there is nothing left to
verify. And read PLATFORM.md before you copy any of this into a customer build — these packages are
reference implementations to generate from, and that file is the short list of lines you copy verbatim
instead.
Run it — point .env at your bucket, create the tables from the models, serve:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then set DODIL_TOKEN (your `dodil auth login` token)
# BUCKET defaults to "gl-suite"; create it once (Step 1), then the tables from the models:
python -c "import db, models; models.Base.metadata.create_all(db.engine)"
# journal_audit belongs to gl/journal-entry, so it is not in THIS package's models — create it
# with the CLI from Step 1 (or install gl/core + gl/journal-entry) before the first post.
# no gateway in front of uvicorn, so opt the stub identity in — and grant it the gate
export DEV_ALLOW_ANON=1 DEV_USER_SUB=dev-controller DEV_USER_PERMISSIONS=gl:journal:post
uvicorn routes:app --reload
# POST /accounts /fx-rates /journals · POST /revalue · GET /fx-reval/{id} · GET /trial-balancemodels.Base.metadata.create_all is the ORM tab's every class at once — the same tables Steps 1–3 built
by CLI, created from the natural-key models with no migration tool.
The suite — six components, one app
This package runs on its own — uvicorn routes:app — and that is what the post above walks through; the
/code/gl-multi-currency download is exactly that. Deployed, the six GL components are one app:
gl-suite-app is a single FastAPI with a router per component, one canonical models.py, and the one
posting.py write path, wired with plain imports (no importlib loader), over one bucket — gl-suite — and
one dodil-appid pool, so a controller signs in once for the whole ledger. It ships by the ordinary git cycle,
repo → CI → registry → CD, which is Ship a DODIL app. One app rather than
six is the ERP default: you split only for a stated reason — a public surface against a private engine,
independent scaling, a distinct trust boundary — and the GL has none of those. It does, as the section above
shows, have a strong reason to stay on one bucket.
How the pillars map
One bucket, one bill, one auth context — the rate you quote, the ledger balance you revalue, the register you write, and the journal you post are all rows in the same bucket, over one copy. What this reval would otherwise be:
| Job | The usual stack | On DataK3 |
|---|---|---|
| Store FX rates exactly | A rates feed / a spreadsheet tab | fx_rates — DECIMAL(18,6), exact, keyed on (from_ccy, to_ccy, rate_date) |
| "What's the EUR position worth in USD now?" | Export the ledger, compute in Excel, key it back | one SELECT — the posted lines JOIN fx_rates, ROUND(carrying / prior × new, 2) |
| Gain/loss to the cent | Careful float handling, or a numeric lib | DECIMAL(18,2) columns — exact ROUND, no float tail |
| Revalue exactly once (retry-safe) | App-level dedupe | INSERT … ON CONFLICT (reval_id) DO UPDATE for the write, plus a base that excludes the reval's own journals for the computation |
| Reverse it next period | A manual reversing entry someone remembers to book | a mirror journal linked by reverses_journal_id; nets to 0.00 |
| Point your own tools at it | Per-system drivers & creds | data connect — psql / a BI tool, DB = bucket |
No rates feed + a ledger + a spreadsheet in the middle — the reval is a query against the same wire that
holds the ledger. This skill consumes the gl/core masters and adds only fx_rates + fx_reval, so it
composes onto the same rows every other gl/* workflow writes.
Customize — the decisions this skill asks you
Q1 · functional_currency — the currency you revalue into
"What functional (reporting) currency do you revalue foreign balances INTO?" → USD (default) is the currency every foreign balance is converted to at the period-end rate, and the currency the gain/loss is expressed and rounded in.
fx_ratesare quotedfrom_ccy → USD. Inherit it fromgl/core— set it once there and every reval uses it.
Q2 · reval_period — the period being revalued
"Which accounting period are you revaluing (YYYY-MM)?" → 2026-09 (default) makes the reval read the
2026-09-30period-end rate (1.114700) and the prior period-end rate (2026-08-31,1.082400), and stampsfx_reval.period+ the reval journal's period. The engine reads the rate fromfx_rates, never a hard-coded constant — change the period and it re-derives against that period's rate. The period must be open:posting.assert_period_open()rejects an operational posting into a closed one with409.
Q3 · auto_reverse — reverse next period?
"Auto-reverse the revaluation at the start of the next period?"
- true (default) → posts the reversing mirror journal (
reverses_journal_idset) so the two periods net to0.00— the standard treatment for an unrealized reval, re-struck fresh each month. - false → the reval stands as a period-end carrying adjustment (this is what the live
gl-suiterun did); next month's reval still computes off the transaction-date carrying value, because the base excludesfx-revaljournals either way — which is exactly what makes both settings safe to re-run.
Q4 · rate_source — the rate provenance
"What is the source/provenance stamped on each FX rate?" → ECB (default) stamps every
fx_ratesrow with where the rate came from (a central-bank fix, your treasury desk, a rate feed) so a controller signing the reval can audit each rate to its source.
Test
Every command below ran live against DataK3 on 2026-09-08, on bucket gl-suite (org IHDIASH) — with
all six GL components running together on that one bucket, not on a throwaway bucket of this component's
own. That is what makes assertions 2–5 meaningful, and it is where the five bugs above came from. The bucket
is still up. The default branch is {functional_currency: USD, reval_period: 2026-09, auto_reverse: true, rate_source: ECB}.
# 1) FX rates read back EXACT at DECIMAL(18,6) — no float tail
dodil data sql -b "$BUCKET" "SELECT from_ccy, to_ccy, rate_date, rate FROM fx_rates ORDER BY from_ccy, rate_date"
# EUR USD 2026-08-20 1.082400 · EUR USD 2026-08-31 1.082400 · EUR USD 2026-09-30 1.114700
# GBP USD 2026-08-31 1.267845 · GBP USD 2026-09-30 1.291200
# 2) the reval — gain/loss exact
dodil data sql -b "$BUCKET" "SELECT account_id, ccy, orig_balance, revalued_balance, gain_loss, rate FROM fx_reval WHERE reval_id = 1"
# 1400 EUR, orig_balance 108240.00, revalued_balance 111470.00, gain_loss 3230.00, rate 1.114700
# (EUR 100,000 recovered as 108240.00 / 1.082400, re-converted at 1.114700)
# 3) the reval journal balances
dodil data sql -b "$BUCKET" "SELECT SUM(debit) AS d, SUM(credit) AS c, SUM(debit)-SUM(credit) AS balance FROM journal_lines WHERE journal_id = 20269001"
# d 3230.00, c 3230.00, balance 0.00
# 4) the gain reached the LEDGER — bug 1's assertion, and the EUR marker survived (bug 3's)
dodil data sql -b "$BUCKET" "SELECT account_id, balance, currency FROM ledger WHERE account_id IN (1400, 4900) ORDER BY account_id"
# 1400 111470.00 EUR 4900 -3230.00 USD
# 5) idempotent END TO END — the whole reval re-run three more times changed nothing
# → fx_reval 1 row, gain_loss 3230.00, ledger 1400 111470.00, reval journal 2 lines, balance 0.00
# 6) the books still tie across the whole suite — 21 journals, six components, one bucket
dodil data sql -b "$BUCKET" "SELECT SUM(debit) AS d, SUM(credit) AS c, (SELECT SUM(balance) FROM ledger) AS trial_balance, (SELECT COUNT(*) FROM ledger) AS rows FROM journal_lines"
# d 1286470.00, c 1286470.00, trial_balance 0.00, rows 15
# 7) drop-in clients: same bucket, your own psql / BI tool
dodil data connect "$BUCKET" # pg / bolt / grpc endpoints
# pg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/gl-suite
# bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 · grpc table-rpc.uk-lon-1.dodil.io:443One-shot
With the DODIL MCP connected, paste this to run the whole FX revaluation at once (assumes the gl/core
masters, or Step 1's stub, are in place):
Run the month-end FX revaluation on DataK3 bucket gl-suite (functional currency USD, period 2026-09).
Confirm each step.
1. Seed fx_rates (composite key from_ccy, to_ccy, rate_date; rate DECIMAL(18,6), source): EUR→USD 2026-08-20
= 1.082400, 2026-08-31 = 1.082400 and 2026-09-30 = 1.114700; GBP→USD 2026-08-31 = 1.267845 and 2026-09-30
= 1.291200. Source ECB. Rates must read back EXACT at (18,6).
2. Create fx_reval (key reval_id: account_id, ccy, orig_balance DECIMAL(18,2), revalued_balance DECIMAL(18,2),
gain_loss DECIMAL(18,2), rate DECIMAL(18,6), period). Revalue account 1400 (EUR Bank Account). Derive the
base as SUM(debit) − SUM(credit) over its posted/reversed lines EXCLUDING journals whose source is
fx-reval or fx-reval-reversal — that is orig_balance, and it is what makes a re-run a no-op. Recover the
euro units by dividing by the 2026-08-31 rate, re-convert at the 2026-09-30 rate for revalued_balance,
gain_loss = revalued − orig. Write reval_id 1 with INSERT … SELECT … ON CONFLICT (reval_id) DO UPDATE.
orig_balance = 108240.00, revalued_balance = 111470.00, gain_loss = 3230.00.
3. Post the balanced reval journal (journal 20269001, status posted, period 2026-09, source fx-reval): debit
1400 3230.00, credit 4900 3230.00. Assert SUM(debit) = SUM(credit) = 3230.00 before posting. THEN
re-derive the ledger for accounts 1400 and 4900 in a separate statement — taking each row's currency from
accounts.currency — so the gain reaches the reports: ledger 1400 = 111470.00 (currency still EUR),
4900 = -3230.00.
4. Prove idempotency both ways: a bare re-INSERT of fx_reval reval_id 1 raises SQLSTATE 23505; and re-running
steps 2–3 end to end three more times leaves fx_reval = 1 row, gain_loss = 3230.00 and ledger 1400 =
111470.00 unchanged.
5. Reverse into the next period (auto_reverse): journal 20269002 (period 2026-10, source fx-reval-reversal,
reverses_journal_id 20269001), mirror lines credit 1400 3230.00 / debit 4900 3230.00. Over the two
journals each account nets to 0.00.Connect your tools
Everything this build wrote lives in one DataK3 bucket, reachable by your own stack — not just the CLI. A
treasury or close team drives the reval from psql, a Python close script, or a BI tool over the same
wire. data connect gl-suite prints the endpoints; point your tools straight at the same rows:
- SQL over Postgres wire —
psql,psycopg/asyncpg(Python),node-postgres(TS), any BI tool. The reval is oneINSERT … SELECT … ON CONFLICTjoining the posted lines tofx_rates; the gain/loss is oneSELECT.
Full, live-validated walkthrough: Connect your tools.
Conclusion
You now have FX revaluation on one DataK3 bucket, onto the gl/core masters: an fx_rates table
where rates are DECIMAL(18,6) and exact, an fx_reval register that restates a EUR 100,000 bank
position carried at 108240.00 into 111470.00 with an unrealized gain of exactly 3230.00, a
balanced reval journal, a ledger re-derive that puts that gain in front of every report, and an
auto-reversal that nets the two periods to 0.00. Money is DECIMAL(18,2), rates are DECIMAL(18,6),
every write is INSERT … ON CONFLICT — and, the part that took a shared bucket to learn, the reval's input
is idempotent too, so a re-run of the close recomputes the same number instead of revaluing its own output.
No rates feed + a ledger + a spreadsheet in the middle. One bucket, one bill, one copy of the rows.
If you take one thing from this post that is not about currency: components that pass on their own can still be wrong together, and on DataK3 the cheapest way to find out is to run them on one bucket. Four of the five bugs above were in code that had already been validated, twice, and every one of them was invisible until this component had neighbours.
Next steps:
- Compose the rest of the GL suite onto the same masters:
journal-entry(post balanced journals idempotently, reverse by mirror),period-close(lock a period, roll net income to retained earnings),financial-reporting(a balance sheet that ties over the account graph),subledger-reconciliation(AP/AR control-account tie-out). - Run the reval on a schedule with the
gl-reval-engine— a pure-SQL, image-mode Ignite app that recomputesfx_reval+ posts the reval journal for every open foreign-currency balance over the pg wire. Being pure SQL, its service account needs onlyk3.editor+ignite.app-developer(noignite.model-user). - The GL is the deliberate stress test of the DataK3 pg wire for finance — this workflow proves the
multi-currency corner: rates exact at
DECIMAL(18,6), the reval balanced, the gain/loss exact, and the whole thing reversible.