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

# How a journal entry is formed

> The double-entry engine end to end: the request shape, the eighteen checks in order, the two hash chains, and what the caller still owns.

# 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](/greatbook/capabilities/general-ledger) page states the guarantee.
This page walks the code path that delivers it, in the order it executes.

<Info>
  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`.
</Info>

## The three things that make an entry

A posting request is a header, a set of lines, and a source identity.

| Part       | Shape                                                                           | Why it is separate                                                  |
| ---------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **header** | `org_id`, `entry_date`, `description`, `created_by`, `entry_set`                | the entry's identity and attribution                                |
| **lines**  | `account_code`, `debit` or `credit`, `currency`, `exchange_rate?`, `dimensions` | the accounting content, and the only part that is hashed as content |
| **source** | `source_type` + `source_id`                                                     | the idempotency key: what real-world thing this entry *is*          |

```python theme={null}
PostEntryRequest(
    org_id=org, entry_date=date(2026, 7, 20),
    description="Supplier bill VN-2026-0417",
    source_type="bill", source_id="a3f1…:VN-2026-0417",
    created_by="thai@vinmake.com",
    lines=[
        EntryLine("1200", debit=Decimal("1000000"), currency="VND",
                  dimensions={"activity": "operating", "flow": "domestic"}),
        EntryLine("1300", debit=Decimal("100000"), currency="VND",
                  dimensions={"activity": "operating", "flow": "domestic"}),
        EntryLine("2000", credit=Decimal("1100000"), currency="VND",
                  dimensions={"activity": "operating", "flow": "domestic"}),
    ],
)
```

`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](/greatbook/capabilities/idempotency-and-durability) returns the existing entry, while reuse of the key with different immutable request facts raises a typed conflict.

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

## The path a posting takes

```mermaid theme={null}
flowchart TD
  R["PostEntryRequest"] --> L["1· per-org advisory lock<br/>pg_advisory_xact_lock(org)"]
  L --> I{"2· entry already exists for<br/>(org, source_type, source_id, entry_set)?"}
  I -- yes --> M{"same immutable<br/>request facts?"}
  M -- yes --> RET["return the existing entry<br/>nothing written"]
  M -- no --> RC["typed replay conflict<br/>nothing written"]
  I -- no --> P["3· prepare every line<br/>one-sided · non-negative · account valid<br/>currency resolved · base amounts computed<br/>Tier-1 dimensions present"]
  P --> B{"4· Σ debit_base == Σ credit_base?"}
  B -- no --> U["UnbalancedError"]
  B -- yes --> PER{"5· entry_date in an OPEN period?"}
  PER -- no --> PC["PeriodClosedError"]
  PER -- yes --> N["6· allocate entry_number (monotonic per org)"]
  N --> C["7· read the org's chain tail<br/>checksum = sha256(prev + head + canonical(lines))"]
  C --> H["8· insert the header, born 'posted'"]
  H --> LN["9· insert the lines, with base amounts"]
  LN --> EV["10· append event_stream 'entry.posted', hash-chained"]
  EV --> DONE["PostedEntry(id, entry_number, checksum)"]
```

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.

<Warning>
  The lock and the chain-tail read need a stable **session**, not a transaction pooler.
  `ledger_bridge.gl_dsn` prefers the session-mode database URL over the pooled one for exactly this reason.
  A posting run pointed at the transaction pooler can have its session swapped underneath the lock.
</Warning>

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

| Without the discipline                                            | With it                                     |
| ----------------------------------------------------------------- | ------------------------------------------- |
| GL account `2000` carries a credit no bill accounts for           | the obligation and the credit are one write |
| GL account `1300` carries a debit the VAT register cannot declare | the register line and the leg are one write |
| the queue says `posted` and the ledger holds nothing              | the row and the entry commit together       |

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 stores `debit_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](/greatbook/capabilities/fx) for how the rate is chosen and what happens when there is none.

## Two chains, not one

| Chain       | Row                        | Content hashed                                                                        |
| ----------- | -------------------------- | ------------------------------------------------------------------------------------- |
| entry chain | `journal_entries.checksum` | `prev_checksum` + a canonical header + a canonical, sorted serialisation of the lines |
| event chain | `event_stream.hash`        | `prev_hash` + `seq` + `kind` + the JSON payload                                       |

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](/greatbook/capabilities/immutability-and-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 original `posted` 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

| Concern                                        | Code                                                |
| ---------------------------------------------- | --------------------------------------------------- |
| the writer, the 18 invariants, both chains     | `backend/app/services/ledger_service.py`            |
| the typed error hierarchy every refusal uses   | `backend/app/core/errors.py`                        |
| CHECK constraints, immutability triggers, RLS  | migration `0002_gl_guards_fx_rls`                   |
| the agent path's one write channel             | `langgraph_chat/agents/bookkeeper/ledger_bridge.py` |
| the sub-ledger operations that post through it | `backend/app/logics/finance/`                       |
| typed native and GL compensation               | `backend/app/services/business_reversal_service.py` |

## 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](/greatbook/gl/posting-rules) - which accounts each document type moves, and who decides
* [What guards the engine](/greatbook/gl/guards) - the 18 GL invariants, the 104 Close checksums, and the gate above them
* [The 18 GL invariants](/greatbook/capabilities/gl-invariants) - the numbered table
* [The chart of accounts](/greatbook/capabilities/chart-of-accounts) - the codes every line names
