The general ledger
GreatBook’s book of record is a double-entry general ledger in Postgres. It is not a report over a pile of documents and it is not a spreadsheet with formulas. It is a table of balanced journal entries that only one function is allowed to write.What it guarantees
Every business event, whether it arrived as a bank line, a supplier bill, a customer invoice, a payment or a manual adjustment, becomes a balanced journal entry or it does not exist to the system. There is no second path in.“Single writer” is a stronger claim than “we always use the service”.
It means the validation, the numbering, the hash chain and the audit event are all inside one function in one transaction, so there is no arrangement of callers that can produce a half-written entry.
How it works
LedgerService.post_entry() takes a request and does the same sequence every time.
Three details of that sequence carry more weight than they look like they do.
The advisory lock comes first.
It is a per-org transaction lock, taken before the idempotency read, so the chain-tail read and the append happen under one lock in one transaction.
That is what makes the hash chain safe under concurrency and behind a connection pooler: two concurrent postings cannot both extend the chain from the same head.
The engine never commits.
It writes inside the caller’s open transaction and leaves commit and rollback to the caller.
This is what lets a sub-ledger row and its journal entry be one atomic fact rather than two hopeful ones.
Balance is checked in the base currency, on stored columns.
Every line carries debit_base and credit_base, computed at post time from the resolved rate.
The balance test is over those, so an entry cannot be “balanced in the native currencies but off in the book”.
The data model, in one table each
Statuses are deliberately boring.
An entry is born
posted, in the same statement that inserts it.
There is no draft state in the ledger, because a draft in the book of record is a thing that can be mistaken for a fact.
Where it is enforced
The engine’s dependency closure is psycopg plus the standard library, deliberately.
That is what lets the LangGraph runtime import the real engine rather than reimplementing a second, subtly different one.
The test that would fail if it broke
backend/tests/test_ledger_engine.py drives the writer against a real migrated Postgres and exercises every refusal path.
langgraph_chat/agents/bookkeeper/tests/ runs the same engine from the agent side, including live acceptance against an alembic upgrade head database.
What goes wrong without it
Two writers is the failure. The moment a second code path can insert a journal line, every guarantee on this page becomes a convention: the invariants are whatever the newest writer remembered, the chain has a gap nobody notices, and idempotency depends on which caller ran. The legacy path this replaced did exactly that. It truncated and re-posted, which is fine for a reporting cube and catastrophic for a book of record: the entry a human approved on Tuesday is not the entry that exists on Wednesday, and nothing in the system can tell you it changed.Related
- How a journal entry is formed - the same writer, walked step by step
- The 18 GL invariants - what the writer refuses, and why
- Immutability and the hash chain - what happens after the write
- Idempotency and durability - why re-running is safe
- The Bookkeeper - the only agent on the write path