@crewhaus/tool-ledger 0.7.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/dist/index.js ADDED
@@ -0,0 +1,888 @@
1
+ /**
2
+ * `@crewhaus/tool-ledger` — a local double-entry ledger, and the reports that
3
+ * make one usable.
4
+ *
5
+ * Four tools: post balanced entries to a hash-chained `bun:sqlite` journal,
6
+ * read the standard views out of it, match it against a statement, and render
7
+ * a sequentially numbered invoice.
8
+ *
9
+ * THREE PROPERTIES HOLD ACROSS ALL OF THEM.
10
+ *
11
+ * 1. **Nothing moves money.** Nothing here opens a socket. No schema in this
12
+ * package accepts an account number, a card, an API key or any other
13
+ * credential, and `index.test.ts` asserts that over every schema rather
14
+ * than trusting this paragraph. An invoice is rendered to a file; a
15
+ * reconciliation returns entries a person could post. Sending a payment
16
+ * instruction is a different job in a different package with different
17
+ * gates.
18
+ * 2. **Money is never a JS float.** Amounts arrive as decimal strings or as
19
+ * integer minor units and are carried as bigints; the arithmetic and the
20
+ * ISO 4217 exponent table come from `@crewhaus/tool-math`'s money kernel,
21
+ * and the reconciler's row shape is `@crewhaus/tool-money`'s `Transaction`,
22
+ * imported rather than re-declared.
23
+ * 3. **Every write is one `BEGIN IMMEDIATE` transaction.** The rows, the hash
24
+ * chain's head and the idempotency claim commit together or not at all —
25
+ * see `./lib/db` for why anything less leaves a ledger that verifies and
26
+ * still posts the same payment twice.
27
+ *
28
+ * There is no network seam here because there is no network. The one seam is
29
+ * `_setClock`, so a test can fix `postedAt` — which is inside the hash chain,
30
+ * so a wall-clock read would make the same posting hash differently twice.
31
+ */
32
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
33
+ import { dirname, join } from "node:path";
34
+ import { buildTool } from "@crewhaus/tool-builder";
35
+ import { parseStatement } from "@crewhaus/tool-money";
36
+ import { z } from "zod";
37
+ import { LedgerError, assertInstant, assertIsoDate, canonicalJson, exponentFor, json, parseMinorUnits, sha256Hex, } from "./lib/amount";
38
+ import { ACCOUNT_TYPES, DEFAULT_BUSY_TIMEOUT_MS, inImmediateTransaction, openDb, readMeta, writeMeta, } from "./lib/db";
39
+ import { BUILTIN_TEMPLATES, DOCUMENT_KINDS, OUTPUT_FORMATS, allocateDocument, assertNumberIsAFileName, computeInvoice, documentModel, escapeHtml, escapeMarkdown, numberFor, renderTemplate, validateInvoiceDates, } from "./lib/invoice";
40
+ import { POST_LIMITS, ensureAccounts, postBatch } from "./lib/post";
41
+ import { FORMATS, VIEWS, renderRows, runQuery } from "./lib/query";
42
+ import { RECONCILE_LIMITS, reconcile } from "./lib/reconcile";
43
+ import { resolveSafe } from "./paths";
44
+ // ---------------------------------------------------------------------------
45
+ // the one seam
46
+ // ---------------------------------------------------------------------------
47
+ let clock = Date.now;
48
+ /**
49
+ * Replace the clock. Tests drive this so that `postedAt` — which is inside the
50
+ * hashed payload — is fixed, and so no assertion in this package depends on
51
+ * wall-clock time.
52
+ */
53
+ export function _setClock(fn) {
54
+ clock = fn;
55
+ }
56
+ const nowIso = () => new Date(clock()).toISOString();
57
+ // ---------------------------------------------------------------------------
58
+ // shared schema pieces
59
+ // ---------------------------------------------------------------------------
60
+ const DEFAULT_DB_PATH = ".crewhaus/ledger.sqlite";
61
+ const dbPathField = z
62
+ .string()
63
+ .min(1)
64
+ .max(1_024)
65
+ .optional()
66
+ .describe(`workspace-relative path to the ledger file; default ${DEFAULT_DB_PATH}`);
67
+ const isoDate = z
68
+ .string()
69
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "an ISO calendar date, YYYY-MM-DD")
70
+ .describe("ISO calendar date, YYYY-MM-DD");
71
+ const currencyField = z
72
+ .string()
73
+ .regex(/^[A-Za-z]{3}$/, "a three-letter ISO 4217 code")
74
+ .describe("ISO 4217 code; sets the minor-unit exponent");
75
+ const exponentField = z
76
+ .number()
77
+ .int()
78
+ .min(0)
79
+ .max(6)
80
+ .optional()
81
+ .describe("minor-unit exponent for a code the ISO 4217 table does not carry");
82
+ /** Decimal string in major units, or a whole number of minor units. */
83
+ const amountField = z
84
+ .union([z.string().min(1).max(64), z.number().int()])
85
+ .describe("a whole number of minor units; never a float");
86
+ const busyTimeoutField = z
87
+ .number()
88
+ .int()
89
+ .positive()
90
+ .max(120_000)
91
+ .optional()
92
+ .describe(`ms to wait for another writer's lock; default ${DEFAULT_BUSY_TIMEOUT_MS}`);
93
+ /**
94
+ * Open the ledger and settle the base currency.
95
+ *
96
+ * The base currency is fixed by the FIRST posting and stored in the file. A
97
+ * later call that names a different one is refused rather than converted: a
98
+ * ledger whose base currency changed halfway is a set of totals that cannot be
99
+ * added together, and nothing in the rows records which half is which.
100
+ */
101
+ function open(toolName, input, mode, needBaseCurrency = true) {
102
+ const at = resolveSafe(toolName, input.dbPath ?? DEFAULT_DB_PATH);
103
+ const db = openDb(at.real, mode, input.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS, at.rel);
104
+ const stored = readMeta(db, "baseCurrency");
105
+ const storedExponent = readMeta(db, "baseExponent");
106
+ if (stored === null) {
107
+ // `InvoiceRender` only ever touches the numbering tables, so it does not
108
+ // need the book's reporting currency and must not be the call that fixes
109
+ // it — an invoice in EUR should not decide that the ledger reports in EUR.
110
+ if (!needBaseCurrency)
111
+ return { db, rel: at.rel, baseCurrency: "", exponent: 0 };
112
+ if (mode === "read") {
113
+ throw new LedgerError(`the ledger at ${at.rel} has no entries yet, so it has no base currency — post to it before reading it`);
114
+ }
115
+ if (input.baseCurrency === undefined) {
116
+ throw new LedgerError("this ledger is new and has no base currency — state baseCurrency on the first post; defaulting it would fix the reporting currency of somebody's books by accident");
117
+ }
118
+ const currency = input.baseCurrency.toUpperCase();
119
+ const exponent = exponentFor(currency, input.baseExponent);
120
+ writeMeta(db, "baseCurrency", currency);
121
+ writeMeta(db, "baseExponent", String(exponent));
122
+ return { db, rel: at.rel, baseCurrency: currency, exponent };
123
+ }
124
+ if (needBaseCurrency &&
125
+ input.baseCurrency !== undefined &&
126
+ input.baseCurrency.toUpperCase() !== stored) {
127
+ db.close();
128
+ throw new LedgerError(`this ledger's base currency is ${stored} and the call says ${input.baseCurrency.toUpperCase()} — refusing, because re-basing a book of record is not something a tool call should do silently`);
129
+ }
130
+ return {
131
+ db,
132
+ rel: at.rel,
133
+ baseCurrency: stored,
134
+ exponent: Number(storedExponent ?? String(exponentFor(stored))),
135
+ };
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // LedgerPost
139
+ // ---------------------------------------------------------------------------
140
+ const lineSchema = z
141
+ .object({
142
+ account: z.string().min(1).max(128).describe("account code from the chart of accounts"),
143
+ debit: z
144
+ .string()
145
+ .min(1)
146
+ .max(64)
147
+ .optional()
148
+ .describe("major units as a decimal string, e.g. '1250.00'"),
149
+ credit: z.string().min(1).max(64).optional(),
150
+ debitMinor: amountField.optional(),
151
+ creditMinor: amountField.optional(),
152
+ currency: currencyField.optional().describe("defaults to the ledger's base currency"),
153
+ exponent: exponentField,
154
+ fxRate: z
155
+ .string()
156
+ .min(1)
157
+ .max(64)
158
+ .optional()
159
+ .describe("required on a line not in the base currency; units of base per unit of this line"),
160
+ counterparty: z.string().max(256).optional().describe("required by the aging view"),
161
+ dueDate: isoDate.optional().describe("required by the aging view"),
162
+ memo: z.string().max(2_000).optional(),
163
+ })
164
+ .strict();
165
+ const entrySchema = z
166
+ .object({
167
+ date: isoDate,
168
+ memo: z.string().min(1).max(2_000),
169
+ reference: z.string().max(256).optional(),
170
+ // min(1), not min(2): "at least two lines" is the double-entry rule, and
171
+ // it belongs with the balance check, where it produces a rejection
172
+ // carrying this entry's index. Enforced by the schema it would instead
173
+ // fail the whole call, so one malformed entry would stop the 499 good
174
+ // ones — which is the behaviour the rejected list exists to avoid.
175
+ lines: z.array(lineSchema).min(1).max(POST_LIMITS.linesPerEntry),
176
+ tags: z.array(z.string().max(64)).max(POST_LIMITS.tagsPerEntry).optional(),
177
+ source: z
178
+ .object({ system: z.string().min(1).max(64), id: z.string().min(1).max(256) })
179
+ .strict()
180
+ .optional()
181
+ .describe("where this came from; the pair is unique, so the same payment cannot post twice"),
182
+ })
183
+ .strict();
184
+ export const ledgerPost = buildTool({
185
+ name: "LedgerPost",
186
+ description: "Append balanced double-entry journal entries to a local, hash-chained SQLite ledger, refusing anything that would corrupt it. Use it instead of having a model track what has been booked: debits must equal credits or the entry is rejected with the difference named, a (source system, id) pair can only post once, a closed period stays closed, and the rows, the chain's new head and the idempotency claim all commit in ONE transaction — so a crash mid-batch leaves the book exactly as it was rather than a ledger that verifies while its duplicate suppression has forgotten the entry. Amounts are decimal strings or integer minor units, never floats. Nothing is transmitted anywhere; this writes a file.",
187
+ inputSchema: z
188
+ .object({
189
+ dbPath: dbPathField,
190
+ idempotencyKey: z
191
+ .string()
192
+ .min(1)
193
+ .max(200)
194
+ .describe("replaying it returns the first result; re-using it for different content is refused"),
195
+ entries: z.array(entrySchema).min(1).max(POST_LIMITS.entriesPerCall),
196
+ baseCurrency: currencyField
197
+ .optional()
198
+ .describe("required on the first post; fixed thereafter"),
199
+ baseExponent: exponentField,
200
+ accounts: z
201
+ .array(z
202
+ .object({
203
+ code: z.string().min(1).max(128),
204
+ name: z.string().max(256).optional(),
205
+ type: z.enum(ACCOUNT_TYPES),
206
+ })
207
+ .strict())
208
+ .max(10_000)
209
+ .optional()
210
+ .describe("chart of accounts to declare or update before posting"),
211
+ autoCreateAccounts: z
212
+ .boolean()
213
+ .optional()
214
+ .describe("off by default; an account created from a typo is a balance that reconciles to nothing"),
215
+ lockedBefore: isoDate.optional().describe("entries dated before this are refused"),
216
+ fxAccount: z
217
+ .string()
218
+ .min(1)
219
+ .max(128)
220
+ .optional()
221
+ .describe("absorbs conversion rounding; required when an entry mixes currencies"),
222
+ maxFxResidueMinor: z
223
+ .number()
224
+ .int()
225
+ .nonnegative()
226
+ .max(1_000_000)
227
+ .optional()
228
+ .describe("largest residue treated as rounding; default one minor unit per converted line"),
229
+ postedAt: z
230
+ .string()
231
+ .max(64)
232
+ .optional()
233
+ .describe("ISO-8601 with a UTC offset; it is inside the hash"),
234
+ dryRun: z.boolean().optional().describe("runs the real transaction and rolls it back"),
235
+ busyTimeoutMs: busyTimeoutField,
236
+ })
237
+ .strict(),
238
+ destructive: true,
239
+ concurrencySafe: false,
240
+ execute: async (input) => {
241
+ const opened = open("LedgerPost", input, "write");
242
+ try {
243
+ if (input.accounts !== undefined)
244
+ ensureAccounts(opened.db, input.accounts);
245
+ const result = postBatch(opened.db, input.entries, {
246
+ baseCurrency: opened.baseCurrency,
247
+ baseExponent: opened.exponent,
248
+ autoCreateAccounts: input.autoCreateAccounts === true,
249
+ lockedBefore: input.lockedBefore,
250
+ fxAccount: input.fxAccount,
251
+ maxFxResidueMinor: input.maxFxResidueMinor,
252
+ postedAt: input.postedAt === undefined ? nowIso() : assertInstant(input.postedAt, "postedAt"),
253
+ idempotencyKey: input.idempotencyKey,
254
+ dryRun: input.dryRun === true,
255
+ });
256
+ return json({ ledger: opened.rel, ...result });
257
+ }
258
+ finally {
259
+ opened.db.close();
260
+ }
261
+ },
262
+ });
263
+ // ---------------------------------------------------------------------------
264
+ // LedgerQuery
265
+ // ---------------------------------------------------------------------------
266
+ /** The tighter of two upper date bounds, either of which may be absent. */
267
+ function earlier(a, b) {
268
+ if (a === undefined)
269
+ return b;
270
+ if (b === undefined)
271
+ return a;
272
+ return a < b ? a : b;
273
+ }
274
+ /** An amount filter as minor units, refused by name rather than by SyntaxError. */
275
+ function minorFilter(value, label) {
276
+ return value === undefined ? undefined : parseMinorUnits(value, label).toString();
277
+ }
278
+ export const ledgerQuery = buildTool({
279
+ name: "LedgerQuery",
280
+ description: "Answer trial-balance, balance, P&L, balance-sheet, account-ledger, aging, journal, search and chain-verification questions directly from the local ledger, pre-aggregated. Use it instead of dumping the journal into a context window. Signs come from the chart of accounts, so a contra account reports correctly; totals are summed as exact integers rather than by SQL's SUM, which turns a 64-bit overflow into a float. The aging view REFUSES when the lines it would age carry no counterparty or no due date, naming the missing field, rather than returning a table in which everything is conveniently current. Read-only: it opens the file read-only and will not create one.",
281
+ inputSchema: z
282
+ .object({
283
+ dbPath: dbPathField,
284
+ view: z.enum(VIEWS),
285
+ accounts: z.array(z.string().min(1).max(128)).max(5_000).optional(),
286
+ from: isoDate.optional(),
287
+ to: isoDate.optional(),
288
+ asOf: isoDate.optional().describe("required by aging; never read from the clock"),
289
+ tag: z.string().max(64).optional(),
290
+ reference: z.string().max(256).optional(),
291
+ sourceSystem: z.string().max(64).optional(),
292
+ counterparty: z.string().max(256).optional(),
293
+ text: z
294
+ .string()
295
+ .max(256)
296
+ .optional()
297
+ .describe("case-insensitive substring over memo, reference and account"),
298
+ amountMinMinor: amountField.optional().describe("on the absolute base-currency amount"),
299
+ amountMaxMinor: amountField.optional(),
300
+ format: z.enum(FORMATS).optional().describe("json (default), csv or markdown"),
301
+ limit: z
302
+ .number()
303
+ .int()
304
+ .positive()
305
+ .max(20_000)
306
+ .optional()
307
+ .describe("rows returned; default 200"),
308
+ busyTimeoutMs: busyTimeoutField,
309
+ })
310
+ .strict(),
311
+ readOnly: true,
312
+ concurrencySafe: true,
313
+ execute: async (input) => {
314
+ const opened = open("LedgerQuery", input, "read");
315
+ try {
316
+ if (input.from !== undefined)
317
+ assertIsoDate(input.from, "from");
318
+ if (input.to !== undefined)
319
+ assertIsoDate(input.to, "to");
320
+ const result = runQuery(opened.db, {
321
+ view: input.view,
322
+ baseCurrency: opened.baseCurrency,
323
+ baseExponent: opened.exponent,
324
+ limit: input.limit ?? 200,
325
+ asOf: input.asOf,
326
+ filter: {
327
+ accounts: input.accounts,
328
+ from: input.from,
329
+ // A balance sheet and an aging are both "as of" a date, so asOf
330
+ // narrows the selection as well as labelling it — otherwise the
331
+ // report carries one date and is computed over every entry ever
332
+ // posted, and an aging as of June quietly includes an August
333
+ // invoice as "current". The EARLIER of to/asOf wins, because both
334
+ // are upper bounds and honouring only one of them is how a report
335
+ // ends up labelled with a date it does not cover.
336
+ to: earlier(input.to, input.view === "balance_sheet" || input.view === "aging" ? input.asOf : undefined),
337
+ tag: input.tag,
338
+ reference: input.reference,
339
+ sourceSystem: input.sourceSystem,
340
+ counterparty: input.counterparty,
341
+ text: input.text,
342
+ // Parsed, not stringified. `String(input.amountMinMinor)` handed
343
+ // "50.00" or 1e21 straight to `BigInt()`, which throws a bare
344
+ // SyntaxError naming neither the field nor the rule — and "50.00"
345
+ // for a fifty-dollar floor is the obvious thing to type.
346
+ amountMinMinor: minorFilter(input.amountMinMinor, "amountMinMinor"),
347
+ amountMaxMinor: minorFilter(input.amountMaxMinor, "amountMaxMinor"),
348
+ },
349
+ });
350
+ const format = input.format ?? "json";
351
+ if (format === "json")
352
+ return json({ ledger: opened.rel, ...result });
353
+ return json({
354
+ ledger: opened.rel,
355
+ view: result.view,
356
+ totals: result.totals,
357
+ rowCount: result.rowCount,
358
+ truncated: result.truncated,
359
+ notes: result.notes,
360
+ [format]: renderRows(result.rows, format),
361
+ });
362
+ }
363
+ finally {
364
+ opened.db.close();
365
+ }
366
+ },
367
+ });
368
+ // ---------------------------------------------------------------------------
369
+ // LedgerReconcile
370
+ // ---------------------------------------------------------------------------
371
+ const MAX_STATEMENT_BYTES = 64 * 1024 * 1024;
372
+ /** The row shape is `@crewhaus/tool-money`'s, so the schema mirrors it exactly. */
373
+ const transactionSchema = z
374
+ .object({
375
+ id: z.string().min(1).max(256),
376
+ date: isoDate,
377
+ description: z.string().max(2_000),
378
+ amountMinor: z.number().int().describe("negative for money leaving the account"),
379
+ direction: z.enum(["debit", "credit"]),
380
+ reference: z.string().max(256),
381
+ balanceMinor: z.number().int().nullable(),
382
+ })
383
+ .strict();
384
+ const sideSchema = z
385
+ .object({
386
+ kind: z.enum(["lines", "statement", "ledger"]),
387
+ lines: z
388
+ .array(transactionSchema)
389
+ .max(RECONCILE_LIMITS.rowsPerSide)
390
+ .optional()
391
+ .describe("kind 'lines': rows in StatementParse's shape"),
392
+ file: z
393
+ .string()
394
+ .min(1)
395
+ .max(1_024)
396
+ .optional()
397
+ .describe("kind 'statement': workspace-relative CSV or OFX"),
398
+ dateOrder: z.enum(["iso", "dmy", "mdy"]).optional(),
399
+ decimalComma: z.boolean().optional(),
400
+ decimals: z.number().int().min(0).max(8).optional(),
401
+ account: z.string().min(1).max(128).optional().describe("kind 'ledger': the account to read"),
402
+ from: isoDate.optional(),
403
+ to: isoDate.optional(),
404
+ })
405
+ .strict();
406
+ function loadSide(side, label, input) {
407
+ if (side.kind === "lines") {
408
+ if (side.lines === undefined)
409
+ throw new LedgerError(`${label}.kind is "lines" but no lines were given`);
410
+ return side.lines;
411
+ }
412
+ if (side.kind === "statement") {
413
+ if (side.file === undefined)
414
+ throw new LedgerError(`${label}.kind is "statement" but no file was given`);
415
+ const at = resolveSafe("LedgerReconcile", side.file);
416
+ const size = statSync(at.real).size;
417
+ if (size > MAX_STATEMENT_BYTES) {
418
+ throw new LedgerError(`${at.rel} is ${size} bytes, over the ${MAX_STATEMENT_BYTES}-byte limit`);
419
+ }
420
+ // tool-money's parser, not a second one: it is the thing that refuses a
421
+ // file whose dates could be day-first or month-first, and a reconciliation
422
+ // run on months guessed the wrong way balances to twice the error.
423
+ return [
424
+ ...parseStatement(readTextFile(at.real), {
425
+ dateOrder: side.dateOrder,
426
+ decimalComma: side.decimalComma,
427
+ decimals: side.decimals,
428
+ }).transactions,
429
+ ];
430
+ }
431
+ if (side.account === undefined) {
432
+ throw new LedgerError(`${label}.kind is "ledger" but no account was given`);
433
+ }
434
+ const opened = open("LedgerReconcile", input, "read");
435
+ try {
436
+ return ledgerAsTransactions(opened.db, side.account, side.from, side.to);
437
+ }
438
+ finally {
439
+ opened.db.close();
440
+ }
441
+ }
442
+ /** One place that reads a file, so one place decides how much of it is held. */
443
+ function readTextFile(path) {
444
+ return readFileSync(path, "utf-8");
445
+ }
446
+ /**
447
+ * One account's posted lines as statement rows.
448
+ *
449
+ * The id is `<entryId>:<idx>` because a line, not an entry, is what a
450
+ * statement row corresponds to; the amount is the signed base-currency
451
+ * movement, which is what the other side of a reconciliation carries.
452
+ */
453
+ function ledgerAsTransactions(db, account, from, to) {
454
+ const where = ["l.account = ?"];
455
+ const params = [account];
456
+ if (from !== undefined) {
457
+ where.push("e.date >= ?");
458
+ params.push(assertIsoDate(from, "from"));
459
+ }
460
+ if (to !== undefined) {
461
+ where.push("e.date <= ?");
462
+ params.push(assertIsoDate(to, "to"));
463
+ }
464
+ const rows = db
465
+ .query(`SELECT e.entry_id, e.date, e.memo, e.reference, l.idx, l.base_debit_minor, l.base_credit_minor, l.line_memo
466
+ FROM lines l JOIN entries e ON e.seq = l.entry_seq
467
+ WHERE ${where.join(" AND ")} ORDER BY e.seq ASC, l.idx ASC LIMIT ${RECONCILE_LIMITS.rowsPerSide + 1}`)
468
+ .all(...params);
469
+ if (rows.length > RECONCILE_LIMITS.rowsPerSide) {
470
+ throw new LedgerError(`account ${account} has more than ${RECONCILE_LIMITS.rowsPerSide} lines in this range — narrow it with from/to`);
471
+ }
472
+ return rows.map((row) => {
473
+ const amount = BigInt(row.base_debit_minor) - BigInt(row.base_credit_minor);
474
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < -BigInt(Number.MAX_SAFE_INTEGER)) {
475
+ throw new LedgerError(`entry ${row.entry_id} line ${row.idx} is larger than a safe integer, so it cannot be expressed in the statement row shape`);
476
+ }
477
+ return {
478
+ id: `${row.entry_id}:${row.idx}`,
479
+ date: row.date,
480
+ description: row.line_memo ?? row.memo,
481
+ amountMinor: Number(amount),
482
+ direction: amount < 0n ? "debit" : "credit",
483
+ reference: row.reference,
484
+ balanceMinor: null,
485
+ };
486
+ });
487
+ }
488
+ export const ledgerReconcile = buildTool({
489
+ name: "LedgerReconcile",
490
+ description: "Match two sets of transactions — a parsed statement, a ledger account, or rows handed in — and report matched, unmatched on each side, and near misses SEPARATELY. Use it instead of an in-context comparison that stops being possible past a few dozen rows. A tolerance here does NOT widen matching: a 100.00 and a 100.01 are reported as a near miss for a person to look at, never silently paired, because pairing them destroys the only evidence that something is wrong. Bundled payouts are found by a bounded subset-sum with a canonical choice, so reruns return the same grouping. Rows use StatementParse's shape. It opens no socket and posts nothing — proposed entries come back for the caller to pass to LedgerPost.",
491
+ inputSchema: z
492
+ .object({
493
+ left: sideSchema,
494
+ right: sideSchema,
495
+ dbPath: dbPathField,
496
+ currency: currencyField.optional().describe("for rendering amounts; default USD"),
497
+ exponent: exponentField,
498
+ toleranceMinor: z
499
+ .number()
500
+ .int()
501
+ .nonnegative()
502
+ .max(1_000_000_000)
503
+ .optional()
504
+ .describe("near-miss radius in minor units; it does NOT widen matching. Default 0 (off)"),
505
+ windowDays: z
506
+ .number()
507
+ .int()
508
+ .nonnegative()
509
+ .max(365)
510
+ .optional()
511
+ .describe("date window; default 3"),
512
+ allowManyToOne: z
513
+ .boolean()
514
+ .optional()
515
+ .describe("look for bundles that sum to one row; default true"),
516
+ maxSubsetSize: z
517
+ .number()
518
+ .int()
519
+ .min(2)
520
+ .max(RECONCILE_LIMITS.maxSubsetSize)
521
+ .optional()
522
+ .describe("largest bundle considered; default 6"),
523
+ feeToleranceMinor: z
524
+ .number()
525
+ .int()
526
+ .nonnegative()
527
+ .max(1_000_000)
528
+ .optional()
529
+ .describe("largest residue a bundle may carry as a withheld fee; default 0 (off)"),
530
+ requireReferenceMatch: z
531
+ .boolean()
532
+ .optional()
533
+ .describe("stop after the reference pass; everything else is reported unmatched"),
534
+ propose: z
535
+ .object({
536
+ bankAccount: z.string().min(1).max(128),
537
+ feeAccount: z.string().min(1).max(128),
538
+ suspenseAccount: z.string().min(1).max(128),
539
+ })
540
+ .strict()
541
+ .optional()
542
+ .describe("account codes for draft entries; they are returned, never posted"),
543
+ busyTimeoutMs: busyTimeoutField,
544
+ })
545
+ .strict(),
546
+ readOnly: true,
547
+ concurrencySafe: true,
548
+ execute: async (input) => {
549
+ const currency = (input.currency ?? "USD").toUpperCase();
550
+ const result = reconcile(loadSide(input.left, "left", input), loadSide(input.right, "right", input), {
551
+ toleranceMinor: BigInt(input.toleranceMinor ?? 0),
552
+ windowDays: input.windowDays ?? 3,
553
+ allowManyToOne: input.allowManyToOne !== false,
554
+ maxSubsetSize: input.maxSubsetSize ?? 6,
555
+ maxCombinations: RECONCILE_LIMITS.maxCombinations,
556
+ feeToleranceMinor: BigInt(input.feeToleranceMinor ?? 0),
557
+ requireReferenceMatch: input.requireReferenceMatch === true,
558
+ exponent: exponentFor(currency, input.exponent),
559
+ currency,
560
+ propose: input.propose,
561
+ });
562
+ return json(result);
563
+ },
564
+ });
565
+ // ---------------------------------------------------------------------------
566
+ // InvoiceRender
567
+ // ---------------------------------------------------------------------------
568
+ const partySchema = z
569
+ .object({
570
+ name: z.string().min(1).max(256),
571
+ address: z.array(z.string().max(256)).max(12).optional(),
572
+ taxId: z.string().max(64).optional().describe("VAT/GST registration, not a credential"),
573
+ email: z.string().max(256).optional(),
574
+ phone: z.string().max(64).optional(),
575
+ })
576
+ .strict();
577
+ export const invoiceRender = buildTool({
578
+ name: "InvoiceRender",
579
+ description: "Render an invoice, receipt, credit note or quote to HTML, Markdown and JSON with a gap-free document number and totals computed rather than supplied. Use it instead of a model emitting invoice HTML: the number and the document record are allocated in ONE transaction, so a crash cannot burn a number out of a legally required sequence, and repeating the call with the same idempotency key returns the same number and the same bytes. Nothing is formatted through Intl, so the output does not move with an ICU upgrade; the yearly reset takes its year from the issue date, not the clock. Files are written under the workspace. It submits nothing and asks for no payment credentials.",
580
+ inputSchema: z
581
+ .object({
582
+ dbPath: dbPathField,
583
+ kind: z.enum(DOCUMENT_KINDS),
584
+ idempotencyKey: z
585
+ .string()
586
+ .min(1)
587
+ .max(200)
588
+ .describe("repeating it returns the same number and re-renders the same bytes"),
589
+ seller: partySchema,
590
+ buyer: partySchema,
591
+ lines: z
592
+ .array(z
593
+ .object({
594
+ description: z.string().min(1).max(2_000),
595
+ quantity: z.string().max(32).optional().describe("exact decimal string; default '1'"),
596
+ unitAmount: z
597
+ .string()
598
+ .min(1)
599
+ .max(64)
600
+ .optional()
601
+ .describe("major units, e.g. '125.00'"),
602
+ unitAmountMinor: amountField.optional(),
603
+ discount: z.string().min(1).max(64).optional(),
604
+ discountMinor: amountField.optional(),
605
+ taxRateBps: z
606
+ .number()
607
+ .int()
608
+ .nonnegative()
609
+ .max(100_000)
610
+ .optional()
611
+ .describe("2000 is 20%"),
612
+ taxMinor: amountField
613
+ .optional()
614
+ .describe("from TaxCalculate, for anything beyond a flat rate"),
615
+ })
616
+ .strict())
617
+ .min(1)
618
+ .max(1_000),
619
+ currency: currencyField,
620
+ exponent: exponentField,
621
+ issueDate: isoDate,
622
+ dueDate: isoDate.optional(),
623
+ reference: z.string().max(256).optional(),
624
+ notes: z.string().max(8_000).optional(),
625
+ paymentInstructions: z
626
+ .string()
627
+ .max(2_000)
628
+ .optional()
629
+ .describe("free text the operator wrote; this tool never asks for account details of its own"),
630
+ number: z
631
+ .string()
632
+ .min(1)
633
+ .max(64)
634
+ .optional()
635
+ .describe("an externally assigned number; omit to take the next one from the sequence"),
636
+ numbering: z
637
+ .object({
638
+ prefix: z.string().max(32).optional().describe("default 'INV-'"),
639
+ pad: z.number().int().min(1).max(12).optional().describe("default 5"),
640
+ resetYearly: z.boolean().optional().describe("the year comes from issueDate"),
641
+ start: z.number().int().nonnegative().max(1_000_000_000).optional().describe("default 1"),
642
+ })
643
+ .strict()
644
+ .optional(),
645
+ template: z
646
+ .string()
647
+ .max(256 * 1024)
648
+ .optional()
649
+ .describe("inline logic-less template"),
650
+ templateFile: z
651
+ .string()
652
+ .min(1)
653
+ .max(1_024)
654
+ .optional()
655
+ .describe("workspace-relative template file"),
656
+ strictTemplate: z
657
+ .boolean()
658
+ .optional()
659
+ .describe("refuse an unknown placeholder instead of rendering a blank; default true"),
660
+ numberFormat: z
661
+ .object({
662
+ decimalSeparator: z.string().max(4).optional(),
663
+ groupSeparator: z.string().max(4).optional(),
664
+ })
665
+ .strict()
666
+ .optional(),
667
+ outputs: z.array(z.enum(OUTPUT_FORMATS)).min(1).max(3),
668
+ outDir: z
669
+ .string()
670
+ .min(1)
671
+ .max(1_024)
672
+ .optional()
673
+ .describe("workspace-relative directory for the files; omit to return the text only"),
674
+ createdAt: z.string().max(64).optional().describe("ISO-8601 with a UTC offset"),
675
+ busyTimeoutMs: busyTimeoutField,
676
+ })
677
+ .strict(),
678
+ destructive: true,
679
+ concurrencySafe: false,
680
+ execute: async (input) => {
681
+ validateInvoiceDates(input.issueDate, input.dueDate);
682
+ const currency = input.currency.toUpperCase();
683
+ const exponent = exponentFor(currency, input.exponent);
684
+ if (input.template !== undefined && input.templateFile !== undefined) {
685
+ throw new LedgerError("give template or templateFile, not both");
686
+ }
687
+ const computed = computeInvoice(input.lines, exponent);
688
+ const numbering = {
689
+ prefix: input.numbering?.prefix ?? "INV-",
690
+ pad: input.numbering?.pad ?? 5,
691
+ resetYearly: input.numbering?.resetYearly === true,
692
+ start: input.numbering?.start ?? 1,
693
+ };
694
+ // The number names a FILE. `outDir` is resolved through the workspace
695
+ // resolver below, but the leaf under it is `${number}.${format}`, and the
696
+ // number is the caller's — directly as `number`, or through
697
+ // `numbering.prefix`. Both are checked HERE, before the sequence is
698
+ // touched, so a number that would walk out of the directory is a refusal
699
+ // rather than a burnt number and an overwritten file somewhere else.
700
+ if (input.number !== undefined) {
701
+ assertNumberIsAFileName(input.number, "number");
702
+ }
703
+ else {
704
+ // The ordinal contributes only digits, so checking the number this
705
+ // sequence would issue next checks the prefix and the year with it.
706
+ assertNumberIsAFileName(numberFor(numbering, input.issueDate, numbering.start), "the number numbering.prefix would produce");
707
+ }
708
+ const custom = input.templateFile === undefined
709
+ ? input.template
710
+ : readTextFile(resolveSafe("InvoiceRender", input.templateFile).real);
711
+ // The hash covers everything that determines the rendered bytes, so a
712
+ // replay under the same key can be checked for being the same document —
713
+ // and a re-render after a crash produces byte-identical files.
714
+ const payloadHash = sha256Hex(canonicalJson({
715
+ kind: input.kind,
716
+ seller: input.seller,
717
+ buyer: input.buyer,
718
+ lines: input.lines,
719
+ currency,
720
+ exponent,
721
+ issueDate: input.issueDate,
722
+ dueDate: input.dueDate ?? null,
723
+ reference: input.reference ?? null,
724
+ notes: input.notes ?? null,
725
+ paymentInstructions: input.paymentInstructions ?? null,
726
+ numbering,
727
+ template: custom ?? null,
728
+ numberFormat: input.numberFormat ?? null,
729
+ outputs: [...input.outputs].sort(),
730
+ explicitNumber: input.number ?? null,
731
+ }));
732
+ const createdAt = input.createdAt === undefined ? nowIso() : assertInstant(input.createdAt, "createdAt");
733
+ // Render ONCE against a provisional number, before the sequence is
734
+ // touched. Rendering is where a bad template or a missing field is found,
735
+ // and a number allocated and then thrown away is a gap in a sequence that
736
+ // is legally required not to have one. Rendering is a pure function of the
737
+ // payload, so proving it first and doing it again costs only time.
738
+ const render = (number) => {
739
+ const model = documentModel({
740
+ kind: input.kind,
741
+ number,
742
+ currency,
743
+ exponent,
744
+ issueDate: input.issueDate,
745
+ dueDate: input.dueDate,
746
+ reference: input.reference,
747
+ notes: input.notes,
748
+ paymentInstructions: input.paymentInstructions,
749
+ seller: input.seller,
750
+ buyer: input.buyer,
751
+ computed,
752
+ numberFormat: input.numberFormat ?? {},
753
+ });
754
+ const out = {};
755
+ for (const format of [...input.outputs].sort()) {
756
+ if (format === "json") {
757
+ out["json"] = JSON.stringify(model, null, 2);
758
+ continue;
759
+ }
760
+ const template = custom ?? BUILTIN_TEMPLATES[format];
761
+ out[format] = renderTemplate(template, model, {
762
+ // Markdown escapes a pipe for the same reason HTML escapes a "<":
763
+ // an unescaped one ends the table cell early and every amount after
764
+ // it lands under the wrong heading. See ./lib/invoice.
765
+ escape: format === "html" ? escapeHtml : escapeMarkdown,
766
+ strict: input.strictTemplate !== false,
767
+ });
768
+ }
769
+ return { outputs: out, totals: model["totals"] };
770
+ };
771
+ render("PENDING");
772
+ // The destination is checked before the sequence is touched too, for the
773
+ // same reason: a path that escapes the workspace must not cost a number.
774
+ const outDir = input.outDir === undefined ? null : resolveSafe("InvoiceRender", input.outDir);
775
+ const opened = open("InvoiceRender", { dbPath: input.dbPath, busyTimeoutMs: input.busyTimeoutMs }, "write", false);
776
+ let allocation;
777
+ try {
778
+ const plannedFiles = outDir === null ? [] : input.outputs.map((f) => `${outDir.rel}/${placeholderName(f)}`);
779
+ allocation =
780
+ input.number === undefined
781
+ ? allocateDocument(opened.db, {
782
+ kind: input.kind,
783
+ config: numbering,
784
+ issueDate: input.issueDate,
785
+ idempotencyKey: input.idempotencyKey,
786
+ payloadHash,
787
+ files: plannedFiles,
788
+ createdAt,
789
+ })
790
+ : recordExternalNumber(opened.db, input.number, input.kind, input.issueDate, input.idempotencyKey, payloadHash, createdAt);
791
+ }
792
+ finally {
793
+ opened.db.close();
794
+ }
795
+ const { outputs: rendered, totals } = render(allocation.number);
796
+ const files = [];
797
+ if (outDir !== null) {
798
+ const dir = outDir;
799
+ mkdirSync(dir.real, { recursive: true });
800
+ for (const [format, text] of Object.entries(rendered).sort()) {
801
+ const name = `${allocation.number}.${format === "markdown" ? "md" : format}`;
802
+ // Belt and braces over the check above: the composed path goes back
803
+ // through the workspace resolver, so containment is enforced by the
804
+ // resolver every other path in this package uses and not only by the
805
+ // character rule that produced this name.
806
+ const target = resolveSafe("InvoiceRender", `${dir.rel}/${name}`).abs;
807
+ // Written to a temporary name and renamed: a rename within one
808
+ // directory is atomic, so a reader never sees half an invoice, and a
809
+ // crash mid-write leaves the previous file rather than a truncated one.
810
+ const temp = `${target}.${payloadHash.slice(0, 8)}.tmp`;
811
+ mkdirSync(dirname(temp), { recursive: true });
812
+ writeFileSync(temp, text, "utf-8");
813
+ renameSync(temp, target);
814
+ files.push(`${dir.rel}/${name}`);
815
+ }
816
+ }
817
+ return json({
818
+ ledger: opened.rel,
819
+ number: allocation.number,
820
+ ordinal: allocation.ordinal,
821
+ sequence: allocation.sequenceName,
822
+ replayed: allocation.replayed,
823
+ // `null`, never `true`, when the number did not come from a sequence
824
+ // this tool keeps: a gap is a finding, and a check that could not run
825
+ // must not report as one that passed.
826
+ gapFree: allocation.gapFree,
827
+ ...(allocation.gapFree === null
828
+ ? {
829
+ gapFreeNote: "this number was assigned by the caller, so there is no sequence here to check for gaps — gap-freeness is the caller's to keep and this tool has NOT verified it",
830
+ }
831
+ : {}),
832
+ currency,
833
+ totals,
834
+ files,
835
+ ...(outDir === null ? rendered : {}),
836
+ note: "rendered and numbered locally; nothing was sent and no payment was requested",
837
+ });
838
+ },
839
+ });
840
+ /** The name a file will get, before the number is known. Only its shape matters. */
841
+ function placeholderName(format) {
842
+ return `<number>.${format === "markdown" ? "md" : format}`;
843
+ }
844
+ /**
845
+ * Record a number the caller assigned.
846
+ *
847
+ * Supported because some operators' numbers come from elsewhere, but recorded
848
+ * all the same: the `documents` table's primary key is the number, so issuing
849
+ * the same one twice is refused here rather than discovered by a tax
850
+ * inspector. Gap-freeness is the caller's to keep in this mode, so `gapFree`
851
+ * comes back `null` and the result says in words that nothing was checked —
852
+ * `true` from a check that never ran is the answer that gets believed.
853
+ */
854
+ function recordExternalNumber(db, number, kind, issueDate, idempotencyKey, payloadHash, createdAt) {
855
+ return inImmediateTransaction(db, () => {
856
+ const existing = db
857
+ .query("SELECT number, payload_hash FROM documents WHERE idempotency_key = ?")
858
+ .get(idempotencyKey);
859
+ if (existing !== null) {
860
+ if (existing.payload_hash !== payloadHash) {
861
+ throw new LedgerError(`idempotency key "${idempotencyKey}" already issued ${existing.number} for different content`);
862
+ }
863
+ return {
864
+ number: existing.number,
865
+ ordinal: 0,
866
+ sequenceName: "external",
867
+ replayed: true,
868
+ gapFree: null,
869
+ };
870
+ }
871
+ const clash = db
872
+ .query("SELECT idempotency_key FROM documents WHERE number = ?")
873
+ .get(number);
874
+ if (clash !== null) {
875
+ throw new LedgerError(`document number "${number}" has already been issued (under key "${clash.idempotency_key}") — one number, one document`);
876
+ }
877
+ db.run("INSERT INTO documents (number, kind, sequence_name, ordinal, issue_date, idempotency_key, payload_hash, files, created_at) VALUES (?, ?, 'external', 0, ?, ?, ?, '[]', ?)", [number, kind, issueDate, idempotencyKey, payloadHash, createdAt]);
878
+ return { number, ordinal: 0, sequenceName: "external", replayed: false, gapFree: null };
879
+ });
880
+ }
881
+ export { LedgerError };
882
+ /** Every tool this package registers, in the order a catalog should list them. */
883
+ export const LEDGER_TOOLS = Object.freeze([
884
+ invoiceRender,
885
+ ledgerPost,
886
+ ledgerQuery,
887
+ ledgerReconcile,
888
+ ]);