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

# The general ledger

> One writer, one path, one transaction. How a business event becomes an immutable, hash-chained journal entry.

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

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

## How it works

`LedgerService.post_entry()` takes a request and does the same sequence every time.

```mermaid theme={null}
flowchart TD
  REQ["PostEntryRequest<br/>org · date · description<br/>source_type · source_id · lines"] --> LOCK
  LOCK["1 · per-org advisory lock<br/>pg_advisory_xact_lock"] --> IDEM
  IDEM{"2 · already posted for this<br/>(org, source_type, source_id, entry_set)?"}
  IDEM -- yes --> MATCH{"same immutable<br/>request facts?"}
  MATCH -- yes --> RET["return the existing entry<br/>nothing written"]
  MATCH -- no --> CONFLICT["typed replay conflict<br/>nothing written"]
  IDEM -- no --> PREP["3 · prepare every line<br/>Decimal · one-sided · account · FX · base amounts · dimensions"]
  PREP --> BAL{"4 · debits == credits<br/>in base currency?"}
  BAL -- no --> RAISE["UnbalancedError<br/>nothing written"]
  BAL -- yes --> PERIOD["5 · entry_date falls in an OPEN fiscal period"]
  PERIOD --> NUM["6 · allocate entry_number monotonically"]
  NUM --> CHAIN["7 · checksum = sha256(prev + head + canonical(lines))"]
  CHAIN --> WRITE["8 · insert the header, born 'posted'"]
  WRITE --> LINES["9 · insert the lines with base amounts"]
  LINES --> EVENT["10 · append a hash-chained event_stream record"]
  EVENT --> DONE["PostedEntry<br/>id · entry_number · checksum"]
```

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

| Table                 | What it holds                                         | Notable columns                                                                                                                                 |
| --------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `journal_entries`     | one row per posted entry                              | `entry_number`, `entry_date`, `source_type`, `source_id`, `entry_set`, `status`, `prev_checksum`, `checksum`, `reverses_entry_id`, `created_by` |
| `journal_entry_lines` | one row per leg                                       | `debit_amount`, `credit_amount`, `currency`, `exchange_rate`, `debit_base`, `credit_base`, and the six dimension columns                        |
| `event_stream`        | an org-scoped, hash-chained append log                | `seq`, `kind`, `payload`, `prev_hash`, `hash`                                                                                                   |
| `fiscal_periods`      | the open/closed calendar the writer validates against | `start_date`, `end_date`, `status`                                                                                                              |

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

| Claim                                                    | Code                                                                                                 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| the single writer, and the 18 GL invariants it validates | `backend/app/services/ledger_service.py`                                                             |
| every finance operation posts through it                 | `backend/app/logics/finance/` - settlement, costing, payroll, loans, tax, adjustments, consolidation |
| the agent path posts through it too                      | `langgraph_chat/agents/bookkeeper/ledger_bridge.py`                                                  |
| the database refuses an UPDATE or DELETE on a posted row | migration `0002_gl_guards_fx_rls`                                                                    |

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.

<Warning>
  A green test run against **no** database proves nothing here.
  The finance suites skip without a real Postgres, so CI gives both database jobs a `postgres:16` service container.
  A suite that can silently skip is a suite that can silently stop testing.
</Warning>

## 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](/greatbook/gl/index) - the same writer, walked step by step
* [The 18 GL invariants](/greatbook/capabilities/gl-invariants) - what the writer refuses, and why
* [Immutability and the hash chain](/greatbook/capabilities/immutability-and-hash-chain) - what happens after the write
* [Idempotency and durability](/greatbook/capabilities/idempotency-and-durability) - why re-running is safe
* [The Bookkeeper](/greatbook/agents/bookkeeper) - the only agent on the write path
