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.
Multi-tenant · PostgreSQL · Soft-realtime
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.
What it is
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.
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.
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.
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.
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.
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.
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
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.
| Kind | Scale | What it models | What 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 |
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_TRANSACTIONA ledger is the wrong shape for some problems, and using it anyway costs you write throughput in exchange for guarantees you did not need.
The integration contract
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.
Surface
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.
| Method | Path |
|---|---|
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 consumedThe 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
Anywhere a number represents something someone owns, and being off by one is a real-world problem rather than a cosmetic one.
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.
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.
Customer balances with a floor of zero, overdraft facilities where you allow them, and frozen accounts that reject movement without losing history.
Reserve inventory for a basket, release it automatically when the basket expires, and move goods between warehouses as balanced transfers.
Points earned, points spent, points expired — each an entry with a counterparty, so the liability on your balance sheet is always derivable.
A finite pool of entitlements, allocated and reclaimed with the same guarantees as money, because over-allocating a licence is a contractual problem.
Status
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.