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 +240 -0
- package/package.json +44 -0
- package/src/db/repository.ts +42 -0
- package/src/db/schema.ts +83 -0
- package/src/db/sqlite-storage-repository.ts +419 -0
- package/src/domain/account.ts +61 -0
- package/src/domain/amount.ts +153 -0
- package/src/domain/entry.ts +181 -0
- package/src/domain/errors.ts +61 -0
- package/src/domain/ledger.ts +188 -0
- package/src/domain/schemas.ts +118 -0
- package/src/domain/types.ts +45 -0
- package/src/index.ts +49 -0
|
@@ -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
|
+
}
|