@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as resolveFiscalYearEnd, B as ACCOUNTING_API, D as isFiscalYearEnd, F as ACCOUNTING_BOOKS_CHANNEL, I as BOOK_EVENT_KINDS, L as bookChannel, M as errorMessage, N as isRecord, P as isUnknownArray, R as ACCOUNT_TYPES, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, V as ACCOUNTING_ACTIONS, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as BALANCE_SHEET_ACCOUNT_TYPES } from "./shared-qp9j-GTD.js";
1
+ import { A as resolveFiscalYearEnd, B as BALANCE_SHEET_ACCOUNT_TYPES, D as isFiscalYearEnd, F as isUnknownArray, H as ACCOUNTING_API, I as ACCOUNTING_BOOKS_CHANNEL, L as BOOK_EVENT_KINDS, M as errorMessage, N as hasStringProp, P as isRecord, R as bookChannel, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, U as ACCOUNTING_ACTIONS, V as JOURNAL_ENTRY_KINDS, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as ACCOUNT_TYPES } from "./shared-BZAAF-I4.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
@@ -7,18 +7,36 @@ import { isEnoent, writeJsonAtomic } from "@mulmoclaude/core/files";
7
7
  //#region src/server/bodyFields.ts
8
8
  var optionalString = (value) => typeof value === "string" ? value : void 0;
9
9
  var optionalRecord = (value) => isRecord(value) ? value : void 0;
10
+ var YEAR_MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
11
+ var YEAR_MONTH_DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
12
+ /** `YYYY-MM-DD` that names a day the calendar actually has. The regex alone
13
+ * admits `2026-02-30`, and `Date.UTC` rolls that forward to March instead of
14
+ * refusing it, so the round trip is what says the date is real. */
15
+ var isCalendarDate = (value) => {
16
+ if (!YEAR_MONTH_DAY_RE.test(value)) return false;
17
+ const [year, month, day] = value.split("-").map(Number);
18
+ if (year === void 0 || month === void 0 || day === void 0) return false;
19
+ return new Date(Date.UTC(year, month - 1, day)).getUTCDate() === day;
20
+ };
10
21
  /** Rebuilt field by field rather than narrowed with a predicate, so the
11
22
  * returned object is one this function actually proved. A half-formed
12
23
  * `{ kind: "month" }` reads as absent and the caller raises its own
13
24
  * "period is required" — previously it reached the report builders and
14
- * produced an `undefined-01` date. */
25
+ * produced an `undefined-01` date.
26
+ *
27
+ * The FORMAT is checked here too, not just the type: `typeof === "string"`
28
+ * let `{ kind: "month", period: "banana" }` through, which came back as a
29
+ * balance sheet dated `"banana-NaN"` and made the snapshot cache write
30
+ * `banana.json` / `0NaN-NaN.json` into the book (#2765). Malformed reads as
31
+ * absent so it lands on the caller's existing 400, which already spells out
32
+ * both accepted shapes for the LLM to repair its payload from. */
15
33
  var optionalReportPeriod = (value) => {
16
34
  const period = optionalRecord(value);
17
- if (period?.kind === "month" && typeof period.period === "string") return {
35
+ if (period?.kind === "month" && typeof period.period === "string" && YEAR_MONTH_RE.test(period.period)) return {
18
36
  kind: "month",
19
37
  period: period.period
20
38
  };
21
- if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string") return {
39
+ if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string" && isCalendarDate(period.from) && isCalendarDate(period.to)) return {
22
40
  kind: "range",
23
41
  from: period.from,
24
42
  to: period.to
@@ -62,254 +80,6 @@ var log = {
62
80
  debug: (namespace, msg, data) => (deps?.logger ?? consoleLogger).debug(namespace, msg, data)
63
81
  };
64
82
  //#endregion
65
- //#region src/server/io.ts
66
- var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
67
- function accountingRoot(workspaceRoot) {
68
- return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accounting);
69
- }
70
- function configPath(workspaceRoot) {
71
- return path.join(accountingRoot(workspaceRoot), "config.json");
72
- }
73
- /** Allowed shape for a book id used as a directory name. Defense
74
- * against path traversal: a crafted id like "../../config" or
75
- * "/tmp/x" would otherwise let `bookRoot` escape the
76
- * `data/accounting/books/` tree, since every write path joins
77
- * `bookId` directly into the filesystem. The first character is
78
- * alphanumeric to forbid leading dashes / underscores that some
79
- * shells / docs render confusingly; `_` and `-` are allowed inside.
80
- * 64 chars is plenty for any reasonable book name. */
81
- var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
82
- function isSafeBookId(bookId) {
83
- return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
84
- }
85
- function assertSafeBookId(bookId) {
86
- if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
87
- }
88
- function bookRoot(bookId, workspaceRoot) {
89
- assertSafeBookId(bookId);
90
- return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accountingBooks, bookId);
91
- }
92
- function accountsPath(bookId, workspaceRoot) {
93
- return path.join(bookRoot(bookId, workspaceRoot), "accounts.json");
94
- }
95
- function journalDir(bookId, workspaceRoot) {
96
- return path.join(bookRoot(bookId, workspaceRoot), "journal");
97
- }
98
- function journalFileFor(bookId, period, workspaceRoot) {
99
- return path.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
100
- }
101
- function snapshotsDir(bookId, workspaceRoot) {
102
- return path.join(bookRoot(bookId, workspaceRoot), "snapshots");
103
- }
104
- function snapshotFileFor(bookId, period, workspaceRoot) {
105
- return path.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
106
- }
107
- async function fileExists(filePath) {
108
- try {
109
- await promises.access(filePath);
110
- return true;
111
- } catch {
112
- return false;
113
- }
114
- }
115
- /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
116
- * on ENOENT but RETHROWS other read errors and parse failures so a
117
- * corrupted accounting journal surfaces rather than silently
118
- * collapsing to "no data". `./json.ts` keeps the permissive
119
- * variant for user-config files where a single bad keystroke
120
- * shouldn't 500 the server. */
121
- async function readJsonStrict(filePath) {
122
- try {
123
- const raw = await promises.readFile(filePath, "utf-8");
124
- return JSON.parse(raw);
125
- } catch (err) {
126
- if (isEnoent(err)) return null;
127
- throw err;
128
- }
129
- }
130
- /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
131
- * to its closing-month number in memory so every downstream consumer
132
- * (reports, time-series, the UI selects) sees one shape. Absent stays
133
- * absent — the field is optional and resolves to the default on read;
134
- * we don't stamp an explicit December onto a book that never chose one.
135
- * Nothing is written back here (no auto-migrate on disk). */
136
- function normalizeBookFiscalYearEnd(book) {
137
- if (book.fiscalYearEnd === void 0) return book;
138
- const resolved = resolveFiscalYearEnd(book.fiscalYearEnd);
139
- return book.fiscalYearEnd === resolved ? book : {
140
- ...book,
141
- fiscalYearEnd: resolved
142
- };
143
- }
144
- async function readConfig(workspaceRoot) {
145
- const config = await readJsonStrict(configPath(workspaceRoot));
146
- if (!config) return null;
147
- return {
148
- ...config,
149
- books: config.books.map(normalizeBookFiscalYearEnd)
150
- };
151
- }
152
- async function writeConfig(config, workspaceRoot) {
153
- await writeJsonAtomic(configPath(workspaceRoot), config);
154
- }
155
- async function readAccounts(bookId, workspaceRoot) {
156
- return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
157
- }
158
- async function writeAccounts(bookId, accounts, workspaceRoot) {
159
- await writeJsonAtomic(accountsPath(bookId, workspaceRoot), accounts);
160
- }
161
- /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
162
- * month bucket dictates which JSONL file the entry lives in. */
163
- function periodFromDate(date) {
164
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
165
- return date.slice(0, 7);
166
- }
167
- /** Append one entry to the appropriate month's JSONL.
168
- *
169
- * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
170
- * Two concurrent callers landing in the same month file are
171
- * serialised by the kernel — neither overwrites the other, which
172
- * is the bug the previous read-modify-write implementation had.
173
- *
174
- * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
175
- * on every supported platform) writes atomically; a single
176
- * serialised `JournalEntry` is comfortably under that. If the
177
- * process is killed during the syscall the worst case is a torn
178
- * trailing line, which `readJournalMonth` already tolerates by
179
- * skipping unparseable lines and surfacing a `skipped` count to
180
- * the caller. */
181
- async function appendJournal(bookId, entry, workspaceRoot) {
182
- const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
183
- await promises.mkdir(path.dirname(file), { recursive: true });
184
- await promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
185
- }
186
- function groupEntriesByPeriod(entries) {
187
- const byPeriod = /* @__PURE__ */ new Map();
188
- for (const entry of entries) {
189
- const period = periodFromDate(entry.date);
190
- const list = byPeriod.get(period) ?? [];
191
- list.push(entry);
192
- byPeriod.set(period, list);
193
- }
194
- return byPeriod;
195
- }
196
- /** Append a batch of entries: same-period entries are concatenated
197
- * into one `appendFile` call so the whole same-period chunk hits
198
- * the kernel as a single `O_APPEND` write — small chunks (under
199
- * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
200
- * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
201
- * concurrent appender (a parallel `appendJournal` / `addEntries`
202
- * call can never overwrite our write or vice versa). Cross-period
203
- * batches loop one append per period; each is independently
204
- * concurrency-safe but their union is not transactional across
205
- * files (out of scope for the append-only JSONL design). */
206
- async function appendJournalBatch(bookId, entries, workspaceRoot) {
207
- if (entries.length === 0) return;
208
- const byPeriod = groupEntriesByPeriod(entries);
209
- for (const [period, items] of byPeriod) {
210
- const file = journalFileFor(bookId, period, workspaceRoot);
211
- await promises.mkdir(path.dirname(file), { recursive: true });
212
- const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
213
- await promises.appendFile(file, chunk, { encoding: "utf-8" });
214
- }
215
- }
216
- /** Read a single month's JSONL. Malformed lines are skipped (logged
217
- * by the caller; this layer just returns the parseable subset) so
218
- * one bad line doesn't lock the user out of their book. */
219
- async function readJournalMonth(bookId, period, workspaceRoot) {
220
- const file = journalFileFor(bookId, period, workspaceRoot);
221
- let raw;
222
- try {
223
- raw = await promises.readFile(file, "utf-8");
224
- } catch (err) {
225
- if (isEnoent(err)) return {
226
- entries: [],
227
- skipped: 0
228
- };
229
- throw err;
230
- }
231
- const entries = [];
232
- let skipped = 0;
233
- for (const line of raw.split("\n")) {
234
- if (line.trim() === "") continue;
235
- try {
236
- entries.push(JSON.parse(line));
237
- } catch {
238
- skipped += 1;
239
- }
240
- }
241
- return {
242
- entries,
243
- skipped
244
- };
245
- }
246
- /** List the YYYY-MM periods that have a journal file on disk, sorted
247
- * ascending. Useful for full-history scans (rebuilding snapshots
248
- * from scratch). */
249
- async function listJournalPeriods(bookId, workspaceRoot) {
250
- let names;
251
- try {
252
- names = await promises.readdir(journalDir(bookId, workspaceRoot));
253
- } catch (err) {
254
- if (isEnoent(err)) return [];
255
- throw err;
256
- }
257
- return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
258
- }
259
- async function readSnapshot(bookId, period, workspaceRoot) {
260
- return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
261
- }
262
- async function writeSnapshot(bookId, snapshot, workspaceRoot) {
263
- const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
264
- await promises.mkdir(path.dirname(file), { recursive: true });
265
- await writeJsonAtomic(file, snapshot, { uniqueTmp: true });
266
- }
267
- /** Drop snapshot files for all periods >= `fromPeriod`. The next
268
- * read regenerates them. Idempotent: missing files are silently
269
- * ignored. */
270
- async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
271
- let names;
272
- try {
273
- names = await promises.readdir(snapshotsDir(bookId, workspaceRoot));
274
- } catch (err) {
275
- if (isEnoent(err)) return { removed: [] };
276
- throw err;
277
- }
278
- const removed = [];
279
- for (const name of names) {
280
- const match = /^(\d{4}-\d{2})\.json$/.exec(name);
281
- if (!match) continue;
282
- const [, period] = match;
283
- if (period >= fromPeriod) {
284
- await promises.rm(path.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
285
- removed.push(period);
286
- }
287
- }
288
- return { removed: removed.sort() };
289
- }
290
- /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
291
- * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
292
- * but reads more clearly at call sites. */
293
- async function invalidateAllSnapshots(bookId, workspaceRoot) {
294
- return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
295
- }
296
- async function bookExists(bookId, workspaceRoot) {
297
- return fileExists(bookRoot(bookId, workspaceRoot));
298
- }
299
- async function ensureBookDir(bookId, workspaceRoot) {
300
- await promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
301
- await promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
302
- await promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
303
- }
304
- /** Recursively delete a book's directory. Used by `deleteBook` after
305
- * the config has been updated to drop the entry. */
306
- async function removeBookDir(bookId, workspaceRoot) {
307
- await promises.rm(bookRoot(bookId, workspaceRoot), {
308
- recursive: true,
309
- force: true
310
- });
311
- }
312
- //#endregion
313
83
  //#region src/server/journal.ts
314
84
  /** Floating-point tolerance for the debit = credit check. Currency
315
85
  * amounts arrive as JavaScript numbers (the on-wire format is JSON,
@@ -342,6 +112,7 @@ function localDateString(now = /* @__PURE__ */ new Date()) {
342
112
  function isValidCalendarDate(date) {
343
113
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
344
114
  const [year, month, day] = date.split("-").map((segment) => parseInt(segment, 10));
115
+ if (year === void 0 || month === void 0 || day === void 0) return false;
345
116
  const parsed = new Date(Date.UTC(year, month - 1, day));
346
117
  return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
347
118
  }
@@ -411,7 +182,7 @@ function buildLine(raw) {
411
182
  * belong to the caller, because journal entries and opening balances
412
183
  * disagree about them. Returns null when nothing usable came out —
413
184
  * the caller drops the line rather than reading fields off it. */
414
- function parseJournalLine(raw, idx, errors) {
185
+ function parseJournalLine$1(raw, idx, errors) {
415
186
  if (!isRecord(raw)) {
416
187
  errors.push({
417
188
  field: `lines[${idx}]`,
@@ -437,7 +208,7 @@ function validateEntryLine(line, idx, accountCodes, errors) {
437
208
  function parseEntryLines(raw, accountCodes, errors) {
438
209
  const lines = [];
439
210
  raw.forEach((rawLine, idx) => {
440
- const line = parseJournalLine(rawLine, idx, errors);
211
+ const line = parseJournalLine$1(rawLine, idx, errors);
441
212
  if (line === null) return;
442
213
  validateEntryLine(line, idx, accountCodes, errors);
443
214
  lines.push(line);
@@ -465,6 +236,20 @@ function parseOptionalString(value, field, errors) {
465
236
  message: `${field} must be a string when supplied`
466
237
  });
467
238
  }
239
+ var isOptionalString = (value) => value === void 0 || typeof value === "string";
240
+ var isOptionalNumber = (value) => value === void 0 || typeof value === "number";
241
+ function isJournalLine(value) {
242
+ return hasStringProp(value, "accountCode") && isOptionalNumber(value.debit) && isOptionalNumber(value.credit) && isOptionalString(value.memo) && isOptionalString(value.taxRegistrationId);
243
+ }
244
+ /** Checks every field `JournalEntry` and `JournalLine` declare, so a value
245
+ * that passes really is one. Used when reading the journal JSONL back:
246
+ * everything this module ever wrote satisfies it (`id` / `date` / `kind` /
247
+ * `lines` / `createdAt` have been required since the plugin's first
248
+ * release), while a line that doesn't is exactly the line that takes the
249
+ * whole book down — `report.ts` iterates `entry.lines` unguarded. */
250
+ function isJournalEntry(value) {
251
+ return hasStringProp(value, "id") && hasStringProp(value, "date") && hasStringProp(value, "createdAt") && JOURNAL_ENTRY_KINDS.some((kind) => kind === value.kind) && isUnknownArray(value.lines) && value.lines.every(isJournalLine) && isOptionalString(value.memo) && isOptionalString(value.voidedEntryId) && isOptionalString(value.voidReason) && isOptionalString(value.replacesEntryId);
252
+ }
468
253
  /** Normalize a journal line before persistence: trim string fields
469
254
  * and drop empty-string optionals so the JSONL doesn't accumulate
470
255
  * noise like `"taxRegistrationId":""`. Pure — does not mutate
@@ -611,6 +396,258 @@ function voidedIdSet(entries) {
611
396
  return set;
612
397
  }
613
398
  //#endregion
399
+ //#region src/server/io.ts
400
+ var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
401
+ function accountingRoot(workspaceRoot) {
402
+ return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accounting);
403
+ }
404
+ function configPath(workspaceRoot) {
405
+ return path.join(accountingRoot(workspaceRoot), "config.json");
406
+ }
407
+ /** Allowed shape for a book id used as a directory name. Defense
408
+ * against path traversal: a crafted id like "../../config" or
409
+ * "/tmp/x" would otherwise let `bookRoot` escape the
410
+ * `data/accounting/books/` tree, since every write path joins
411
+ * `bookId` directly into the filesystem. The first character is
412
+ * alphanumeric to forbid leading dashes / underscores that some
413
+ * shells / docs render confusingly; `_` and `-` are allowed inside.
414
+ * 64 chars is plenty for any reasonable book name. */
415
+ var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
416
+ function isSafeBookId(bookId) {
417
+ return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
418
+ }
419
+ function assertSafeBookId(bookId) {
420
+ if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
421
+ }
422
+ function bookRoot(bookId, workspaceRoot) {
423
+ assertSafeBookId(bookId);
424
+ return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accountingBooks, bookId);
425
+ }
426
+ function accountsPath(bookId, workspaceRoot) {
427
+ return path.join(bookRoot(bookId, workspaceRoot), "accounts.json");
428
+ }
429
+ function journalDir(bookId, workspaceRoot) {
430
+ return path.join(bookRoot(bookId, workspaceRoot), "journal");
431
+ }
432
+ function journalFileFor(bookId, period, workspaceRoot) {
433
+ return path.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
434
+ }
435
+ function snapshotsDir(bookId, workspaceRoot) {
436
+ return path.join(bookRoot(bookId, workspaceRoot), "snapshots");
437
+ }
438
+ function snapshotFileFor(bookId, period, workspaceRoot) {
439
+ return path.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
440
+ }
441
+ async function fileExists(filePath) {
442
+ try {
443
+ await promises.access(filePath);
444
+ return true;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
450
+ * on ENOENT but RETHROWS other read errors and parse failures so a
451
+ * corrupted accounting journal surfaces rather than silently
452
+ * collapsing to "no data". `./json.ts` keeps the permissive
453
+ * variant for user-config files where a single bad keystroke
454
+ * shouldn't 500 the server. */
455
+ async function readJsonStrict(filePath) {
456
+ try {
457
+ const raw = await promises.readFile(filePath, "utf-8");
458
+ return JSON.parse(raw);
459
+ } catch (err) {
460
+ if (isEnoent(err)) return null;
461
+ throw err;
462
+ }
463
+ }
464
+ /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
465
+ * to its closing-month number in memory so every downstream consumer
466
+ * (reports, time-series, the UI selects) sees one shape. Absent stays
467
+ * absent — the field is optional and resolves to the default on read;
468
+ * we don't stamp an explicit December onto a book that never chose one.
469
+ * Nothing is written back here (no auto-migrate on disk). */
470
+ function normalizeBookFiscalYearEnd(book) {
471
+ if (book.fiscalYearEnd === void 0) return book;
472
+ const resolved = resolveFiscalYearEnd(book.fiscalYearEnd);
473
+ return book.fiscalYearEnd === resolved ? book : {
474
+ ...book,
475
+ fiscalYearEnd: resolved
476
+ };
477
+ }
478
+ async function readConfig(workspaceRoot) {
479
+ const config = await readJsonStrict(configPath(workspaceRoot));
480
+ if (!config) return null;
481
+ return {
482
+ ...config,
483
+ books: config.books.map(normalizeBookFiscalYearEnd)
484
+ };
485
+ }
486
+ async function writeConfig(config, workspaceRoot) {
487
+ await writeJsonAtomic(configPath(workspaceRoot), config);
488
+ }
489
+ async function readAccounts(bookId, workspaceRoot) {
490
+ return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
491
+ }
492
+ async function writeAccounts(bookId, accounts, workspaceRoot) {
493
+ await writeJsonAtomic(accountsPath(bookId, workspaceRoot), accounts);
494
+ }
495
+ /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
496
+ * month bucket dictates which JSONL file the entry lives in. */
497
+ function periodFromDate(date) {
498
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
499
+ return date.slice(0, 7);
500
+ }
501
+ /** Append one entry to the appropriate month's JSONL.
502
+ *
503
+ * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
504
+ * Two concurrent callers landing in the same month file are
505
+ * serialised by the kernel — neither overwrites the other, which
506
+ * is the bug the previous read-modify-write implementation had.
507
+ *
508
+ * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
509
+ * on every supported platform) writes atomically; a single
510
+ * serialised `JournalEntry` is comfortably under that. If the
511
+ * process is killed during the syscall the worst case is a torn
512
+ * trailing line, which `readJournalMonth` already tolerates by
513
+ * skipping unparseable lines and surfacing a `skipped` count to
514
+ * the caller. */
515
+ async function appendJournal(bookId, entry, workspaceRoot) {
516
+ const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
517
+ await promises.mkdir(path.dirname(file), { recursive: true });
518
+ await promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
519
+ }
520
+ function groupEntriesByPeriod(entries) {
521
+ const byPeriod = /* @__PURE__ */ new Map();
522
+ for (const entry of entries) {
523
+ const period = periodFromDate(entry.date);
524
+ const list = byPeriod.get(period) ?? [];
525
+ list.push(entry);
526
+ byPeriod.set(period, list);
527
+ }
528
+ return byPeriod;
529
+ }
530
+ /** Append a batch of entries: same-period entries are concatenated
531
+ * into one `appendFile` call so the whole same-period chunk hits
532
+ * the kernel as a single `O_APPEND` write — small chunks (under
533
+ * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
534
+ * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
535
+ * concurrent appender (a parallel `appendJournal` / `addEntries`
536
+ * call can never overwrite our write or vice versa). Cross-period
537
+ * batches loop one append per period; each is independently
538
+ * concurrency-safe but their union is not transactional across
539
+ * files (out of scope for the append-only JSONL design). */
540
+ async function appendJournalBatch(bookId, entries, workspaceRoot) {
541
+ if (entries.length === 0) return;
542
+ const byPeriod = groupEntriesByPeriod(entries);
543
+ for (const [period, items] of byPeriod) {
544
+ const file = journalFileFor(bookId, period, workspaceRoot);
545
+ await promises.mkdir(path.dirname(file), { recursive: true });
546
+ const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
547
+ await promises.appendFile(file, chunk, { encoding: "utf-8" });
548
+ }
549
+ }
550
+ /** One JSONL line, or null when it isn't a journal entry — unparseable JSON
551
+ * and JSON of the wrong shape are the same failure to a caller that can only
552
+ * skip the line. */
553
+ function parseJournalLine(line) {
554
+ try {
555
+ const parsed = JSON.parse(line);
556
+ return isJournalEntry(parsed) ? parsed : null;
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+ /** Read a single month's JSONL. Malformed lines are skipped (logged
562
+ * by the caller; this layer just returns the parseable subset) so
563
+ * one bad line doesn't lock the user out of their book. */
564
+ async function readJournalMonth(bookId, period, workspaceRoot) {
565
+ const file = journalFileFor(bookId, period, workspaceRoot);
566
+ let raw;
567
+ try {
568
+ raw = await promises.readFile(file, "utf-8");
569
+ } catch (err) {
570
+ if (isEnoent(err)) return {
571
+ entries: [],
572
+ skipped: 0
573
+ };
574
+ throw err;
575
+ }
576
+ const parsed = raw.split("\n").filter((line) => line.trim() !== "").map(parseJournalLine);
577
+ const entries = parsed.filter((entry) => entry !== null);
578
+ return {
579
+ entries,
580
+ skipped: parsed.length - entries.length
581
+ };
582
+ }
583
+ /** List the YYYY-MM periods that have a journal file on disk, sorted
584
+ * ascending. Useful for full-history scans (rebuilding snapshots
585
+ * from scratch). */
586
+ async function listJournalPeriods(bookId, workspaceRoot) {
587
+ let names;
588
+ try {
589
+ names = await promises.readdir(journalDir(bookId, workspaceRoot));
590
+ } catch (err) {
591
+ if (isEnoent(err)) return [];
592
+ throw err;
593
+ }
594
+ return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
595
+ }
596
+ async function readSnapshot(bookId, period, workspaceRoot) {
597
+ return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
598
+ }
599
+ async function writeSnapshot(bookId, snapshot, workspaceRoot) {
600
+ const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
601
+ await promises.mkdir(path.dirname(file), { recursive: true });
602
+ await writeJsonAtomic(file, snapshot, { uniqueTmp: true });
603
+ }
604
+ /** Drop snapshot files for all periods >= `fromPeriod`. The next
605
+ * read regenerates them. Idempotent: missing files are silently
606
+ * ignored. */
607
+ async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
608
+ let names;
609
+ try {
610
+ names = await promises.readdir(snapshotsDir(bookId, workspaceRoot));
611
+ } catch (err) {
612
+ if (isEnoent(err)) return { removed: [] };
613
+ throw err;
614
+ }
615
+ const removed = [];
616
+ for (const name of names) {
617
+ const match = /^(\d{4}-\d{2})\.json$/.exec(name);
618
+ if (!match) continue;
619
+ const [, period] = match;
620
+ if (period === void 0) continue;
621
+ if (period >= fromPeriod) {
622
+ await promises.rm(path.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
623
+ removed.push(period);
624
+ }
625
+ }
626
+ return { removed: removed.sort() };
627
+ }
628
+ /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
629
+ * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
630
+ * but reads more clearly at call sites. */
631
+ async function invalidateAllSnapshots(bookId, workspaceRoot) {
632
+ return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
633
+ }
634
+ async function bookExists(bookId, workspaceRoot) {
635
+ return fileExists(bookRoot(bookId, workspaceRoot));
636
+ }
637
+ async function ensureBookDir(bookId, workspaceRoot) {
638
+ await promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
639
+ await promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
640
+ await promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
641
+ }
642
+ /** Recursively delete a book's directory. Used by `deleteBook` after
643
+ * the config has been updated to drop the entry. */
644
+ async function removeBookDir(bookId, workspaceRoot) {
645
+ await promises.rm(bookRoot(bookId, workspaceRoot), {
646
+ recursive: true,
647
+ force: true
648
+ });
649
+ }
650
+ //#endregion
614
651
  //#region src/server/openingBalances.ts
615
652
  /** Find the existing opening entry for a book, if any. Multiple
616
653
  * openings shouldn't coexist (the route enforces void-then-append),
@@ -648,7 +685,7 @@ function parseOpeningLines(raw, accounts, errors) {
648
685
  const accountByCode = new Map(accounts.map((account) => [account.code, account]));
649
686
  const lines = [];
650
687
  raw.forEach((rawLine, idx) => {
651
- const line = parseJournalLine(rawLine, idx, errors);
688
+ const line = parseJournalLine$1(rawLine, idx, errors);
652
689
  if (line === null) return;
653
690
  validateOpeningAccount(line, idx, accountByCode, errors);
654
691
  lines.push(line);
@@ -817,48 +854,38 @@ function computeCurrentEarnings(accounts, balanceByCode) {
817
854
  }
818
855
  return earnings;
819
856
  }
857
+ function buildBalanceSheetSection(type, accounts, balanceByCode, currentEarnings) {
858
+ const rows = accounts.filter((account) => account.type === type).map((account) => ({
859
+ accountCode: account.code,
860
+ accountName: account.name,
861
+ balance: naturalSign$1(type, balanceByCode.get(account.code) ?? 0)
862
+ })).filter((row) => Math.abs(row.balance) > ZERO_TOLERANCE);
863
+ if (type === "equity" && Math.abs(currentEarnings) > ZERO_TOLERANCE) rows.push({
864
+ accountCode: CURRENT_EARNINGS_ACCOUNT_CODE,
865
+ accountName: "Current period earnings",
866
+ balance: currentEarnings
867
+ });
868
+ return {
869
+ type,
870
+ rows,
871
+ total: rows.reduce((sum, row) => sum + row.balance, 0)
872
+ };
873
+ }
820
874
  function buildBalanceSheet(input) {
821
875
  const balanceByCode = new Map(input.balances.map((row) => [row.accountCode, row.netDebit]));
822
876
  const currentEarnings = computeCurrentEarnings(input.accounts, balanceByCode);
823
- const sections = [];
824
- for (const type of [
825
- "asset",
826
- "liability",
827
- "equity"
828
- ]) {
829
- const rows = [];
830
- let total = 0;
831
- for (const account of input.accounts) {
832
- if (account.type !== type) continue;
833
- const presented = naturalSign$1(type, balanceByCode.get(account.code) ?? 0);
834
- if (Math.abs(presented) <= ZERO_TOLERANCE) continue;
835
- rows.push({
836
- accountCode: account.code,
837
- accountName: account.name,
838
- balance: presented
839
- });
840
- total += presented;
841
- }
842
- if (type === "equity" && Math.abs(currentEarnings) > ZERO_TOLERANCE) {
843
- rows.push({
844
- accountCode: CURRENT_EARNINGS_ACCOUNT_CODE,
845
- accountName: "Current period earnings",
846
- balance: currentEarnings
847
- });
848
- total += currentEarnings;
849
- }
850
- sections.push({
851
- type,
852
- rows,
853
- total
854
- });
855
- }
856
- const assetTotal = sections[0].total;
857
- const liabEquityTotal = sections[1].total + sections[2].total;
877
+ const section = (type) => buildBalanceSheetSection(type, input.accounts, balanceByCode, currentEarnings);
878
+ const assets = section("asset");
879
+ const liabilities = section("liability");
880
+ const equity = section("equity");
858
881
  return {
859
882
  asOf: input.asOf,
860
- sections,
861
- imbalance: assetTotal - liabEquityTotal
883
+ sections: [
884
+ assets,
885
+ liabilities,
886
+ equity
887
+ ],
888
+ imbalance: assets.total - (liabilities.total + equity.total)
862
889
  };
863
890
  }
864
891
  function buildProfitLoss(input) {
@@ -960,7 +987,7 @@ function fmtYmd(year, month, day) {
960
987
  return `${year}-${pad2(month)}-${pad2(day)}`;
961
988
  }
962
989
  function parseYmd(value) {
963
- const [year, month, day] = value.split("-").map((segment) => parseInt(segment, 10));
990
+ const [year = NaN, month = NaN, day = NaN] = value.split("-").map((segment) => parseInt(segment, 10));
964
991
  return {
965
992
  year,
966
993
  month,
@@ -1140,7 +1167,7 @@ function publishBooksChanged() {
1140
1167
  //#endregion
1141
1168
  //#region src/server/snapshotCache.ts
1142
1169
  function previousPeriod(period) {
1143
- const [year, month] = period.split("-").map((segment) => parseInt(segment, 10));
1170
+ const [year = NaN, month = NaN] = period.split("-").map((segment) => parseInt(segment, 10));
1144
1171
  if (month === 1) return `${(year - 1).toString().padStart(4, "0")}-12`;
1145
1172
  return `${year.toString().padStart(4, "0")}-${(month - 1).toString().padStart(2, "0")}`;
1146
1173
  }
@@ -1166,12 +1193,12 @@ async function buildEmptySnapshot(bookId, period, workspaceRoot) {
1166
1193
  async function getOrBuildSnapshot(bookId, period, workspaceRoot) {
1167
1194
  const cached = await readSnapshot(bookId, period, workspaceRoot);
1168
1195
  if (cached) return cached;
1169
- const periods = await listJournalPeriods(bookId, workspaceRoot);
1170
- if (periods.length === 0 || period < periods[0]) return buildEmptySnapshot(bookId, period, workspaceRoot);
1196
+ const [earliestPeriod] = await listJournalPeriods(bookId, workspaceRoot);
1197
+ if (earliestPeriod === void 0 || period < earliestPeriod) return buildEmptySnapshot(bookId, period, workspaceRoot);
1171
1198
  const { entries } = await readJournalMonth(bookId, period, workspaceRoot);
1172
1199
  const monthDelta = aggregateBalances(entries);
1173
1200
  let priorBalances = [];
1174
- if (period > periods[0]) priorBalances = (await getOrBuildSnapshot(bookId, previousPeriod(period), workspaceRoot)).balances;
1201
+ if (period > earliestPeriod) priorBalances = (await getOrBuildSnapshot(bookId, previousPeriod(period), workspaceRoot)).balances;
1175
1202
  const snap = {
1176
1203
  period,
1177
1204
  balances: mergeBalances(priorBalances, monthDelta),
@@ -1643,19 +1670,22 @@ function coerceFiscalYearEndInput(raw) {
1643
1670
  if (!isFiscalYearEnd(month)) throw unsupportedFiscalYearEndError(raw);
1644
1671
  return month;
1645
1672
  }
1646
- /** Boundary checks shared by updateBook (name / country only —
1647
- * fiscalYearEnd is coerced + validated separately via
1648
- * `coerceFiscalYearEndInput`). Throws on the first failure so the
1649
- * surrounding function stays under the cognitive-complexity threshold;
1650
- * each rule is also unit-testable independently via the service entry
1651
- * point. */
1652
- function validateUpdateBookInput(input) {
1673
+ /** Boundary checks for updateBook (name / country only — fiscalYearEnd is
1674
+ * coerced + validated separately via `coerceFiscalYearEndInput`). Throws on
1675
+ * the first failure so the surrounding function stays under the
1676
+ * cognitive-complexity threshold, and hands back the country to persist:
1677
+ * `undefined` = the field was omitted, `""` = explicit clear. */
1678
+ function parseUpdateBookInput(input) {
1653
1679
  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");
1654
- if (input.country !== void 0 && input.country !== "" && !isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1680
+ const { country } = input;
1681
+ if (country === void 0 || country === "") return country;
1682
+ if (!isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1683
+ return country;
1655
1684
  }
1656
1685
  async function createBook(input, workspaceRoot) {
1657
1686
  if (typeof input.name !== "string" || input.name.trim() === "") throw new AccountingError(400, "name is required");
1658
- if (input.country !== void 0 && !isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1687
+ const { country } = input;
1688
+ if (country !== void 0 && !isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1659
1689
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd) ?? 12;
1660
1690
  const config = await loadOrInitConfig(workspaceRoot);
1661
1691
  const bookId = input.id ?? await generateBookId(config, workspaceRoot);
@@ -1666,7 +1696,7 @@ async function createBook(input, workspaceRoot) {
1666
1696
  id: bookId,
1667
1697
  name: input.name,
1668
1698
  currency: input.currency ?? DEFAULT_CURRENCY,
1669
- ...input.country ? { country: input.country } : {},
1699
+ ...country ? { country } : {},
1670
1700
  fiscalYearEnd,
1671
1701
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1672
1702
  };
@@ -1680,15 +1710,15 @@ async function updateBook(input, workspaceRoot) {
1680
1710
  const config = await loadOrInitConfig(workspaceRoot);
1681
1711
  const target = findBook(config, input.bookId);
1682
1712
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1683
- validateUpdateBookInput(input);
1713
+ const country = parseUpdateBookInput(input);
1684
1714
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd);
1685
1715
  const next = {
1686
1716
  ...target,
1687
1717
  ...input.name !== void 0 ? { name: input.name } : {},
1688
- ...input.country !== void 0 && input.country !== "" ? { country: input.country } : {},
1718
+ ...country ? { country } : {},
1689
1719
  ...fiscalYearEnd !== void 0 ? { fiscalYearEnd } : {}
1690
1720
  };
1691
- if (input.country === "") delete next.country;
1721
+ if (country === "") delete next.country;
1692
1722
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1693
1723
  publishBooksChanged();
1694
1724
  return { book: next };
@@ -1903,7 +1933,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1903
1933
  }
1904
1934
  function endDateOfPeriod(period) {
1905
1935
  if (period.kind === "month") {
1906
- const [year, month] = period.period.split("-").map((segment) => parseInt(segment, 10));
1936
+ const [year = NaN, month = NaN] = period.period.split("-").map((segment) => parseInt(segment, 10));
1907
1937
  const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
1908
1938
  return `${period.period}-${String(last).padStart(2, "0")}`;
1909
1939
  }
@@ -1959,12 +1989,14 @@ function ensureValidYmd(label, value) {
1959
1989
  return value;
1960
1990
  }
1961
1991
  function ensureMetric(value) {
1962
- if (typeof value !== "string" || !TIME_SERIES_METRICS.includes(value)) throw new AccountingError(400, `getTimeSeries: metric must be one of ${TIME_SERIES_METRICS.join(", ")}`);
1963
- return value;
1992
+ const metric = TIME_SERIES_METRICS.find((candidate) => candidate === value);
1993
+ if (metric === void 0) throw new AccountingError(400, `getTimeSeries: metric must be one of ${TIME_SERIES_METRICS.join(", ")}`);
1994
+ return metric;
1964
1995
  }
1965
1996
  function ensureGranularity(value) {
1966
- if (typeof value !== "string" || !TIME_SERIES_GRANULARITIES.includes(value)) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${TIME_SERIES_GRANULARITIES.join(", ")}`);
1967
- return value;
1997
+ const granularity = TIME_SERIES_GRANULARITIES.find((candidate) => candidate === value);
1998
+ if (granularity === void 0) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${TIME_SERIES_GRANULARITIES.join(", ")}`);
1999
+ return granularity;
1968
2000
  }
1969
2001
  function resolveAccountCode(metric, raw) {
1970
2002
  if (metric === "accountBalance") {
@@ -2070,6 +2102,7 @@ async function handleGetReport(rest) {
2070
2102
  const periodInput = optionalReportPeriod(rest.period);
2071
2103
  const bookId = optionalString(rest.bookId);
2072
2104
  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" }`);
2105
+ if (rest.period !== void 0 && rest.period !== null && !periodInput) throw periodRequired(`getReport ${kind || "(no kind)"}`);
2073
2106
  if (kind === "balance") {
2074
2107
  if (!periodInput) throw periodRequired("getReport balance");
2075
2108
  return getBalanceSheetReport({
@@ -2195,11 +2228,11 @@ var MESSAGE_BUILDERS = {
2195
2228
  },
2196
2229
  [ACCOUNTING_ACTIONS.addEntries]: (fields) => {
2197
2230
  const entries = (isUnknownArray(fields.entries) ? fields.entries : []).map(describeEntry);
2198
- if (entries.length === 0) return "Posted 0 journal entries.";
2199
- if (entries.length === 1) {
2200
- const [entry] = entries;
2201
- const idFragment = entry.id ? ` (id: ${entry.id})` : "";
2202
- return `Posted a journal entry on ${entry.date ?? "the requested date"}${idFragment}.`;
2231
+ const [firstEntry, ...furtherEntries] = entries;
2232
+ if (!firstEntry) return "Posted 0 journal entries.";
2233
+ if (furtherEntries.length === 0) {
2234
+ const idFragment = firstEntry.id ? ` (id: ${firstEntry.id})` : "";
2235
+ return `Posted a journal entry on ${firstEntry.date ?? "the requested date"}${idFragment}.`;
2203
2236
  }
2204
2237
  const summary = entries.map((entry) => `${entry.date ?? "?"} (id: ${entry.id ?? "?"})`).join(", ");
2205
2238
  return `Posted ${entries.length} journal entries: ${summary}.`;
@@ -2227,14 +2260,22 @@ var MESSAGE_BUILDERS = {
2227
2260
  return `Updated ${bookName ? JSON.stringify(bookName) : "the book"}${country ? ` (country: ${country})` : ""}.`;
2228
2261
  }
2229
2262
  };
2263
+ /** Read a record entry under a user/LLM-controlled key. The
2264
+ * `Object.hasOwn` gate is load-bearing: a bare `record[key]` resolves
2265
+ * inherited prototype members, so a crafted action ("constructor",
2266
+ * "toString") would dispatch to an unexpected target instead of
2267
+ * reading as absent. */
2268
+ function ownEntry(record, key) {
2269
+ return Object.hasOwn(record, key) ? record[key] : void 0;
2270
+ }
2230
2271
  function previewMessage(action, fields) {
2231
- const head = Object.hasOwn(MESSAGE_BUILDERS, action) ? MESSAGE_BUILDERS[action](fields) : void 0;
2272
+ const head = ownEntry(MESSAGE_BUILDERS, action)?.(fields);
2232
2273
  return head ? `${head} ${VIEW_VISIBLE_TRAILER}` : VIEW_VISIBLE_TRAILER;
2233
2274
  }
2234
2275
  async function dispatch(body) {
2235
2276
  const { action, ...rest } = body;
2236
- if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2237
- const handler = ACTION_HANDLERS[action];
2277
+ const handler = ownEntry(ACTION_HANDLERS, action);
2278
+ if (!handler) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2238
2279
  const result = await handler(rest);
2239
2280
  const handlerFields = isRecord(result) ? result : { value: result };
2240
2281
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {