Config
Refusals carry an error code, not a sentence.
ledger({
currencies: [
{ code: "chips", name: "Casino Chips" },
{ code: "gold", name: "Gold", decimals: 2 },
],
})| Key | Default | |
|---|---|---|
currencies | — | Required, non-empty. Duplicate codes refused |
currencies[].decimals | 0 | Display only. Storage is always integer minor units |
adminScope | ledger:admin | The scope the credit and debit routes stack |
database | DB | |
basePath | /ledger |
The in-process API
const ledger = openLedger(env.DB);| Call | |
|---|---|
credit(userId, currency, amount, ref) | Opens the account if absent |
debit(userId, currency, amount, ref) | Fails if the balance cannot cover it |
transfer(from, to, currency, amount, ref) | Atomic |
hold(userId, currency, amount, ref) | Reserves. Balance unchanged |
capture(ref) | The stake is spent |
release(ref) | The stake returns |
balance(userId, currency) | { balance, held, available } |
Tables
Every constraint below is one of the three invariants written down.
pithy_ledger_accounts
userId, currency | UNIQUE together — one account per player per currency |
balance, held | Integers, minor units |
CHECK | balance >= 0 AND held >= 0 AND held <= balance |
pithy_ledger_transactions — append-only.
ref | UNIQUE across the whole ledger. The idempotency anchor |
userId, currency, kind, amount | |
relatedRef | Ties the halves of a transfer, or a payout to its wager |
memo | Yours |
pithy_ledger_holds — status is open, captured or released.
Routes
| Route | Verified by |
|---|---|
GET /ledger/:currency | user |
GET /ledger/:currency/transactions | user |
POST /ledger/:currency/credit | user + admin scope |
POST /ledger/:currency/debit | user + admin scope |
GET /ledger/admin/accounts | control-plane |
GET /ledger/admin/accounts/:userId | control-plane |
GET /ledger/admin/accounts/:userId/:currency/transactions | control-plane |
A read is always scoped to the authenticated caller — never a user id in a request body. The writes are in-process only: Using Ledger has them, and holds and settlement covers the three-call shape.
Scopes
| Scope | Discloses |
|---|---|
ledger:accounts:read | A number |
ledger:transactions:read | Every wager, payout and purchase in order |
Two rather than one admin flag, because those are different things. Matching is exact.
Every management read is audited, including the reads.
Error codes
| Code | |
|---|---|
ledger/insufficient_funds | From the CHECK, not from a handler — so it holds under concurrency |
ledger/invalid_amount | |
ledger/account_not_found | |
ledger/currency_not_found | Not in your configured set |
ledger/hold_not_found | |
ledger/hold_not_open | Already captured or released |
What is deliberately absent
No adjustment route on the management surface. A console write with no idempotency key, no recorded reason and no reversal path would be the one place the guarantees do not hold.
No exchange between currencies. A rate is a business decision. Build it as a debit and a credit under one ref prefix.
No hold expiry. A hold that timed out on its own would release a stake the game may still be settling.
License
MIT. The root license covers it.