Add Ledger

pithy add ledger

No required prerequisites. Add auth if it is not already there: reads scope to the caller, so without an identity composed every route denies.

What lands in your repo

apps/<worker>/pithy.config.ts gains the registration, with an example currency you are meant to replace:

ledger({
  currencies: [{ code: "chips", name: "Chips" }],
  adminScope: "ledger:admin",
}),

apps/<worker>/wrangler.jsonc gains the DB binding, in every environment stanza. That is the only binding this capability needs — no bucket, no namespace, no Workflow.

One migration runs, creating the balance and movement tables and the constraint that makes an overdraft impossible.

The two config options

OptionDefaultWhat it decides
currenciesone example, chipsEvery currency this app’s ledger holds
adminScopeledger:adminThe scope a session must carry to credit or debit another player’s balance over HTTP

Replace the example currency

chips is the schema’s own example — the smallest ledger that does anything. Your currencies are a list, and each is an independent balance per user:

currencies: [
  { code: "coins", name: "Coins" },
  { code: "gems", name: "Gems" },
],

There is no conversion between them. Two currencies is two balances, and moving value across is a movement you author on both sides.

A currency code is effectively permanent. It is stored on every movement row, so renaming one is a data migration rather than a config edit. Pick codes you can live with.

The admin scope is the whole authorization model

Reads scope to the caller. Moving somebody else’s balance — a payout, a grant, a correction — requires the admin scope, which you mint for your own server-side code.

That default inverts the usual vendor posture on purpose: a client that can credit itself is not a ledger. If your game awards coins for winning, the award is made by your Worker after it decided who won, not by the client reporting that it did.

Minor units, decided once

A currency’s smallest indivisible unit is the unit the ledger stores. There are no fractions, and there is no rounding, because rounding a balance is how a balance stops adding up.

If your product has a concept of half a coin, the coin is not your unit — the half-coin is, and one coin is two of them. Decide that before the first movement, because it is baked into every row afterwards.

The three calls you will actually make

A movement is a credit, a debit, or a hold that is later released or captured. Every one carries an idempotency reference of your own choosing — an order id, a match id, a payout id.

await ledger.credit({ userId, currency: "coins", amount: 100, ref: `signup:${userId}` });
await ledger.debit({ userId, currency: "coins", amount: 30, ref: `purchase:${orderId}` });
const hold = await ledger.hold({ userId, currency: "coins", amount: 50, ref: `bet:${handId}` });

Choose the reference so a retry produces the same one. That is the entire contract: a repeated reference is a no-op rather than a second movement, and a timeout you cannot classify becomes safe to retry.

A reference derived from a timestamp or a random id defeats it, because the retry generates a different one.

Selling a currency pack

payments is what takes the money; this is what holds the result. A product’s catalog entry declares a balance grant, and the seam between the two does the rest:

coins_100: {
  type: "consumable",
  name: "100 coins",
  grants: { currency: "coins", amount: 100 },
  apple: { productId: "com.acme.coins100" },
},

Most products never touch a balance. Composing payments is only necessary if you sell one.

Check it worked

pithy doctor reports ledger under the Worker’s health. In dev, credit a seeded user and read the balance back — pithy seed then two calls is the whole verification, and it exercises the constraint as well as the wiring if you try to debit more than you credited.

ESC