ledger

Architecture and internals

How the ledger works under the hood: the code layout, what happens during a posting, how idempotency, locking, holds, refunds and untracked accounts are implemented, and which rules the database enforces on its own. Read this before changing the code.

Contents

Design principles

  1. The database is the last line of defence. Every rule that keeps the books correct (balanced transactions, immutable entries, no overdrafts, consistent balances) is enforced by Postgres constraints and triggers, not only by Go code. A bug in Go, or someone writing SQL by hand, can’t corrupt the ledger.
  2. Exact integers only. Amounts are whole numbers of a currency’s smallest unit. Nothing is ever rounded; decimals exist only at the edges, converted exactly.
  3. Every write is idempotent. Each write is keyed by a caller-chosen reference (or, for hold commits, the hold), so retries are always safe.
  4. Embeddable first. The core is a Go library that can join the caller’s database transaction. The HTTP service is a thin optional layer on top.
  5. Append-only history. Entries are never updated or deleted. Corrections are new transactions (refunds), so history is complete.

Code layout

Package ledger (repository root):

File Responsibility
ledger.go Service, New, OpenDB, the UTC clock (now)
models.go Ledger, Account, Transaction, Entry, Hold structs and their database mapping
ids.go Typed IDs (AccountID, …), generation and validation of <prefix>_<uuidv7>
amount.go Amount: arbitrary-precision integer, JSON, SQL scanning
currency.go Currency, registration, the cache, decimal Parse/Format, converting Value to Amount
account.go Creating and reading ledgers and accounts; computing untracked balances
post.go Post, locking (lockAccounts), applying legs (posting), validation, request hashing
holds.go Create, commit, void and expire holds
refunds.go Refunds, reversals and what is refundable
batch.go Batches and exchanges
reports.go Transaction search, trial balance, balance sheet, income statement, statements, period close
events.go The event outbox (emit), ListEvents, consumer cursors
snapshot.go Running totals for untracked accounts
partial.go Shared logic for partial commits and refunds (aggregate, pickLegs)
query.go Reading transactions, listing entries, BalanceAt, updating transaction metadata
tx.go Transactions and savepoints (inTx), idempotent execution (once), error classification
errors.go Sentinel errors and Postgres error matching
migrate.go Embedded migration runner, CheckSchema
migrations/ SQL schema, one up/down pair per version

Other packages:

Path Responsibility
httpapi/httpapi.go Routes, auth and timeout middleware, request decoding, error mapping
httpapi/business.go Handlers for account controls, corrections, batches, reports, period close, events
httpapi/views.go Response shapes that add the decimal *_value fields
httpapi/ops.go Health checks, Prometheus metrics, rate limiting
webhook/ Delivers events to an HTTP endpoint
cmd/ledgerd/ The standalone server: configuration, port binding, background jobs, graceful shutdown
ledgertest/ Test helpers: an embedded Postgres and a fresh migrated database per test

Database schema

Everything lives in the ledger schema.

Table Purpose Notable columns
currencies Registered currencies code (PK), decimals (NULL only for pre-0005 currencies until registered)
ledgers Books id, name, metadata, closed_through
accounts Balances credit_total, debit_total, balance, pending_debit, pending_credit (all NUMERIC(78,0)), version, untracked, allow_negative, overdraft_limit, status, status_reason, type; FK to currencies
transactions Business events reference (unique per ledger), request_hash, refund_of, reversal_reason, hold_id (unique), batch_id, refund_count, txid
entries One per leg amount, direction, balance_after, account_version (unique per account), txid
holds Reservations legs (JSONB), status, request_hash, transaction_id, expires_at, resolved_at
batches Groups of transactions posted together reference (unique per ledger), kind, rate, request_hash
period_closes Closed periods ledger_id, closed_through, trial_balance (JSONB)
events Outbox of every change seq, id, type, subject_id, data (JSONB), txid
webhook_cursors Each event consumer’s progress name, event_id
account_snapshots Running totals of untracked accounts txid_bound, credit_total, debit_total, entry_count
schema_migrations Applied migration versions version, applied_at

Columns that aren’t exposed in the API:

Main indexes: entries (account_id, created_at) for BalanceAt, entries (account_id, txid, id) for paging untracked accounts, the unique entries (account_id, account_version) for paging tracked ones, entries (transaction_id), partial indexes on pending holds, expiring holds, refunds and batch members, transactions (ledger_id, id) for search, a GIN index on transaction metadata, and events (txid, seq) for the event feed.

The life of a posting

Service.PostTx (post.go), step by step:

  1. Validate the request without touching the database: reference length, currency code format.
  2. Convert values. If any leg has a Value, look up the currency’s decimals (cached) and convert each to an Amount, then clear Value. From here on only amounts exist.
  3. Validate the legs: at least 2, valid account IDs, positive amounts within 78 digits, valid directions, debits = credits, at least 2 distinct accounts.
  4. Hash the request: requestHash("post", currency, legsKey(legs)).
  5. Run once inside a database transaction (or a savepoint in the caller’s):
    1. Find: look for a transaction with this reference in the ledger of the first account. Same hash → return it (a replay). Different hash → ErrReferenceConflict. Not found → continue.
    2. Lock: lockAccounts loads every account, locking the tracked ones (FOR NO KEY UPDATE, sorted by ID). It checks they all exist and share one ledger, and picks the timestamp for this posting.
    3. Apply in memory (posting.post): check each account’s currency, then for each leg add the amount to the account’s credit or debit total, recompute the balance, bump the version, and build an entry carrying balance_after and account_version. Untracked accounts get entries but no totals.
    4. Check funds on the net result of all legs: every locked account without allow_negative must have balance − pending_debit ≥ 0.
    5. Save: insert the transaction and its entries, update the locked accounts.
  6. At COMMIT, the deferred transactions_balanced trigger re-checks that the transaction has at least 2 entries and that they balance.

Transactions, savepoints and errors

tx.go holds the plumbing every write uses.

inTx runs a function in a new database transaction, or, if it was given the caller’s bun.Tx, in a savepoint inside it. A failed ledger operation then rolls back only its savepoint, and the caller’s transaction stays usable. The savepoint:

classify turns Postgres concurrency failures (serialization failure, deadlock, lock not available) into ErrRetryable, and wraps context cancellation so errors.Is(err, context.DeadlineExceeded) works.

Idempotency

Every write stores a request_hash: SHA-256 over the request’s parts, separated by NUL bytes.

Operation Hashed parts Looked up by
Post "post", currency, legs (ledger_id, reference)
Create hold "hold", currency, legs (ledger_id, reference) on holds
Commit hold "commit", hold ID, amount, legs hold_id (unique)
Refund "refund", original ID, amount, legs (ledger_id, reference)

legsKey makes the comparison independent of how the request was written: legs are merged per (account, direction), sorted, and serialized as account|direction|amount;. So reordered legs, a leg split in two, or a value instead of an amount all give the same key. Description and metadata aren’t part of the hash.

Races. Two identical requests can run at the same time. Both miss in “find”; one inserts first, and the other hits a unique violation on transactions_reference_key, transactions_hold_id_key or holds_reference_key. once catches that and runs “find” again, returning the winner’s result. If the caller’s REPEATABLE READ snapshot can’t see the winner yet, it returns ErrRetryable instead.

A duplicate can also lose earlier: it waits for the winner’s account locks, and once the winner commits, its own checks fail (the funds are spent, nothing is left to refund, a batch member’s reference is taken) before it ever reaches the unique insert. So whenever create fails, once runs “find” again in a fresh transaction, and returns the winner’s result if there is one with the same request hash. A genuinely different request keeps its error.

Hold commits have no reference. The commit transaction carries hold_id, which is unique, so a hold commits at most once and no user-chosen reference can block it.

Locking and concurrency

Two postings that touch different accounts run fully in parallel. Two that share an account run one after the other on that account. Hot accounts that everyone touches (like @world) should be untracked.

Holds

holds.go:

The holds_guard_update trigger only lets a hold move from pending to committed or voided, never back, and requires a committed hold to point at its own commit transaction.

Refunds

refunds.go:

  1. Lock the original (lockForRefund): SELECT … FOR NO KEY UPDATE, then refund_count = refund_count + 1. Concurrent refunds of the same transaction therefore run one at a time. Because each one writes the row, a refund in a REPEATABLE READ transaction with a stale snapshot fails with a serialization error (ErrRetryable) instead of computing what’s left from old data. A refund of a refund is rejected.
  2. Compute what’s refundable: the original entries with directions reversed, merged per (account, direction), minus the sum of all earlier refund entries.
  3. Choose the legs with pickLegs, the same logic as partial hold commits:
    • nothing given → everything left;
    • an amount → only if what’s left is one debit and one credit leg, both reduced by that amount;
    • explicit legs → each (account, direction) at most what’s left.

    An explicit zero amount is an error. Amount.isSet tells “not given” apart from 0.

  4. Post a normal transaction with refund_of set.

Untracked accounts

Untracked accounts trade read speed for write throughput.

  Tracked Untracked
Locked during postings Yes No
Stored totals Yes, updated on every posting Always 0 (the accounts_untracked constraint enforces it)
Reading the balance One row Sums all of the account’s entries (fillUntracked)
Entries have balance_after and account_version Yes No (NULL)
Pending amounts from holds Yes Not tracked
Overdraft check Yes None, so allow_negative is required

Paging untracked entries safely. Without a lock, two postings can commit in a different order than they started. Ordering by ID could then insert an older entry behind a cursor a client already passed. So untracked entries are ordered by (txid, id), where txid is the writing Postgres transaction’s ID, and only entries with txid < pg_snapshot_xmin(pg_current_snapshot()) are returned: those from transactions older than every transaction still running. An entry appears only once nothing older can still commit. The cost: a long-running transaction anywhere in the database delays new entries on untracked accounts.

Tracked accounts don’t need this: account_version is assigned under the lock, gap-free, so ordering by it is final.

History and timestamps

BalanceAt(t):

For this to be correct, an account’s entry timestamps must never go backwards, even across servers with skewed clocks. lockAccounts therefore sets the posting’s timestamp to the later of the current time and every locked account’s updated_at. Timestamps are UTC, truncated to microseconds (what Postgres stores), so values returned by writes equal what later reads return.

Amounts

Amount (amount.go) wraps an immutable *big.Int.

Currencies and decimal values

currency.go:

Account controls

Status, overdraft limits and types live on the account row (account.go UpdateAccount).

Hold expiry

Reversals and metadata updates

Batches and exchanges

batch.go builds on the posting engine, split for this purpose into steps:

  1. Validate every member and convert values, as Post does. Hash ("batch", kind, rate, each member's reference, currency and legs).
  2. In once: lock the union of all members’ accounts in one sorted pass (still deadlock-free), and check that member references aren’t taken.
  3. posting.apply each member in order, without checking funds; then checkFunds once on the combined result. So an earlier member can fund a later one, and the batch fails as a whole if the end result overdraws an account.
  4. Insert the batch row, then each transaction with batch_id (insertTransaction), then write the accounts back once (saveAccounts).

Members’ reference is optional (the transactions_reference_or_hold constraint accepts batch_id instead). A batch is limited to 1,000 transactions to bound lock and transaction size.

Exchanges are batches of kind exchange with exactly two two-leg members in different currencies. The rate is stored as given; the ledger doesn’t compute or verify one amount from the other.

Events and webhooks

The outbox. emit inserts a row into ledger.events inside the same database transaction (or savepoint) as the change it describes. So an event exists if and only if its change committed: no lost events, no events for rolled-back changes. Every write path emits: currencies, ledgers, accounts, transactions (including refunds, reversals, hold commits and batch members), holds, batches and period closes.

Ordering without gaps. Events get a seq (BIGSERIAL) and the txid of the writing transaction. Like untracked entries, ListEvents orders by (txid, seq) and only returns events with txid < pg_snapshot_xmin(pg_current_snapshot()): those whose transactions, and every older one, have finished. A consumer’s cursor therefore never passes an event that could still appear.

Webhooks (webhook/):

Snapshots

Reading an untracked account’s balance used to sum all of its entries. snapshot.go keeps a running total per untracked account in account_snapshots:

Reports and period close

reports.go:

Operations endpoints

httpapi/ops.go:

Database-enforced invariants

Rule Enforced by
IDs are <prefix>_<uuidv7> CHECK (ledger.is_id(…)) on every ID column
balance = credit_total − debit_total accounts_balance_consistent
No overdraft unless allowed, counting holds and overdraft limits accounts_no_overdraft: allow_negative OR balance − pending_debit ≥ −COALESCE(overdraft_limit, 0); accounts_overdraft_limit (a limit only without allow_negative)
Only empty accounts are closed; closing is final; a type is set once accounts_closed_empty, accounts_guard_update
No entries on closed accounts, no debits on frozen ones, none in a closed period entries_check
Periods can’t be reopened ledgers_guard_update
A reversal is a refund with a reason transactions_reversal
Batches and period closes never change or disappear; events never change batches_forbid_change, batches_forbid_truncate, period_closes_forbid_change, period_closes_forbid_truncate, events_forbid_update
Untracked accounts keep no totals accounts_untracked
An account’s ledger, indicator, currency and tracking mode never change accounts_guard_update
Every account’s currency is registered FK accounts_currency_fkey
Transactions balance and have ≥ 2 entries transactions_balanced (deferred to COMMIT)
Entries are added only by the database transaction that created their transaction, and match its ledger and currency entries_check
Entries never change or disappear entries_forbid_change, entries_forbid_truncate
Only a transaction’s metadata (and refund count, upward) can change; transactions can’t be deleted transactions_guard_update, transactions_forbid_delete, transactions_forbid_truncate
balance_after and account_version are both set or both NULL; versions are unique per account entries_running_balance, entries_account_version_key
Holds only move from pending to committed, voided or expired; can’t be deleted holds_guard_update, holds_resolution, holds_forbid_delete, holds_forbid_truncate
A hold commits at most once transactions_hold_id_key
References are unique per ledger transactions_reference_key, holds_reference_key
A currency’s decimals are set once; currencies can’t be deleted currencies_guard_update, currencies_forbid_delete, currencies_forbid_truncate

Migrations

migrate.go runs the SQL files embedded from migrations/ in version order.

Version Change
0001 Ledgers, accounts, transactions, entries; immutability and balance triggers
0002 Holds, refunds, untracked accounts, pending amounts
0003 Ledger scoping of transactions and holds, per-ledger references, txid, refund_count, entries_check
0004 Amount columns BIGINT → NUMERIC(78,0); hold leg amounts as strings
0005 currencies table with immutable decimals; FK from accounts
0006 Account status, overdraft limits and types; hold expiry; batches; reversals; period close; events; webhook cursors; snapshots

To add a migration: create migrations/<next version>_<name>.up.sql and .down.sql, make the down migration refuse if it would lose data, and add a test that migrates down and up again.

HTTP layer

httpapi translates HTTP to Service calls and nothing more.

Testing

Run everything with make check (formatting, vet, tests with the race detector).

Known limitations