Ledger reference

Config

Refusals carry an error code, not a sentence.

ledger({
  currencies: [
    { code: "chips", name: "Casino Chips" },
    { code: "gold", name: "Gold", decimals: 2 },
  ],
})
KeyDefault
currencies—Required, non-empty. Duplicate codes refused
currencies[].decimals0Display only. Storage is always integer minor units
adminScopeledger:adminThe scope the credit and debit routes stack
databaseDB
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, currencyUNIQUE together — one account per player per currency
balance, heldIntegers, minor units
CHECKbalance >= 0 AND held >= 0 AND held <= balance

pithy_ledger_transactions — append-only.

refUNIQUE across the whole ledger. The idempotency anchor
userId, currency, kind, amount
relatedRefTies the halves of a transfer, or a payout to its wager
memoYours

pithy_ledger_holds — status is open, captured or released.

Routes

RouteVerified by
GET /ledger/:currencyuser
GET /ledger/:currency/transactionsuser
POST /ledger/:currency/credituser + admin scope
POST /ledger/:currency/debituser + admin scope
GET /ledger/admin/accountscontrol-plane
GET /ledger/admin/accounts/:userIdcontrol-plane
GET /ledger/admin/accounts/:userId/:currency/transactionscontrol-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

ScopeDiscloses
ledger:accounts:readA number
ledger:transactions:readEvery 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_fundsFrom the CHECK, not from a handler — so it holds under concurrency
ledger/invalid_amount
ledger/account_not_found
ledger/currency_not_foundNot in your configured set
ledger/hold_not_found
ledger/hold_not_openAlready 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.

ESC