@pithy-sh/ledger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @pithy-sh/ledger
2
+
3
+ A per-user balance ledger for your app's economy — chips, gold, gems, credits, tokens — in your own D1. Currency-agnostic, and correct by construction.
4
+
5
+ Every game with an economy needs this, not just gambling. It is the primitive under in-game currency, rewards, buy-ins, prize pools and wagers.
6
+
7
+ ```sh
8
+ pithy add ledger
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/ledger](https://pithy.sh/docs/capabilities/ledger).** Overview, adding it, using it, and the reference: currencies, holds, idempotency, the invariants.
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## License
16
+
17
+ MIT — adopter-side app value. The root `LICENSE` covers it.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@pithy-sh/ledger",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/ledger"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "!src/**/*.test.*"
14
+ ],
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "exports": {
20
+ "./src/*": "./src/*.ts"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "test:node": "vitest run --project=node",
27
+ "test:workers": "vitest run --project=workers",
28
+ "clean": "rm -rf dist .turbo",
29
+ "reset": "bun run clean && rm -rf node_modules"
30
+ },
31
+ "dependencies": {
32
+ "@cloudflare/workers-types": "^5.20260729.1",
33
+ "@hono/zod-validator": "^0.9.0",
34
+ "@pithy-sh/core": "workspace:*",
35
+ "hono": "^4.13.2",
36
+ "kysely": "^0.29.0",
37
+ "zod": "^4.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@cloudflare/vitest-plugin": "^1.0.0",
41
+ "@pithy-sh/tsconfig": "workspace:*",
42
+ "@types/node": "^22.15.0",
43
+ "@vitest/coverage-v8": "^4.1.0",
44
+ "kysely-d1": "^0.4.0",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^4.1.0",
47
+ "wrangler": "^4.115.0"
48
+ }
49
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "ledger",
3
+ "package": "@pithy-sh/ledger",
4
+ "requiredBindings": [{ "type": "d1", "name": "DB" }],
5
+ "peerCapabilities": [],
6
+ "optionalCapabilities": ["auth", "controlplane", "audit"],
7
+ "migrationNamespace": "ledger",
8
+ "whenToEnable": "Give every player a balance. A per-user ledger for whatever your economy runs on — chips, gold, gems, credits, tokens — in your own D1, currency-agnostic. Every movement is atomic (the ledger entry and the balance change commit together), idempotent (a replayed operation applies once, so a payout delivered twice pays once), and overdraft-safe (a database CHECK constraint makes a balance that can go negative impossible). It has holds — reserve a player's stake the moment a bet is placed, then release it or capture it when the outcome lands — which is what makes wagering safe, so it pairs naturally with @pithy-sh/multiplayer. It takes no position on whether your units map to money; that, and any regulation it implies, is yours. Reads are scoped to the caller; moving another player's balance over HTTP is server-authoritative and needs the admin scope, so add auth too.",
9
+ "scaffold": [
10
+ "Add a `ledger({ currencies: [...] })` block to pithy.config.ts and declare at least one currency.",
11
+ "Give each currency a `code` (a lowercase id like `chips`), a `name`, and optionally `decimals` (display scale — balances are always stored as integers in the minor unit).",
12
+ "Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
13
+ "Run `pithy migrate` to create pithy_ledger_accounts, pithy_ledger_transactions, and pithy_ledger_holds.",
14
+ "Add `@pithy-sh/auth` if it is not already installed. Balance reads scope to the authenticated user; admin credit/debit routes need the `ledger:admin` scope, minted for your trusted server.",
15
+ "Move balances in-process from your own code with `openLedger(env.DB)` — credit, debit, transfer, hold, release, capture — passing a unique `ref` for each operation so retries are safe.",
16
+ "Add `controlplane()` if you want a dashboard to read balances and entry logs. The ledger's management surface is read-only — `ledger:accounts:read` for balances, `ledger:transactions:read` for the history behind them — and grants nothing that can move money."
17
+ ],
18
+ "configOptions": [
19
+ {
20
+ "key": "currencies",
21
+ "default": [{ "code": "chips", "name": "Chips" }],
22
+ "describe": "Every currency this app's ledger holds. Replace this example — one currency is the smallest ledger that does anything, and `chips` is the schema's own first example of a unit that is plainly not money. `code` is a lowercase id, used as a URL path segment and an account key; `name` is what a player sees; optional `decimals` is display scale only, because a balance is always stored as an integer in the minor unit (`decimals: 2` shows a stored 150 as 1.50). At least one currency is required."
23
+ },
24
+ {
25
+ "key": "adminScope",
26
+ "default": "ledger:admin",
27
+ "describe": "The scope a session must carry to credit or debit another player's balance over HTTP. Balance-moving writes are server-authoritative — mint it for your trusted server's token, never a player's."
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,150 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
6
+ import { LedgerAccount } from "../data/account";
7
+ import { LEDGER_ACCOUNTS_TABLE, LEDGER_TRANSACTIONS_TABLE, ledgerDatabase } from "../data/tables";
8
+ import { LedgerTransaction } from "../data/transaction";
9
+
10
+ /**
11
+ * The management read model — the queries behind the control-plane routes, and nothing else.
12
+ *
13
+ * **It lives beside `ledger.ts` rather than inside it.** The `Ledger` interface is the
14
+ * server-authoritative movement primitive: everything on it changes a balance or reads the caller's
15
+ * own. These read across *every* player, which is a different operation with a different blast radius,
16
+ * and keeping it out of the primitive means an in-process caller cannot reach it by accident.
17
+ *
18
+ * ## Keyset, never offset
19
+ *
20
+ * Both listings paginate on `id`, descending, through the shared cursor helper in
21
+ * `@pithy-sh/core/src/data/cursor`. `id` is an autoincrement primary key on both tables, so it is
22
+ * monotonic and unique — which makes it a sufficient keyset position on its own, with the cursor's
23
+ * `sort` and `id` carrying the same value. `OFFSET` would shift under a reader the moment any balance
24
+ * moves, and on a ledger something is always moving: a page 2 fetched a second later would repeat rows
25
+ * it already showed and skip rows it never did.
26
+ *
27
+ * ## The indexes these were written for
28
+ *
29
+ * `pithyLedgerTransactionsOwnerIdx` is `(userId, currency, id)`, which is exactly the shape
30
+ * {@link listTransactions} needs — both filters are equalities and the ordering column is the index's
31
+ * tail, so the page is a range scan and the `LIMIT` genuinely stops it. That is why the route requires
32
+ * a currency rather than offering "everything this player did": without it the index gives no ordering
33
+ * and the database sorts the player's whole history to return twenty-five rows.
34
+ *
35
+ * {@link listAccounts} orders by the accounts table's primary key for the same reason — it is the only
36
+ * indexed monotonic column on that table. `updatedAt` would be the more useful sort for an operator
37
+ * ("who moved most recently"), and it has no index; adding one is a migration a read-only pane does not
38
+ * justify, so the order is account-opened order and this says so rather than pretending otherwise.
39
+ */
40
+
41
+ /** One page of a keyset listing, and where the next one resumes. */
42
+ export interface LedgerPage<T> {
43
+ /** The rows, newest first. */
44
+ items: T[];
45
+ /** The cursor for the next page, or null at the end of the list. Opaque; hand it back verbatim. */
46
+ nextCursor: string | null;
47
+ }
48
+
49
+ /** What {@link listAccounts} accepts. */
50
+ export interface AccountsQuery {
51
+ /** Restrict to one currency code. Already resolved against config by the handler. */
52
+ currency?: string;
53
+ /** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
54
+ cursor?: string;
55
+ /** How many rows to return, clamped by `pageLimit`. */
56
+ limit?: number;
57
+ }
58
+
59
+ /** What {@link listTransactions} accepts, beyond the account it is for. */
60
+ export interface TransactionsQuery {
61
+ /** Where to resume, from a previous page's `nextCursor`. A malformed one is a first page. */
62
+ cursor?: string;
63
+ /** How many rows to return, clamped by `pageLimit`. */
64
+ limit?: number;
65
+ }
66
+
67
+ /** A row's keyset position. `id` is monotonic on both tables, so it is both the sort and the tiebreak. */
68
+ function position(row: { id: number }): PageCursor {
69
+ return { sort: row.id, id: row.id };
70
+ }
71
+
72
+ /**
73
+ * The `id` a page resumes before, or undefined for the first page.
74
+ *
75
+ * A cursor whose `id` is not a number was not one of ours — a truncated value, a cursor from another
76
+ * listing, a hand-built guess. It resolves to the first page rather than to an error, which is what
77
+ * core's `decodeCursor` promises and what stops a bad cursor from being a probe.
78
+ */
79
+ function resumeBefore(cursor: string | undefined): number | undefined {
80
+ const decoded = decodeCursor(cursor);
81
+ return typeof decoded?.id === "number" ? decoded.id : undefined;
82
+ }
83
+
84
+ /**
85
+ * Every account holding a balance, newest first, optionally in one currency.
86
+ *
87
+ * The rows are parsed through {@link LedgerAccount}, so dates decode and the shape is validated on the
88
+ * way out of D1 exactly as it is on the way in.
89
+ */
90
+ export async function listAccounts(d1: D1Database, query: AccountsQuery = {}): Promise<LedgerPage<LedgerAccount>> {
91
+ const limit = pageLimit(query.limit);
92
+ const before = resumeBefore(query.cursor);
93
+ let selection = ledgerDatabase(d1)
94
+ .selectFrom(LEDGER_ACCOUNTS_TABLE)
95
+ .selectAll()
96
+ .orderBy("id", "desc")
97
+ // One more than asked for: the extra row is how "is there another page" is answered without a
98
+ // COUNT, which on this table would be a full scan on every page load.
99
+ .limit(limit + 1);
100
+ if (query.currency !== undefined) selection = selection.where("currency", "=", query.currency);
101
+ if (before !== undefined) selection = selection.where("id", "<", before);
102
+ const rows = await selection.execute();
103
+ return toPage(
104
+ rows.map((row) => LedgerAccount.parse(row)),
105
+ limit,
106
+ position,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Every account one player holds, ordered by currency code.
112
+ *
113
+ * Unpaginated on purpose: an account is keyed `(userId, currency)` and currencies are config rather
114
+ * than rows, so this returns at most one row per configured currency. A cursor over a list bounded by
115
+ * the adopter's own config would be ceremony.
116
+ */
117
+ export async function readAccounts(d1: D1Database, userId: string): Promise<LedgerAccount[]> {
118
+ const rows = await ledgerDatabase(d1)
119
+ .selectFrom(LEDGER_ACCOUNTS_TABLE)
120
+ .selectAll()
121
+ .where("userId", "=", userId)
122
+ .orderBy("currency", "asc")
123
+ .execute();
124
+ return rows.map((row) => LedgerAccount.parse(row));
125
+ }
126
+
127
+ /** One account's entry log, newest first — the history that explains its balance. */
128
+ export async function listTransactions(
129
+ d1: D1Database,
130
+ userId: string,
131
+ currency: string,
132
+ query: TransactionsQuery = {},
133
+ ): Promise<LedgerPage<LedgerTransaction>> {
134
+ const limit = pageLimit(query.limit);
135
+ const before = resumeBefore(query.cursor);
136
+ let selection = ledgerDatabase(d1)
137
+ .selectFrom(LEDGER_TRANSACTIONS_TABLE)
138
+ .selectAll()
139
+ .where("userId", "=", userId)
140
+ .where("currency", "=", currency)
141
+ .orderBy("id", "desc")
142
+ .limit(limit + 1);
143
+ if (before !== undefined) selection = selection.where("id", "<", before);
144
+ const rows = await selection.execute();
145
+ return toPage(
146
+ rows.map((row) => LedgerTransaction.parse(row)),
147
+ limit,
148
+ position,
149
+ );
150
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The audit actions this capability emits, through the core `emit()` seam.
6
+ *
7
+ * **Every one of these is a read, and every one is audited anyway.** A ledger is a record of what
8
+ * people own and what they did to earn or spend it; a management credential that can page through it
9
+ * can reconstruct a player's whole economic history. "Who pulled the balances for every account on the
10
+ * ninth, and whose transaction log did they open afterwards" is a question with an answer, and these
11
+ * are what make it one. An unaudited read surface over other people's money is how a leaked dashboard
12
+ * credential stays invisible — nothing changes, so nothing shows up.
13
+ *
14
+ * Emitted with `c.var.emit`, never by importing `@pithy-sh/audit` — the seam is always present
15
+ * (`noopEmit` when no audit capability is composed), so there is no null check and no hard dependency.
16
+ * Identifiers and counts only in metadata: never a balance, never a memo, never a `ref`. The trail is
17
+ * queryable and long-lived, and copying the ledger into it would just make a second ledger with weaker
18
+ * access rules than the first.
19
+ */
20
+ export const LedgerAuditActions = {
21
+ /** A management client paged the account list — every holder of a balance, or of one currency's. */
22
+ accountsListed: "ledger/accounts_listed",
23
+ /** A management client read one player's balances. */
24
+ accountRead: "ledger/account_read",
25
+ /** A management client paged one account's entry log — the history behind the number. */
26
+ transactionsRead: "ledger/transactions_read",
27
+ } as const;
28
+
29
+ /** One of the ledger capability's audit actions. */
30
+ export type LedgerAuditAction = (typeof LedgerAuditActions)[keyof typeof LedgerAuditActions];
@@ -0,0 +1,97 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
5
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { Migration } from "kysely/migration";
7
+ import { LedgerConfig, type LedgerConfigInput } from "./config/config";
8
+ import { ledgerTables } from "./data/tables";
9
+ import { LEDGER_DEFAULT_BASE_PATH, registerLedgerRoutes } from "./http/routes";
10
+ import { ledgerAdminRoutes } from "./http/scopes";
11
+ import { ledger_0001_accounts } from "./migrations/0001_accounts";
12
+ import { ledgerExampleSeed } from "./seeds/example";
13
+ import { PACKAGE_VERSION } from "./version.generated";
14
+
15
+ /**
16
+ * Where ledger's migrations sort in the app database. Unique per database; the registry composes keys like
17
+ * `0650_ledger_0001_accounts`. Sits after rating (600).
18
+ *
19
+ * Ledger was also at 600 until this was corrected — `pithy migrate` threw `duplicate migration order 600
20
+ * in database "app"` for any project composing both. Nothing orders ledger against rating in particular;
21
+ * rating simply kept the slot, and the two tables do not reference each other.
22
+ */
23
+ export const LEDGER_MIGRATION_ORDER = 650;
24
+
25
+ export type LedgerOptions = LedgerConfigInput & {
26
+ /** Mount the routes somewhere other than `/ledger`. Moves the management surface with them. */
27
+ basePath?: string;
28
+ };
29
+
30
+ export interface LedgerCapability extends Capability {
31
+ ledgerConfig: LedgerConfig;
32
+ }
33
+
34
+ /**
35
+ * The ledger capability: a per-user balance ledger for an app's economy — chips, gold, credits, tokens.
36
+ *
37
+ * Fully optional. Config, migrations, routes, and bindings arrive only on `pithy add ledger`, and
38
+ * `pithy remove ledger` is the clean inverse. The store is D1, always: the ledger's correctness — atomic
39
+ * movements, idempotent operations, overdraft protection — is enforced by D1's own transactions and `CHECK`
40
+ * constraints (see `ledger.ts`), which is exactly what a balance store must guarantee.
41
+ *
42
+ * It is currency-agnostic and takes no position on whether an app's units map to money — that, and any
43
+ * regulation it implies, is the adopter's concern. Pithy provides the ledger; the adopter provides the
44
+ * compliance.
45
+ *
46
+ * `dependsOn` is deliberately empty. Auth is a seam, not a peer: reads scope to `c.var.auth.userId` and
47
+ * admin writes require a scope, so without `@pithy-sh/auth` every player route is denied. The ledger
48
+ * itself is a server-authoritative primitive other capabilities call in-process —
49
+ * `@pithy-sh/multiplayer` uses it to escrow wagers and settle payouts.
50
+ *
51
+ * The control-plane seam is the same kind of optional: `adminRoutes` are always declared and always
52
+ * mounted, and with `controlplane()` uncomposed each one denies with `controlplane/not_connected`
53
+ * rather than being absent. A management surface that appears only when something else is installed is
54
+ * a surface nobody can discover; one that is always there and always default-denied is.
55
+ */
56
+ export function ledger(options: LedgerOptions = { currencies: [] }): LedgerCapability {
57
+ const { basePath, ...configInput } = options;
58
+ // Parse the currency set at assembly — a duplicate code or a bad currency fails on deploy, not on the
59
+ // first transaction.
60
+ const resolved = LedgerConfig.parse(configInput);
61
+ // Resolved once, here, and handed to both the router and the manifest. The fallback used to live only
62
+ // inside `registerLedgerRoutes`, which would have let the advertised admin paths and the mounted ones
63
+ // disagree the moment either side changed its mind about the default.
64
+ const mountPath = basePath ?? LEDGER_DEFAULT_BASE_PATH;
65
+
66
+ const migrations: Record<string, Migration> = { "0001_accounts": ledger_0001_accounts };
67
+ const requiredBindings: BindingSpecInput[] = [{ type: "d1", name: "DB" }];
68
+
69
+ const capability = defineCapability({
70
+ name: "ledger",
71
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
72
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
73
+ version: PACKAGE_VERSION,
74
+ requiredBindings,
75
+ config: LedgerConfig,
76
+ databases: {
77
+ app: {
78
+ binding: "DB",
79
+ tables: ledgerTables(),
80
+ migrationOrder: LEDGER_MIGRATION_ORDER,
81
+ migrations,
82
+ },
83
+ },
84
+ routes: registerLedgerRoutes({ config: resolved, basePath: mountPath }),
85
+ // Built from the resolved mount path, never the default: an adopter who mounts the ledger at
86
+ // `/wallet` gets a manifest naming `/wallet/admin/accounts`, which is what a management client
87
+ // composes its calls from.
88
+ adminRoutes: ledgerAdminRoutes(mountPath),
89
+ seeds: [ledgerExampleSeed],
90
+ });
91
+
92
+ return Object.assign(capability, { ledgerConfig: resolved });
93
+ }
94
+
95
+ export function isLedgerCapability(capability: Capability): capability is LedgerCapability {
96
+ return capability.name === "ledger" && "ledgerConfig" in capability;
97
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare config
7
+ // in `vitest.workers.config.ts`: the app `DB` database the `pithy_ledger_*` tables live in.
8
+ declare namespace Cloudflare {
9
+ interface Env {
10
+ DB: D1Database;
11
+ }
12
+ }
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The ledger capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
8
+ * `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
9
+ *
10
+ * The ledger keeps a per-user balance for whatever an app's economy runs on — chips, gold, gems,
11
+ * credits, tokens. It is currency-agnostic and holds no opinion about whether those units map to money;
12
+ * that (and any regulation around it) is the adopter's concern. **Amounts are integers in a currency's
13
+ * minor unit** — never floats — so arithmetic is exact; a currency's `decimals` only says how to *display*
14
+ * them. Every mutation is atomic, idempotent on a caller-supplied `ref`, and overdraft-protected by a
15
+ * database `CHECK` constraint, because a ledger that can double-spend or go negative is not a ledger.
16
+ */
17
+
18
+ /** A currency code is a short identifier used in URLs and keys, so it is lowercase, digits, and dashes. */
19
+ const CURRENCY_CODE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
20
+
21
+ export const LedgerCurrency = z
22
+ .object({
23
+ code: z
24
+ .string()
25
+ .regex(CURRENCY_CODE_PATTERN, "A currency code is lowercase, digits, and dashes.")
26
+ .describe("The currency's stable id — `chips`, `gold`, `credits`. A path segment and an account key."),
27
+ name: z.string().min(1).describe("The human-readable name shown to players — `Casino Chips`, `Gold`."),
28
+ decimals: z
29
+ .number()
30
+ .int()
31
+ .min(0)
32
+ .default(0)
33
+ .describe(
34
+ "How many decimal places this currency displays. Balances are always stored as integers in the minor unit; `decimals: 2` means a stored `150` displays as `1.50`. Whole chips are `0`.",
35
+ ),
36
+ })
37
+ .describe("One currency an app's economy runs on — a code, a display name, and a display scale.");
38
+ export type LedgerCurrency = z.output<typeof LedgerCurrency>;
39
+
40
+ export const LedgerConfig = z
41
+ .object({
42
+ currencies: z
43
+ .array(LedgerCurrency)
44
+ .min(1, "A ledger with no currencies does nothing — configure at least one.")
45
+ .describe("Every currency this app's ledger holds. Currencies are config, not database rows."),
46
+ adminScope: z
47
+ .string()
48
+ .min(1)
49
+ .default("ledger:admin")
50
+ .describe(
51
+ "The AuthContext scope a session must carry to credit or debit another player's balance over HTTP. Balance-moving writes are server-authoritative — mint this scope for your trusted server's token, never a player's. Players can always read their own balance without it.",
52
+ ),
53
+ })
54
+ .describe("Configuration for the ledger capability — the set of currencies an app's economy runs on.")
55
+ .check((ctx) => {
56
+ const codes = ctx.value.currencies.map((currency) => currency.code);
57
+ const duplicates = [...new Set(codes.filter((code, i) => codes.indexOf(code) !== i))];
58
+ if (duplicates.length > 0) {
59
+ ctx.issues.push({
60
+ code: "custom",
61
+ input: ctx.value,
62
+ path: ["currencies"],
63
+ message: `Duplicate currency codes: ${duplicates.join(", ")}. Two currencies sharing a code would merge their balances.`,
64
+ });
65
+ }
66
+ });
67
+ export type LedgerConfig = z.output<typeof LedgerConfig>;
68
+ export type LedgerConfigInput = z.input<typeof LedgerConfig>;
69
+
70
+ /** The currency with this code, or undefined. Codes come from config, so an unknown code is a 404. */
71
+ export function resolveCurrency(config: LedgerConfig, code: string): LedgerCurrency | undefined {
72
+ return config.currencies.find((currency) => currency.code === code);
73
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * One player's balance in one currency — the row in `pithy_ledger_accounts`, keyed `(userId, currency)`.
9
+ *
10
+ * `balance` is everything the player owns; `held` is the portion reserved by open holds (escrowed wagers).
11
+ * `available = balance - held` is what they can spend. All three are integers in the currency's minor unit.
12
+ * A database `CHECK (balance >= 0 AND held >= 0 AND held <= balance)` is the overdraft guard — a debit or
13
+ * hold that would break it aborts its transaction, so an account can never go negative or over-reserve.
14
+ */
15
+ export const LedgerAccount = z
16
+ .object({
17
+ id: z
18
+ .number()
19
+ .int()
20
+ .describe("Autoincrement primary key. Internal only; accounts are addressed by (userId, currency)."),
21
+ userId: z.string().describe("The account owner — an authenticated user id."),
22
+ currency: z.string().describe("The currency code this balance is in, from `currencies` in pithy.config.ts."),
23
+ balance: z.number().int().describe("Total owned, in the currency's minor unit. Never negative."),
24
+ held: z
25
+ .number()
26
+ .int()
27
+ .describe("The portion reserved by open holds. `available = balance - held`. Never exceeds balance."),
28
+ createdAt: SQLiteDate.describe("When the account was first opened."),
29
+ updatedAt: SQLiteDate.describe("When the balance last changed."),
30
+ })
31
+ .describe("One player's balance in one currency — the row in `pithy_ledger_accounts`.");
32
+ export type LedgerAccount = z.output<typeof LedgerAccount>;
33
+ export type LedgerAccountRow = z.input<typeof LedgerAccount>;
34
+
35
+ /** A player's spendable position in a currency — the shape a balance read returns. */
36
+ export interface Balance {
37
+ /** Total owned. */
38
+ balance: number;
39
+ /** Reserved by open holds. */
40
+ held: number;
41
+ /** Spendable now (`balance - held`). */
42
+ available: number;
43
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /** The lifecycle of a hold. Terminal states (`released`, `captured`) never change again. */
8
+ export const HoldStatus = z
9
+ .enum(["open", "released", "captured"])
10
+ .describe(
11
+ "A hold's state: `open` (funds reserved), `released` (canceled, funds returned), `captured` (finalized, funds taken).",
12
+ );
13
+ export type HoldStatus = z.infer<typeof HoldStatus>;
14
+
15
+ /**
16
+ * An escrow on a player's funds — the row in `pithy_ledger_holds`. A hold reserves part of a balance (a
17
+ * placed wager) without spending it: the amount moves from `available` to `held` on the account. It later
18
+ * resolves — `release`d back to the player, or `capture`d (spent, e.g. a lost bet). Holds are what make
19
+ * wagering safe: the stake is locked the moment a bet is placed, so it cannot be spent twice while the
20
+ * outcome is pending.
21
+ *
22
+ * `ref` is the hold's stable id and idempotency key; `release`/`capture` reference it.
23
+ */
24
+ export const LedgerHold = z
25
+ .object({
26
+ id: z.number().int().describe("Autoincrement primary key. Internal only; holds are addressed by ref."),
27
+ ref: z
28
+ .string()
29
+ .describe("The caller-supplied idempotency key, unique across holds. Used to release or capture this hold."),
30
+ userId: z.string().describe("The account owner whose funds are held."),
31
+ currency: z.string().describe("The currency code this hold is in."),
32
+ amount: z.number().int().describe("The reserved amount in the currency's minor unit."),
33
+ status: HoldStatus.describe("The hold's current state."),
34
+ createdAt: SQLiteDate.describe("When the hold was placed."),
35
+ resolvedAt: SQLiteDate.nullable().describe("When the hold was released or captured, or null while open."),
36
+ })
37
+ .describe("An escrow on a player's funds — the row in `pithy_ledger_holds`.");
38
+ export type LedgerHold = z.output<typeof LedgerHold>;
39
+ export type LedgerHoldRow = z.input<typeof LedgerHold>;
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import type { z } from "zod";
8
+ import { LedgerAccount } from "./account";
9
+ import { LedgerHold } from "./hold";
10
+ import { LedgerTransaction } from "./transaction";
11
+
12
+ /** The accounts table. `CamelCasePlugin` snake-cases it to `pithy_ledger_accounts`. */
13
+ export const LEDGER_ACCOUNTS_TABLE = "pithyLedgerAccounts";
14
+ /** The append-only entry log. `CamelCasePlugin` snake-cases it to `pithy_ledger_transactions`. */
15
+ export const LEDGER_TRANSACTIONS_TABLE = "pithyLedgerTransactions";
16
+ /** The holds table. `CamelCasePlugin` snake-cases it to `pithy_ledger_holds`. */
17
+ export const LEDGER_HOLDS_TABLE = "pithyLedgerHolds";
18
+
19
+ /** The ledger tables map. All are always present — none is behind a config flag. */
20
+ export function ledgerTables(): Record<string, z.ZodObject> {
21
+ return {
22
+ [LEDGER_ACCOUNTS_TABLE]: LedgerAccount,
23
+ [LEDGER_TRANSACTIONS_TABLE]: LedgerTransaction,
24
+ [LEDGER_HOLDS_TABLE]: LedgerHold,
25
+ };
26
+ }
27
+
28
+ /** The typed Kysely database over the ledger tables. */
29
+ export type LedgerTables = {
30
+ [LEDGER_ACCOUNTS_TABLE]: typeof LedgerAccount;
31
+ [LEDGER_TRANSACTIONS_TABLE]: typeof LedgerTransaction;
32
+ [LEDGER_HOLDS_TABLE]: typeof LedgerHold;
33
+ };
34
+ export type LedgerDatabase = Kysely<DatabaseSchema<LedgerTables>>;
35
+
36
+ /** Build the ledger database from the `DB` binding (CamelCasePlugin installed). */
37
+ export function ledgerDatabase(d1: D1Database): LedgerDatabase {
38
+ return createDatabase(d1, {
39
+ [LEDGER_ACCOUNTS_TABLE]: LedgerAccount,
40
+ [LEDGER_TRANSACTIONS_TABLE]: LedgerTransaction,
41
+ [LEDGER_HOLDS_TABLE]: LedgerHold,
42
+ }) as unknown as LedgerDatabase;
43
+ }