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.
@@ -0,0 +1,419 @@
1
+ import type { DurableObjectStorage } from "@cloudflare/workers-types";
2
+ import { Account } from "../domain/account";
3
+ import { Amount } from "../domain/amount";
4
+ import {
5
+ type AmountKind,
6
+ AmountRecord,
7
+ amountsFromPayload,
8
+ Entry,
9
+ type EntryPayload,
10
+ } from "../domain/entry";
11
+ import { RepositoryError, ValidationError } from "../domain/errors";
12
+ import { type AccountType, type DateRange, toDateISO } from "../domain/types";
13
+ import type { Repository } from "./repository";
14
+
15
+ /**
16
+ * Raw row shapes (snake_case column names, as returned by SqlStorage cursors),
17
+ * matching the columns defined in `./schema.ts`.
18
+ */
19
+ interface AccountRow {
20
+ [key: string]: SqlStorageValue;
21
+ id: string;
22
+ name: string;
23
+ type: string;
24
+ contra: number;
25
+ created_at: string;
26
+ }
27
+ interface EntryRow {
28
+ [key: string]: SqlStorageValue;
29
+ id: string;
30
+ description: string;
31
+ date: string;
32
+ posted_at: string;
33
+ }
34
+ interface AmountRow {
35
+ [key: string]: SqlStorageValue;
36
+ id: string;
37
+ type: string;
38
+ account_id: string;
39
+ entry_id: string;
40
+ amount: number;
41
+ }
42
+
43
+ function uuid(): string {
44
+ return crypto.randomUUID();
45
+ }
46
+
47
+ /**
48
+ * SQLite surfaces unique-constraint violations as thrown errors whose `message`
49
+ * contains "UNIQUE constraint failed" (case-insensitive). There is no typed
50
+ * error class, so this string-matches the message.
51
+ */
52
+ export function isUniqueConstraintError(e: unknown): boolean {
53
+ if (typeof e !== "object" || e === null || !("message" in e)) return false;
54
+ return /UNIQUE constraint failed/i.test(
55
+ String((e as { message: unknown }).message),
56
+ );
57
+ }
58
+
59
+ function toAccount(row: AccountRow): Account {
60
+ return new Account(
61
+ row.id,
62
+ row.name,
63
+ row.type as AccountType,
64
+ !!row.contra,
65
+ row.created_at,
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Builds a date-range predicate fragment for the `pluts_entries` table. Returns
71
+ * either the empty string (no range) or ` AND date >= ? AND date <= ?`; the
72
+ * matching bound values are returned alongside for the exec bind list.
73
+ */
74
+ function dateRangeClause(range: DateRange | undefined): {
75
+ sql: string;
76
+ binds: string[];
77
+ } {
78
+ if (!range) return { sql: "", binds: [] };
79
+ const clauses: string[] = [];
80
+ const binds: string[] = [];
81
+ if (range.fromDate) {
82
+ clauses.push("date >= ?");
83
+ binds.push(toDateISO(range.fromDate));
84
+ }
85
+ if (range.toDate) {
86
+ clauses.push("date <= ?");
87
+ binds.push(toDateISO(range.toDate));
88
+ }
89
+ if (clauses.length === 0) return { sql: "", binds: [] };
90
+ return { sql: ` AND ${clauses.join(" AND ")}`, binds };
91
+ }
92
+
93
+ /**
94
+ * Production {@link Repository} over a SQLite-backed Durable Object's own
95
+ * storage (`ctx.storage.sql`), using the synchronous `SqlStorage` API.
96
+ *
97
+ * This is the storage backend when a Pluts ledger is hosted *inside* a Durable
98
+ * Object: the DO's private SQLite database is the ledger. Each DO instance =
99
+ * one isolated ledger.
100
+ *
101
+ * Notes on the `SqlStorage` API:
102
+ * - `sql.exec(sql, ...binds)` is **synchronous** and returns a cursor that must
103
+ * be consumed (`.toArray()` / `.one()`) before the next `await` — there is no
104
+ * snapshot isolation across awaits. Every read here consumes its cursor
105
+ * immediately.
106
+ * - Atomic entry posting uses `ctx.storage.transactionSync(callback)`: the
107
+ * entry row, all amount rows, and the idempotency-key row commit together or
108
+ * roll back together.
109
+ *
110
+ * Construct with the DO's `DurableObjectStorage` (which exposes both `.sql` and
111
+ * `.transactionSync`):
112
+ *
113
+ * ```ts
114
+ * new SqlStorageRepository(this.ctx.storage);
115
+ * ```
116
+ */
117
+ export class SqlStorageRepository implements Repository {
118
+ constructor(private readonly storage: DurableObjectStorage) {}
119
+
120
+ private get sql() {
121
+ return this.storage.sql;
122
+ }
123
+
124
+ async insertAccount(input: {
125
+ name: string;
126
+ type: AccountType;
127
+ contra: boolean;
128
+ }): Promise<Account> {
129
+ const id = uuid();
130
+ const now = new Date().toISOString();
131
+ try {
132
+ this.sql
133
+ .exec(
134
+ "INSERT INTO pluts_accounts (id, name, type, contra, created_at) VALUES (?, ?, ?, ?, ?)",
135
+ id,
136
+ input.name,
137
+ input.type,
138
+ input.contra ? 1 : 0,
139
+ now,
140
+ )
141
+ .toArray();
142
+ } catch (e) {
143
+ if (isUniqueConstraintError(e)) {
144
+ throw new ValidationError(
145
+ [{ path: ["name"], message: "has already been taken" }],
146
+ "Account already exists",
147
+ );
148
+ }
149
+ throw e;
150
+ }
151
+ return new Account(id, input.name, input.type, input.contra, now);
152
+ }
153
+
154
+ async getAccount(id: string): Promise<Account | null> {
155
+ const rows = this.sql
156
+ .exec<AccountRow>(
157
+ "SELECT id, name, type, contra, created_at FROM pluts_accounts WHERE id = ?",
158
+ id,
159
+ )
160
+ .toArray();
161
+ const row = rows[0];
162
+ return row ? toAccount(row) : null;
163
+ }
164
+
165
+ async getAccountByName(name: string): Promise<Account | null> {
166
+ const rows = this.sql
167
+ .exec<AccountRow>(
168
+ "SELECT id, name, type, contra, created_at FROM pluts_accounts WHERE name = ?",
169
+ name,
170
+ )
171
+ .toArray();
172
+ const row = rows[0];
173
+ return row ? toAccount(row) : null;
174
+ }
175
+
176
+ async getAccountsByType(type: AccountType): Promise<Account[]> {
177
+ return this.sql
178
+ .exec<AccountRow>(
179
+ "SELECT id, name, type, contra, created_at FROM pluts_accounts WHERE type = ? ORDER BY name ASC",
180
+ type,
181
+ )
182
+ .toArray()
183
+ .map(toAccount);
184
+ }
185
+
186
+ async allAccounts(): Promise<Account[]> {
187
+ return this.sql
188
+ .exec<AccountRow>(
189
+ "SELECT id, name, type, contra, created_at FROM pluts_accounts ORDER BY name ASC",
190
+ )
191
+ .toArray()
192
+ .map(toAccount);
193
+ }
194
+
195
+ async insertEntry(payload: EntryPayload): Promise<Entry> {
196
+ const id = uuid();
197
+ const now = new Date().toISOString();
198
+ const { debits, credits } = amountsFromPayload(payload, id);
199
+
200
+ // The entry row, every amount row, and (if present) the idempotency-key row
201
+ // must commit atomically. transactionSync runs its callback in one SQLite
202
+ // transaction; if any statement throws, the whole thing rolls back.
203
+ try {
204
+ this.storage.transactionSync(() => {
205
+ this.sql
206
+ .exec(
207
+ "INSERT INTO pluts_entries (id, description, date, posted_at) VALUES (?, ?, ?, ?)",
208
+ id,
209
+ payload.description,
210
+ payload.date,
211
+ now,
212
+ )
213
+ .toArray();
214
+
215
+ for (const a of [...debits, ...credits]) {
216
+ this.sql
217
+ .exec(
218
+ "INSERT INTO pluts_amounts (id, type, account_id, entry_id, amount) VALUES (?, ?, ?, ?, ?)",
219
+ a.id,
220
+ a.kind,
221
+ a.account.id,
222
+ id,
223
+ Number(a.amount.minor),
224
+ )
225
+ .toArray();
226
+ }
227
+
228
+ if (payload.idempotencyKey) {
229
+ this.sql
230
+ .exec(
231
+ "INSERT INTO pluts_entry_keys (key, entry_id) VALUES (?, ?)",
232
+ payload.idempotencyKey,
233
+ id,
234
+ )
235
+ .toArray();
236
+ }
237
+ });
238
+ } catch (e) {
239
+ // Two concurrent posts sharing an idempotency key can race past the
240
+ // pre-check in Ledger.postEntry; the loser's key insert hits the unique
241
+ // constraint and the whole transaction rolls back. Recover by returning
242
+ // the already-persisted entry.
243
+ if (payload.idempotencyKey && isUniqueConstraintError(e)) {
244
+ const existing = await this.getEntryByKey(payload.idempotencyKey);
245
+ if (existing) return existing;
246
+ }
247
+ throw new RepositoryError("Failed to persist entry", e);
248
+ }
249
+
250
+ return new Entry(
251
+ id,
252
+ payload.description,
253
+ payload.date,
254
+ debits,
255
+ credits,
256
+ now,
257
+ );
258
+ }
259
+
260
+ async getEntry(id: string): Promise<Entry | null> {
261
+ const rows = this.sql
262
+ .exec<EntryRow>(
263
+ "SELECT id, description, date, posted_at FROM pluts_entries WHERE id = ?",
264
+ id,
265
+ )
266
+ .toArray();
267
+ const row = rows[0];
268
+ return row ? this.loadEntry(row) : null;
269
+ }
270
+
271
+ async getEntryByKey(key: string): Promise<Entry | null> {
272
+ const rows = this.sql
273
+ .exec<EntryRow>(
274
+ `SELECT e.id, e.description, e.date, e.posted_at
275
+ FROM pluts_entries e
276
+ INNER JOIN pluts_entry_keys k ON k.entry_id = e.id
277
+ WHERE k.key = ?`,
278
+ key,
279
+ )
280
+ .toArray();
281
+ const row = rows[0];
282
+ return row ? this.loadEntry(row) : null;
283
+ }
284
+
285
+ async allEntries(order: "asc" | "desc" = "desc"): Promise<Entry[]> {
286
+ const dir = order === "asc" ? "ASC" : "DESC";
287
+ const rows = this.sql
288
+ .exec<EntryRow>(
289
+ `SELECT id, description, date, posted_at FROM pluts_entries ORDER BY date ${dir}`,
290
+ )
291
+ .toArray();
292
+ // loadEntry issues its own exec per entry. SqlStorage cursors are consumed
293
+ // synchronously inside loadEntry, so iterating here is safe.
294
+ return rows.map((r) => this.loadEntry(r));
295
+ }
296
+
297
+ async sumCredits(accountId: string, range?: DateRange): Promise<Amount> {
298
+ return this.sumAmounts(accountId, "credit", range);
299
+ }
300
+
301
+ async sumDebits(accountId: string, range?: DateRange): Promise<Amount> {
302
+ return this.sumAmounts(accountId, "debit", range);
303
+ }
304
+
305
+ async sumByType(
306
+ type: AccountType,
307
+ kind: AmountKind,
308
+ range?: DateRange,
309
+ ): Promise<Amount> {
310
+ const rangeClause = dateRangeClause(range);
311
+ // An aggregate SELECT always returns exactly one row, so .one() is safe.
312
+ const row = this.sql
313
+ .exec<{ total: number | null }>(
314
+ `SELECT COALESCE(SUM(a.amount), 0) AS total
315
+ FROM pluts_amounts a
316
+ INNER JOIN pluts_accounts acc ON acc.id = a.account_id
317
+ INNER JOIN pluts_entries e ON e.id = a.entry_id
318
+ WHERE acc.type = ? AND a.type = ?${rangeClause.sql}`,
319
+ type,
320
+ kind,
321
+ ...rangeClause.binds,
322
+ )
323
+ .one();
324
+ return Amount.fromMinor(BigInt(row.total ?? 0));
325
+ }
326
+
327
+ async amountsForAccount(accountId: string): Promise<AmountRecord[]> {
328
+ const rows = this.sql
329
+ .exec<AmountRow>(
330
+ "SELECT id, type, account_id, entry_id, amount FROM pluts_amounts WHERE account_id = ? ORDER BY entry_id ASC",
331
+ accountId,
332
+ )
333
+ .toArray();
334
+ return this.hydrateAmounts(rows);
335
+ }
336
+
337
+ async entriesForAccount(accountId: string): Promise<Entry[]> {
338
+ const rows = this.sql
339
+ .exec<EntryRow>(
340
+ `SELECT DISTINCT e.id, e.description, e.date, e.posted_at
341
+ FROM pluts_entries e
342
+ INNER JOIN pluts_amounts a ON a.entry_id = e.id
343
+ WHERE a.account_id = ?
344
+ ORDER BY e.date DESC`,
345
+ accountId,
346
+ )
347
+ .toArray();
348
+ return rows.map((r) => this.loadEntry(r));
349
+ }
350
+
351
+ private async sumAmounts(
352
+ accountId: string,
353
+ kind: AmountKind,
354
+ range?: DateRange,
355
+ ): Promise<Amount> {
356
+ const rangeClause = dateRangeClause(range);
357
+ const row = this.sql
358
+ .exec<{ total: number | null }>(
359
+ `SELECT COALESCE(SUM(a.amount), 0) AS total
360
+ FROM pluts_amounts a
361
+ INNER JOIN pluts_entries e ON e.id = a.entry_id
362
+ WHERE a.account_id = ? AND a.type = ?${rangeClause.sql}`,
363
+ accountId,
364
+ kind,
365
+ ...rangeClause.binds,
366
+ )
367
+ .one();
368
+ return Amount.fromMinor(BigInt(row.total ?? 0));
369
+ }
370
+
371
+ /** Builds a fully-formed immutable Entry from a row, loading its amounts. */
372
+ private loadEntry(row: EntryRow): Entry {
373
+ const amounts = this.sql
374
+ .exec<AmountRow>(
375
+ "SELECT id, type, account_id, entry_id, amount FROM pluts_amounts WHERE entry_id = ?",
376
+ row.id,
377
+ )
378
+ .toArray();
379
+ const records = this.hydrateAmounts(amounts);
380
+ const debits = records.filter((r) => r.kind === "debit");
381
+ const credits = records.filter((r) => r.kind === "credit");
382
+ return new Entry(
383
+ row.id,
384
+ row.description,
385
+ row.date,
386
+ debits,
387
+ credits,
388
+ row.posted_at,
389
+ );
390
+ }
391
+
392
+ private hydrateAmounts(rows: AmountRow[]): AmountRecord[] {
393
+ if (rows.length === 0) return [];
394
+ const accountIds = [...new Set(rows.map((r) => r.account_id))];
395
+ // SqlStorage.exec takes a fixed number of placeholders; build a single
396
+ // IN (...) query with one ? per id. No await between the exec and the
397
+ // .toArray() consumption, so the cursor is safe.
398
+ const placeholders = accountIds.map(() => "?").join(", ");
399
+ const accountRows = this.sql
400
+ .exec<AccountRow>(
401
+ `SELECT id, name, type, contra, created_at FROM pluts_accounts WHERE id IN (${placeholders})`,
402
+ ...accountIds,
403
+ )
404
+ .toArray();
405
+ const accountMap = new Map(accountRows.map((r) => [r.id, toAccount(r)]));
406
+ return rows.map((r) => {
407
+ const account = accountMap.get(r.account_id);
408
+ if (!account)
409
+ throw new Error(`Missing account ${r.account_id} for amount ${r.id}`);
410
+ return new AmountRecord(
411
+ r.id,
412
+ r.type as AmountKind,
413
+ account,
414
+ Amount.fromMinor(BigInt(r.amount)),
415
+ r.entry_id,
416
+ );
417
+ });
418
+ }
419
+ }
@@ -0,0 +1,61 @@
1
+ import type { Amount } from "./amount";
2
+ import { type AccountType, normalCreditBalance } from "./types";
3
+
4
+ /** A persisted account record. The `type` discriminates the accounting behaviour. */
5
+ export class Account {
6
+ constructor(
7
+ readonly id: string,
8
+ readonly name: string,
9
+ readonly type: AccountType,
10
+ readonly contra: boolean,
11
+ readonly createdAt: string,
12
+ ) {}
13
+ }
14
+
15
+ /**
16
+ * Compute an account's balance from its summed credit and debit totals.
17
+ *
18
+ * Mirrors Plutus `Account#balance`:
19
+ * - normal credit balance, non-contra => credits - debits
20
+ * - normal credit balance, contra => debits - credits
21
+ * - normal debit balance, non-contra => debits - credits
22
+ * - normal debit balance, contra => credits - debits
23
+ *
24
+ * Returns a raw signed `bigint` (may be negative; balances normally are not).
25
+ * The non-negative {@link Amount} type cannot represent a negative balance, so
26
+ * balance math stays in `bigint`; format for display with {@link formatAmount}.
27
+ */
28
+ export function computeBalance(
29
+ type: AccountType,
30
+ contra: boolean,
31
+ credits: Amount,
32
+ debits: Amount,
33
+ ): bigint {
34
+ const creditNormal = normalCreditBalance(type);
35
+ // creditNormal XOR contra => credits - debits; else debits - credits
36
+ if (creditNormal !== contra) {
37
+ return credits.minor - debits.minor;
38
+ }
39
+ return debits.minor - credits.minor;
40
+ }
41
+
42
+ /**
43
+ * Aggregate a set of account balances by type, subtracting contra accounts.
44
+ * Mirrors the Ruby class-level `Account.balance` (e.g. `Plutus::Asset.balance`).
45
+ * Balances are signed `bigint`; the result is signed `bigint`.
46
+ */
47
+ export function aggregateBalances(
48
+ accounts: ReadonlyArray<{
49
+ type: AccountType;
50
+ contra: boolean;
51
+ balance: bigint;
52
+ }>,
53
+ type: AccountType,
54
+ ): bigint {
55
+ let total = 0n;
56
+ for (const a of accounts) {
57
+ if (a.type !== type) continue;
58
+ total += a.contra ? -a.balance : a.balance;
59
+ }
60
+ return total;
61
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Number of decimal places of precision supported for monetary amounts.
3
+ *
4
+ * Pluts stores money as integer minor units (e.g. cents). SCALE = 2 covers
5
+ * 2-decimal currencies such as AUD, USD, and NZD. Raising SCALE is the only
6
+ * change required to support higher-precision currencies (e.g. scale 3 for
7
+ * KWD, 8 for crypto); existing stored minor units would need a rescale
8
+ * migration. All amount math flows through {@link Amount}, so the rest of the
9
+ * domain is scale-agnostic.
10
+ */
11
+ export const SCALE = 2;
12
+
13
+ const FACTOR = 10n ** BigInt(SCALE);
14
+
15
+ /**
16
+ * A fixed-precision monetary amount stored as integer minor units.
17
+ *
18
+ * Amounts are exact integers in storage; rounding to the supported scale only
19
+ * happens at the input boundary ({@link Amount.fromMajor}) using half-up
20
+ * rounding. This keeps posted entries and the trial balance exact.
21
+ *
22
+ * An {@link Amount} is strictly non-negative: it represents a *quantity* of
23
+ * money, not a signed *balance*. Balance arithmetic (which may legitimately be
24
+ * negative) operates on raw `bigint` and is formatted with {@link formatAmount};
25
+ * see {@link computeBalance}. This separation keeps the type's non-negative
26
+ * invariant honest — there is no `neg()`/`fromSigned()` back door.
27
+ */
28
+ export class Amount {
29
+ private constructor(readonly minor: bigint) {}
30
+
31
+ static fromMinor(minor: bigint): Amount {
32
+ if (minor < 0n)
33
+ throw new RangeError("Amount minor units must be non-negative");
34
+ return new Amount(minor);
35
+ }
36
+
37
+ /**
38
+ * Build an Amount from a major-unit value (e.g. dollars).
39
+ * Half-up rounding to the nearest minor unit, via exact bigint arithmetic.
40
+ */
41
+ static fromMajor(value: number | string): Amount {
42
+ if (typeof value === "number") {
43
+ if (!Number.isFinite(value))
44
+ throw new RangeError("Amount must be finite");
45
+ if (value < 0) throw new RangeError("Amount must be non-negative");
46
+ // Stringify preserving significant fractional digits, then parse exactly.
47
+ return Amount.fromString(value.toString());
48
+ }
49
+ return Amount.fromString(value);
50
+ }
51
+
52
+ static zero(): Amount {
53
+ return new Amount(0n);
54
+ }
55
+
56
+ private static fromString(s: string): Amount {
57
+ const trimmed = s.trim();
58
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
59
+ throw new RangeError(`Invalid amount string: ${s}`);
60
+ }
61
+ const [wholeStr, fracRaw = ""] = trimmed.split(".");
62
+ const whole = wholeStr ?? "0";
63
+ const frac = fracRaw ?? "";
64
+
65
+ let minor: bigint;
66
+ if (frac.length <= SCALE) {
67
+ const fracPadded = (frac + "0".repeat(SCALE)).slice(0, SCALE);
68
+ minor = BigInt(whole) * FACTOR + BigInt(fracPadded || "0");
69
+ } else {
70
+ // Half-up round the excess fractional digits.
71
+ const keep = frac.slice(0, SCALE);
72
+ const nextDigit = frac.slice(SCALE, SCALE + 1);
73
+ const base = BigInt(whole) * FACTOR + BigInt(keep || "0");
74
+ minor = BigInt(nextDigit) >= 5n ? base + 1n : base;
75
+ }
76
+ return new Amount(minor);
77
+ }
78
+
79
+ add(other: Amount): Amount {
80
+ return new Amount(this.minor + other.minor);
81
+ }
82
+
83
+ sub(other: Amount): Amount {
84
+ const result = this.minor - other.minor;
85
+ if (result < 0n)
86
+ throw new RangeError("Amount subtraction would be negative");
87
+ return new Amount(result);
88
+ }
89
+
90
+ /** Multiply by a non-negative integer scalar. */
91
+ mul(scalar: number | bigint): Amount {
92
+ const s = typeof scalar === "number" ? BigInt(scalar) : scalar;
93
+ if (s < 0n) throw new RangeError("Amount scalar must be non-negative");
94
+ return new Amount(this.minor * s);
95
+ }
96
+
97
+ eq(other: Amount): boolean {
98
+ return this.minor === other.minor;
99
+ }
100
+
101
+ gt(other: Amount): boolean {
102
+ return this.minor > other.minor;
103
+ }
104
+
105
+ lt(other: Amount): boolean {
106
+ return this.minor < other.minor;
107
+ }
108
+
109
+ gte(other: Amount): boolean {
110
+ return this.minor >= other.minor;
111
+ }
112
+
113
+ lte(other: Amount): boolean {
114
+ return this.minor <= other.minor;
115
+ }
116
+
117
+ isZero(): boolean {
118
+ return this.minor === 0n;
119
+ }
120
+
121
+ isPositive(): boolean {
122
+ return this.minor > 0n;
123
+ }
124
+
125
+ /** Display as a fixed-precision major-unit string, e.g. "10.00". */
126
+ toMajor(): string {
127
+ const whole = this.minor / FACTOR;
128
+ const frac = this.minor % FACTOR;
129
+ return `${whole}.${frac.toString().padStart(SCALE, "0")}`;
130
+ }
131
+
132
+ toJSON(): string {
133
+ return this.minor.toString();
134
+ }
135
+
136
+ toString(): string {
137
+ return this.toMajor();
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Format a signed balance (raw minor-unit `bigint`, possibly negative) as a
143
+ * fixed-precision major-unit string, e.g. "10.00" or "-3.50". This is the
144
+ * display helper for balance computations, which return `bigint` rather than
145
+ * the strictly-non-negative {@link Amount}.
146
+ */
147
+ export function formatAmount(minor: bigint): string {
148
+ const sign = minor < 0n ? "-" : "";
149
+ const abs = minor < 0n ? -minor : minor;
150
+ const whole = abs / FACTOR;
151
+ const frac = abs % FACTOR;
152
+ return `${sign}${whole}.${frac.toString().padStart(SCALE, "0")}`;
153
+ }