@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/README.md +243 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +888 -0
- package/dist/lib/amount.d.ts +73 -0
- package/dist/lib/amount.js +204 -0
- package/dist/lib/db.d.ts +171 -0
- package/dist/lib/db.js +326 -0
- package/dist/lib/invoice.d.ts +222 -0
- package/dist/lib/invoice.js +500 -0
- package/dist/lib/post.d.ts +129 -0
- package/dist/lib/post.js +440 -0
- package/dist/lib/query.d.ts +62 -0
- package/dist/lib/query.js +480 -0
- package/dist/lib/reconcile.d.ts +117 -0
- package/dist/lib/reconcile.js +335 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +48 -0
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
|
+
]);
|