How a journal entry is formed
This section is the mechanics of the ledger: what a journal entry is in GreatBook, how one is built, which accounts each kind of business document debits and credits, and what refuses a bad entry before it reaches the database. The general ledger page states the guarantee. This page walks the code path that delivers it, in the order it executes.There is exactly one function that writes the book of record:
LedgerService.post_entry() in backend/app/services/ledger_service.py.
Routers, agent nodes, sub-ledger operations, connectors and the retired ingestion pipeline all funnel through it.
Nothing else writes journal_entries or journal_entry_lines.The three things that make an entry
A posting request is a header, a set of lines, and a source identity.source_type and source_id are not metadata.
(org_id, source_type, source_id, entry_set) is unique, and the writer looks it up first.
An exact replay returns the existing entry, while reuse of the key with different immutable request facts raises a typed conflict.
entry_set exists so one business event can carry more than one book: primary today, with room for a statutory or management set later.
It is part of the idempotency key precisely so the same source can legitimately produce one entry per set and no more.The path a posting takes
Every step above the insert either passes or raises. Nothing is written until step 8, and step 18 of the invariant list is the rule that makes that true: on any failure, raise a typed error and write nothing.The lock comes first, and it is a transaction lock
pg_advisory_xact_lock(hashtext(org_id)) is taken as the first statement, before the idempotency read.
That ordering is the whole concurrency design.
The chain tail is read and extended inside one critical section, so two postings racing for the same head cannot both read it and both claim it.
The lock is released when the caller’s transaction ends, not when the function returns - which is why the engine must never commit.
The engine never commits
post_entry writes inside the caller’s open transaction and does neither commit nor rollback.
That is invariant 17, and it is what lets a sub-ledger row and its journal entry be one atomic fact.
The bookkeeper’s post-back is the clearest example.
In one transaction it posts the entry, opens the AP obligation the control-account credit stands for, records the VAT register line the input-VAT leg stands for, flips the intake row to posted with its entry id, and writes the audit event.
All of it lands or none of it does.
The first two are not hypotheses: both were found by real Close runs, and both are now covered by tests that post the pre-fix shape and assert the tie fails.
Base amounts are computed once and stored
Each line storesdebit_base / credit_base alongside its native amount and the rate used.
They are never recomputed on read.
A rate table correction therefore changes future postings and the revaluation sub-ledger, never what a closed period already reported.
See FX is as-of-date for how the rate is chosen and what happens when there is none.
Two chains, not one
The line serialisation is sorted by account, debit and credit, and every
Decimal is stringified, so line order cannot change a checksum and 10 hashes the same as 10.0000.
A verifier can therefore reproduce a checksum from the stored rows - which is what the Auditor’s hash-chain family does, using the writer’s own hashing code rather than a reimplementation.
Full detail in Immutability and the hash chain.
Correction is a new entry, never an edit
The typed business-reversal service posts a mirror entry that swaps every line’s debit and credit on an explicitly reviewed date in an open period. It validates the native source object first, compensates the GL and native register atomically, and leaves the originalposted and untouched; a database trigger enforces ledger immutability even against a direct SQL session.
The request binds the original object, written reason, reversal date, checksum version and exact source fingerprint.
An exact replay returns the original receipt, while changed immutable facts are a conflict rather than a second reversal.
The web product can stage an eligible event from Transactions, Close or posted Adjustments.
The MCP contract exposes a typed staging tool over the same server-read facts; both paths create temporary review state, and the shared staged-workspace commit path can invoke the canonical service only after exact validation and confirmation.
Where it is enforced
The test that would fail if it broke
backend/tests/test_ledger_engine.py drives every refusal path and the idempotency and chain behaviour against a real migrated Postgres.
backend/tests/test_gl_security.py covers the database-enforced half.
Related
- Posting rules: the map - which accounts each document type moves, and who decides
- What guards the engine - the 18 GL invariants, the 104 Close checksums, and the gate above them
- The 18 GL invariants - the numbered table
- The chart of accounts - the codes every line names