pluts 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/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # Pluts
2
+
3
+ A double-entry accounting ledger for TypeScript, targeting Cloudflare Durable Objects (SQLite-backed storage).
4
+
5
+ Pluts is a TypeScript refactor of the Ruby [Plutus](https://github.com/mbulat/plutus) gem. It implements a complete double-entry bookkeeping system: accounts (Asset, Liability, Equity, Revenue, Expense), journal entries with balanced debit/credit amounts, and reporting (trial balance, balance sheet, income statement).
6
+
7
+ All monetary amounts are stored as exact integer minor units (no floating-point errors), with a configurable scale (currently 2 decimal places, suitable for AUD, USD, NZD).
8
+
9
+ ## Design
10
+
11
+ ### Amounts
12
+
13
+ Money is represented by the `Amount` value object, stored internally as a `bigint` of minor units (cents at scale 2). The `SCALE` constant in `src/domain/amount.ts` is the single source of truth for precision. Rounding to the supported scale happens only at the input boundary, using half-up rounding; stored values are never rounded, so posted entries and the trial balance remain exact.
14
+
15
+ At public input boundaries (entry amounts, account creation), amounts accept `number | string | Amount`. A Zod schema parses raw values to `Amount` via half-up rounding at the boundary, so float imprecision (e.g. `0.1 + 0.2` = 0.30000000000000004) is resolved before storage. The stored integer is exact.
16
+
17
+ To support higher-precision currencies in the future (e.g. 3 decimals for KWD, 8 for crypto), raise `SCALE` and run a rescale migration on stored minor units. The rest of the domain is scale-agnostic.
18
+
19
+ ### Accounts
20
+
21
+ The five account types are represented by the `AccountType` enum. Balance logic is driven by `normalCreditBalance(type)` plus the account's `contra` flag:
22
+
23
+ - Normal credit balance (Liability, Equity, Revenue), non-contra: `credits - debits`
24
+ - Normal credit balance, contra: `debits - credits`
25
+ - Normal debit balance (Asset, Expense), non-contra: `debits - credits`
26
+ - Normal debit balance, contra: `credits - debits`
27
+
28
+ Contra accounts are automatically subtracted from type-level balance aggregation.
29
+
30
+ ### Entries
31
+
32
+ An entry requires a description, at least one debit and one credit, and the sum of debits must exactly equal the sum of credits. Validation is driven by [Zod](https://zod.dev) schemas: input shape (per-line account/amount presence, amount positivity) is validated by the schema, and entry-level invariants (≥1 debit, ≥1 credit, debits-sum === credits-sum) are enforced via `superRefine`. On failure, `Ledger.postEntry` throws a `ValidationError` carrying a flat list of path-tagged `issues`.
33
+
34
+ `Entry` (the persisted read model) and `EntryPayload` (the validated, pre-persistence input) are distinct immutable types — there is no mutable "new record" state.
35
+
36
+ ### Validation
37
+
38
+ All public input boundaries are Zod-gated: entry input, account creation, amount parsing, and date ranges. `ValidationError.issues` is a flat array of `{ path: PropertyKey[]; message: string }` (Zod-native); record-level invariants use an empty path. Use `errorsByField()` to collapse paths to a field-keyed map for form binding.
39
+
40
+ ### Persistence
41
+
42
+ Pluts persists over a SQLite-backed Durable Object's own storage (`ctx.storage.sql`), using the synchronous `SqlStorage` API (`sql.exec(sql, ...binds).toArray()/.one()`). The `Repository` interface decouples the domain from persistence, so the domain can be unit-tested with an in-memory repository. `SqlStorageRepository` is the production implementation.
43
+
44
+ - `src/db/schema.ts` is the single source of truth for DDL: idempotent `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` statements.
45
+ - `migrate(sql)` applies the schema via `sql.exec(...)`. It is idempotent and safe to run on every cold start; existing tables and indexes are left untouched. There is no separate migrations tracking table and no code generator to run.
46
+ - A ledger is hosted _inside_ a Durable Object: the DO's private SQLite database (declared via `new_sqlite_classes` in `wrangler.jsonc`) is the ledger. One DO instance = one isolated ledger. `migrate(ctx.storage.sql)` self-provisions each one, typically in the DO constructor under `blockConcurrencyWhile`.
47
+
48
+ ### SQL implementation and portability
49
+
50
+ Pluts's production persistence is implemented against Cloudflare Durable Objects' embedded SQLite via the synchronous `SqlStorage` API. The concrete implementation lives in `src/db/sqlite-storage-repository.ts` and runs the schema statements defined in `src/db/schema.ts` using `migrate(ctx.storage.sql)`.
51
+
52
+ If you want to run Pluts outside of Cloudflare (or support additional backends), implement the `Repository` interface in `src/db/repository.ts` for your chosen storage. Key guidance:
53
+
54
+ - Implement the same transactional semantics for `insertEntry` (all row inserts + idempotency-key insert must be atomic). Use your DB's transactions.
55
+ - Apply the schema in `SCHEMA_STATEMENTS` (see `src/db/schema.ts`) or translate it to your DB's DDL before first use.
56
+ - Persist amounts as integer minor units (the library uses `bigint` internally; convert to/from your driver's numeric type safely).
57
+ - Ensure `getAccountByName`, `sumByType`, `sumCredits`/`sumDebits`, and `amountsForAccount` match the SQL semantics expected by the domain code.
58
+
59
+ Examples:
60
+
61
+ - Node + SQLite: implement a `NodeSqliteRepository` using `better-sqlite3` or `sqlite3`, run the `SCHEMA_STATEMENTS` once on startup, and wrap `insertEntry` in a transaction.
62
+ - Postgres: translate the DDL (types, index syntax) and implement `Repository` methods with `pg`/`knex`; be mindful of integer sizes and use `bigint`/numeric as appropriate.
63
+
64
+ Because the domain is decoupled via `Repository` and tests exercise the domain with an in-memory repository, porting is limited to a single new repository adapter — the rest of Pluts should work unchanged.
65
+
66
+ ### Tenancy
67
+
68
+ Tenancy is intentionally **not** included. Multi-tenancy is provided by Durable Object isolation: one DO instance = one ledger. Account names are unique within a ledger.
69
+
70
+ ### Schema
71
+
72
+ Four tables (prefixed `pluts_`), defined in `src/db/schema.ts`:
73
+
74
+ - `pluts_accounts` — id, name, type (CHECK-constrained to the five account types), contra, created_at
75
+ - `pluts_entries` — id, description, date, posted_at
76
+ - `pluts_amounts` — id, type (`'credit'` | `'debit'`), account_id, entry_id, amount (integer minor units)
77
+ - `pluts_entry_keys` — key, entry_id (idempotency-key dedup table)
78
+
79
+ Run `migrate(ctx.storage.sql)` to apply the schema; it is idempotent and a no-op on an up-to-date database.
80
+
81
+ ## Installation
82
+
83
+ ```sh
84
+ bun add pluts
85
+ ```
86
+
87
+ Peer dependency: `zod`. Types for the Workers runtime (`SqlStorage`, `DurableObjectStorage`, `crypto`) come from `@cloudflare/workers-types`.
88
+
89
+ ## Usage
90
+
91
+ Hosted inside a Durable Object, where `ctx.storage.sql` is the ledger's database:
92
+
93
+ ```ts
94
+ import { DurableObject } from "cloudflare:workers";
95
+ import {
96
+ AccountType,
97
+ Amount,
98
+ Ledger,
99
+ migrate,
100
+ SqlStorageRepository,
101
+ } from "pluts";
102
+
103
+ export class LedgerDO extends DurableObject {
104
+ constructor(ctx: DurableObjectState, env: Env) {
105
+ super(ctx, env);
106
+ // Provision the schema before any request is served.
107
+ ctx.blockConcurrencyWhile(() => {
108
+ migrate(ctx.storage.sql);
109
+ return Promise.resolve();
110
+ });
111
+ }
112
+
113
+ async fetch(req: Request): Promise<Response> {
114
+ const ledger = new Ledger(new SqlStorageRepository(this.ctx.storage));
115
+ // ...route requests to ledger.createAccount / ledger.postEntry / reports...
116
+ return new Response("ok");
117
+ }
118
+ }
119
+
120
+ // Create accounts
121
+ const cash = await ledger.createAccount({
122
+ name: "Cash",
123
+ type: AccountType.Asset,
124
+ });
125
+ await ledger.createAccount({
126
+ name: "Sales Revenue",
127
+ type: AccountType.Revenue,
128
+ });
129
+ await ledger.createAccount({
130
+ name: "Sales Tax Payable",
131
+ type: AccountType.Liability,
132
+ });
133
+
134
+ // Post a balanced entry (debits === credits). Amounts accept number|string|Amount.
135
+ await ledger.postEntry({
136
+ description: "Sold widgets",
137
+ debits: [{ accountName: "Cash", amount: 50 }],
138
+ credits: [
139
+ { accountName: "Sales Revenue", amount: 45 },
140
+ { accountName: "Sales Tax Payable", amount: "5.00" },
141
+ ],
142
+ });
143
+
144
+ // Balances
145
+ await ledger.accountBalance(cash); // per-account
146
+ await ledger.balanceByType(AccountType.Asset); // per-type
147
+ await ledger.trialBalance(); // should be zero
148
+
149
+ // Reports
150
+ await ledger.balanceSheet(); // { assets, liabilities, equity, balanced }
151
+ await ledger.incomeStatement({ fromDate: "2024-01-01" }); // { revenue, expenses, netIncome }
152
+ ```
153
+
154
+ A complete runnable example lives in the [pluts-ledger-do](https://github.com/adamburmister/pluts-ledger-do) app, which wraps this pattern in a DO with a JSON REST surface and a seed route.
155
+
156
+ ### Contra accounts
157
+
158
+ A contra account has its normal balance swapped:
159
+
160
+ ```ts
161
+ await ledger.createAccount({
162
+ name: "Drawing",
163
+ type: AccountType.Equity,
164
+ contra: true,
165
+ });
166
+ ```
167
+
168
+ ### Date ranges
169
+
170
+ Balance methods accept an optional `{ fromDate, toDate }` (Date or `yyyy-mm-dd` string):
171
+
172
+ ```ts
173
+ await ledger.accountBalance(cash, {
174
+ fromDate: "2024-01-01",
175
+ toDate: new Date(),
176
+ });
177
+ ```
178
+
179
+ ## Validation
180
+
181
+ `Ledger.postEntry` and `Ledger.createAccount` throw `ValidationError` with a flat list of path-tagged `issues` on failure:
182
+
183
+ ```ts
184
+ try {
185
+ await ledger.postEntry({ ... });
186
+ } catch (e) {
187
+ if (e instanceof ValidationError) {
188
+ // e.issues: { path: PropertyKey[]; message: string }[]
189
+ // e.g. [{ path: [], message: 'The credit and debit amounts are not equal' }]
190
+ console.log(e.issues);
191
+ // For form binding, collapse paths to a field-keyed map:
192
+ console.log(e.errorsByField()); // { _base: ['The credit and debit amounts are not equal'] }
193
+ }
194
+ }
195
+ ```
196
+
197
+ Rules (enforced via Zod schema + `superRefine`):
198
+
199
+ - `description` required (non-empty)
200
+ - at least one debit and one credit
201
+ - every amount requires an account and a non-negative value
202
+ - sum(debits) === sum(credits) (exact)
203
+ - `date` defaults to today if omitted
204
+ - account names must resolve to existing accounts
205
+
206
+ ## Testing
207
+
208
+ ```sh
209
+ bun run test # all tests
210
+ bun run test:unit # domain unit tests (in-memory, fast)
211
+ bun run typecheck # tsc --noEmit
212
+ bun run lint # biome
213
+ ```
214
+
215
+ The unit tests cover amount math, balance computation for all account types and contra variants, entry validation, and the trial balance invariant, all against an in-memory `Repository`. The `SqlStorageRepository` (production) is exercised end-to-end by the [pluts-ledger-do](https://github.com/adamburmister/pluts-ledger-do) app's Durable Object.
216
+
217
+ ## Development
218
+
219
+ ```sh
220
+ bun install
221
+ bun run test # vitest (domain unit tests, in-memory)
222
+ ```
223
+
224
+ Pluts is a library; the runnable Durable Object app that consumes it lives in [pluts-ledger-do](https://github.com/adamburmister/pluts-ledger-do) project. Run `npm run dev` there for a local DO with SQLite storage.
225
+
226
+ ### Migrations
227
+
228
+ The schema is defined as idempotent DDL in `src/db/schema.ts` (the `SCHEMA_STATEMENTS` array). To change the schema, edit that file and add/adjust the `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` statements. `migrate(ctx.storage.sql)` applies them on every cold start; existing objects are skipped, so there is no separate "generate migrations" step or tracking table.
229
+
230
+ Fresh-DBs only: if you change a `CREATE TABLE` definition in a way that conflicts with an already-provisioned DO SQLite database, reset the local DO storage so it is recreated cleanly:
231
+
232
+ ```sh
233
+ rm -rf ../ledger/.wrangler/state/v3/do
234
+ ```
235
+
236
+ The next `npm run dev` in `ledger` provisions a fresh DO and `migrate` applies the schema cleanly.
237
+
238
+ ## License
239
+
240
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "pluts",
3
+ "version": "0.1.0",
4
+ "description": "A double-entry accounting ledger for TypeScript, targeting Cloudflare Durable Objects (SQLite-backed storage).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/adamburmister/pluts.git"
16
+ },
17
+ "keywords": [
18
+ "typescript",
19
+ "accounting",
20
+ "double-entry",
21
+ "ledger",
22
+ "bookkeeping",
23
+ "cloudflare",
24
+ "durable-objects",
25
+ "sqlite"
26
+ ],
27
+ "scripts": {
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "lint": "biome check src test",
31
+ "fix": "biome check --write src test",
32
+ "format": "biome format --write src test",
33
+ "typecheck": "tsc --noEmit"
34
+ },
35
+ "dependencies": {
36
+ "zod": "^4.4.3"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.5.2",
40
+ "@cloudflare/workers-types": "^5.20260706.1",
41
+ "typescript": "^6.0.3",
42
+ "vitest": "^4.1.10"
43
+ }
44
+ }
@@ -0,0 +1,42 @@
1
+ import type { Account } from "../domain/account";
2
+ import type { Amount } from "../domain/amount";
3
+ import type { AmountRecord, Entry, EntryPayload } from "../domain/entry";
4
+ import type { AccountType, DateRange } from "../domain/types";
5
+
6
+ export interface Repository {
7
+ insertAccount(input: {
8
+ name: string;
9
+ type: AccountType;
10
+ contra: boolean;
11
+ }): Promise<Account>;
12
+
13
+ getAccount(id: string): Promise<Account | null>;
14
+ getAccountByName(name: string): Promise<Account | null>;
15
+ getAccountsByType(type: AccountType): Promise<Account[]>;
16
+ allAccounts(): Promise<Account[]>;
17
+
18
+ /** Validate and persist an entry (and its amounts) atomically. Returns the persisted entry. */
19
+ insertEntry(payload: EntryPayload): Promise<Entry>;
20
+
21
+ getEntry(id: string): Promise<Entry | null>;
22
+ /** Look up an entry by its client-supplied idempotency key, or null if none. */
23
+ getEntryByKey(key: string): Promise<Entry | null>;
24
+ allEntries(order?: "asc" | "desc"): Promise<Entry[]>;
25
+
26
+ /** Sum of credit amounts for an account, optionally within a date range. */
27
+ sumCredits(accountId: string, range?: DateRange): Promise<Amount>;
28
+ /** Sum of debit amounts for an account, optionally within a date range. */
29
+ sumDebits(accountId: string, range?: DateRange): Promise<Amount>;
30
+
31
+ /** Sum of amounts of a given kind across all accounts of a type, optionally within a date range. */
32
+ sumByType(
33
+ type: AccountType,
34
+ kind: "credit" | "debit",
35
+ range?: DateRange,
36
+ ): Promise<Amount>;
37
+
38
+ /** All amounts (credit + debit) for an account, with their entries. */
39
+ amountsForAccount(accountId: string): Promise<AmountRecord[]>;
40
+ /** All entries referencing an account (via credit or debit amounts). */
41
+ entriesForAccount(accountId: string): Promise<Entry[]>;
42
+ }
@@ -0,0 +1,83 @@
1
+ import type { SqlStorage } from "@cloudflare/workers-types";
2
+
3
+ /**
4
+ * Pluts schema — the single source of truth for DDL, applied to a
5
+ * SQLite-backed Durable Object's own storage (`ctx.storage.sql`).
6
+ *
7
+ * Precision note: `SqlStorage` returns INTEGER as a JS number. `amount` (minor
8
+ * units) is exact up to Number.MAX_SAFE_INTEGER (~$90T at scale 2), an accepted
9
+ * ceiling for a personal-finance ledger. The write path binds
10
+ * `Number(amount.minor)`.
11
+ */
12
+
13
+ export const SCHEMA_STATEMENTS: string[] = [
14
+ `CREATE TABLE IF NOT EXISTS pluts_accounts (
15
+ id TEXT PRIMARY KEY NOT NULL,
16
+ name TEXT NOT NULL,
17
+ type TEXT NOT NULL,
18
+ contra INTEGER DEFAULT 0 NOT NULL,
19
+ created_at TEXT NOT NULL,
20
+ CONSTRAINT pluts_accounts_type_check CHECK (type IN ('Asset','Liability','Equity','Revenue','Expense'))
21
+ )`,
22
+ `CREATE UNIQUE INDEX IF NOT EXISTS pluts_accounts_name_type_idx ON pluts_accounts (name, type)`,
23
+ `CREATE INDEX IF NOT EXISTS pluts_accounts_type_idx ON pluts_accounts (type, name)`,
24
+ `CREATE TABLE IF NOT EXISTS pluts_entries (
25
+ id TEXT PRIMARY KEY NOT NULL,
26
+ description TEXT NOT NULL,
27
+ date TEXT NOT NULL,
28
+ posted_at TEXT NOT NULL
29
+ )`,
30
+ `CREATE INDEX IF NOT EXISTS pluts_entries_date_idx ON pluts_entries (date)`,
31
+ `CREATE TABLE IF NOT EXISTS pluts_amounts (
32
+ id TEXT PRIMARY KEY NOT NULL,
33
+ type TEXT NOT NULL,
34
+ account_id TEXT NOT NULL,
35
+ entry_id TEXT NOT NULL,
36
+ amount INTEGER NOT NULL,
37
+ FOREIGN KEY (account_id) REFERENCES pluts_accounts(id) ON UPDATE no action ON DELETE no action,
38
+ FOREIGN KEY (entry_id) REFERENCES pluts_entries(id) ON UPDATE no action ON DELETE no action
39
+ )`,
40
+ `CREATE INDEX IF NOT EXISTS pluts_amounts_type_idx ON pluts_amounts (type)`,
41
+ `CREATE INDEX IF NOT EXISTS pluts_amounts_account_entry_idx ON pluts_amounts (account_id, entry_id)`,
42
+ `CREATE INDEX IF NOT EXISTS pluts_amounts_entry_account_idx ON pluts_amounts (entry_id, account_id)`,
43
+ `CREATE TABLE IF NOT EXISTS pluts_entry_keys (
44
+ key TEXT PRIMARY KEY NOT NULL,
45
+ entry_id TEXT NOT NULL,
46
+ FOREIGN KEY (entry_id) REFERENCES pluts_entries(id) ON UPDATE no action ON DELETE no action
47
+ )`,
48
+ ];
49
+
50
+ /**
51
+ * The full Pluts schema as a single SQL string (statements joined with `;\n`),
52
+ * for inspection or for runtimes whose `exec` splits on `;`. To apply it to a
53
+ * Durable Object's storage, prefer {@link migrate}, which runs each
54
+ * statement individually via `SqlStorage.exec` (robust across runtimes).
55
+ */
56
+ export const SCHEMA_SQL: string = SCHEMA_STATEMENTS.map((s) => `${s};`).join(
57
+ "\n",
58
+ );
59
+
60
+ /**
61
+ * Apply the Pluts schema to a SQLite-backed Durable Object's own storage
62
+ * (`ctx.storage.sql`). Idempotent: a database already at the latest schema is a
63
+ * no-op. `SqlStorage.exec` runs synchronously against the DO's embedded SQLite
64
+ * with no network round-trip, so it is safe to call from the DO constructor
65
+ * inside `blockConcurrencyWhile` — the recommended place to provision storage
66
+ * before any request is served.
67
+ *
68
+ * ```ts
69
+ * import { migrate } from 'pluts';
70
+ *
71
+ * export class LedgerDO extends DurableObject {
72
+ * constructor(ctx, env) {
73
+ * super(ctx, env);
74
+ * ctx.blockConcurrencyWhile(() => { migrate(ctx.storage.sql); return Promise.resolve(); });
75
+ * }
76
+ * }
77
+ * ```
78
+ */
79
+ export function migrate(sql: SqlStorage): void {
80
+ for (const stmt of SCHEMA_STATEMENTS) {
81
+ sql.exec(stmt).toArray();
82
+ }
83
+ }