@mulmoclaude/accounting-plugin 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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-xYPtGNvH.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
@@ -62,254 +62,6 @@ var log = {
62
62
  debug: (namespace, msg, data) => (deps?.logger ?? consoleLogger).debug(namespace, msg, data)
63
63
  };
64
64
  //#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
65
  //#region src/server/journal.ts
314
66
  /** Floating-point tolerance for the debit = credit check. Currency
315
67
  * amounts arrive as JavaScript numbers (the on-wire format is JSON,
@@ -411,7 +163,7 @@ function buildLine(raw) {
411
163
  * belong to the caller, because journal entries and opening balances
412
164
  * disagree about them. Returns null when nothing usable came out —
413
165
  * the caller drops the line rather than reading fields off it. */
414
- function parseJournalLine(raw, idx, errors) {
166
+ function parseJournalLine$1(raw, idx, errors) {
415
167
  if (!isRecord(raw)) {
416
168
  errors.push({
417
169
  field: `lines[${idx}]`,
@@ -437,7 +189,7 @@ function validateEntryLine(line, idx, accountCodes, errors) {
437
189
  function parseEntryLines(raw, accountCodes, errors) {
438
190
  const lines = [];
439
191
  raw.forEach((rawLine, idx) => {
440
- const line = parseJournalLine(rawLine, idx, errors);
192
+ const line = parseJournalLine$1(rawLine, idx, errors);
441
193
  if (line === null) return;
442
194
  validateEntryLine(line, idx, accountCodes, errors);
443
195
  lines.push(line);
@@ -465,6 +217,20 @@ function parseOptionalString(value, field, errors) {
465
217
  message: `${field} must be a string when supplied`
466
218
  });
467
219
  }
220
+ var isOptionalString = (value) => value === void 0 || typeof value === "string";
221
+ var isOptionalNumber = (value) => value === void 0 || typeof value === "number";
222
+ function isJournalLine(value) {
223
+ return hasStringProp(value, "accountCode") && isOptionalNumber(value.debit) && isOptionalNumber(value.credit) && isOptionalString(value.memo) && isOptionalString(value.taxRegistrationId);
224
+ }
225
+ /** Checks every field `JournalEntry` and `JournalLine` declare, so a value
226
+ * that passes really is one. Used when reading the journal JSONL back:
227
+ * everything this module ever wrote satisfies it (`id` / `date` / `kind` /
228
+ * `lines` / `createdAt` have been required since the plugin's first
229
+ * release), while a line that doesn't is exactly the line that takes the
230
+ * whole book down — `report.ts` iterates `entry.lines` unguarded. */
231
+ function isJournalEntry(value) {
232
+ 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);
233
+ }
468
234
  /** Normalize a journal line before persistence: trim string fields
469
235
  * and drop empty-string optionals so the JSONL doesn't accumulate
470
236
  * noise like `"taxRegistrationId":""`. Pure — does not mutate
@@ -611,6 +377,257 @@ function voidedIdSet(entries) {
611
377
  return set;
612
378
  }
613
379
  //#endregion
380
+ //#region src/server/io.ts
381
+ var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
382
+ function accountingRoot(workspaceRoot) {
383
+ return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accounting);
384
+ }
385
+ function configPath(workspaceRoot) {
386
+ return path.join(accountingRoot(workspaceRoot), "config.json");
387
+ }
388
+ /** Allowed shape for a book id used as a directory name. Defense
389
+ * against path traversal: a crafted id like "../../config" or
390
+ * "/tmp/x" would otherwise let `bookRoot` escape the
391
+ * `data/accounting/books/` tree, since every write path joins
392
+ * `bookId` directly into the filesystem. The first character is
393
+ * alphanumeric to forbid leading dashes / underscores that some
394
+ * shells / docs render confusingly; `_` and `-` are allowed inside.
395
+ * 64 chars is plenty for any reasonable book name. */
396
+ var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
397
+ function isSafeBookId(bookId) {
398
+ return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
399
+ }
400
+ function assertSafeBookId(bookId) {
401
+ if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
402
+ }
403
+ function bookRoot(bookId, workspaceRoot) {
404
+ assertSafeBookId(bookId);
405
+ return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accountingBooks, bookId);
406
+ }
407
+ function accountsPath(bookId, workspaceRoot) {
408
+ return path.join(bookRoot(bookId, workspaceRoot), "accounts.json");
409
+ }
410
+ function journalDir(bookId, workspaceRoot) {
411
+ return path.join(bookRoot(bookId, workspaceRoot), "journal");
412
+ }
413
+ function journalFileFor(bookId, period, workspaceRoot) {
414
+ return path.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
415
+ }
416
+ function snapshotsDir(bookId, workspaceRoot) {
417
+ return path.join(bookRoot(bookId, workspaceRoot), "snapshots");
418
+ }
419
+ function snapshotFileFor(bookId, period, workspaceRoot) {
420
+ return path.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
421
+ }
422
+ async function fileExists(filePath) {
423
+ try {
424
+ await promises.access(filePath);
425
+ return true;
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+ /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
431
+ * on ENOENT but RETHROWS other read errors and parse failures so a
432
+ * corrupted accounting journal surfaces rather than silently
433
+ * collapsing to "no data". `./json.ts` keeps the permissive
434
+ * variant for user-config files where a single bad keystroke
435
+ * shouldn't 500 the server. */
436
+ async function readJsonStrict(filePath) {
437
+ try {
438
+ const raw = await promises.readFile(filePath, "utf-8");
439
+ return JSON.parse(raw);
440
+ } catch (err) {
441
+ if (isEnoent(err)) return null;
442
+ throw err;
443
+ }
444
+ }
445
+ /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
446
+ * to its closing-month number in memory so every downstream consumer
447
+ * (reports, time-series, the UI selects) sees one shape. Absent stays
448
+ * absent — the field is optional and resolves to the default on read;
449
+ * we don't stamp an explicit December onto a book that never chose one.
450
+ * Nothing is written back here (no auto-migrate on disk). */
451
+ function normalizeBookFiscalYearEnd(book) {
452
+ if (book.fiscalYearEnd === void 0) return book;
453
+ const resolved = resolveFiscalYearEnd(book.fiscalYearEnd);
454
+ return book.fiscalYearEnd === resolved ? book : {
455
+ ...book,
456
+ fiscalYearEnd: resolved
457
+ };
458
+ }
459
+ async function readConfig(workspaceRoot) {
460
+ const config = await readJsonStrict(configPath(workspaceRoot));
461
+ if (!config) return null;
462
+ return {
463
+ ...config,
464
+ books: config.books.map(normalizeBookFiscalYearEnd)
465
+ };
466
+ }
467
+ async function writeConfig(config, workspaceRoot) {
468
+ await writeJsonAtomic(configPath(workspaceRoot), config);
469
+ }
470
+ async function readAccounts(bookId, workspaceRoot) {
471
+ return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
472
+ }
473
+ async function writeAccounts(bookId, accounts, workspaceRoot) {
474
+ await writeJsonAtomic(accountsPath(bookId, workspaceRoot), accounts);
475
+ }
476
+ /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
477
+ * month bucket dictates which JSONL file the entry lives in. */
478
+ function periodFromDate(date) {
479
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
480
+ return date.slice(0, 7);
481
+ }
482
+ /** Append one entry to the appropriate month's JSONL.
483
+ *
484
+ * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
485
+ * Two concurrent callers landing in the same month file are
486
+ * serialised by the kernel — neither overwrites the other, which
487
+ * is the bug the previous read-modify-write implementation had.
488
+ *
489
+ * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
490
+ * on every supported platform) writes atomically; a single
491
+ * serialised `JournalEntry` is comfortably under that. If the
492
+ * process is killed during the syscall the worst case is a torn
493
+ * trailing line, which `readJournalMonth` already tolerates by
494
+ * skipping unparseable lines and surfacing a `skipped` count to
495
+ * the caller. */
496
+ async function appendJournal(bookId, entry, workspaceRoot) {
497
+ const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
498
+ await promises.mkdir(path.dirname(file), { recursive: true });
499
+ await promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
500
+ }
501
+ function groupEntriesByPeriod(entries) {
502
+ const byPeriod = /* @__PURE__ */ new Map();
503
+ for (const entry of entries) {
504
+ const period = periodFromDate(entry.date);
505
+ const list = byPeriod.get(period) ?? [];
506
+ list.push(entry);
507
+ byPeriod.set(period, list);
508
+ }
509
+ return byPeriod;
510
+ }
511
+ /** Append a batch of entries: same-period entries are concatenated
512
+ * into one `appendFile` call so the whole same-period chunk hits
513
+ * the kernel as a single `O_APPEND` write — small chunks (under
514
+ * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
515
+ * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
516
+ * concurrent appender (a parallel `appendJournal` / `addEntries`
517
+ * call can never overwrite our write or vice versa). Cross-period
518
+ * batches loop one append per period; each is independently
519
+ * concurrency-safe but their union is not transactional across
520
+ * files (out of scope for the append-only JSONL design). */
521
+ async function appendJournalBatch(bookId, entries, workspaceRoot) {
522
+ if (entries.length === 0) return;
523
+ const byPeriod = groupEntriesByPeriod(entries);
524
+ for (const [period, items] of byPeriod) {
525
+ const file = journalFileFor(bookId, period, workspaceRoot);
526
+ await promises.mkdir(path.dirname(file), { recursive: true });
527
+ const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
528
+ await promises.appendFile(file, chunk, { encoding: "utf-8" });
529
+ }
530
+ }
531
+ /** One JSONL line, or null when it isn't a journal entry — unparseable JSON
532
+ * and JSON of the wrong shape are the same failure to a caller that can only
533
+ * skip the line. */
534
+ function parseJournalLine(line) {
535
+ try {
536
+ const parsed = JSON.parse(line);
537
+ return isJournalEntry(parsed) ? parsed : null;
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+ /** Read a single month's JSONL. Malformed lines are skipped (logged
543
+ * by the caller; this layer just returns the parseable subset) so
544
+ * one bad line doesn't lock the user out of their book. */
545
+ async function readJournalMonth(bookId, period, workspaceRoot) {
546
+ const file = journalFileFor(bookId, period, workspaceRoot);
547
+ let raw;
548
+ try {
549
+ raw = await promises.readFile(file, "utf-8");
550
+ } catch (err) {
551
+ if (isEnoent(err)) return {
552
+ entries: [],
553
+ skipped: 0
554
+ };
555
+ throw err;
556
+ }
557
+ const parsed = raw.split("\n").filter((line) => line.trim() !== "").map(parseJournalLine);
558
+ const entries = parsed.filter((entry) => entry !== null);
559
+ return {
560
+ entries,
561
+ skipped: parsed.length - entries.length
562
+ };
563
+ }
564
+ /** List the YYYY-MM periods that have a journal file on disk, sorted
565
+ * ascending. Useful for full-history scans (rebuilding snapshots
566
+ * from scratch). */
567
+ async function listJournalPeriods(bookId, workspaceRoot) {
568
+ let names;
569
+ try {
570
+ names = await promises.readdir(journalDir(bookId, workspaceRoot));
571
+ } catch (err) {
572
+ if (isEnoent(err)) return [];
573
+ throw err;
574
+ }
575
+ return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
576
+ }
577
+ async function readSnapshot(bookId, period, workspaceRoot) {
578
+ return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
579
+ }
580
+ async function writeSnapshot(bookId, snapshot, workspaceRoot) {
581
+ const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
582
+ await promises.mkdir(path.dirname(file), { recursive: true });
583
+ await writeJsonAtomic(file, snapshot, { uniqueTmp: true });
584
+ }
585
+ /** Drop snapshot files for all periods >= `fromPeriod`. The next
586
+ * read regenerates them. Idempotent: missing files are silently
587
+ * ignored. */
588
+ async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
589
+ let names;
590
+ try {
591
+ names = await promises.readdir(snapshotsDir(bookId, workspaceRoot));
592
+ } catch (err) {
593
+ if (isEnoent(err)) return { removed: [] };
594
+ throw err;
595
+ }
596
+ const removed = [];
597
+ for (const name of names) {
598
+ const match = /^(\d{4}-\d{2})\.json$/.exec(name);
599
+ if (!match) continue;
600
+ const [, period] = match;
601
+ if (period >= fromPeriod) {
602
+ await promises.rm(path.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
603
+ removed.push(period);
604
+ }
605
+ }
606
+ return { removed: removed.sort() };
607
+ }
608
+ /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
609
+ * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
610
+ * but reads more clearly at call sites. */
611
+ async function invalidateAllSnapshots(bookId, workspaceRoot) {
612
+ return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
613
+ }
614
+ async function bookExists(bookId, workspaceRoot) {
615
+ return fileExists(bookRoot(bookId, workspaceRoot));
616
+ }
617
+ async function ensureBookDir(bookId, workspaceRoot) {
618
+ await promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
619
+ await promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
620
+ await promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
621
+ }
622
+ /** Recursively delete a book's directory. Used by `deleteBook` after
623
+ * the config has been updated to drop the entry. */
624
+ async function removeBookDir(bookId, workspaceRoot) {
625
+ await promises.rm(bookRoot(bookId, workspaceRoot), {
626
+ recursive: true,
627
+ force: true
628
+ });
629
+ }
630
+ //#endregion
614
631
  //#region src/server/openingBalances.ts
615
632
  /** Find the existing opening entry for a book, if any. Multiple
616
633
  * openings shouldn't coexist (the route enforces void-then-append),
@@ -648,7 +665,7 @@ function parseOpeningLines(raw, accounts, errors) {
648
665
  const accountByCode = new Map(accounts.map((account) => [account.code, account]));
649
666
  const lines = [];
650
667
  raw.forEach((rawLine, idx) => {
651
- const line = parseJournalLine(rawLine, idx, errors);
668
+ const line = parseJournalLine$1(rawLine, idx, errors);
652
669
  if (line === null) return;
653
670
  validateOpeningAccount(line, idx, accountByCode, errors);
654
671
  lines.push(line);
@@ -1643,19 +1660,22 @@ function coerceFiscalYearEndInput(raw) {
1643
1660
  if (!isFiscalYearEnd(month)) throw unsupportedFiscalYearEndError(raw);
1644
1661
  return month;
1645
1662
  }
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) {
1663
+ /** Boundary checks for updateBook (name / country only — fiscalYearEnd is
1664
+ * coerced + validated separately via `coerceFiscalYearEndInput`). Throws on
1665
+ * the first failure so the surrounding function stays under the
1666
+ * cognitive-complexity threshold, and hands back the country to persist:
1667
+ * `undefined` = the field was omitted, `""` = explicit clear. */
1668
+ function parseUpdateBookInput(input) {
1653
1669
  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);
1670
+ const { country } = input;
1671
+ if (country === void 0 || country === "") return country;
1672
+ if (!isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1673
+ return country;
1655
1674
  }
1656
1675
  async function createBook(input, workspaceRoot) {
1657
1676
  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);
1677
+ const { country } = input;
1678
+ if (country !== void 0 && !isSupportedCountryCode(country)) throw unsupportedCountryError(country);
1659
1679
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd) ?? 12;
1660
1680
  const config = await loadOrInitConfig(workspaceRoot);
1661
1681
  const bookId = input.id ?? await generateBookId(config, workspaceRoot);
@@ -1666,7 +1686,7 @@ async function createBook(input, workspaceRoot) {
1666
1686
  id: bookId,
1667
1687
  name: input.name,
1668
1688
  currency: input.currency ?? DEFAULT_CURRENCY,
1669
- ...input.country ? { country: input.country } : {},
1689
+ ...country ? { country } : {},
1670
1690
  fiscalYearEnd,
1671
1691
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1672
1692
  };
@@ -1680,15 +1700,15 @@ async function updateBook(input, workspaceRoot) {
1680
1700
  const config = await loadOrInitConfig(workspaceRoot);
1681
1701
  const target = findBook(config, input.bookId);
1682
1702
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1683
- validateUpdateBookInput(input);
1703
+ const country = parseUpdateBookInput(input);
1684
1704
  const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd);
1685
1705
  const next = {
1686
1706
  ...target,
1687
1707
  ...input.name !== void 0 ? { name: input.name } : {},
1688
- ...input.country !== void 0 && input.country !== "" ? { country: input.country } : {},
1708
+ ...country ? { country } : {},
1689
1709
  ...fiscalYearEnd !== void 0 ? { fiscalYearEnd } : {}
1690
1710
  };
1691
- if (input.country === "") delete next.country;
1711
+ if (country === "") delete next.country;
1692
1712
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1693
1713
  publishBooksChanged();
1694
1714
  return { book: next };
@@ -1959,12 +1979,14 @@ function ensureValidYmd(label, value) {
1959
1979
  return value;
1960
1980
  }
1961
1981
  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;
1982
+ const metric = TIME_SERIES_METRICS.find((candidate) => candidate === value);
1983
+ if (metric === void 0) throw new AccountingError(400, `getTimeSeries: metric must be one of ${TIME_SERIES_METRICS.join(", ")}`);
1984
+ return metric;
1964
1985
  }
1965
1986
  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;
1987
+ const granularity = TIME_SERIES_GRANULARITIES.find((candidate) => candidate === value);
1988
+ if (granularity === void 0) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${TIME_SERIES_GRANULARITIES.join(", ")}`);
1989
+ return granularity;
1968
1990
  }
1969
1991
  function resolveAccountCode(metric, raw) {
1970
1992
  if (metric === "accountBalance") {