@mulmoclaude/accounting-plugin 1.2.1 → 1.3.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.
Files changed (42) hide show
  1. package/dist/server/bodyFields.d.ts +8 -1
  2. package/dist/server/bodyFields.d.ts.map +1 -1
  3. package/dist/server/io.d.ts.map +1 -1
  4. package/dist/server/journal.d.ts +7 -0
  5. package/dist/server/journal.d.ts.map +1 -1
  6. package/dist/server/report.d.ts.map +1 -1
  7. package/dist/server/router.d.ts.map +1 -1
  8. package/dist/server/service.d.ts.map +1 -1
  9. package/dist/server/snapshotCache.d.ts.map +1 -1
  10. package/dist/server/timeSeries.d.ts.map +1 -1
  11. package/dist/server.cjs +364 -323
  12. package/dist/server.cjs.map +1 -1
  13. package/dist/server.js +364 -323
  14. package/dist/server.js.map +1 -1
  15. package/dist/shared/fiscalYear.d.ts.map +1 -1
  16. package/dist/shared/types.d.ts +2 -1
  17. package/dist/shared/types.d.ts.map +1 -1
  18. package/dist/{shared-qp9j-GTD.js → shared-BZAAF-I4.js} +16 -11
  19. package/dist/shared-BZAAF-I4.js.map +1 -0
  20. package/dist/{shared-C9K9ZkfK.cjs → shared-BpFVwa6Y.cjs} +27 -10
  21. package/dist/shared-BpFVwa6Y.cjs.map +1 -0
  22. package/dist/shared.cjs +2 -1
  23. package/dist/shared.js +2 -2
  24. package/dist/style.css +3 -3
  25. package/dist/vue/Preview.vue.d.ts.map +1 -1
  26. package/dist/vue/View.vue.d.ts.map +1 -1
  27. package/dist/vue/components/BalanceSheet.vue.d.ts.map +1 -1
  28. package/dist/vue/components/BookSwitcher.vue.d.ts.map +1 -1
  29. package/dist/vue/components/DateRangePicker.vue.d.ts.map +1 -1
  30. package/dist/vue/components/NewBookForm.vue.d.ts.map +1 -1
  31. package/dist/vue/components/OpeningBalancesForm.vue.d.ts.map +1 -1
  32. package/dist/vue/components/accountNumbering.d.ts.map +1 -1
  33. package/dist/vue/previewSummary.d.ts +15 -0
  34. package/dist/vue/previewSummary.d.ts.map +1 -0
  35. package/dist/vue/useAccountingChannel.d.ts.map +1 -1
  36. package/dist/vue.cjs +138 -89
  37. package/dist/vue.cjs.map +1 -1
  38. package/dist/vue.js +138 -89
  39. package/dist/vue.js.map +1 -1
  40. package/package.json +3 -3
  41. package/dist/shared-C9K9ZkfK.cjs.map +0 -1
  42. package/dist/shared-qp9j-GTD.js.map +0 -1
package/dist/server.cjs CHANGED
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_shared = require("./shared-C9K9ZkfK.cjs");
24
+ const require_shared = require("./shared-BpFVwa6Y.cjs");
25
25
  let express = require("express");
26
26
  let node_crypto = require("node:crypto");
27
27
  let node_fs = require("node:fs");
@@ -31,18 +31,36 @@ let _mulmoclaude_core_files = require("@mulmoclaude/core/files");
31
31
  //#region src/server/bodyFields.ts
32
32
  var optionalString = (value) => typeof value === "string" ? value : void 0;
33
33
  var optionalRecord = (value) => require_shared.isRecord(value) ? value : void 0;
34
+ var YEAR_MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
35
+ var YEAR_MONTH_DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
36
+ /** `YYYY-MM-DD` that names a day the calendar actually has. The regex alone
37
+ * admits `2026-02-30`, and `Date.UTC` rolls that forward to March instead of
38
+ * refusing it, so the round trip is what says the date is real. */
39
+ var isCalendarDate = (value) => {
40
+ if (!YEAR_MONTH_DAY_RE.test(value)) return false;
41
+ const [year, month, day] = value.split("-").map(Number);
42
+ if (year === void 0 || month === void 0 || day === void 0) return false;
43
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDate() === day;
44
+ };
34
45
  /** Rebuilt field by field rather than narrowed with a predicate, so the
35
46
  * returned object is one this function actually proved. A half-formed
36
47
  * `{ kind: "month" }` reads as absent and the caller raises its own
37
48
  * "period is required" — previously it reached the report builders and
38
- * produced an `undefined-01` date. */
49
+ * produced an `undefined-01` date.
50
+ *
51
+ * The FORMAT is checked here too, not just the type: `typeof === "string"`
52
+ * let `{ kind: "month", period: "banana" }` through, which came back as a
53
+ * balance sheet dated `"banana-NaN"` and made the snapshot cache write
54
+ * `banana.json` / `0NaN-NaN.json` into the book (#2765). Malformed reads as
55
+ * absent so it lands on the caller's existing 400, which already spells out
56
+ * both accepted shapes for the LLM to repair its payload from. */
39
57
  var optionalReportPeriod = (value) => {
40
58
  const period = optionalRecord(value);
41
- if (period?.kind === "month" && typeof period.period === "string") return {
59
+ if (period?.kind === "month" && typeof period.period === "string" && YEAR_MONTH_RE.test(period.period)) return {
42
60
  kind: "month",
43
61
  period: period.period
44
62
  };
45
- if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string") return {
63
+ if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string" && isCalendarDate(period.from) && isCalendarDate(period.to)) return {
46
64
  kind: "range",
47
65
  from: period.from,
48
66
  to: period.to
@@ -86,254 +104,6 @@ var log = {
86
104
  debug: (namespace, msg, data) => (deps?.logger ?? consoleLogger).debug(namespace, msg, data)
87
105
  };
88
106
  //#endregion
89
- //#region src/server/io.ts
90
- var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
91
- function accountingRoot(workspaceRoot) {
92
- return node_path.default.join(root(workspaceRoot), require_shared.ACCOUNTING_DIRS.accounting);
93
- }
94
- function configPath(workspaceRoot) {
95
- return node_path.default.join(accountingRoot(workspaceRoot), "config.json");
96
- }
97
- /** Allowed shape for a book id used as a directory name. Defense
98
- * against path traversal: a crafted id like "../../config" or
99
- * "/tmp/x" would otherwise let `bookRoot` escape the
100
- * `data/accounting/books/` tree, since every write path joins
101
- * `bookId` directly into the filesystem. The first character is
102
- * alphanumeric to forbid leading dashes / underscores that some
103
- * shells / docs render confusingly; `_` and `-` are allowed inside.
104
- * 64 chars is plenty for any reasonable book name. */
105
- var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
106
- function isSafeBookId(bookId) {
107
- return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
108
- }
109
- function assertSafeBookId(bookId) {
110
- if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
111
- }
112
- function bookRoot(bookId, workspaceRoot) {
113
- assertSafeBookId(bookId);
114
- return node_path.default.join(root(workspaceRoot), require_shared.ACCOUNTING_DIRS.accountingBooks, bookId);
115
- }
116
- function accountsPath(bookId, workspaceRoot) {
117
- return node_path.default.join(bookRoot(bookId, workspaceRoot), "accounts.json");
118
- }
119
- function journalDir(bookId, workspaceRoot) {
120
- return node_path.default.join(bookRoot(bookId, workspaceRoot), "journal");
121
- }
122
- function journalFileFor(bookId, period, workspaceRoot) {
123
- return node_path.default.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
124
- }
125
- function snapshotsDir(bookId, workspaceRoot) {
126
- return node_path.default.join(bookRoot(bookId, workspaceRoot), "snapshots");
127
- }
128
- function snapshotFileFor(bookId, period, workspaceRoot) {
129
- return node_path.default.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
130
- }
131
- async function fileExists(filePath) {
132
- try {
133
- await node_fs.promises.access(filePath);
134
- return true;
135
- } catch {
136
- return false;
137
- }
138
- }
139
- /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
140
- * on ENOENT but RETHROWS other read errors and parse failures so a
141
- * corrupted accounting journal surfaces rather than silently
142
- * collapsing to "no data". `./json.ts` keeps the permissive
143
- * variant for user-config files where a single bad keystroke
144
- * shouldn't 500 the server. */
145
- async function readJsonStrict(filePath) {
146
- try {
147
- const raw = await node_fs.promises.readFile(filePath, "utf-8");
148
- return JSON.parse(raw);
149
- } catch (err) {
150
- if ((0, _mulmoclaude_core_files.isEnoent)(err)) return null;
151
- throw err;
152
- }
153
- }
154
- /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
155
- * to its closing-month number in memory so every downstream consumer
156
- * (reports, time-series, the UI selects) sees one shape. Absent stays
157
- * absent — the field is optional and resolves to the default on read;
158
- * we don't stamp an explicit December onto a book that never chose one.
159
- * Nothing is written back here (no auto-migrate on disk). */
160
- function normalizeBookFiscalYearEnd(book) {
161
- if (book.fiscalYearEnd === void 0) return book;
162
- const resolved = require_shared.resolveFiscalYearEnd(book.fiscalYearEnd);
163
- return book.fiscalYearEnd === resolved ? book : {
164
- ...book,
165
- fiscalYearEnd: resolved
166
- };
167
- }
168
- async function readConfig(workspaceRoot) {
169
- const config = await readJsonStrict(configPath(workspaceRoot));
170
- if (!config) return null;
171
- return {
172
- ...config,
173
- books: config.books.map(normalizeBookFiscalYearEnd)
174
- };
175
- }
176
- async function writeConfig(config, workspaceRoot) {
177
- await (0, _mulmoclaude_core_files.writeJsonAtomic)(configPath(workspaceRoot), config);
178
- }
179
- async function readAccounts(bookId, workspaceRoot) {
180
- return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
181
- }
182
- async function writeAccounts(bookId, accounts, workspaceRoot) {
183
- await (0, _mulmoclaude_core_files.writeJsonAtomic)(accountsPath(bookId, workspaceRoot), accounts);
184
- }
185
- /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
186
- * month bucket dictates which JSONL file the entry lives in. */
187
- function periodFromDate(date) {
188
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
189
- return date.slice(0, 7);
190
- }
191
- /** Append one entry to the appropriate month's JSONL.
192
- *
193
- * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
194
- * Two concurrent callers landing in the same month file are
195
- * serialised by the kernel — neither overwrites the other, which
196
- * is the bug the previous read-modify-write implementation had.
197
- *
198
- * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
199
- * on every supported platform) writes atomically; a single
200
- * serialised `JournalEntry` is comfortably under that. If the
201
- * process is killed during the syscall the worst case is a torn
202
- * trailing line, which `readJournalMonth` already tolerates by
203
- * skipping unparseable lines and surfacing a `skipped` count to
204
- * the caller. */
205
- async function appendJournal(bookId, entry, workspaceRoot) {
206
- const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
207
- await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
208
- await node_fs.promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
209
- }
210
- function groupEntriesByPeriod(entries) {
211
- const byPeriod = /* @__PURE__ */ new Map();
212
- for (const entry of entries) {
213
- const period = periodFromDate(entry.date);
214
- const list = byPeriod.get(period) ?? [];
215
- list.push(entry);
216
- byPeriod.set(period, list);
217
- }
218
- return byPeriod;
219
- }
220
- /** Append a batch of entries: same-period entries are concatenated
221
- * into one `appendFile` call so the whole same-period chunk hits
222
- * the kernel as a single `O_APPEND` write — small chunks (under
223
- * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
224
- * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
225
- * concurrent appender (a parallel `appendJournal` / `addEntries`
226
- * call can never overwrite our write or vice versa). Cross-period
227
- * batches loop one append per period; each is independently
228
- * concurrency-safe but their union is not transactional across
229
- * files (out of scope for the append-only JSONL design). */
230
- async function appendJournalBatch(bookId, entries, workspaceRoot) {
231
- if (entries.length === 0) return;
232
- const byPeriod = groupEntriesByPeriod(entries);
233
- for (const [period, items] of byPeriod) {
234
- const file = journalFileFor(bookId, period, workspaceRoot);
235
- await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
236
- const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
237
- await node_fs.promises.appendFile(file, chunk, { encoding: "utf-8" });
238
- }
239
- }
240
- /** Read a single month's JSONL. Malformed lines are skipped (logged
241
- * by the caller; this layer just returns the parseable subset) so
242
- * one bad line doesn't lock the user out of their book. */
243
- async function readJournalMonth(bookId, period, workspaceRoot) {
244
- const file = journalFileFor(bookId, period, workspaceRoot);
245
- let raw;
246
- try {
247
- raw = await node_fs.promises.readFile(file, "utf-8");
248
- } catch (err) {
249
- if ((0, _mulmoclaude_core_files.isEnoent)(err)) return {
250
- entries: [],
251
- skipped: 0
252
- };
253
- throw err;
254
- }
255
- const entries = [];
256
- let skipped = 0;
257
- for (const line of raw.split("\n")) {
258
- if (line.trim() === "") continue;
259
- try {
260
- entries.push(JSON.parse(line));
261
- } catch {
262
- skipped += 1;
263
- }
264
- }
265
- return {
266
- entries,
267
- skipped
268
- };
269
- }
270
- /** List the YYYY-MM periods that have a journal file on disk, sorted
271
- * ascending. Useful for full-history scans (rebuilding snapshots
272
- * from scratch). */
273
- async function listJournalPeriods(bookId, workspaceRoot) {
274
- let names;
275
- try {
276
- names = await node_fs.promises.readdir(journalDir(bookId, workspaceRoot));
277
- } catch (err) {
278
- if ((0, _mulmoclaude_core_files.isEnoent)(err)) return [];
279
- throw err;
280
- }
281
- return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
282
- }
283
- async function readSnapshot(bookId, period, workspaceRoot) {
284
- return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
285
- }
286
- async function writeSnapshot(bookId, snapshot, workspaceRoot) {
287
- const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
288
- await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
289
- await (0, _mulmoclaude_core_files.writeJsonAtomic)(file, snapshot, { uniqueTmp: true });
290
- }
291
- /** Drop snapshot files for all periods >= `fromPeriod`. The next
292
- * read regenerates them. Idempotent: missing files are silently
293
- * ignored. */
294
- async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
295
- let names;
296
- try {
297
- names = await node_fs.promises.readdir(snapshotsDir(bookId, workspaceRoot));
298
- } catch (err) {
299
- if ((0, _mulmoclaude_core_files.isEnoent)(err)) return { removed: [] };
300
- throw err;
301
- }
302
- const removed = [];
303
- for (const name of names) {
304
- const match = /^(\d{4}-\d{2})\.json$/.exec(name);
305
- if (!match) continue;
306
- const [, period] = match;
307
- if (period >= fromPeriod) {
308
- await node_fs.promises.rm(node_path.default.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
309
- removed.push(period);
310
- }
311
- }
312
- return { removed: removed.sort() };
313
- }
314
- /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
315
- * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
316
- * but reads more clearly at call sites. */
317
- async function invalidateAllSnapshots(bookId, workspaceRoot) {
318
- return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
319
- }
320
- async function bookExists(bookId, workspaceRoot) {
321
- return fileExists(bookRoot(bookId, workspaceRoot));
322
- }
323
- async function ensureBookDir(bookId, workspaceRoot) {
324
- await node_fs.promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
325
- await node_fs.promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
326
- await node_fs.promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
327
- }
328
- /** Recursively delete a book's directory. Used by `deleteBook` after
329
- * the config has been updated to drop the entry. */
330
- async function removeBookDir(bookId, workspaceRoot) {
331
- await node_fs.promises.rm(bookRoot(bookId, workspaceRoot), {
332
- recursive: true,
333
- force: true
334
- });
335
- }
336
- //#endregion
337
107
  //#region src/server/journal.ts
338
108
  /** Floating-point tolerance for the debit = credit check. Currency
339
109
  * amounts arrive as JavaScript numbers (the on-wire format is JSON,
@@ -366,6 +136,7 @@ function localDateString(now = /* @__PURE__ */ new Date()) {
366
136
  function isValidCalendarDate(date) {
367
137
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
368
138
  const [year, month, day] = date.split("-").map((segment) => parseInt(segment, 10));
139
+ if (year === void 0 || month === void 0 || day === void 0) return false;
369
140
  const parsed = new Date(Date.UTC(year, month - 1, day));
370
141
  return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
371
142
  }
@@ -435,7 +206,7 @@ function buildLine(raw) {
435
206
  * belong to the caller, because journal entries and opening balances
436
207
  * disagree about them. Returns null when nothing usable came out —
437
208
  * the caller drops the line rather than reading fields off it. */
438
- function parseJournalLine(raw, idx, errors) {
209
+ function parseJournalLine$1(raw, idx, errors) {
439
210
  if (!require_shared.isRecord(raw)) {
440
211
  errors.push({
441
212
  field: `lines[${idx}]`,
@@ -461,7 +232,7 @@ function validateEntryLine(line, idx, accountCodes, errors) {
461
232
  function parseEntryLines(raw, accountCodes, errors) {
462
233
  const lines = [];
463
234
  raw.forEach((rawLine, idx) => {
464
- const line = parseJournalLine(rawLine, idx, errors);
235
+ const line = parseJournalLine$1(rawLine, idx, errors);
465
236
  if (line === null) return;
466
237
  validateEntryLine(line, idx, accountCodes, errors);
467
238
  lines.push(line);
@@ -489,6 +260,20 @@ function parseOptionalString(value, field, errors) {
489
260
  message: `${field} must be a string when supplied`
490
261
  });
491
262
  }
263
+ var isOptionalString = (value) => value === void 0 || typeof value === "string";
264
+ var isOptionalNumber = (value) => value === void 0 || typeof value === "number";
265
+ function isJournalLine(value) {
266
+ return require_shared.hasStringProp(value, "accountCode") && isOptionalNumber(value.debit) && isOptionalNumber(value.credit) && isOptionalString(value.memo) && isOptionalString(value.taxRegistrationId);
267
+ }
268
+ /** Checks every field `JournalEntry` and `JournalLine` declare, so a value
269
+ * that passes really is one. Used when reading the journal JSONL back:
270
+ * everything this module ever wrote satisfies it (`id` / `date` / `kind` /
271
+ * `lines` / `createdAt` have been required since the plugin's first
272
+ * release), while a line that doesn't is exactly the line that takes the
273
+ * whole book down — `report.ts` iterates `entry.lines` unguarded. */
274
+ function isJournalEntry(value) {
275
+ return require_shared.hasStringProp(value, "id") && require_shared.hasStringProp(value, "date") && require_shared.hasStringProp(value, "createdAt") && require_shared.JOURNAL_ENTRY_KINDS.some((kind) => kind === value.kind) && require_shared.isUnknownArray(value.lines) && value.lines.every(isJournalLine) && isOptionalString(value.memo) && isOptionalString(value.voidedEntryId) && isOptionalString(value.voidReason) && isOptionalString(value.replacesEntryId);
276
+ }
492
277
  /** Normalize a journal line before persistence: trim string fields
493
278
  * and drop empty-string optionals so the JSONL doesn't accumulate
494
279
  * noise like `"taxRegistrationId":""`. Pure — does not mutate
@@ -635,6 +420,258 @@ function voidedIdSet(entries) {
635
420
  return set;
636
421
  }
637
422
  //#endregion
423
+ //#region src/server/io.ts
424
+ var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
425
+ function accountingRoot(workspaceRoot) {
426
+ return node_path.default.join(root(workspaceRoot), require_shared.ACCOUNTING_DIRS.accounting);
427
+ }
428
+ function configPath(workspaceRoot) {
429
+ return node_path.default.join(accountingRoot(workspaceRoot), "config.json");
430
+ }
431
+ /** Allowed shape for a book id used as a directory name. Defense
432
+ * against path traversal: a crafted id like "../../config" or
433
+ * "/tmp/x" would otherwise let `bookRoot` escape the
434
+ * `data/accounting/books/` tree, since every write path joins
435
+ * `bookId` directly into the filesystem. The first character is
436
+ * alphanumeric to forbid leading dashes / underscores that some
437
+ * shells / docs render confusingly; `_` and `-` are allowed inside.
438
+ * 64 chars is plenty for any reasonable book name. */
439
+ var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
440
+ function isSafeBookId(bookId) {
441
+ return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
442
+ }
443
+ function assertSafeBookId(bookId) {
444
+ if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
445
+ }
446
+ function bookRoot(bookId, workspaceRoot) {
447
+ assertSafeBookId(bookId);
448
+ return node_path.default.join(root(workspaceRoot), require_shared.ACCOUNTING_DIRS.accountingBooks, bookId);
449
+ }
450
+ function accountsPath(bookId, workspaceRoot) {
451
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "accounts.json");
452
+ }
453
+ function journalDir(bookId, workspaceRoot) {
454
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "journal");
455
+ }
456
+ function journalFileFor(bookId, period, workspaceRoot) {
457
+ return node_path.default.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
458
+ }
459
+ function snapshotsDir(bookId, workspaceRoot) {
460
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "snapshots");
461
+ }
462
+ function snapshotFileFor(bookId, period, workspaceRoot) {
463
+ return node_path.default.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
464
+ }
465
+ async function fileExists(filePath) {
466
+ try {
467
+ await node_fs.promises.access(filePath);
468
+ return true;
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+ /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
474
+ * on ENOENT but RETHROWS other read errors and parse failures so a
475
+ * corrupted accounting journal surfaces rather than silently
476
+ * collapsing to "no data". `./json.ts` keeps the permissive
477
+ * variant for user-config files where a single bad keystroke
478
+ * shouldn't 500 the server. */
479
+ async function readJsonStrict(filePath) {
480
+ try {
481
+ const raw = await node_fs.promises.readFile(filePath, "utf-8");
482
+ return JSON.parse(raw);
483
+ } catch (err) {
484
+ if ((0, _mulmoclaude_core_files.isEnoent)(err)) return null;
485
+ throw err;
486
+ }
487
+ }
488
+ /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
489
+ * to its closing-month number in memory so every downstream consumer
490
+ * (reports, time-series, the UI selects) sees one shape. Absent stays
491
+ * absent — the field is optional and resolves to the default on read;
492
+ * we don't stamp an explicit December onto a book that never chose one.
493
+ * Nothing is written back here (no auto-migrate on disk). */
494
+ function normalizeBookFiscalYearEnd(book) {
495
+ if (book.fiscalYearEnd === void 0) return book;
496
+ const resolved = require_shared.resolveFiscalYearEnd(book.fiscalYearEnd);
497
+ return book.fiscalYearEnd === resolved ? book : {
498
+ ...book,
499
+ fiscalYearEnd: resolved
500
+ };
501
+ }
502
+ async function readConfig(workspaceRoot) {
503
+ const config = await readJsonStrict(configPath(workspaceRoot));
504
+ if (!config) return null;
505
+ return {
506
+ ...config,
507
+ books: config.books.map(normalizeBookFiscalYearEnd)
508
+ };
509
+ }
510
+ async function writeConfig(config, workspaceRoot) {
511
+ await (0, _mulmoclaude_core_files.writeJsonAtomic)(configPath(workspaceRoot), config);
512
+ }
513
+ async function readAccounts(bookId, workspaceRoot) {
514
+ return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
515
+ }
516
+ async function writeAccounts(bookId, accounts, workspaceRoot) {
517
+ await (0, _mulmoclaude_core_files.writeJsonAtomic)(accountsPath(bookId, workspaceRoot), accounts);
518
+ }
519
+ /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
520
+ * month bucket dictates which JSONL file the entry lives in. */
521
+ function periodFromDate(date) {
522
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
523
+ return date.slice(0, 7);
524
+ }
525
+ /** Append one entry to the appropriate month's JSONL.
526
+ *
527
+ * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
528
+ * Two concurrent callers landing in the same month file are
529
+ * serialised by the kernel — neither overwrites the other, which
530
+ * is the bug the previous read-modify-write implementation had.
531
+ *
532
+ * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
533
+ * on every supported platform) writes atomically; a single
534
+ * serialised `JournalEntry` is comfortably under that. If the
535
+ * process is killed during the syscall the worst case is a torn
536
+ * trailing line, which `readJournalMonth` already tolerates by
537
+ * skipping unparseable lines and surfacing a `skipped` count to
538
+ * the caller. */
539
+ async function appendJournal(bookId, entry, workspaceRoot) {
540
+ const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
541
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
542
+ await node_fs.promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
543
+ }
544
+ function groupEntriesByPeriod(entries) {
545
+ const byPeriod = /* @__PURE__ */ new Map();
546
+ for (const entry of entries) {
547
+ const period = periodFromDate(entry.date);
548
+ const list = byPeriod.get(period) ?? [];
549
+ list.push(entry);
550
+ byPeriod.set(period, list);
551
+ }
552
+ return byPeriod;
553
+ }
554
+ /** Append a batch of entries: same-period entries are concatenated
555
+ * into one `appendFile` call so the whole same-period chunk hits
556
+ * the kernel as a single `O_APPEND` write — small chunks (under
557
+ * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
558
+ * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
559
+ * concurrent appender (a parallel `appendJournal` / `addEntries`
560
+ * call can never overwrite our write or vice versa). Cross-period
561
+ * batches loop one append per period; each is independently
562
+ * concurrency-safe but their union is not transactional across
563
+ * files (out of scope for the append-only JSONL design). */
564
+ async function appendJournalBatch(bookId, entries, workspaceRoot) {
565
+ if (entries.length === 0) return;
566
+ const byPeriod = groupEntriesByPeriod(entries);
567
+ for (const [period, items] of byPeriod) {
568
+ const file = journalFileFor(bookId, period, workspaceRoot);
569
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
570
+ const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
571
+ await node_fs.promises.appendFile(file, chunk, { encoding: "utf-8" });
572
+ }
573
+ }
574
+ /** One JSONL line, or null when it isn't a journal entry — unparseable JSON
575
+ * and JSON of the wrong shape are the same failure to a caller that can only
576
+ * skip the line. */
577
+ function parseJournalLine(line) {
578
+ try {
579
+ const parsed = JSON.parse(line);
580
+ return isJournalEntry(parsed) ? parsed : null;
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+ /** Read a single month's JSONL. Malformed lines are skipped (logged
586
+ * by the caller; this layer just returns the parseable subset) so
587
+ * one bad line doesn't lock the user out of their book. */
588
+ async function readJournalMonth(bookId, period, workspaceRoot) {
589
+ const file = journalFileFor(bookId, period, workspaceRoot);
590
+ let raw;
591
+ try {
592
+ raw = await node_fs.promises.readFile(file, "utf-8");
593
+ } catch (err) {
594
+ if ((0, _mulmoclaude_core_files.isEnoent)(err)) return {
595
+ entries: [],
596
+ skipped: 0
597
+ };
598
+ throw err;
599
+ }
600
+ const parsed = raw.split("\n").filter((line) => line.trim() !== "").map(parseJournalLine);
601
+ const entries = parsed.filter((entry) => entry !== null);
602
+ return {
603
+ entries,
604
+ skipped: parsed.length - entries.length
605
+ };
606
+ }
607
+ /** List the YYYY-MM periods that have a journal file on disk, sorted
608
+ * ascending. Useful for full-history scans (rebuilding snapshots
609
+ * from scratch). */
610
+ async function listJournalPeriods(bookId, workspaceRoot) {
611
+ let names;
612
+ try {
613
+ names = await node_fs.promises.readdir(journalDir(bookId, workspaceRoot));
614
+ } catch (err) {
615
+ if ((0, _mulmoclaude_core_files.isEnoent)(err)) return [];
616
+ throw err;
617
+ }
618
+ return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
619
+ }
620
+ async function readSnapshot(bookId, period, workspaceRoot) {
621
+ return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
622
+ }
623
+ async function writeSnapshot(bookId, snapshot, workspaceRoot) {
624
+ const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
625
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
626
+ await (0, _mulmoclaude_core_files.writeJsonAtomic)(file, snapshot, { uniqueTmp: true });
627
+ }
628
+ /** Drop snapshot files for all periods >= `fromPeriod`. The next
629
+ * read regenerates them. Idempotent: missing files are silently
630
+ * ignored. */
631
+ async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
632
+ let names;
633
+ try {
634
+ names = await node_fs.promises.readdir(snapshotsDir(bookId, workspaceRoot));
635
+ } catch (err) {
636
+ if ((0, _mulmoclaude_core_files.isEnoent)(err)) return { removed: [] };
637
+ throw err;
638
+ }
639
+ const removed = [];
640
+ for (const name of names) {
641
+ const match = /^(\d{4}-\d{2})\.json$/.exec(name);
642
+ if (!match) continue;
643
+ const [, period] = match;
644
+ if (period === void 0) continue;
645
+ if (period >= fromPeriod) {
646
+ await node_fs.promises.rm(node_path.default.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
647
+ removed.push(period);
648
+ }
649
+ }
650
+ return { removed: removed.sort() };
651
+ }
652
+ /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
653
+ * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
654
+ * but reads more clearly at call sites. */
655
+ async function invalidateAllSnapshots(bookId, workspaceRoot) {
656
+ return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
657
+ }
658
+ async function bookExists(bookId, workspaceRoot) {
659
+ return fileExists(bookRoot(bookId, workspaceRoot));
660
+ }
661
+ async function ensureBookDir(bookId, workspaceRoot) {
662
+ await node_fs.promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
663
+ await node_fs.promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
664
+ await node_fs.promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
665
+ }
666
+ /** Recursively delete a book's directory. Used by `deleteBook` after
667
+ * the config has been updated to drop the entry. */
668
+ async function removeBookDir(bookId, workspaceRoot) {
669
+ await node_fs.promises.rm(bookRoot(bookId, workspaceRoot), {
670
+ recursive: true,
671
+ force: true
672
+ });
673
+ }
674
+ //#endregion
638
675
  //#region src/server/openingBalances.ts
639
676
  /** Find the existing opening entry for a book, if any. Multiple
640
677
  * openings shouldn't coexist (the route enforces void-then-append),
@@ -672,7 +709,7 @@ function parseOpeningLines(raw, accounts, errors) {
672
709
  const accountByCode = new Map(accounts.map((account) => [account.code, account]));
673
710
  const lines = [];
674
711
  raw.forEach((rawLine, idx) => {
675
- const line = parseJournalLine(rawLine, idx, errors);
712
+ const line = parseJournalLine$1(rawLine, idx, errors);
676
713
  if (line === null) return;
677
714
  validateOpeningAccount(line, idx, accountByCode, errors);
678
715
  lines.push(line);
@@ -841,48 +878,38 @@ function computeCurrentEarnings(accounts, balanceByCode) {
841
878
  }
842
879
  return earnings;
843
880
  }
881
+ function buildBalanceSheetSection(type, accounts, balanceByCode, currentEarnings) {
882
+ const rows = accounts.filter((account) => account.type === type).map((account) => ({
883
+ accountCode: account.code,
884
+ accountName: account.name,
885
+ balance: naturalSign$1(type, balanceByCode.get(account.code) ?? 0)
886
+ })).filter((row) => Math.abs(row.balance) > ZERO_TOLERANCE);
887
+ if (type === "equity" && Math.abs(currentEarnings) > ZERO_TOLERANCE) rows.push({
888
+ accountCode: CURRENT_EARNINGS_ACCOUNT_CODE,
889
+ accountName: "Current period earnings",
890
+ balance: currentEarnings
891
+ });
892
+ return {
893
+ type,
894
+ rows,
895
+ total: rows.reduce((sum, row) => sum + row.balance, 0)
896
+ };
897
+ }
844
898
  function buildBalanceSheet(input) {
845
899
  const balanceByCode = new Map(input.balances.map((row) => [row.accountCode, row.netDebit]));
846
900
  const currentEarnings = computeCurrentEarnings(input.accounts, balanceByCode);
847
- const sections = [];
848
- for (const type of [
849
- "asset",
850
- "liability",
851
- "equity"
852
- ]) {
853
- const rows = [];
854
- let total = 0;
855
- for (const account of input.accounts) {
856
- if (account.type !== type) continue;
857
- const presented = naturalSign$1(type, balanceByCode.get(account.code) ?? 0);
858
- if (Math.abs(presented) <= ZERO_TOLERANCE) continue;
859
- rows.push({
860
- accountCode: account.code,
861
- accountName: account.name,
862
- balance: presented
863
- });
864
- total += presented;
865
- }
866
- if (type === "equity" && Math.abs(currentEarnings) > ZERO_TOLERANCE) {
867
- rows.push({
868
- accountCode: CURRENT_EARNINGS_ACCOUNT_CODE,
869
- accountName: "Current period earnings",
870
- balance: currentEarnings
871
- });
872
- total += currentEarnings;
873
- }
874
- sections.push({
875
- type,
876
- rows,
877
- total
878
- });
879
- }
880
- const assetTotal = sections[0].total;
881
- const liabEquityTotal = sections[1].total + sections[2].total;
901
+ const section = (type) => buildBalanceSheetSection(type, input.accounts, balanceByCode, currentEarnings);
902
+ const assets = section("asset");
903
+ const liabilities = section("liability");
904
+ const equity = section("equity");
882
905
  return {
883
906
  asOf: input.asOf,
884
- sections,
885
- imbalance: assetTotal - liabEquityTotal
907
+ sections: [
908
+ assets,
909
+ liabilities,
910
+ equity
911
+ ],
912
+ imbalance: assets.total - (liabilities.total + equity.total)
886
913
  };
887
914
  }
888
915
  function buildProfitLoss(input) {
@@ -984,7 +1011,7 @@ function fmtYmd(year, month, day) {
984
1011
  return `${year}-${pad2(month)}-${pad2(day)}`;
985
1012
  }
986
1013
  function parseYmd(value) {
987
- const [year, month, day] = value.split("-").map((segment) => parseInt(segment, 10));
1014
+ const [year = NaN, month = NaN, day = NaN] = value.split("-").map((segment) => parseInt(segment, 10));
988
1015
  return {
989
1016
  year,
990
1017
  month,
@@ -1164,7 +1191,7 @@ function publishBooksChanged() {
1164
1191
  //#endregion
1165
1192
  //#region src/server/snapshotCache.ts
1166
1193
  function previousPeriod(period) {
1167
- const [year, month] = period.split("-").map((segment) => parseInt(segment, 10));
1194
+ const [year = NaN, month = NaN] = period.split("-").map((segment) => parseInt(segment, 10));
1168
1195
  if (month === 1) return `${(year - 1).toString().padStart(4, "0")}-12`;
1169
1196
  return `${year.toString().padStart(4, "0")}-${(month - 1).toString().padStart(2, "0")}`;
1170
1197
  }
@@ -1190,12 +1217,12 @@ async function buildEmptySnapshot(bookId, period, workspaceRoot) {
1190
1217
  async function getOrBuildSnapshot(bookId, period, workspaceRoot) {
1191
1218
  const cached = await readSnapshot(bookId, period, workspaceRoot);
1192
1219
  if (cached) return cached;
1193
- const periods = await listJournalPeriods(bookId, workspaceRoot);
1194
- if (periods.length === 0 || period < periods[0]) return buildEmptySnapshot(bookId, period, workspaceRoot);
1220
+ const [earliestPeriod] = await listJournalPeriods(bookId, workspaceRoot);
1221
+ if (earliestPeriod === void 0 || period < earliestPeriod) return buildEmptySnapshot(bookId, period, workspaceRoot);
1195
1222
  const { entries } = await readJournalMonth(bookId, period, workspaceRoot);
1196
1223
  const monthDelta = aggregateBalances(entries);
1197
1224
  let priorBalances = [];
1198
- if (period > periods[0]) priorBalances = (await getOrBuildSnapshot(bookId, previousPeriod(period), workspaceRoot)).balances;
1225
+ if (period > earliestPeriod) priorBalances = (await getOrBuildSnapshot(bookId, previousPeriod(period), workspaceRoot)).balances;
1199
1226
  const snap = {
1200
1227
  period,
1201
1228
  balances: mergeBalances(priorBalances, monthDelta),
@@ -1667,19 +1694,22 @@ function coerceFiscalYearEndInput(raw) {
1667
1694
  if (!require_shared.isFiscalYearEnd(month)) throw unsupportedFiscalYearEndError(raw);
1668
1695
  return month;
1669
1696
  }
1670
- /** Boundary checks shared by updateBook (name / country only —
1671
- * fiscalYearEnd is coerced + validated separately via
1672
- * `coerceFiscalYearEndInput`). Throws on the first failure so the
1673
- * surrounding function stays under the cognitive-complexity threshold;
1674
- * each rule is also unit-testable independently via the service entry
1675
- * point. */
1676
- function validateUpdateBookInput(input) {
1697
+ /** Boundary checks for updateBook (name / country only — fiscalYearEnd is
1698
+ * coerced + validated separately via `coerceFiscalYearEndInput`). Throws on
1699
+ * the first failure so the surrounding function stays under the
1700
+ * cognitive-complexity threshold, and hands back the country to persist:
1701
+ * `undefined` = the field was omitted, `""` = explicit clear. */
1702
+ function parseUpdateBookInput(input) {
1677
1703
  if (input.name !== void 0 && (typeof input.name !== "string" || input.name.trim() === "")) throw new AccountingError(400, "name must be a non-empty string when supplied");
1678
- if (input.country !== void 0 && input.country !== "" && !require_shared.isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1704
+ const { country } = input;
1705
+ if (country === void 0 || country === "") return country;
1706
+ if (!require_shared.isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1707
+ return country;
1679
1708
  }
1680
1709
  async function createBook(input, workspaceRoot) {
1681
1710
  if (typeof input.name !== "string" || input.name.trim() === "") throw new AccountingError(400, "name is required");
1682
- if (input.country !== void 0 && !require_shared.isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1711
+ const { country } = input;
1712
+ if (country !== void 0 && !require_shared.isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1683
1713
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd) ?? 12;
1684
1714
  const config = await loadOrInitConfig(workspaceRoot);
1685
1715
  const bookId = input.id ?? await generateBookId(config, workspaceRoot);
@@ -1690,7 +1720,7 @@ async function createBook(input, workspaceRoot) {
1690
1720
  id: bookId,
1691
1721
  name: input.name,
1692
1722
  currency: input.currency ?? DEFAULT_CURRENCY,
1693
- ...input.country ? { country: input.country } : {},
1723
+ ...country ? { country } : {},
1694
1724
  fiscalYearEnd,
1695
1725
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1696
1726
  };
@@ -1704,15 +1734,15 @@ async function updateBook(input, workspaceRoot) {
1704
1734
  const config = await loadOrInitConfig(workspaceRoot);
1705
1735
  const target = findBook(config, input.bookId);
1706
1736
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1707
- validateUpdateBookInput(input);
1737
+ const country = parseUpdateBookInput(input);
1708
1738
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd);
1709
1739
  const next = {
1710
1740
  ...target,
1711
1741
  ...input.name !== void 0 ? { name: input.name } : {},
1712
- ...input.country !== void 0 && input.country !== "" ? { country: input.country } : {},
1742
+ ...country ? { country } : {},
1713
1743
  ...fiscalYearEnd !== void 0 ? { fiscalYearEnd } : {}
1714
1744
  };
1715
- if (input.country === "") delete next.country;
1745
+ if (country === "") delete next.country;
1716
1746
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1717
1747
  publishBooksChanged();
1718
1748
  return { book: next };
@@ -1927,7 +1957,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1927
1957
  }
1928
1958
  function endDateOfPeriod(period) {
1929
1959
  if (period.kind === "month") {
1930
- const [year, month] = period.period.split("-").map((segment) => parseInt(segment, 10));
1960
+ const [year = NaN, month = NaN] = period.period.split("-").map((segment) => parseInt(segment, 10));
1931
1961
  const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
1932
1962
  return `${period.period}-${String(last).padStart(2, "0")}`;
1933
1963
  }
@@ -1983,12 +2013,14 @@ function ensureValidYmd(label, value) {
1983
2013
  return value;
1984
2014
  }
1985
2015
  function ensureMetric(value) {
1986
- if (typeof value !== "string" || !require_shared.TIME_SERIES_METRICS.includes(value)) throw new AccountingError(400, `getTimeSeries: metric must be one of ${require_shared.TIME_SERIES_METRICS.join(", ")}`);
1987
- return value;
2016
+ const metric = require_shared.TIME_SERIES_METRICS.find((candidate) => candidate === value);
2017
+ if (metric === void 0) throw new AccountingError(400, `getTimeSeries: metric must be one of ${require_shared.TIME_SERIES_METRICS.join(", ")}`);
2018
+ return metric;
1988
2019
  }
1989
2020
  function ensureGranularity(value) {
1990
- if (typeof value !== "string" || !require_shared.TIME_SERIES_GRANULARITIES.includes(value)) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${require_shared.TIME_SERIES_GRANULARITIES.join(", ")}`);
1991
- return value;
2021
+ const granularity = require_shared.TIME_SERIES_GRANULARITIES.find((candidate) => candidate === value);
2022
+ if (granularity === void 0) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${require_shared.TIME_SERIES_GRANULARITIES.join(", ")}`);
2023
+ return granularity;
1992
2024
  }
1993
2025
  function resolveAccountCode(metric, raw) {
1994
2026
  if (metric === "accountBalance") {
@@ -2094,6 +2126,7 @@ async function handleGetReport(rest) {
2094
2126
  const periodInput = optionalReportPeriod(rest.period);
2095
2127
  const bookId = optionalString(rest.bookId);
2096
2128
  const periodRequired = (label) => new AccountingError(400, `${label}: period is required — { kind: "month", period: "YYYY-MM" } or { kind: "range", from: "YYYY-MM-DD", to: "YYYY-MM-DD" }`);
2129
+ if (rest.period !== void 0 && rest.period !== null && !periodInput) throw periodRequired(`getReport ${kind || "(no kind)"}`);
2097
2130
  if (kind === "balance") {
2098
2131
  if (!periodInput) throw periodRequired("getReport balance");
2099
2132
  return getBalanceSheetReport({
@@ -2219,11 +2252,11 @@ var MESSAGE_BUILDERS = {
2219
2252
  },
2220
2253
  [require_shared.ACCOUNTING_ACTIONS.addEntries]: (fields) => {
2221
2254
  const entries = (require_shared.isUnknownArray(fields.entries) ? fields.entries : []).map(describeEntry);
2222
- if (entries.length === 0) return "Posted 0 journal entries.";
2223
- if (entries.length === 1) {
2224
- const [entry] = entries;
2225
- const idFragment = entry.id ? ` (id: ${entry.id})` : "";
2226
- return `Posted a journal entry on ${entry.date ?? "the requested date"}${idFragment}.`;
2255
+ const [firstEntry, ...furtherEntries] = entries;
2256
+ if (!firstEntry) return "Posted 0 journal entries.";
2257
+ if (furtherEntries.length === 0) {
2258
+ const idFragment = firstEntry.id ? ` (id: ${firstEntry.id})` : "";
2259
+ return `Posted a journal entry on ${firstEntry.date ?? "the requested date"}${idFragment}.`;
2227
2260
  }
2228
2261
  const summary = entries.map((entry) => `${entry.date ?? "?"} (id: ${entry.id ?? "?"})`).join(", ");
2229
2262
  return `Posted ${entries.length} journal entries: ${summary}.`;
@@ -2251,14 +2284,22 @@ var MESSAGE_BUILDERS = {
2251
2284
  return `Updated ${bookName ? JSON.stringify(bookName) : "the book"}${country ? ` (country: ${country})` : ""}.`;
2252
2285
  }
2253
2286
  };
2287
+ /** Read a record entry under a user/LLM-controlled key. The
2288
+ * `Object.hasOwn` gate is load-bearing: a bare `record[key]` resolves
2289
+ * inherited prototype members, so a crafted action ("constructor",
2290
+ * "toString") would dispatch to an unexpected target instead of
2291
+ * reading as absent. */
2292
+ function ownEntry(record, key) {
2293
+ return Object.hasOwn(record, key) ? record[key] : void 0;
2294
+ }
2254
2295
  function previewMessage(action, fields) {
2255
- const head = Object.hasOwn(MESSAGE_BUILDERS, action) ? MESSAGE_BUILDERS[action](fields) : void 0;
2296
+ const head = ownEntry(MESSAGE_BUILDERS, action)?.(fields);
2256
2297
  return head ? `${head} ${VIEW_VISIBLE_TRAILER}` : VIEW_VISIBLE_TRAILER;
2257
2298
  }
2258
2299
  async function dispatch(body) {
2259
2300
  const { action, ...rest } = body;
2260
- if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2261
- const handler = ACTION_HANDLERS[action];
2301
+ const handler = ownEntry(ACTION_HANDLERS, action);
2302
+ if (!handler) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2262
2303
  const result = await handler(rest);
2263
2304
  const handlerFields = require_shared.isRecord(result) ? result : { value: result };
2264
2305
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {