Multi-tenant · PostgreSQL · Soft-realtime

A ledger for anything
that must add up.

Double-entry accounting as a service — for money, for credits, and for stock on a shelf. Every movement has a source, a destination and an audit trail, and nothing is created or destroyed by accident.

Most ledgers stop at currency. This one settles fiat, prepaid credits and physical inventory in the same balanced transaction — because a sale is one event, not two systems hoping to agree later.

Asset kinds
3
Decimal scale
0–12
Holds
2-phase
Exact, never float
NUMERIC(38,0)

What it is

A conserved-quantity ledger

Ledger is a multi-tenant, soft-realtime double-entry ledger built on PostgreSQL. The unit of work is a balanced transaction: debits equal credits, per asset, or the whole thing is refused. That single rule is what makes the books provable rather than merely stored.

Balanced per asset

Debits must equal credits for every asset type in the transaction. Unbalanced entries are rejected at the API boundary and again in the database — the rule cannot be bypassed by a client.

Idempotent by contract

Every write carries an Idempotency-Key. Retry the same key and the original answer is replayed, never re-executed. Reuse it with a different body and the request is refused rather than guessed.

Reserve, then settle

Holds lower available without touching posted. Commit moves the money, void releases it, and an expires_at lets the server release whatever you never settled.

Exact arithmetic

Amounts are stored in minor units as NUMERIC(38,0) and travel as strings. No float ever touches the money path, so a cent cannot evaporate in a rounding step.

Floors and overdrafts

An account carries a balance floor — zero for customer wallets, negative for an overdraft facility, unbounded for control accounts. Breaching it is a business fact with the numbers attached, not a stack trace.

Tenants that cannot see each other

Every id is scoped to the tenant in your token. Another tenant’s id reads as 404, never 403 — so no caller can probe for the existence of someone else’s data.

Not just money

Three kinds of thing worth counting

The schema names three asset kinds, and the difference between them is only scale and intent. Anything countable that must never be created or destroyed by accident belongs here — currency is just the most familiar case.

Asset kinds supported by Ledger
KindScaleWhat it modelsWhat a hold means
fiat 2 Wallets, payouts, escrow, marketplace settlement, multi-currency balances Authorise now, capture later
credit 0–9 API credits, prepaid packages, loyalty points, gift cards, entitlement units Reserve while a long job runs; commit on success, void on failure
inventory 0 Stock on hand, warehouse transfers, seat and licence allocation Hold stock for a basket; release automatically if checkout never happens
one transaction, two assets

A sale is a single event

Balancing is enforced per asset type, independently. So currency legs and stock legs ride in one request and settle atomically — the money and the goods can never disagree, because there is no window in which only one of them has happened.

# money and goods, or neither — one write
POST /v1/transactions
{
  "description": "sale: 2 widgets for 50.00",
  "entries": [
    { "account_id": cash,
      "direction": "debit",  "amount": "50.00" },
    { "account_id": customer,
      "direction": "credit", "amount": "50.00" },
    { "account_id": stock,
      "direction": "credit", "amount": "2" },
    { "account_id": shipped,
      "direction": "debit",  "amount": "2" }
  ]
}

# unbalance either asset and the write is
# refused: 422 UNBALANCED_TRANSACTION
where it does not fit

What to use instead

A ledger is the wrong shape for some problems, and using it anyway costs you write throughput in exchange for guarantees you did not need.

  • Quotas and rate limits. A quota service meters usage — “120 of 1000 calls this month”. Reach for a ledger when the units are owned property somebody bought, refunds, or disputes.
  • Non-conserved metrics. Page views and signups have no source account, so double-entry buys you nothing.
  • Workflow state. A hold is a two-phase reservation of value, not a state machine for an order.

The integration contract

Seven rules that matter

These are the whole contract. Honour them and correct integration is close to automatic; ignore one and the failure will be quiet, which is the expensive kind.

  1. Money is a string, never a float. Amounts are display-scale decimal strings — "100.00". A cent lost to float("0.1") is lost forever.
  2. A *_minor field is an integer of minor units. "available": "100.00" and "available_minor": "10000" are the same money at scale 2. Parse the minor field with int(), never float().
  3. Every write needs an Idempotency-Key. Derive it from your own business id so a retry after a network timeout cannot double-charge. Replays come back with Idempotent-Replay: true.
  4. Entries must balance per asset. Debits equal credits for each asset type in the transaction, and both sides must be present.
  5. A 409 with Retry-After means try again. Lock timeouts and in-flight conflicts are transient — wait the advertised seconds and retry with the same key. Any other 4xx is a business fact, and retrying it unchanged will not help.
  6. Holds reserve; they do not move. A pending transaction lowers available and leaves posted untouched. expires_at must carry a UTC offset — a naive timestamp is refused rather than read as UTC.
  7. Your token is your tenant. Every id is scoped to the tenant in the JWT, and another tenant’s id reads as 404, so existence never leaks.

Surface

Ten endpoints, plain HTTP and JSON

There is no SDK to learn and no wire format to negotiate. Authentication is a tenant-scoped JWT; roles are ledger:read, ledger:post and ledger:admin, enforced per endpoint.

Ledger HTTP endpoints
MethodPath
POST /v1/asset-types
POST /v1/accounts
PATCH /v1/accounts/{id}
GET /v1/accounts/{id}/balance
GET /v1/accounts/{id}/entries
POST /v1/transactions
GET /v1/transactions/{id}
POST /v1/transactions/{id}/commit
POST /v1/transactions/{id}/void
GET /healthz
# every failure is the same envelope —
# branch on code, never on message text
{
  "error": {
    "code": "INSUFFICIENT_AVAILABLE",
    "message": "insufficient available",
    "details": [{
      "account_id": "019fdfe1-…",
      "available_minor": "7500",
      "balance_floor_minor": "0"
    }]
  }
}

# how to react, by class
409 + Retry-After   wait, retry, same key
422 / 404 / 403     a fact; fix it or surface it
5xx                 transient; the key is NOT consumed

The repository ships examples/ledger_tour.py — a single runnable file that walks the entire integration path against a live instance and prints the real request and response of every step, from authentication to tenant isolation.

Where to use it

Built for the awkward cases

Anywhere a number represents something someone owns, and being off by one is a real-world problem rather than a cosmetic one.

Marketplaces & payouts

Hold a buyer’s funds at checkout, split the settlement across seller, platform and tax accounts, and keep a statement that reconciles line by line.

Prepaid credits

Sell credit packs, reserve credits while a long job runs, commit what was consumed and void the rest — with refunds and disputes on the same books.

Wallets & stored value

Customer balances with a floor of zero, overdraft facilities where you allow them, and frozen accounts that reject movement without losing history.

Stock & fulfilment

Reserve inventory for a basket, release it automatically when the basket expires, and move goods between warehouses as balanced transfers.

Loyalty & rewards

Points earned, points spent, points expired — each an entry with a counterparty, so the liability on your balance sheet is always derivable.

Seats & licences

A finite pool of entitlements, allocated and reclaimed with the same guarantees as money, because over-allocating a licence is a contractual problem.

Status

Where this actually is

The public API is not open yet

This page is live. The Ledger API is not publicly exposed at this host — it currently runs bound to loopback on the origin server, against a development database.

What has been exercised: the full integration path end to end against a live instance, the pytest suite over real PostgreSQL, and a live probe harness in audit/evaluations/. What has not: public ingress, real concurrency, sustained queue depth, and production credential handling.

Those are named rather than rounded up to “ready”, because untested paths are where the outage comes from.