← All projects

ledger Backend

Move money without losing the other half

Two entries, one transaction, and a receipt that makes a retry safe.

Double-entry books that stay balanced.

Python / Postgres / HTTP

GitHub ↗
Notes & checks

A double-entry ledger on Postgres, with an HTTP API and an invariant checker. Recorded test suite: 113 tests.

The problem

Imagine a payment succeeds, but the response gets lost. Clicking again should not move the money twice. And if the server stops halfway through, one account should not lose money without the other receiving it. ledger tackles both problems with Postgres and double-entry bookkeeping.

How it works

  1. Alice: −125¢Debit posting
  2. One transactionSame transfer receipt
  3. Bob: +125¢Credit posting
Illustrative transfer of 125 cents. The two postings add to zero.
  1. Name the request

    The caller supplies an idempotency key, a receipt name for one intended transfer. The database reserves that key with the source, destination, and amount. An identical retry returns the original transfer. Reusing the key for a different request fails.

  2. Move both sides together

    The writer locks the accounts in a stable order, checks the balance rules, then writes two opposite postings and updates the balances. A posting is one account's side of a transfer. All of this happens in a transaction, so it commits together or rolls back together.

  3. Check the books independently

    A separate checker compares balances with postings, checks that transfers have the right pair of entries, and looks for broken references and duplicate keys. Its read-only snapshot sees one consistent view even while other requests write.

Decisions & tradeoffs

Count cents, not fractions

Amounts are integer cents, avoiding floating-point rounding. Each database represents one currency. Normal accounts cannot go negative; a clearing account can explicitly allow it for funding.

Audit without repairing

The checker reports violations rather than changing records. It loads a complete snapshot into memory, which keeps the checks straightforward but makes memory use grow with the ledger.

Try it yourself

With Python 3.12+ and uv, install a fresh checkout and try the offline checker. This example uses empty account lists and never connects to Postgres.

uv sync --group dev
uv run --no-env-file python - <<'PY'
from ledger import check_snapshot
print(check_snapshot([], [], []).to_dict())
PY

This checks an explicitly supplied empty snapshot. It does not prove a database is initialized or healthy. For real transfers, the README has a disposable, local-socket Postgres demo and cleanup commands.

Sources & scope

Based on the local README, transaction writer, and independent checker. The diagram is an example, not a measured result.