@mulmoclaude/accounting-plugin 0.1.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 (112) hide show
  1. package/dist/server/accountNormalize.d.ts +3 -0
  2. package/dist/server/accountNormalize.d.ts.map +1 -0
  3. package/dist/server/atomic.d.ts +13 -0
  4. package/dist/server/atomic.d.ts.map +1 -0
  5. package/dist/server/context.d.ts +39 -0
  6. package/dist/server/context.d.ts.map +1 -0
  7. package/dist/server/defaultAccounts.d.ts +3 -0
  8. package/dist/server/defaultAccounts.d.ts.map +1 -0
  9. package/dist/server/eventPublisher.d.ts +14 -0
  10. package/dist/server/eventPublisher.d.ts.map +1 -0
  11. package/dist/server/http.d.ts +3 -0
  12. package/dist/server/http.d.ts.map +1 -0
  13. package/dist/server/index.d.ts +6 -0
  14. package/dist/server/index.d.ts.map +1 -0
  15. package/dist/server/io.d.ts +67 -0
  16. package/dist/server/io.d.ts.map +1 -0
  17. package/dist/server/journal.d.ts +74 -0
  18. package/dist/server/journal.d.ts.map +1 -0
  19. package/dist/server/openingBalances.d.ts +30 -0
  20. package/dist/server/openingBalances.d.ts.map +1 -0
  21. package/dist/server/report.d.ts +98 -0
  22. package/dist/server/report.d.ts.map +1 -0
  23. package/dist/server/router.d.ts +7 -0
  24. package/dist/server/router.d.ts.map +1 -0
  25. package/dist/server/service.d.ts +148 -0
  26. package/dist/server/service.d.ts.map +1 -0
  27. package/dist/server/snapshotCache.d.ts +52 -0
  28. package/dist/server/snapshotCache.d.ts.map +1 -0
  29. package/dist/server/timeSeries.d.ts +47 -0
  30. package/dist/server/timeSeries.d.ts.map +1 -0
  31. package/dist/server/types.d.ts +134 -0
  32. package/dist/server/types.d.ts.map +1 -0
  33. package/dist/server.cjs +2101 -0
  34. package/dist/server.cjs.map +1 -0
  35. package/dist/server.js +2074 -0
  36. package/dist/server.js.map +1 -0
  37. package/dist/shared/actions.d.ts +19 -0
  38. package/dist/shared/actions.d.ts.map +1 -0
  39. package/dist/shared/channels.d.ts +46 -0
  40. package/dist/shared/channels.d.ts.map +1 -0
  41. package/dist/shared/countries.d.ts +51 -0
  42. package/dist/shared/countries.d.ts.map +1 -0
  43. package/dist/shared/currencies.d.ts +34 -0
  44. package/dist/shared/currencies.d.ts.map +1 -0
  45. package/dist/shared/dates.d.ts +15 -0
  46. package/dist/shared/dates.d.ts.map +1 -0
  47. package/dist/shared/errors.d.ts +2 -0
  48. package/dist/shared/errors.d.ts.map +1 -0
  49. package/dist/shared/fiscalYear.d.ts +22 -0
  50. package/dist/shared/fiscalYear.d.ts.map +1 -0
  51. package/dist/shared/index.d.ts +9 -0
  52. package/dist/shared/index.d.ts.map +1 -0
  53. package/dist/shared/timeSeriesEnums.d.ts +5 -0
  54. package/dist/shared/timeSeriesEnums.d.ts.map +1 -0
  55. package/dist/shared.cjs +466 -0
  56. package/dist/shared.cjs.map +1 -0
  57. package/dist/shared.js +432 -0
  58. package/dist/shared.js.map +1 -0
  59. package/dist/style.css +1255 -0
  60. package/dist/vue/Preview.vue.d.ts +8 -0
  61. package/dist/vue/Preview.vue.d.ts.map +1 -0
  62. package/dist/vue/View.vue.d.ts +30 -0
  63. package/dist/vue/View.vue.d.ts.map +1 -0
  64. package/dist/vue/api.d.ts +269 -0
  65. package/dist/vue/api.d.ts.map +1 -0
  66. package/dist/vue/components/AccountEditor.vue.d.ts +19 -0
  67. package/dist/vue/components/AccountEditor.vue.d.ts.map +1 -0
  68. package/dist/vue/components/AccountRow.vue.d.ts +14 -0
  69. package/dist/vue/components/AccountRow.vue.d.ts.map +1 -0
  70. package/dist/vue/components/AccountsList.vue.d.ts +15 -0
  71. package/dist/vue/components/AccountsList.vue.d.ts.map +1 -0
  72. package/dist/vue/components/AccountsModal.vue.d.ts +15 -0
  73. package/dist/vue/components/AccountsModal.vue.d.ts.map +1 -0
  74. package/dist/vue/components/BalanceSheet.vue.d.ts +13 -0
  75. package/dist/vue/components/BalanceSheet.vue.d.ts.map +1 -0
  76. package/dist/vue/components/BookSettings.vue.d.ts +18 -0
  77. package/dist/vue/components/BookSettings.vue.d.ts.map +1 -0
  78. package/dist/vue/components/BookSwitcher.vue.d.ts +17 -0
  79. package/dist/vue/components/BookSwitcher.vue.d.ts.map +1 -0
  80. package/dist/vue/components/DateRangePicker.vue.d.ts +19 -0
  81. package/dist/vue/components/DateRangePicker.vue.d.ts.map +1 -0
  82. package/dist/vue/components/JournalEntryForm.vue.d.ts +19 -0
  83. package/dist/vue/components/JournalEntryForm.vue.d.ts.map +1 -0
  84. package/dist/vue/components/JournalList.vue.d.ts +30 -0
  85. package/dist/vue/components/JournalList.vue.d.ts.map +1 -0
  86. package/dist/vue/components/Ledger.vue.d.ts +21 -0
  87. package/dist/vue/components/Ledger.vue.d.ts.map +1 -0
  88. package/dist/vue/components/NewBookForm.vue.d.ts +20 -0
  89. package/dist/vue/components/NewBookForm.vue.d.ts.map +1 -0
  90. package/dist/vue/components/OpeningBalancesForm.vue.d.ts +15 -0
  91. package/dist/vue/components/OpeningBalancesForm.vue.d.ts.map +1 -0
  92. package/dist/vue/components/ProfitLoss.vue.d.ts +19 -0
  93. package/dist/vue/components/ProfitLoss.vue.d.ts.map +1 -0
  94. package/dist/vue/components/accountDraft.d.ts +8 -0
  95. package/dist/vue/components/accountDraft.d.ts.map +1 -0
  96. package/dist/vue/components/accountNumbering.d.ts +20 -0
  97. package/dist/vue/components/accountNumbering.d.ts.map +1 -0
  98. package/dist/vue/components/accountValidation.d.ts +34 -0
  99. package/dist/vue/components/accountValidation.d.ts.map +1 -0
  100. package/dist/vue/components/useLatestRequest.d.ts +10 -0
  101. package/dist/vue/components/useLatestRequest.d.ts.map +1 -0
  102. package/dist/vue/hostContext.d.ts +31 -0
  103. package/dist/vue/hostContext.d.ts.map +1 -0
  104. package/dist/vue/index.d.ts +7 -0
  105. package/dist/vue/index.d.ts.map +1 -0
  106. package/dist/vue/useAccountingChannel.d.ts +13 -0
  107. package/dist/vue/useAccountingChannel.d.ts.map +1 -0
  108. package/dist/vue.cjs +3641 -0
  109. package/dist/vue.cjs.map +1 -0
  110. package/dist/vue.js +3638 -0
  111. package/dist/vue.js.map +1 -0
  112. package/package.json +74 -0
@@ -0,0 +1,2101 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ const require_shared = require("./shared.cjs");
25
+ let express = require("express");
26
+ let node_crypto = require("node:crypto");
27
+ let node_fs = require("node:fs");
28
+ let node_path = require("node:path");
29
+ node_path = __toESM(node_path, 1);
30
+ //#region src/server/context.ts
31
+ var deps = null;
32
+ /** Called once by the host before the accounting router is mounted. */
33
+ function configureAccountingServer(context) {
34
+ deps = context;
35
+ }
36
+ /** Default workspace root for io calls that don't pass one explicitly.
37
+ * Throws if the host never configured the server — a real wiring bug
38
+ * (unit tests always pass an explicit root, so they never hit this). */
39
+ function defaultWorkspaceRoot() {
40
+ if (!deps) throw new Error("@mulmoclaude/accounting-plugin: configureAccountingServer() must be called before serving accounting requests");
41
+ return deps.workspaceRoot;
42
+ }
43
+ var consoleLogger = {
44
+ error: (namespace, msg, data) => console.error(`[${namespace}] ${msg}`, data ?? ""),
45
+ warn: (namespace, msg, data) => console.warn(`[${namespace}] ${msg}`, data ?? ""),
46
+ info: () => {},
47
+ debug: () => {}
48
+ };
49
+ /** Logger proxy — forwards to the injected logger, console fallback
50
+ * before configuration. Lets call sites keep `log.warn("accounting", …)`. */
51
+ var log = {
52
+ error: (namespace, msg, data) => (deps?.logger ?? consoleLogger).error(namespace, msg, data),
53
+ warn: (namespace, msg, data) => (deps?.logger ?? consoleLogger).warn(namespace, msg, data),
54
+ info: (namespace, msg, data) => (deps?.logger ?? consoleLogger).info(namespace, msg, data),
55
+ debug: (namespace, msg, data) => (deps?.logger ?? consoleLogger).debug(namespace, msg, data)
56
+ };
57
+ /** Workspace-relative directories this plugin owns. Mirrors the host
58
+ * META's `workspaceDirs` (kept in sync by value; the host META stays
59
+ * the codegen-discoverable source for the aggregator merge). */
60
+ var ACCOUNTING_DIRS = {
61
+ accounting: "data/accounting",
62
+ accountingBooks: "data/accounting/books"
63
+ };
64
+ //#endregion
65
+ //#region src/server/atomic.ts
66
+ /** True for a `not found` filesystem error. */
67
+ function isEnoent(err) {
68
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
69
+ }
70
+ /** Atomic write: tmp alongside destination, then rename. */
71
+ async function writeFileAtomic(filePath, content, opts = {}) {
72
+ const tmp = opts.uniqueTmp ? `${filePath}.${(0, node_crypto.randomUUID)()}.tmp` : `${filePath}.tmp`;
73
+ await node_fs.promises.mkdir(node_path.default.dirname(filePath), { recursive: true });
74
+ try {
75
+ await node_fs.promises.writeFile(tmp, content, { encoding: "utf-8" });
76
+ await node_fs.promises.rename(tmp, filePath);
77
+ } catch (err) {
78
+ await node_fs.promises.unlink(tmp).catch(() => {});
79
+ throw err;
80
+ }
81
+ }
82
+ /** Atomic JSON write (2-space indent), the only serialization shape the
83
+ * accounting io layer needs. */
84
+ async function writeJsonAtomic(filePath, data, opts = {}) {
85
+ await writeFileAtomic(filePath, JSON.stringify(data, null, 2), opts);
86
+ }
87
+ //#endregion
88
+ //#region src/server/io.ts
89
+ var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
90
+ function accountingRoot(workspaceRoot) {
91
+ return node_path.default.join(root(workspaceRoot), ACCOUNTING_DIRS.accounting);
92
+ }
93
+ function configPath(workspaceRoot) {
94
+ return node_path.default.join(accountingRoot(workspaceRoot), "config.json");
95
+ }
96
+ /** Allowed shape for a book id used as a directory name. Defense
97
+ * against path traversal: a crafted id like "../../config" or
98
+ * "/tmp/x" would otherwise let `bookRoot` escape the
99
+ * `data/accounting/books/` tree, since every write path joins
100
+ * `bookId` directly into the filesystem. The first character is
101
+ * alphanumeric to forbid leading dashes / underscores that some
102
+ * shells / docs render confusingly; `_` and `-` are allowed inside.
103
+ * 64 chars is plenty for any reasonable book name. */
104
+ var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
105
+ function isSafeBookId(bookId) {
106
+ return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
107
+ }
108
+ function assertSafeBookId(bookId) {
109
+ if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
110
+ }
111
+ function bookRoot(bookId, workspaceRoot) {
112
+ assertSafeBookId(bookId);
113
+ return node_path.default.join(root(workspaceRoot), ACCOUNTING_DIRS.accountingBooks, bookId);
114
+ }
115
+ function accountsPath(bookId, workspaceRoot) {
116
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "accounts.json");
117
+ }
118
+ function journalDir(bookId, workspaceRoot) {
119
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "journal");
120
+ }
121
+ function journalFileFor(bookId, period, workspaceRoot) {
122
+ return node_path.default.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
123
+ }
124
+ function snapshotsDir(bookId, workspaceRoot) {
125
+ return node_path.default.join(bookRoot(bookId, workspaceRoot), "snapshots");
126
+ }
127
+ function snapshotFileFor(bookId, period, workspaceRoot) {
128
+ return node_path.default.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
129
+ }
130
+ async function fileExists(filePath) {
131
+ try {
132
+ await node_fs.promises.access(filePath);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+ /** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
139
+ * on ENOENT but RETHROWS other read errors and parse failures so a
140
+ * corrupted accounting journal surfaces rather than silently
141
+ * collapsing to "no data". `./json.ts` keeps the permissive
142
+ * variant for user-config files where a single bad keystroke
143
+ * shouldn't 500 the server. */
144
+ async function readJsonStrict(filePath) {
145
+ try {
146
+ const raw = await node_fs.promises.readFile(filePath, "utf-8");
147
+ return JSON.parse(raw);
148
+ } catch (err) {
149
+ if (isEnoent(err)) return null;
150
+ throw err;
151
+ }
152
+ }
153
+ async function readConfig(workspaceRoot) {
154
+ return readJsonStrict(configPath(workspaceRoot));
155
+ }
156
+ async function writeConfig(config, workspaceRoot) {
157
+ await writeJsonAtomic(configPath(workspaceRoot), config);
158
+ }
159
+ async function readAccounts(bookId, workspaceRoot) {
160
+ return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
161
+ }
162
+ async function writeAccounts(bookId, accounts, workspaceRoot) {
163
+ await writeJsonAtomic(accountsPath(bookId, workspaceRoot), accounts);
164
+ }
165
+ /** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
166
+ * month bucket dictates which JSONL file the entry lives in. */
167
+ function periodFromDate(date) {
168
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
169
+ return date.slice(0, 7);
170
+ }
171
+ /** Append one entry to the appropriate month's JSONL.
172
+ *
173
+ * Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
174
+ * Two concurrent callers landing in the same month file are
175
+ * serialised by the kernel — neither overwrites the other, which
176
+ * is the bug the previous read-modify-write implementation had.
177
+ *
178
+ * Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
179
+ * on every supported platform) writes atomically; a single
180
+ * serialised `JournalEntry` is comfortably under that. If the
181
+ * process is killed during the syscall the worst case is a torn
182
+ * trailing line, which `readJournalMonth` already tolerates by
183
+ * skipping unparseable lines and surfacing a `skipped` count to
184
+ * the caller. */
185
+ async function appendJournal(bookId, entry, workspaceRoot) {
186
+ const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
187
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
188
+ await node_fs.promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
189
+ }
190
+ function groupEntriesByPeriod(entries) {
191
+ const byPeriod = /* @__PURE__ */ new Map();
192
+ for (const entry of entries) {
193
+ const period = periodFromDate(entry.date);
194
+ const list = byPeriod.get(period) ?? [];
195
+ list.push(entry);
196
+ byPeriod.set(period, list);
197
+ }
198
+ return byPeriod;
199
+ }
200
+ /** Append a batch of entries: same-period entries are concatenated
201
+ * into one `appendFile` call so the whole same-period chunk hits
202
+ * the kernel as a single `O_APPEND` write — small chunks (under
203
+ * `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
204
+ * guaranteed atomic by POSIX, and `O_APPEND` serialises with any
205
+ * concurrent appender (a parallel `appendJournal` / `addEntries`
206
+ * call can never overwrite our write or vice versa). Cross-period
207
+ * batches loop one append per period; each is independently
208
+ * concurrency-safe but their union is not transactional across
209
+ * files (out of scope for the append-only JSONL design). */
210
+ async function appendJournalBatch(bookId, entries, workspaceRoot) {
211
+ if (entries.length === 0) return;
212
+ const byPeriod = groupEntriesByPeriod(entries);
213
+ for (const [period, items] of byPeriod) {
214
+ const file = journalFileFor(bookId, period, workspaceRoot);
215
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
216
+ const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
217
+ await node_fs.promises.appendFile(file, chunk, { encoding: "utf-8" });
218
+ }
219
+ }
220
+ /** Read a single month's JSONL. Malformed lines are skipped (logged
221
+ * by the caller; this layer just returns the parseable subset) so
222
+ * one bad line doesn't lock the user out of their book. */
223
+ async function readJournalMonth(bookId, period, workspaceRoot) {
224
+ const file = journalFileFor(bookId, period, workspaceRoot);
225
+ let raw;
226
+ try {
227
+ raw = await node_fs.promises.readFile(file, "utf-8");
228
+ } catch (err) {
229
+ if (isEnoent(err)) return {
230
+ entries: [],
231
+ skipped: 0
232
+ };
233
+ throw err;
234
+ }
235
+ const entries = [];
236
+ let skipped = 0;
237
+ for (const line of raw.split("\n")) {
238
+ if (line.trim() === "") continue;
239
+ try {
240
+ entries.push(JSON.parse(line));
241
+ } catch {
242
+ skipped += 1;
243
+ }
244
+ }
245
+ return {
246
+ entries,
247
+ skipped
248
+ };
249
+ }
250
+ /** List the YYYY-MM periods that have a journal file on disk, sorted
251
+ * ascending. Useful for full-history scans (rebuilding snapshots
252
+ * from scratch). */
253
+ async function listJournalPeriods(bookId, workspaceRoot) {
254
+ let names;
255
+ try {
256
+ names = await node_fs.promises.readdir(journalDir(bookId, workspaceRoot));
257
+ } catch (err) {
258
+ if (isEnoent(err)) return [];
259
+ throw err;
260
+ }
261
+ return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
262
+ }
263
+ async function readSnapshot(bookId, period, workspaceRoot) {
264
+ return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
265
+ }
266
+ async function writeSnapshot(bookId, snapshot, workspaceRoot) {
267
+ const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
268
+ await node_fs.promises.mkdir(node_path.default.dirname(file), { recursive: true });
269
+ await writeJsonAtomic(file, snapshot, { uniqueTmp: true });
270
+ }
271
+ /** Drop snapshot files for all periods >= `fromPeriod`. The next
272
+ * read regenerates them. Idempotent: missing files are silently
273
+ * ignored. */
274
+ async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
275
+ let names;
276
+ try {
277
+ names = await node_fs.promises.readdir(snapshotsDir(bookId, workspaceRoot));
278
+ } catch (err) {
279
+ if (isEnoent(err)) return { removed: [] };
280
+ throw err;
281
+ }
282
+ const removed = [];
283
+ for (const name of names) {
284
+ const match = /^(\d{4}-\d{2})\.json$/.exec(name);
285
+ if (!match) continue;
286
+ const [, period] = match;
287
+ if (period >= fromPeriod) {
288
+ await node_fs.promises.rm(node_path.default.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
289
+ removed.push(period);
290
+ }
291
+ }
292
+ return { removed: removed.sort() };
293
+ }
294
+ /** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
295
+ * with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
296
+ * but reads more clearly at call sites. */
297
+ async function invalidateAllSnapshots(bookId, workspaceRoot) {
298
+ return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
299
+ }
300
+ async function bookExists(bookId, workspaceRoot) {
301
+ return fileExists(bookRoot(bookId, workspaceRoot));
302
+ }
303
+ async function ensureBookDir(bookId, workspaceRoot) {
304
+ await node_fs.promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
305
+ await node_fs.promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
306
+ await node_fs.promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
307
+ }
308
+ /** Recursively delete a book's directory. Used by `deleteBook` after
309
+ * the config has been updated to drop the entry. */
310
+ async function removeBookDir(bookId, workspaceRoot) {
311
+ await node_fs.promises.rm(bookRoot(bookId, workspaceRoot), {
312
+ recursive: true,
313
+ force: true
314
+ });
315
+ }
316
+ //#endregion
317
+ //#region src/server/types.ts
318
+ /** B/S accounts (assets / liabilities / equity). Used by opening
319
+ * balance validation: opening entries reference balance-sheet
320
+ * accounts only. */
321
+ var BALANCE_SHEET_ACCOUNT_TYPES = [
322
+ "asset",
323
+ "liability",
324
+ "equity"
325
+ ];
326
+ //#endregion
327
+ //#region src/server/journal.ts
328
+ /** Floating-point tolerance for the debit = credit check. Currency
329
+ * amounts arrive as JavaScript numbers (the on-wire format is JSON,
330
+ * so amounts are doubles). 0.005 keeps two-decimal currency math
331
+ * honest while accepting the floating-point noise of summing
332
+ * many lines. */
333
+ var EQUALITY_TOLERANCE$1 = .005;
334
+ function lineHasExactlyOneSide(line) {
335
+ return (typeof line.debit === "number" && line.debit !== 0) !== (typeof line.credit === "number" && line.credit !== 0);
336
+ }
337
+ function isNonNegativeNumber(value) {
338
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
339
+ }
340
+ /** Build today's `YYYY-MM-DD` from the host's local timezone.
341
+ * Centralised here so server-side defaults (the void-date on
342
+ * `voidEntry`, the today() stamp on opening replacements, etc.)
343
+ * agree with the client-side `localDateString()` from
344
+ * `src/plugins/accounting/dates.ts`. `toISOString().slice(0, 10)`
345
+ * would emit a UTC date instead — which silently flips into
346
+ * tomorrow / yesterday in negative-offset timezones. */
347
+ function localDateString(now = /* @__PURE__ */ new Date()) {
348
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
349
+ }
350
+ /** Validate that `date` is both shaped as YYYY-MM-DD AND represents
351
+ * a real calendar day. The bare regex accepts impossible values
352
+ * like 2026-02-31 or 2026-13-01 which would then poison
353
+ * `periodFromDate`, sort orders, and snapshot keys. We reparse
354
+ * through the Date constructor and roundtrip-format to catch
355
+ * silent normalisation (e.g. "2026-02-30" → Mar 02). */
356
+ function isValidCalendarDate(date) {
357
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false;
358
+ const [year, month, day] = date.split("-").map((segment) => parseInt(segment, 10));
359
+ const parsed = new Date(Date.UTC(year, month - 1, day));
360
+ return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
361
+ }
362
+ /** Returns Σ debit − Σ credit. Used by callers that need the actual
363
+ * imbalance value (e.g. the OpeningBalancesForm shows live diff). */
364
+ function netBalance(lines) {
365
+ let net = 0;
366
+ for (const line of lines) {
367
+ if (typeof line.debit === "number") net += line.debit;
368
+ if (typeof line.credit === "number") net -= line.credit;
369
+ }
370
+ return net;
371
+ }
372
+ /** Pure validation. Does not throw; returns a list of issues so the
373
+ * REST handler can return a structured 400 instead of an opaque
374
+ * 500. */
375
+ function validateLine(line, idx, accountCodes, errors) {
376
+ if (!line.accountCode || !accountCodes.has(line.accountCode)) errors.push({
377
+ field: `lines[${idx}].accountCode`,
378
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
379
+ });
380
+ if (line.debit !== void 0 && !isNonNegativeNumber(line.debit)) errors.push({
381
+ field: `lines[${idx}].debit`,
382
+ message: "debit must be a non-negative finite number"
383
+ });
384
+ if (line.credit !== void 0 && !isNonNegativeNumber(line.credit)) errors.push({
385
+ field: `lines[${idx}].credit`,
386
+ message: "credit must be a non-negative finite number"
387
+ });
388
+ if (!lineHasExactlyOneSide(line)) errors.push({
389
+ field: `lines[${idx}]`,
390
+ message: "each line must set exactly one of debit or credit (and to a non-zero amount)"
391
+ });
392
+ if (line.taxRegistrationId !== void 0) {
393
+ if (typeof line.taxRegistrationId !== "string") errors.push({
394
+ field: `lines[${idx}].taxRegistrationId`,
395
+ message: "must be a string"
396
+ });
397
+ else if (line.taxRegistrationId.trim().length > 32) errors.push({
398
+ field: `lines[${idx}].taxRegistrationId`,
399
+ message: `must be at most 32 characters (got ${line.taxRegistrationId.trim().length})`
400
+ });
401
+ }
402
+ }
403
+ /** Normalize a journal line before persistence: trim string fields
404
+ * and drop empty-string optionals so the JSONL doesn't accumulate
405
+ * noise like `"taxRegistrationId":""`. Pure — does not mutate
406
+ * `line`. */
407
+ function normalizeLine(line) {
408
+ const out = { ...line };
409
+ if (typeof out.taxRegistrationId === "string") {
410
+ const trimmed = out.taxRegistrationId.trim();
411
+ if (trimmed === "") delete out.taxRegistrationId;
412
+ else out.taxRegistrationId = trimmed;
413
+ }
414
+ return out;
415
+ }
416
+ function validateEntry(input) {
417
+ const errors = [];
418
+ if (!isValidCalendarDate(input.date)) errors.push({
419
+ field: "date",
420
+ message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.date)}`
421
+ });
422
+ if (!Array.isArray(input.lines) || input.lines.length < 2) {
423
+ errors.push({
424
+ field: "lines",
425
+ message: "an entry needs at least two lines (one debit, one credit)"
426
+ });
427
+ return {
428
+ ok: false,
429
+ errors
430
+ };
431
+ }
432
+ const accountCodes = new Set(input.accounts.map((account) => account.code));
433
+ input.lines.forEach((line, idx) => validateLine(line, idx, accountCodes, errors));
434
+ const net = netBalance(input.lines);
435
+ if (Math.abs(net) > EQUALITY_TOLERANCE$1) errors.push({
436
+ field: "lines",
437
+ message: `Σ debit − Σ credit = ${net.toFixed(4)}; entry must balance`
438
+ });
439
+ return {
440
+ ok: errors.length === 0,
441
+ errors
442
+ };
443
+ }
444
+ /** Build a JournalEntry — validation is the caller's responsibility
445
+ * (it should have called `validateEntry` first). The id is a fresh
446
+ * UUID; createdAt is the wall clock at the moment of creation.
447
+ * Lines are normalized so optional string fields don't persist as
448
+ * empty strings. */
449
+ function makeEntry(input) {
450
+ const entry = {
451
+ id: (0, node_crypto.randomUUID)(),
452
+ date: input.date,
453
+ kind: input.kind ?? "normal",
454
+ lines: input.lines.map(normalizeLine),
455
+ memo: input.memo,
456
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
457
+ };
458
+ if (input.replacesEntryId) entry.replacesEntryId = input.replacesEntryId;
459
+ return entry;
460
+ }
461
+ /** Pick the most descriptive memo from the original entry to quote
462
+ * in the voiding entry's memo. Precedence: entry-level memo →
463
+ * first non-empty line memo → null (caller falls back to a
464
+ * date-only template). */
465
+ function originalMemoToQuote(target) {
466
+ if (target.memo && target.memo.trim() !== "") return target.memo;
467
+ for (const line of target.lines) if (line.memo && line.memo.trim() !== "") return line.memo;
468
+ return null;
469
+ }
470
+ /** Build the human-readable memo that goes on the voiding entry.
471
+ * Format: `void of '<original memo>' on <original date>` (or the
472
+ * no-memo fallback when the original carried no memo). The reason
473
+ * the user typed is appended after a colon when present. */
474
+ function voidMemo(target, reason) {
475
+ const quoted = originalMemoToQuote(target);
476
+ const base = quoted !== null ? `void of '${quoted}' on ${target.date}` : `void of entry on ${target.date}`;
477
+ return reason && reason.trim() !== "" ? `${base}: ${reason}` : base;
478
+ }
479
+ /** Build the reversing pair for a voided entry. The `void` entry
480
+ * swaps debit / credit on every line so the net effect is zero;
481
+ * the `void-marker` is a zero-line entry that exists purely to
482
+ * carry the `voidedEntryId` reference and the user's reason. The
483
+ * marker keeps `listEntries` queries simple — filtering by
484
+ * `kind: "void-marker"` surfaces every voided id without scanning
485
+ * for matching pairs. */
486
+ function makeVoidEntries(target, reason, voidDate) {
487
+ const swappedLines = target.lines.map((line) => {
488
+ const swapped = {
489
+ accountCode: line.accountCode,
490
+ debit: line.credit,
491
+ credit: line.debit,
492
+ memo: line.memo
493
+ };
494
+ if (line.taxRegistrationId !== void 0) swapped.taxRegistrationId = line.taxRegistrationId;
495
+ return swapped;
496
+ });
497
+ return {
498
+ reverse: {
499
+ id: (0, node_crypto.randomUUID)(),
500
+ date: voidDate,
501
+ kind: "void",
502
+ lines: swappedLines,
503
+ memo: voidMemo(target, reason),
504
+ voidedEntryId: target.id,
505
+ voidReason: reason,
506
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
507
+ },
508
+ marker: {
509
+ id: (0, node_crypto.randomUUID)(),
510
+ date: voidDate,
511
+ kind: "void-marker",
512
+ lines: [],
513
+ voidedEntryId: target.id,
514
+ voidReason: reason,
515
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
516
+ }
517
+ };
518
+ }
519
+ /** Returns the set of entry ids that have been voided — built from
520
+ * every `void-marker` entry's `voidedEntryId`. Reports use this to
521
+ * exclude original-and-reverse pairs from the activity listing
522
+ * (the netting is automatic in B/S aggregates because the reverse
523
+ * entry has equal-and-opposite lines). */
524
+ function voidedIdSet(entries) {
525
+ const set = /* @__PURE__ */ new Set();
526
+ for (const entry of entries) if (entry.kind === "void-marker" && entry.voidedEntryId) set.add(entry.voidedEntryId);
527
+ return set;
528
+ }
529
+ //#endregion
530
+ //#region src/server/openingBalances.ts
531
+ var EQUALITY_TOLERANCE = .005;
532
+ /** Find the existing opening entry for a book, if any. Multiple
533
+ * openings shouldn't coexist (the route enforces void-then-append),
534
+ * but if they do the most recent by `createdAt` wins so callers
535
+ * always see one canonical opening. */
536
+ function findActiveOpening(entries) {
537
+ const voided = voidedIdSet(entries);
538
+ let active = null;
539
+ for (const entry of entries) {
540
+ if (entry.kind !== "opening") continue;
541
+ if (voided.has(entry.id)) continue;
542
+ if (!active || entry.createdAt > active.createdAt) active = entry;
543
+ }
544
+ return active;
545
+ }
546
+ function validateLineAccountTypes(input, errors) {
547
+ const accountByCode = new Map(input.accounts.map((account) => [account.code, account]));
548
+ input.lines.forEach((line, idx) => {
549
+ const acct = accountByCode.get(line.accountCode);
550
+ if (!acct) {
551
+ errors.push({
552
+ field: `lines[${idx}].accountCode`,
553
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
554
+ });
555
+ return;
556
+ }
557
+ if (!BALANCE_SHEET_ACCOUNT_TYPES.includes(acct.type)) errors.push({
558
+ field: `lines[${idx}].accountCode`,
559
+ message: `account ${acct.code} is type ${acct.type}; opening balances may only reference balance-sheet accounts (asset / liability / equity)`
560
+ });
561
+ });
562
+ }
563
+ function validateAsOfPredatesEverything(input, errors) {
564
+ const voided = voidedIdSet(input.existingEntries);
565
+ for (const entry of input.existingEntries) {
566
+ if (entry.kind === "opening") continue;
567
+ if (entry.kind === "void-marker") continue;
568
+ if (voided.has(entry.id)) continue;
569
+ if (entry.date < input.asOfDate) {
570
+ errors.push({
571
+ field: "asOfDate",
572
+ message: `cannot set opening as of ${input.asOfDate}: existing entry ${entry.id} dated ${entry.date} is older. Void it first or pick an earlier asOfDate.`
573
+ });
574
+ break;
575
+ }
576
+ }
577
+ }
578
+ /** Validate inputs for `setOpeningBalances`. Caller passes the full
579
+ * list of journal entries in the book so we can check the
580
+ * "asOfDate must precede every other entry" rule. An opening with
581
+ * zero lines is accepted as a no-op marker — it satisfies the
582
+ * "book has an opening" gate the UI uses without committing the
583
+ * user to specific balances on day one (they can replace it
584
+ * later). */
585
+ function validateOpening(input) {
586
+ const errors = [];
587
+ if (!isValidCalendarDate(input.asOfDate)) errors.push({
588
+ field: "asOfDate",
589
+ message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.asOfDate)}`
590
+ });
591
+ if (!Array.isArray(input.lines)) {
592
+ errors.push({
593
+ field: "lines",
594
+ message: "lines must be an array"
595
+ });
596
+ return {
597
+ ok: false,
598
+ errors
599
+ };
600
+ }
601
+ validateLineAccountTypes(input, errors);
602
+ const net = netBalance(input.lines);
603
+ if (Math.abs(net) > EQUALITY_TOLERANCE) errors.push({
604
+ field: "lines",
605
+ message: `Σ debit − Σ credit = ${net.toFixed(4)}; opening must balance`
606
+ });
607
+ validateAsOfPredatesEverything(input, errors);
608
+ return {
609
+ ok: errors.length === 0,
610
+ errors
611
+ };
612
+ }
613
+ //#endregion
614
+ //#region src/server/accountNormalize.ts
615
+ function normalizeStoredAccount(input, existing) {
616
+ const stored = {
617
+ code: input.code,
618
+ name: input.name,
619
+ type: input.type
620
+ };
621
+ if (typeof input.note === "string" && input.note.length > 0) stored.note = input.note;
622
+ const inheritInactive = input.active === void 0 && existing?.active === false;
623
+ if (input.active === false || inheritInactive) stored.active = false;
624
+ return stored;
625
+ }
626
+ //#endregion
627
+ //#region src/server/report.ts
628
+ var ZERO_TOLERANCE = .0049;
629
+ /** Returns net (debit − credit) per account across the supplied
630
+ * entries. Voids work by having an original + reverse pair that
631
+ * cancel mathematically — both are included in aggregation (their
632
+ * contributions sum to zero). The `void-marker` entries carry no
633
+ * lines, so excluding them is just a formality.
634
+ *
635
+ * Why not "exclude original via voidedIdSet"? Because then the
636
+ * reverse half would remain unmatched, and the net would be the
637
+ * original's amount with the wrong sign. Letting the math cancel
638
+ * naturally is simpler and impossible to get wrong. */
639
+ function aggregateBalances(entries) {
640
+ const map = /* @__PURE__ */ new Map();
641
+ for (const entry of entries) {
642
+ if (entry.kind === "void-marker") continue;
643
+ for (const line of entry.lines) {
644
+ const cur = map.get(line.accountCode) ?? 0;
645
+ const debit = line.debit ?? 0;
646
+ const credit = line.credit ?? 0;
647
+ map.set(line.accountCode, cur + debit - credit);
648
+ }
649
+ }
650
+ return Array.from(map.entries()).map(([accountCode, netDebit]) => ({
651
+ accountCode,
652
+ netDebit
653
+ })).sort((lhs, rhs) => lhs.accountCode.localeCompare(rhs.accountCode));
654
+ }
655
+ function naturalSign$1(type, netDebit) {
656
+ if (type === "asset" || type === "expense") return netDebit;
657
+ return -netDebit;
658
+ }
659
+ /** Sentinel `accountCode` for the synthetic "Current period
660
+ * earnings" row added to the Equity section by `buildBalanceSheet`.
661
+ * The View detects this code and substitutes a localised label
662
+ * for the fixed English fallback. */
663
+ var CURRENT_EARNINGS_ACCOUNT_CODE = "_currentEarnings";
664
+ function computeCurrentEarnings(accounts, balanceByCode) {
665
+ let earnings = 0;
666
+ for (const account of accounts) {
667
+ if (account.type !== "income" && account.type !== "expense") continue;
668
+ const presented = naturalSign$1(account.type, balanceByCode.get(account.code) ?? 0);
669
+ earnings += account.type === "income" ? presented : -presented;
670
+ }
671
+ return earnings;
672
+ }
673
+ function buildBalanceSheet(input) {
674
+ const balanceByCode = new Map(input.balances.map((row) => [row.accountCode, row.netDebit]));
675
+ const currentEarnings = computeCurrentEarnings(input.accounts, balanceByCode);
676
+ const sections = [];
677
+ for (const type of [
678
+ "asset",
679
+ "liability",
680
+ "equity"
681
+ ]) {
682
+ const rows = [];
683
+ let total = 0;
684
+ for (const account of input.accounts) {
685
+ if (account.type !== type) continue;
686
+ const presented = naturalSign$1(type, balanceByCode.get(account.code) ?? 0);
687
+ if (Math.abs(presented) <= ZERO_TOLERANCE) continue;
688
+ rows.push({
689
+ accountCode: account.code,
690
+ accountName: account.name,
691
+ balance: presented
692
+ });
693
+ total += presented;
694
+ }
695
+ if (type === "equity" && Math.abs(currentEarnings) > ZERO_TOLERANCE) {
696
+ rows.push({
697
+ accountCode: CURRENT_EARNINGS_ACCOUNT_CODE,
698
+ accountName: "Current period earnings",
699
+ balance: currentEarnings
700
+ });
701
+ total += currentEarnings;
702
+ }
703
+ sections.push({
704
+ type,
705
+ rows,
706
+ total
707
+ });
708
+ }
709
+ const assetTotal = sections[0].total;
710
+ const liabEquityTotal = sections[1].total + sections[2].total;
711
+ return {
712
+ asOf: input.asOf,
713
+ sections,
714
+ imbalance: assetTotal - liabEquityTotal
715
+ };
716
+ }
717
+ function buildProfitLoss(input) {
718
+ const balances = aggregateBalances(input.entries.filter((entry) => entry.date >= input.from && entry.date <= input.to));
719
+ const balanceByCode = new Map(balances.map((row) => [row.accountCode, row.netDebit]));
720
+ const incomeRows = [];
721
+ const expenseRows = [];
722
+ let incomeTotal = 0;
723
+ let expenseTotal = 0;
724
+ for (const account of input.accounts) {
725
+ const netDebit = balanceByCode.get(account.code) ?? 0;
726
+ const presented = naturalSign$1(account.type, netDebit);
727
+ if (Math.abs(presented) <= ZERO_TOLERANCE) continue;
728
+ if (account.type === "income") {
729
+ incomeRows.push({
730
+ accountCode: account.code,
731
+ accountName: account.name,
732
+ amount: presented
733
+ });
734
+ incomeTotal += presented;
735
+ } else if (account.type === "expense") {
736
+ expenseRows.push({
737
+ accountCode: account.code,
738
+ accountName: account.name,
739
+ amount: presented
740
+ });
741
+ expenseTotal += presented;
742
+ }
743
+ }
744
+ return {
745
+ from: input.from,
746
+ to: input.to,
747
+ income: {
748
+ rows: incomeRows,
749
+ total: incomeTotal
750
+ },
751
+ expense: {
752
+ rows: expenseRows,
753
+ total: expenseTotal
754
+ },
755
+ netIncome: incomeTotal - expenseTotal
756
+ };
757
+ }
758
+ /** Concatenate the entry-level memo (the *what-happened*) with the
759
+ * line-level memo (the *why-this-account*) so a per-account ledger
760
+ * view shows both. Without this combine, a Sales Tax Receivable
761
+ * ledger row would show "仮払消費税 10%" but lose the originating
762
+ * "Starbucks Tokyo — coffee" — and the user can't tell which
763
+ * transaction the row came from. Identity-collapse handles the
764
+ * case where someone set both fields to the same string. */
765
+ function combineMemo(entryMemo, lineMemo) {
766
+ if (!entryMemo) return lineMemo;
767
+ if (!lineMemo) return entryMemo;
768
+ if (entryMemo === lineMemo) return entryMemo;
769
+ return `${entryMemo} · ${lineMemo}`;
770
+ }
771
+ function accumulateLedgerEntry(entry, accountCode, fromDate, toDate, acc) {
772
+ if (entry.kind === "void-marker") return;
773
+ for (const line of entry.lines) {
774
+ if (line.accountCode !== accountCode) continue;
775
+ const debit = line.debit ?? 0;
776
+ const credit = line.credit ?? 0;
777
+ acc.running += debit - credit;
778
+ if (fromDate && entry.date < fromDate) continue;
779
+ if (toDate && entry.date > toDate) continue;
780
+ const row = {
781
+ entryId: entry.id,
782
+ date: entry.date,
783
+ kind: entry.kind,
784
+ memo: combineMemo(entry.memo, line.memo),
785
+ debit,
786
+ credit,
787
+ runningBalance: acc.running
788
+ };
789
+ if (line.taxRegistrationId !== void 0) row.taxRegistrationId = line.taxRegistrationId;
790
+ acc.rows.push(row);
791
+ }
792
+ }
793
+ function buildLedger(input) {
794
+ const sorted = [...input.entries].sort((lhs, rhs) => lhs.date === rhs.date ? lhs.createdAt.localeCompare(rhs.createdAt) : lhs.date.localeCompare(rhs.date));
795
+ const acc = {
796
+ rows: [],
797
+ running: 0
798
+ };
799
+ for (const entry of sorted) accumulateLedgerEntry(entry, input.account.code, input.from, input.to, acc);
800
+ return {
801
+ accountCode: input.account.code,
802
+ accountName: input.account.name,
803
+ rows: acc.rows,
804
+ closingBalance: acc.running
805
+ };
806
+ }
807
+ //#endregion
808
+ //#region src/server/timeSeries.ts
809
+ function pad2(num) {
810
+ return String(num).padStart(2, "0");
811
+ }
812
+ function fmtYmd(year, month, day) {
813
+ return `${year}-${pad2(month)}-${pad2(day)}`;
814
+ }
815
+ function parseYmd(value) {
816
+ const [year, month, day] = value.split("-").map((segment) => parseInt(segment, 10));
817
+ return {
818
+ year,
819
+ month,
820
+ day
821
+ };
822
+ }
823
+ function lastDayOf(year, month) {
824
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
825
+ }
826
+ function addDay(date) {
827
+ const stepped = new Date(Date.UTC(date.year, date.month - 1, date.day + 1));
828
+ return {
829
+ year: stepped.getUTCFullYear(),
830
+ month: stepped.getUTCMonth() + 1,
831
+ day: stepped.getUTCDate()
832
+ };
833
+ }
834
+ function fyAnchorFor(date, end) {
835
+ const closingMonth = require_shared.fiscalYearEndMonth(end);
836
+ const startMonth = closingMonth % 12 + 1;
837
+ const fyStartYear = date.month >= startMonth ? date.year : date.year - 1;
838
+ return {
839
+ fyStartYear,
840
+ fyEndYear: closingMonth === 12 ? fyStartYear : fyStartYear + 1,
841
+ startMonth
842
+ };
843
+ }
844
+ function monthBucketContaining(date) {
845
+ return {
846
+ from: fmtYmd(date.year, date.month, 1),
847
+ to: fmtYmd(date.year, date.month, lastDayOf(date.year, date.month)),
848
+ label: `${date.year}-${pad2(date.month)}`
849
+ };
850
+ }
851
+ function quarterBucketContaining(date, end) {
852
+ const anchor = fyAnchorFor(date, end);
853
+ const offset = (date.month - anchor.startMonth + 12) % 12;
854
+ const qIdx = Math.floor(offset / 3);
855
+ const startFlat = anchor.fyStartYear * 12 + (anchor.startMonth - 1) + qIdx * 3;
856
+ const endFlat = startFlat + 2;
857
+ const qStartYear = Math.floor(startFlat / 12);
858
+ const qStartMonth = startFlat % 12 + 1;
859
+ const qEndYear = Math.floor(endFlat / 12);
860
+ const qEndMonth = endFlat % 12 + 1;
861
+ return {
862
+ from: fmtYmd(qStartYear, qStartMonth, 1),
863
+ to: fmtYmd(qEndYear, qEndMonth, lastDayOf(qEndYear, qEndMonth)),
864
+ label: `FY${anchor.fyEndYear}-Q${qIdx + 1}`
865
+ };
866
+ }
867
+ function yearBucketContaining(date, end) {
868
+ const anchor = fyAnchorFor(date, end);
869
+ const startFlat = anchor.fyStartYear * 12 + (anchor.startMonth - 1);
870
+ const endFlat = startFlat + 11;
871
+ const yStartYear = Math.floor(startFlat / 12);
872
+ const yStartMonth = startFlat % 12 + 1;
873
+ const yEndYear = Math.floor(endFlat / 12);
874
+ const yEndMonth = endFlat % 12 + 1;
875
+ return {
876
+ from: fmtYmd(yStartYear, yStartMonth, 1),
877
+ to: fmtYmd(yEndYear, yEndMonth, lastDayOf(yEndYear, yEndMonth)),
878
+ label: `FY${anchor.fyEndYear}`
879
+ };
880
+ }
881
+ function bucketContaining(date, granularity, end) {
882
+ if (granularity === "month") return monthBucketContaining(date);
883
+ if (granularity === "quarter") return quarterBucketContaining(date, end);
884
+ return yearBucketContaining(date, end);
885
+ }
886
+ /** Walk inclusive `[from, to]` and return every bucket that overlaps
887
+ * it, ordered ascending by `from`. The first bucket is the one
888
+ * CONTAINING `from` — it can extend earlier than `from`; the last
889
+ * bucket is the one CONTAINING `to` — it can extend past `to`. The
890
+ * caller's response echoes the input range so the LLM can label the
891
+ * chart truthfully ("Revenue Apr 2025 – Sep 2026" even though the
892
+ * outermost buckets cover Apr-Jun 2025 and Jul-Sep 2026). */
893
+ function bucketize(input) {
894
+ if (input.from > input.to) return [];
895
+ const start = parseYmd(input.from);
896
+ const result = [];
897
+ let bucket = bucketContaining(start, input.granularity, input.fiscalYearEnd);
898
+ while (bucket.from <= input.to) {
899
+ result.push(bucket);
900
+ bucket = bucketContaining(addDay(parseYmd(bucket.to)), input.granularity, input.fiscalYearEnd);
901
+ }
902
+ return result;
903
+ }
904
+ /** Convert raw netDebit to natural-sign presentation per account
905
+ * type. Mirrors the helper in `report.ts` (kept private there). */
906
+ function naturalSign(type, netDebit) {
907
+ if (type === "asset" || type === "expense") return netDebit;
908
+ return -netDebit;
909
+ }
910
+ /** Sum presented income and expense values across the supplied
911
+ * entries. Used for window-based metrics (revenue / expense /
912
+ * netIncome). Opening entries reference B/S accounts only and so
913
+ * contribute zero to either total — including them is harmless. */
914
+ function presentedPlTotals(entries, accountTypeByCode) {
915
+ const balances = aggregateBalances(entries);
916
+ let income = 0;
917
+ let expense = 0;
918
+ for (const row of balances) {
919
+ const type = accountTypeByCode.get(row.accountCode);
920
+ if (!type) continue;
921
+ if (type === "income") income += naturalSign(type, row.netDebit);
922
+ else if (type === "expense") expense += naturalSign(type, row.netDebit);
923
+ }
924
+ return {
925
+ income,
926
+ expense
927
+ };
928
+ }
929
+ function entriesInWindow(entries, from, toDate) {
930
+ return entries.filter((entry) => entry.date >= from && entry.date <= toDate);
931
+ }
932
+ function entriesUpTo(entries, toDate) {
933
+ return entries.filter((entry) => entry.date <= toDate);
934
+ }
935
+ function valueForBucket(input) {
936
+ const accountTypeByCode = new Map(input.accounts.map((acct) => [acct.code, acct.type]));
937
+ if (input.metric === "accountBalance") {
938
+ const code = input.accountCode;
939
+ if (!code) return 0;
940
+ const type = accountTypeByCode.get(code);
941
+ if (!type) return 0;
942
+ const row = aggregateBalances(entriesUpTo(input.entries, input.bucket.to)).find((balance) => balance.accountCode === code);
943
+ return row ? naturalSign(type, row.netDebit) : 0;
944
+ }
945
+ const totals = presentedPlTotals(entriesInWindow(input.entries, input.bucket.from, input.bucket.to), accountTypeByCode);
946
+ if (input.metric === "revenue") return totals.income;
947
+ if (input.metric === "expense") return totals.expense;
948
+ return totals.income - totals.expense;
949
+ }
950
+ function buildTimeSeries(input) {
951
+ return input.buckets.map((bucket) => ({
952
+ label: bucket.label,
953
+ from: bucket.from,
954
+ to: bucket.to,
955
+ value: valueForBucket({
956
+ bucket,
957
+ entries: input.entries,
958
+ accounts: input.accounts,
959
+ metric: input.metric,
960
+ accountCode: input.accountCode
961
+ })
962
+ }));
963
+ }
964
+ //#endregion
965
+ //#region src/server/eventPublisher.ts
966
+ var pubsub = null;
967
+ function initAccountingEventPublisher(instance) {
968
+ pubsub = instance;
969
+ }
970
+ function safePublish(channel, payload) {
971
+ if (!pubsub) return;
972
+ try {
973
+ pubsub.publish(channel, payload);
974
+ } catch (err) {
975
+ log.warn("accounting", "publish failed; subscribers will miss this event", {
976
+ channel,
977
+ error: require_shared.errorMessage(err)
978
+ });
979
+ }
980
+ }
981
+ /** Per-book change notification. `period` should be the entry's
982
+ * YYYY-MM bucket (or the earliest invalidated month for snapshot
983
+ * events). */
984
+ function publishBookChange(bookId, payload) {
985
+ safePublish(require_shared.bookChannel(bookId), payload);
986
+ }
987
+ /** Fired when the *list* of books changes (createBook, deleteBook).
988
+ * Payload is intentionally empty — subscribers refetch from
989
+ * /api/accounting. */
990
+ function publishBooksChanged() {
991
+ safePublish(require_shared.ACCOUNTING_BOOKS_CHANNEL, {});
992
+ }
993
+ //#endregion
994
+ //#region src/server/snapshotCache.ts
995
+ function previousPeriod(period) {
996
+ const [year, month] = period.split("-").map((segment) => parseInt(segment, 10));
997
+ if (month === 1) return `${(year - 1).toString().padStart(4, "0")}-12`;
998
+ return `${year.toString().padStart(4, "0")}-${(month - 1).toString().padStart(2, "0")}`;
999
+ }
1000
+ function mergeBalances(base, delta) {
1001
+ const map = /* @__PURE__ */ new Map();
1002
+ for (const row of base) map.set(row.accountCode, row.netDebit);
1003
+ for (const row of delta) map.set(row.accountCode, (map.get(row.accountCode) ?? 0) + row.netDebit);
1004
+ return Array.from(map.entries()).map(([accountCode, netDebit]) => ({
1005
+ accountCode,
1006
+ netDebit
1007
+ })).sort((lhs, rhs) => lhs.accountCode.localeCompare(rhs.accountCode));
1008
+ }
1009
+ async function buildEmptySnapshot(bookId, period, workspaceRoot) {
1010
+ const empty = {
1011
+ period,
1012
+ balances: [],
1013
+ builtAt: (/* @__PURE__ */ new Date()).toISOString()
1014
+ };
1015
+ await writeSnapshot(bookId, empty, workspaceRoot);
1016
+ return empty;
1017
+ }
1018
+ /** Build a snapshot at end-of-`period` for one book, lazily relying
1019
+ * on the previous month's snapshot if it exists. Falls all the way
1020
+ * back to the earliest journal month if no upstream snapshot is
1021
+ * available. Always writes the result to disk before returning. */
1022
+ async function getOrBuildSnapshot(bookId, period, workspaceRoot) {
1023
+ const cached = await readSnapshot(bookId, period, workspaceRoot);
1024
+ if (cached) return cached;
1025
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1026
+ if (periods.length === 0 || period < periods[0]) return buildEmptySnapshot(bookId, period, workspaceRoot);
1027
+ const { entries } = await readJournalMonth(bookId, period, workspaceRoot);
1028
+ const monthDelta = aggregateBalances(entries);
1029
+ let priorBalances = [];
1030
+ if (period > periods[0]) priorBalances = (await getOrBuildSnapshot(bookId, previousPeriod(period), workspaceRoot)).balances;
1031
+ const snap = {
1032
+ period,
1033
+ balances: mergeBalances(priorBalances, monthDelta),
1034
+ builtAt: (/* @__PURE__ */ new Date()).toISOString()
1035
+ };
1036
+ await writeSnapshot(bookId, snap, workspaceRoot);
1037
+ return snap;
1038
+ }
1039
+ /** Compute closing balances at end-of-`period` from journal alone,
1040
+ * bypassing the snapshot cache. Used by the byte-equality
1041
+ * invariant test, and as a safety net for "compute without
1042
+ * trusting cache" paths. */
1043
+ async function balancesAtEndOf(bookId, period, workspaceRoot) {
1044
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1045
+ const all = [];
1046
+ for (const monthKey of periods) {
1047
+ if (period < monthKey) break;
1048
+ const { entries } = await readJournalMonth(bookId, monthKey, workspaceRoot);
1049
+ for (const entry of entries) all.push(entry);
1050
+ }
1051
+ return aggregateBalances(all);
1052
+ }
1053
+ /** Drop all snapshots and rebuild from scratch. Used by the
1054
+ * `rebuildSnapshots` admin action. Returns the periods that were
1055
+ * rebuilt. */
1056
+ async function rebuildAllSnapshots(bookId, workspaceRoot) {
1057
+ await invalidateAllSnapshots(bookId, workspaceRoot);
1058
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1059
+ for (const monthKey of periods) await getOrBuildSnapshot(bookId, monthKey, workspaceRoot);
1060
+ return { rebuilt: periods };
1061
+ }
1062
+ var rebuildQueues = /* @__PURE__ */ new Map();
1063
+ function minPeriod(lhs, rhs) {
1064
+ if (lhs === null) return rhs;
1065
+ return lhs < rhs ? lhs : rhs;
1066
+ }
1067
+ function isInvalidatedDuringRebuild(bookId, period) {
1068
+ const queue = rebuildQueues.get(bookId);
1069
+ return queue !== void 0 && queue.pendingFromPeriod !== null && period >= queue.pendingFromPeriod;
1070
+ }
1071
+ function isCancelled(bookId) {
1072
+ return rebuildQueues.get(bookId)?.cancelled === true;
1073
+ }
1074
+ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1075
+ const startedAt = Date.now();
1076
+ log.info("accounting", "snapshot rebuild started", {
1077
+ bookId,
1078
+ fromPeriod
1079
+ });
1080
+ publishBookChange(bookId, {
1081
+ kind: require_shared.BOOK_EVENT_KINDS.snapshotsRebuilding,
1082
+ period: fromPeriod
1083
+ });
1084
+ const targets = (await listJournalPeriods(bookId, workspaceRoot)).filter((monthKey) => monthKey >= fromPeriod);
1085
+ let written = 0;
1086
+ for (const monthKey of targets) {
1087
+ if (isCancelled(bookId)) break;
1088
+ if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1089
+ const balances = await balancesAtEndOf(bookId, monthKey, workspaceRoot);
1090
+ if (isCancelled(bookId)) break;
1091
+ if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1092
+ await writeSnapshot(bookId, {
1093
+ period: monthKey,
1094
+ balances,
1095
+ builtAt: (/* @__PURE__ */ new Date()).toISOString()
1096
+ }, workspaceRoot);
1097
+ if (isCancelled(bookId)) {
1098
+ await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1099
+ break;
1100
+ }
1101
+ if (isInvalidatedDuringRebuild(bookId, monthKey)) {
1102
+ await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1103
+ break;
1104
+ }
1105
+ written += 1;
1106
+ publishBookChange(bookId, {
1107
+ kind: require_shared.BOOK_EVENT_KINDS.snapshotsReady,
1108
+ period: monthKey
1109
+ });
1110
+ }
1111
+ log.info("accounting", "snapshot rebuild done", {
1112
+ bookId,
1113
+ periods: written,
1114
+ durationMs: Date.now() - startedAt
1115
+ });
1116
+ }
1117
+ function startRebuild(bookId, fromPeriod, workspaceRoot) {
1118
+ const entry = {
1119
+ running: Promise.resolve(),
1120
+ pendingFromPeriod: null,
1121
+ pendingWorkspaceRoot: void 0,
1122
+ coalescedWriteCount: 1,
1123
+ runningFromPeriod: fromPeriod,
1124
+ cancelled: false
1125
+ };
1126
+ entry.running = runRebuild(bookId, fromPeriod, workspaceRoot).catch((err) => {
1127
+ log.error("accounting", "snapshot rebuild failed", {
1128
+ bookId,
1129
+ fromPeriod,
1130
+ error: require_shared.errorMessage(err)
1131
+ });
1132
+ }).then(() => {
1133
+ const current = rebuildQueues.get(bookId);
1134
+ if (!current) return;
1135
+ if (current.cancelled) {
1136
+ rebuildQueues.delete(bookId);
1137
+ return;
1138
+ }
1139
+ if (current.pendingFromPeriod !== null) {
1140
+ const nextFrom = current.pendingFromPeriod;
1141
+ const nextRoot = current.pendingWorkspaceRoot;
1142
+ const carriedCount = current.coalescedWriteCount;
1143
+ const successor = startRebuild(bookId, nextFrom, nextRoot);
1144
+ successor.coalescedWriteCount += carriedCount;
1145
+ rebuildQueues.set(bookId, successor);
1146
+ } else rebuildQueues.delete(bookId);
1147
+ });
1148
+ return entry;
1149
+ }
1150
+ /** Schedule a background rebuild for `bookId` starting at `fromPeriod`.
1151
+ * Multiple calls during an in-flight rebuild coalesce into a single
1152
+ * follow-up rebuild that covers the minimum `fromPeriod` seen.
1153
+ * Returns immediately — the rebuild runs on its own promise chain. */
1154
+ function scheduleRebuild(bookId, fromPeriod, workspaceRoot) {
1155
+ const existing = rebuildQueues.get(bookId);
1156
+ if (!existing) {
1157
+ rebuildQueues.set(bookId, startRebuild(bookId, fromPeriod, workspaceRoot));
1158
+ return;
1159
+ }
1160
+ existing.pendingFromPeriod = minPeriod(existing.pendingFromPeriod, fromPeriod);
1161
+ existing.pendingWorkspaceRoot = workspaceRoot;
1162
+ existing.coalescedWriteCount += 1;
1163
+ }
1164
+ /** Test/diagnostic: resolves when no rebuild is running or queued for
1165
+ * `bookId`. Also called by `deleteBook` after `cancelRebuild` to
1166
+ * ensure a previously running rebuild has fully stopped before the
1167
+ * caller removes the book's directory on disk. */
1168
+ async function awaitRebuildIdle(bookId) {
1169
+ while (rebuildQueues.has(bookId)) {
1170
+ const entry = rebuildQueues.get(bookId);
1171
+ if (!entry) return;
1172
+ await entry.running;
1173
+ }
1174
+ }
1175
+ /** Mark the book's in-flight rebuild as cancelled. The runRebuild
1176
+ * loop checks before each write and bails out, so a subsequent
1177
+ * `removeBookDir` cannot race with a `writeSnapshot` that would
1178
+ * re-create the directory tree. Pair with `awaitRebuildIdle(bookId)`
1179
+ * to wait for the in-flight rebuild to finish bailing. */
1180
+ function cancelRebuild(bookId) {
1181
+ const entry = rebuildQueues.get(bookId);
1182
+ if (!entry) return;
1183
+ entry.cancelled = true;
1184
+ entry.pendingFromPeriod = null;
1185
+ }
1186
+ //#endregion
1187
+ //#region src/server/defaultAccounts.ts
1188
+ var DEFAULT_ACCOUNTS = [
1189
+ {
1190
+ code: "1000",
1191
+ name: "Cash",
1192
+ type: "asset"
1193
+ },
1194
+ {
1195
+ code: "1001",
1196
+ name: "Petty Cash",
1197
+ type: "asset",
1198
+ active: false
1199
+ },
1200
+ {
1201
+ code: "1010",
1202
+ name: "Bank — Checking",
1203
+ type: "asset"
1204
+ },
1205
+ {
1206
+ code: "1020",
1207
+ name: "Bank — Savings",
1208
+ type: "asset"
1209
+ },
1210
+ {
1211
+ code: "1100",
1212
+ name: "Accounts Receivable",
1213
+ type: "asset"
1214
+ },
1215
+ {
1216
+ code: "1200",
1217
+ name: "Inventory",
1218
+ type: "asset",
1219
+ active: false
1220
+ },
1221
+ {
1222
+ code: "1300",
1223
+ name: "Prepaid Expenses",
1224
+ type: "asset",
1225
+ active: false
1226
+ },
1227
+ {
1228
+ code: "1400",
1229
+ name: "Input Tax Receivable",
1230
+ type: "asset"
1231
+ },
1232
+ {
1233
+ code: "1500",
1234
+ name: "Equipment",
1235
+ type: "asset"
1236
+ },
1237
+ {
1238
+ code: "1510",
1239
+ name: "Furniture & Fixtures",
1240
+ type: "asset",
1241
+ active: false
1242
+ },
1243
+ {
1244
+ code: "1520",
1245
+ name: "Vehicles",
1246
+ type: "asset",
1247
+ active: false
1248
+ },
1249
+ {
1250
+ code: "1590",
1251
+ name: "Accumulated Depreciation",
1252
+ type: "asset",
1253
+ active: false
1254
+ },
1255
+ {
1256
+ code: "2000",
1257
+ name: "Accounts Payable",
1258
+ type: "liability"
1259
+ },
1260
+ {
1261
+ code: "2100",
1262
+ name: "Credit Card",
1263
+ type: "liability"
1264
+ },
1265
+ {
1266
+ code: "2200",
1267
+ name: "Loans Payable",
1268
+ type: "liability"
1269
+ },
1270
+ {
1271
+ code: "2300",
1272
+ name: "Accrued Expenses",
1273
+ type: "liability",
1274
+ active: false
1275
+ },
1276
+ {
1277
+ code: "2400",
1278
+ name: "Sales Tax Payable",
1279
+ type: "liability"
1280
+ },
1281
+ {
1282
+ code: "2500",
1283
+ name: "Payroll Liabilities",
1284
+ type: "liability",
1285
+ active: false
1286
+ },
1287
+ {
1288
+ code: "3000",
1289
+ name: "Owner's Equity",
1290
+ type: "equity"
1291
+ },
1292
+ {
1293
+ code: "3100",
1294
+ name: "Retained Earnings",
1295
+ type: "equity"
1296
+ },
1297
+ {
1298
+ code: "3200",
1299
+ name: "Owner's Draws",
1300
+ type: "equity",
1301
+ active: false
1302
+ },
1303
+ {
1304
+ code: "4000",
1305
+ name: "Sales",
1306
+ type: "income"
1307
+ },
1308
+ {
1309
+ code: "4010",
1310
+ name: "Service Revenue",
1311
+ type: "income",
1312
+ active: false
1313
+ },
1314
+ {
1315
+ code: "4100",
1316
+ name: "Other Income",
1317
+ type: "income"
1318
+ },
1319
+ {
1320
+ code: "4200",
1321
+ name: "Interest Income",
1322
+ type: "income",
1323
+ active: false
1324
+ },
1325
+ {
1326
+ code: "4300",
1327
+ name: "Sales Returns & Discounts",
1328
+ type: "income",
1329
+ active: false
1330
+ },
1331
+ {
1332
+ code: "5000",
1333
+ name: "Cost of Goods Sold",
1334
+ type: "expense"
1335
+ },
1336
+ {
1337
+ code: "5100",
1338
+ name: "Rent",
1339
+ type: "expense"
1340
+ },
1341
+ {
1342
+ code: "5200",
1343
+ name: "Utilities",
1344
+ type: "expense"
1345
+ },
1346
+ {
1347
+ code: "5300",
1348
+ name: "Salaries",
1349
+ type: "expense"
1350
+ },
1351
+ {
1352
+ code: "5400",
1353
+ name: "Office Supplies",
1354
+ type: "expense"
1355
+ },
1356
+ {
1357
+ code: "5500",
1358
+ name: "Advertising & Marketing",
1359
+ type: "expense",
1360
+ active: false
1361
+ },
1362
+ {
1363
+ code: "5600",
1364
+ name: "Travel",
1365
+ type: "expense",
1366
+ active: false
1367
+ },
1368
+ {
1369
+ code: "5610",
1370
+ name: "Meals & Entertainment",
1371
+ type: "expense",
1372
+ active: false
1373
+ },
1374
+ {
1375
+ code: "5700",
1376
+ name: "Professional Fees",
1377
+ type: "expense",
1378
+ active: false
1379
+ },
1380
+ {
1381
+ code: "5710",
1382
+ name: "Insurance",
1383
+ type: "expense",
1384
+ active: false
1385
+ },
1386
+ {
1387
+ code: "5720",
1388
+ name: "Software & Subscriptions",
1389
+ type: "expense",
1390
+ active: false
1391
+ },
1392
+ {
1393
+ code: "5730",
1394
+ name: "Bank Fees",
1395
+ type: "expense",
1396
+ active: false
1397
+ },
1398
+ {
1399
+ code: "5800",
1400
+ name: "Depreciation Expense",
1401
+ type: "expense",
1402
+ active: false
1403
+ },
1404
+ {
1405
+ code: "5810",
1406
+ name: "Taxes",
1407
+ type: "expense",
1408
+ active: false
1409
+ },
1410
+ {
1411
+ code: "5900",
1412
+ name: "Miscellaneous Expense",
1413
+ type: "expense"
1414
+ }
1415
+ ];
1416
+ //#endregion
1417
+ //#region src/server/service.ts
1418
+ var AccountingError = class extends Error {
1419
+ status;
1420
+ details;
1421
+ constructor(status, message, details) {
1422
+ super(message);
1423
+ this.status = status;
1424
+ this.details = details;
1425
+ this.name = "AccountingError";
1426
+ }
1427
+ };
1428
+ var DEFAULT_CURRENCY = "USD";
1429
+ var GENERATED_ID_RETRIES = 8;
1430
+ function emptyConfig() {
1431
+ return { books: [] };
1432
+ }
1433
+ async function loadOrInitConfig(workspaceRoot) {
1434
+ return await readConfig(workspaceRoot) ?? emptyConfig();
1435
+ }
1436
+ function findBook(config, bookId) {
1437
+ return config.books.find((book) => book.id === bookId) ?? null;
1438
+ }
1439
+ function resolveBookId(config, requested) {
1440
+ if (!requested) throw new AccountingError(400, "bookId is required");
1441
+ if (!findBook(config, requested)) throw new AccountingError(404, `book ${JSON.stringify(requested)} not found`);
1442
+ return requested;
1443
+ }
1444
+ async function generateBookId(config, workspaceRoot) {
1445
+ for (let attempt = 0; attempt < GENERATED_ID_RETRIES; attempt += 1) {
1446
+ const candidate = `book-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
1447
+ if (!findBook(config, candidate) && !await bookExists(candidate, workspaceRoot)) return candidate;
1448
+ }
1449
+ throw new AccountingError(500, "could not generate a unique book id after several attempts");
1450
+ }
1451
+ /** Read every journal entry across every month, in period-sorted
1452
+ * order. Used by paths that need a full-history view (opening
1453
+ * balance lookups, P/L date filtering). */
1454
+ async function readAllEntries(bookId, workspaceRoot) {
1455
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1456
+ const all = [];
1457
+ for (const monthKey of periods) {
1458
+ const { entries, skipped } = await readJournalMonth(bookId, monthKey, workspaceRoot);
1459
+ for (const entry of entries) all.push(entry);
1460
+ if (skipped > 0) log.warn("accounting", "journal month had unparseable lines", {
1461
+ bookId,
1462
+ period: monthKey,
1463
+ skipped
1464
+ });
1465
+ }
1466
+ return all;
1467
+ }
1468
+ async function listBooks(workspaceRoot) {
1469
+ return { books: (await loadOrInitConfig(workspaceRoot)).books };
1470
+ }
1471
+ function unsupportedCountryError(received) {
1472
+ return new AccountingError(400, `unsupported country code ${JSON.stringify(received)} — must be one of: ${require_shared.SUPPORTED_COUNTRY_CODES.join(", ")}`);
1473
+ }
1474
+ function unsupportedFiscalYearEndError(received) {
1475
+ return new AccountingError(400, `unsupported fiscalYearEnd ${JSON.stringify(received)} — must be one of: ${require_shared.FISCAL_YEAR_ENDS.join(", ")}`);
1476
+ }
1477
+ /** Narrow a free-form `fiscalYearEnd` input. Empty / absent → default
1478
+ * (back-compat with old callers and pre-field on-disk books); any
1479
+ * other value must match the enum or 400. */
1480
+ function narrowFiscalYearEnd(raw) {
1481
+ if (raw === void 0 || raw === "") return "Q4";
1482
+ if (!require_shared.isFiscalYearEnd(raw)) throw unsupportedFiscalYearEndError(raw);
1483
+ return raw;
1484
+ }
1485
+ /** Boundary checks shared by updateBook. Throws on the first failure
1486
+ * so the surrounding function stays under the cognitive-complexity
1487
+ * threshold; each rule is also unit-testable independently via the
1488
+ * service entry point. */
1489
+ function validateUpdateBookInput(input) {
1490
+ 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");
1491
+ if (input.country !== void 0 && input.country !== "" && !require_shared.isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1492
+ if (input.fiscalYearEnd !== void 0 && input.fiscalYearEnd !== "" && !require_shared.isFiscalYearEnd(input.fiscalYearEnd)) throw unsupportedFiscalYearEndError(input.fiscalYearEnd);
1493
+ }
1494
+ async function createBook(input, workspaceRoot) {
1495
+ if (typeof input.name !== "string" || input.name.trim() === "") throw new AccountingError(400, "name is required");
1496
+ if (input.country !== void 0 && !require_shared.isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1497
+ const fiscalYearEnd = narrowFiscalYearEnd(input.fiscalYearEnd);
1498
+ const config = await loadOrInitConfig(workspaceRoot);
1499
+ const bookId = input.id ?? await generateBookId(config, workspaceRoot);
1500
+ if (!isSafeBookId(bookId)) throw new AccountingError(400, `invalid book id ${JSON.stringify(bookId)} — allowed characters are A-Z a-z 0-9 _ - (1-64 chars; cannot start with _ or -)`);
1501
+ if (findBook(config, bookId)) throw new AccountingError(409, `book ${JSON.stringify(bookId)} already exists`);
1502
+ if (await bookExists(bookId, workspaceRoot)) throw new AccountingError(409, `book directory ${JSON.stringify(bookId)} already exists on disk`);
1503
+ const book = {
1504
+ id: bookId,
1505
+ name: input.name,
1506
+ currency: input.currency ?? DEFAULT_CURRENCY,
1507
+ ...input.country ? { country: input.country } : {},
1508
+ fiscalYearEnd,
1509
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1510
+ };
1511
+ await ensureBookDir(bookId, workspaceRoot);
1512
+ await writeAccounts(bookId, [...DEFAULT_ACCOUNTS], workspaceRoot);
1513
+ await writeConfig({ books: [...config.books, book] }, workspaceRoot);
1514
+ publishBooksChanged();
1515
+ return { book };
1516
+ }
1517
+ async function updateBook(input, workspaceRoot) {
1518
+ const config = await loadOrInitConfig(workspaceRoot);
1519
+ const target = findBook(config, input.bookId);
1520
+ if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1521
+ validateUpdateBookInput(input);
1522
+ const next = {
1523
+ ...target,
1524
+ ...input.name !== void 0 ? { name: input.name } : {},
1525
+ ...input.country !== void 0 && input.country !== "" ? { country: input.country } : {},
1526
+ ...input.fiscalYearEnd !== void 0 && input.fiscalYearEnd !== "" ? { fiscalYearEnd: input.fiscalYearEnd } : {}
1527
+ };
1528
+ if (input.country === "") delete next.country;
1529
+ await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1530
+ publishBooksChanged();
1531
+ return { book: next };
1532
+ }
1533
+ async function deleteBook(input, workspaceRoot) {
1534
+ if (!input.confirm) throw new AccountingError(400, "deleteBook requires confirm: true");
1535
+ const config = await loadOrInitConfig(workspaceRoot);
1536
+ const target = findBook(config, input.bookId);
1537
+ if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1538
+ cancelRebuild(input.bookId);
1539
+ await awaitRebuildIdle(input.bookId);
1540
+ await removeBookDir(input.bookId, workspaceRoot);
1541
+ await writeConfig({ books: config.books.filter((book) => book.id !== input.bookId) }, workspaceRoot);
1542
+ publishBooksChanged();
1543
+ return {
1544
+ deletedBookId: input.bookId,
1545
+ deletedBookName: target.name
1546
+ };
1547
+ }
1548
+ async function listAccounts(input, workspaceRoot) {
1549
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1550
+ return {
1551
+ bookId,
1552
+ accounts: await readAccounts(bookId, workspaceRoot)
1553
+ };
1554
+ }
1555
+ async function upsertAccount(input, workspaceRoot) {
1556
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1557
+ if (typeof input.account?.code !== "string" || input.account.code.length === 0) throw new AccountingError(400, "account code is required");
1558
+ if (input.account.code.startsWith("_")) throw new AccountingError(400, `account code ${JSON.stringify(input.account.code)} is reserved (codes starting with _ are used for synthetic report rows)`);
1559
+ const accounts = await readAccounts(bookId, workspaceRoot);
1560
+ const existingIdx = accounts.findIndex((account) => account.code === input.account.code);
1561
+ const next = [...accounts];
1562
+ const oldType = existingIdx >= 0 ? accounts[existingIdx].type : null;
1563
+ const stored = normalizeStoredAccount(input.account, existingIdx >= 0 ? accounts[existingIdx] : void 0);
1564
+ if (existingIdx >= 0) next[existingIdx] = stored;
1565
+ else next.push(stored);
1566
+ await writeAccounts(bookId, next, workspaceRoot);
1567
+ if (oldType !== null && oldType !== input.account.type) {
1568
+ scheduleRebuild(bookId, "0000-00", workspaceRoot);
1569
+ await invalidateAllSnapshots(bookId, workspaceRoot);
1570
+ }
1571
+ publishBookChange(bookId, { kind: require_shared.BOOK_EVENT_KINDS.accounts });
1572
+ return {
1573
+ bookId,
1574
+ account: { ...input.account },
1575
+ accounts: next
1576
+ };
1577
+ }
1578
+ function collectBatchValidationFailures(items, accounts) {
1579
+ const failures = [];
1580
+ for (let idx = 0; idx < items.length; idx++) {
1581
+ const item = items[idx];
1582
+ const validation = validateEntry({
1583
+ date: item.date,
1584
+ lines: item.lines,
1585
+ accounts
1586
+ });
1587
+ if (!validation.ok) failures.push({
1588
+ index: idx,
1589
+ errors: validation.errors
1590
+ });
1591
+ }
1592
+ return failures;
1593
+ }
1594
+ function buildBatchEntries(items) {
1595
+ return items.map((item) => makeEntry({
1596
+ date: item.date,
1597
+ lines: item.lines,
1598
+ memo: item.memo,
1599
+ kind: "normal",
1600
+ replacesEntryId: item.replacesEntryId
1601
+ }));
1602
+ }
1603
+ function earliestPeriodOf(entries) {
1604
+ return entries.map((entry) => periodFromDate(entry.date)).reduce((min, period) => period < min ? period : min);
1605
+ }
1606
+ async function addEntries(input, workspaceRoot) {
1607
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1608
+ if (!Array.isArray(input.entries) || input.entries.length === 0) throw new AccountingError(400, "addEntries: entries must be a non-empty array");
1609
+ const accounts = await readAccounts(bookId, workspaceRoot);
1610
+ const failures = collectBatchValidationFailures(input.entries, accounts);
1611
+ if (failures.length > 0) throw new AccountingError(400, "invalid journal entries", failures);
1612
+ const built = buildBatchEntries(input.entries);
1613
+ await appendJournalBatch(bookId, built, workspaceRoot);
1614
+ const earliestPeriod = earliestPeriodOf(built);
1615
+ scheduleRebuild(bookId, earliestPeriod, workspaceRoot);
1616
+ await invalidateSnapshotsFrom(bookId, earliestPeriod, workspaceRoot);
1617
+ publishBookChange(bookId, {
1618
+ kind: require_shared.BOOK_EVENT_KINDS.journal,
1619
+ period: earliestPeriod
1620
+ });
1621
+ return {
1622
+ bookId,
1623
+ entries: built
1624
+ };
1625
+ }
1626
+ async function findEntryById(bookId, entryId, workspaceRoot) {
1627
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1628
+ for (const monthKey of periods) {
1629
+ const { entries } = await readJournalMonth(bookId, monthKey, workspaceRoot);
1630
+ const hit = entries.find((entry) => entry.id === entryId);
1631
+ if (hit) return hit;
1632
+ }
1633
+ return null;
1634
+ }
1635
+ async function voidEntry(input, workspaceRoot) {
1636
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1637
+ const target = await findEntryById(bookId, input.entryId, workspaceRoot);
1638
+ if (!target) throw new AccountingError(404, `entry ${JSON.stringify(input.entryId)} not found`);
1639
+ const voidDate = input.voidDate ?? localDateString();
1640
+ const { reverse, marker } = makeVoidEntries(target, input.reason, voidDate);
1641
+ await appendJournal(bookId, reverse, workspaceRoot);
1642
+ await appendJournal(bookId, marker, workspaceRoot);
1643
+ const fromPeriod = target.date < voidDate ? periodFromDate(target.date) : periodFromDate(voidDate);
1644
+ scheduleRebuild(bookId, fromPeriod, workspaceRoot);
1645
+ await invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot);
1646
+ publishBookChange(bookId, {
1647
+ kind: require_shared.BOOK_EVENT_KINDS.journal,
1648
+ period: fromPeriod
1649
+ });
1650
+ return {
1651
+ bookId,
1652
+ reverseEntry: reverse,
1653
+ markerEntry: marker
1654
+ };
1655
+ }
1656
+ function entryMatchesFilters(entry, input) {
1657
+ if (input.from && entry.date < input.from) return false;
1658
+ if (input.to && entry.date > input.to) return false;
1659
+ if (input.accountCode && !entry.lines.some((line) => line.accountCode === input.accountCode)) return false;
1660
+ return true;
1661
+ }
1662
+ async function listEntries(input, workspaceRoot) {
1663
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1664
+ const periods = await listJournalPeriods(bookId, workspaceRoot);
1665
+ const entries = [];
1666
+ const allVoidedIds = /* @__PURE__ */ new Set();
1667
+ for (const monthKey of periods) {
1668
+ const { entries: monthEntries } = await readJournalMonth(bookId, monthKey, workspaceRoot);
1669
+ for (const voidedId of voidedIdSet(monthEntries)) allVoidedIds.add(voidedId);
1670
+ if (input.from && monthKey < input.from.slice(0, 7)) continue;
1671
+ if (input.to && monthKey > input.to.slice(0, 7)) continue;
1672
+ for (const entry of monthEntries) if (entryMatchesFilters(entry, input)) entries.push(entry);
1673
+ }
1674
+ return {
1675
+ bookId,
1676
+ entries,
1677
+ voidedEntryIds: Array.from(allVoidedIds).sort()
1678
+ };
1679
+ }
1680
+ async function getOpeningBalances(input, workspaceRoot) {
1681
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1682
+ return {
1683
+ bookId,
1684
+ opening: findActiveOpening(await readAllEntries(bookId, workspaceRoot))
1685
+ };
1686
+ }
1687
+ async function setOpeningBalances(input, workspaceRoot) {
1688
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1689
+ const accounts = await readAccounts(bookId, workspaceRoot);
1690
+ const all = await readAllEntries(bookId, workspaceRoot);
1691
+ const validation = validateOpening({
1692
+ asOfDate: input.asOfDate,
1693
+ lines: input.lines,
1694
+ accounts,
1695
+ existingEntries: all
1696
+ });
1697
+ if (!validation.ok) throw new AccountingError(400, "invalid opening balances", validation.errors);
1698
+ const existing = findActiveOpening(all);
1699
+ if (existing) {
1700
+ const { reverse, marker } = makeVoidEntries(existing, "replaced via setOpeningBalances", localDateString());
1701
+ await appendJournal(bookId, reverse, workspaceRoot);
1702
+ await appendJournal(bookId, marker, workspaceRoot);
1703
+ }
1704
+ const opening = makeEntry({
1705
+ date: input.asOfDate,
1706
+ lines: input.lines,
1707
+ memo: input.memo ?? "Opening balances",
1708
+ kind: "opening"
1709
+ });
1710
+ await appendJournal(bookId, opening, workspaceRoot);
1711
+ scheduleRebuild(bookId, "0000-00", workspaceRoot);
1712
+ await invalidateAllSnapshots(bookId, workspaceRoot);
1713
+ publishBookChange(bookId, { kind: require_shared.BOOK_EVENT_KINDS.opening });
1714
+ return {
1715
+ bookId,
1716
+ openingEntry: opening,
1717
+ replacedExisting: existing !== null
1718
+ };
1719
+ }
1720
+ function endDateOfPeriod(period) {
1721
+ if (period.kind === "month") {
1722
+ const [year, month] = period.period.split("-").map((segment) => parseInt(segment, 10));
1723
+ const last = new Date(Date.UTC(year, month, 0)).getUTCDate();
1724
+ return `${period.period}-${String(last).padStart(2, "0")}`;
1725
+ }
1726
+ return period.to;
1727
+ }
1728
+ async function getBalanceSheetReport(input, workspaceRoot) {
1729
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1730
+ return {
1731
+ bookId,
1732
+ balanceSheet: buildBalanceSheet({
1733
+ accounts: await readAccounts(bookId, workspaceRoot),
1734
+ balances: await balancesAsOf(bookId, input.period, workspaceRoot),
1735
+ asOf: endDateOfPeriod(input.period)
1736
+ })
1737
+ };
1738
+ }
1739
+ /** Resolve closing balances at the end of a `ReportPeriod`. Month
1740
+ * periods hit the snapshot cache; range periods with a mid-month
1741
+ * `to` date have to filter the journal directly because the
1742
+ * end-of-month snapshot would include activity past `to`. */
1743
+ async function balancesAsOf(bookId, period, workspaceRoot) {
1744
+ if (period.kind === "month") return [...(await getOrBuildSnapshot(bookId, period.period, workspaceRoot)).balances];
1745
+ return aggregateBalances((await readAllEntries(bookId, workspaceRoot)).filter((entry) => entry.date <= period.to));
1746
+ }
1747
+ async function getProfitLossReport(input, workspaceRoot) {
1748
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1749
+ return {
1750
+ bookId,
1751
+ profitLoss: buildProfitLoss({
1752
+ accounts: await readAccounts(bookId, workspaceRoot),
1753
+ entries: await readAllEntries(bookId, workspaceRoot),
1754
+ from: input.period.kind === "month" ? `${input.period.period}-01` : input.period.from,
1755
+ to: endDateOfPeriod(input.period)
1756
+ })
1757
+ };
1758
+ }
1759
+ async function getLedgerReport(input, workspaceRoot) {
1760
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1761
+ const account = (await readAccounts(bookId, workspaceRoot)).find((acct) => acct.code === input.accountCode);
1762
+ if (!account) throw new AccountingError(404, `account ${JSON.stringify(input.accountCode)} not found`);
1763
+ return {
1764
+ bookId,
1765
+ ledger: buildLedger({
1766
+ account,
1767
+ entries: await readAllEntries(bookId, workspaceRoot),
1768
+ from: input.period?.kind === "month" ? `${input.period.period}-01` : input.period?.from,
1769
+ to: input.period ? endDateOfPeriod(input.period) : void 0
1770
+ })
1771
+ };
1772
+ }
1773
+ function ensureValidYmd(label, value) {
1774
+ if (typeof value !== "string" || !isValidCalendarDate(value)) throw new AccountingError(400, `getTimeSeries: ${label} must be a valid YYYY-MM-DD calendar date`);
1775
+ return value;
1776
+ }
1777
+ function ensureMetric(value) {
1778
+ 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(", ")}`);
1779
+ return value;
1780
+ }
1781
+ function ensureGranularity(value) {
1782
+ 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(", ")}`);
1783
+ return value;
1784
+ }
1785
+ function resolveAccountCode(metric, raw) {
1786
+ if (metric === "accountBalance") {
1787
+ if (typeof raw !== "string" || raw === "") throw new AccountingError(400, "getTimeSeries: accountCode is required when metric is accountBalance");
1788
+ return raw;
1789
+ }
1790
+ if (raw !== void 0 && raw !== "") throw new AccountingError(400, "getTimeSeries: accountCode is only allowed when metric is accountBalance");
1791
+ }
1792
+ function validateTimeSeriesInput(input) {
1793
+ const metric = ensureMetric(input.metric);
1794
+ const granularity = ensureGranularity(input.granularity);
1795
+ const from = ensureValidYmd("from", input.from);
1796
+ const toDate = ensureValidYmd("to", input.to);
1797
+ if (from > toDate) throw new AccountingError(400, "getTimeSeries: from must be on or before to");
1798
+ return {
1799
+ metric,
1800
+ granularity,
1801
+ from,
1802
+ toDate,
1803
+ accountCode: resolveAccountCode(metric, input.accountCode)
1804
+ };
1805
+ }
1806
+ async function loadTimeSeriesBookContext(requestedBookId, workspaceRoot) {
1807
+ const config = await loadOrInitConfig(workspaceRoot);
1808
+ const bookId = resolveBookId(config, requestedBookId);
1809
+ return {
1810
+ bookId,
1811
+ fiscalYearEnd: findBook(config, bookId)?.fiscalYearEnd ?? "Q4",
1812
+ accounts: await readAccounts(bookId, workspaceRoot)
1813
+ };
1814
+ }
1815
+ async function getTimeSeriesReport(input, workspaceRoot) {
1816
+ const { metric, granularity, from, toDate, accountCode } = validateTimeSeriesInput(input);
1817
+ const { bookId, fiscalYearEnd, accounts } = await loadTimeSeriesBookContext(input.bookId, workspaceRoot);
1818
+ if (accountCode && !accounts.some((acct) => acct.code === accountCode)) throw new AccountingError(404, `getTimeSeries: account ${JSON.stringify(accountCode)} not found`);
1819
+ const entries = await readAllEntries(bookId, workspaceRoot);
1820
+ const report = {
1821
+ bookId,
1822
+ metric,
1823
+ granularity,
1824
+ from,
1825
+ to: toDate,
1826
+ points: buildTimeSeries({
1827
+ buckets: bucketize({
1828
+ from,
1829
+ to: toDate,
1830
+ granularity,
1831
+ fiscalYearEnd
1832
+ }),
1833
+ entries,
1834
+ accounts,
1835
+ metric,
1836
+ accountCode
1837
+ })
1838
+ };
1839
+ if (accountCode) report.accountCode = accountCode;
1840
+ return report;
1841
+ }
1842
+ async function rebuildSnapshots(input, workspaceRoot) {
1843
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1844
+ const result = await rebuildAllSnapshots(bookId, workspaceRoot);
1845
+ publishBookChange(bookId, { kind: require_shared.BOOK_EVENT_KINDS.snapshotsReady });
1846
+ return {
1847
+ bookId,
1848
+ rebuilt: result.rebuilt
1849
+ };
1850
+ }
1851
+ //#endregion
1852
+ //#region src/server/http.ts
1853
+ function asyncHandler(namespace, fallbackMessage, handler) {
1854
+ return async (req, res) => {
1855
+ try {
1856
+ await handler(req, res);
1857
+ } catch (err) {
1858
+ const expressReq = req;
1859
+ const expressRes = res;
1860
+ log.error(namespace, "handler threw", {
1861
+ route: expressReq.path,
1862
+ error: require_shared.errorMessage(err)
1863
+ });
1864
+ if (!expressRes.headersSent) expressRes.status(500).json({ error: fallbackMessage });
1865
+ }
1866
+ };
1867
+ }
1868
+ //#endregion
1869
+ //#region src/server/router.ts
1870
+ async function handleOpenBook(rest) {
1871
+ if (typeof rest.bookId !== "string" || rest.bookId === "") throw new AccountingError(400, "openBook: bookId is required. Call 'getBooks' to enumerate, or 'createBook' first on a fresh workspace.");
1872
+ const list = await listBooks();
1873
+ if (!list.books.some((book) => book.id === rest.bookId)) throw new AccountingError(404, `openBook: book ${JSON.stringify(rest.bookId)} not found`);
1874
+ const initialTab = typeof rest.initialTab === "string" ? rest.initialTab : void 0;
1875
+ return {
1876
+ kind: "accounting-app",
1877
+ bookId: rest.bookId,
1878
+ initialTab,
1879
+ books: list.books
1880
+ };
1881
+ }
1882
+ async function handleGetReport(rest) {
1883
+ const kind = String(rest.kind ?? "");
1884
+ const periodInput = rest.period;
1885
+ const bookId = rest.bookId;
1886
+ if (kind === "balance") {
1887
+ if (!periodInput) throw new AccountingError(400, "getReport balance: period is required");
1888
+ return getBalanceSheetReport({
1889
+ bookId,
1890
+ period: periodInput
1891
+ });
1892
+ }
1893
+ if (kind === "pl") {
1894
+ if (!periodInput) throw new AccountingError(400, "getReport pl: period is required");
1895
+ return getProfitLossReport({
1896
+ bookId,
1897
+ period: periodInput
1898
+ });
1899
+ }
1900
+ if (kind === "ledger") {
1901
+ if (typeof rest.accountCode !== "string" || rest.accountCode === "") throw new AccountingError(400, "getReport ledger: accountCode is required");
1902
+ return getLedgerReport({
1903
+ bookId,
1904
+ accountCode: rest.accountCode,
1905
+ period: periodInput
1906
+ });
1907
+ }
1908
+ throw new AccountingError(400, `getReport: unknown kind ${JSON.stringify(kind)}`);
1909
+ }
1910
+ var ACTION_HANDLERS = {
1911
+ [require_shared.ACCOUNTING_ACTIONS.openBook]: handleOpenBook,
1912
+ [require_shared.ACCOUNTING_ACTIONS.getBooks]: () => listBooks(),
1913
+ [require_shared.ACCOUNTING_ACTIONS.createBook]: async (rest) => {
1914
+ const result = await createBook({
1915
+ name: String(rest.name ?? ""),
1916
+ currency: typeof rest.currency === "string" ? rest.currency : void 0,
1917
+ country: typeof rest.country === "string" ? rest.country : void 0,
1918
+ fiscalYearEnd: typeof rest.fiscalYearEnd === "string" ? rest.fiscalYearEnd : void 0
1919
+ });
1920
+ return {
1921
+ bookId: result.book.id,
1922
+ ...result
1923
+ };
1924
+ },
1925
+ [require_shared.ACCOUNTING_ACTIONS.updateBook]: async (rest) => {
1926
+ const result = await updateBook({
1927
+ bookId: String(rest.bookId ?? ""),
1928
+ name: typeof rest.name === "string" ? rest.name : void 0,
1929
+ country: typeof rest.country === "string" ? rest.country : void 0,
1930
+ fiscalYearEnd: typeof rest.fiscalYearEnd === "string" ? rest.fiscalYearEnd : void 0
1931
+ });
1932
+ return {
1933
+ bookId: result.book.id,
1934
+ ...result
1935
+ };
1936
+ },
1937
+ [require_shared.ACCOUNTING_ACTIONS.deleteBook]: (rest) => deleteBook({
1938
+ bookId: String(rest.bookId ?? ""),
1939
+ confirm: rest.confirm === true
1940
+ }),
1941
+ [require_shared.ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: rest.bookId }),
1942
+ [require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
1943
+ bookId: rest.bookId,
1944
+ account: rest.account
1945
+ }),
1946
+ [require_shared.ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
1947
+ bookId: rest.bookId,
1948
+ entries: rest.entries ?? []
1949
+ }),
1950
+ [require_shared.ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
1951
+ bookId: rest.bookId,
1952
+ entryId: String(rest.entryId ?? ""),
1953
+ reason: rest.reason,
1954
+ voidDate: rest.voidDate
1955
+ }),
1956
+ [require_shared.ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
1957
+ bookId: rest.bookId,
1958
+ from: rest.from,
1959
+ to: rest.to,
1960
+ accountCode: rest.accountCode
1961
+ }),
1962
+ [require_shared.ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: rest.bookId }),
1963
+ [require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
1964
+ bookId: rest.bookId,
1965
+ asOfDate: String(rest.asOfDate ?? ""),
1966
+ lines: rest.lines ?? [],
1967
+ memo: rest.memo
1968
+ }),
1969
+ [require_shared.ACCOUNTING_ACTIONS.getReport]: handleGetReport,
1970
+ [require_shared.ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
1971
+ bookId: rest.bookId,
1972
+ metric: rest.metric,
1973
+ granularity: rest.granularity,
1974
+ from: rest.from,
1975
+ to: rest.to,
1976
+ accountCode: rest.accountCode
1977
+ }),
1978
+ [require_shared.ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: rest.bookId })
1979
+ };
1980
+ var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
1981
+ require_shared.ACCOUNTING_ACTIONS.openBook,
1982
+ require_shared.ACCOUNTING_ACTIONS.createBook,
1983
+ require_shared.ACCOUNTING_ACTIONS.updateBook,
1984
+ require_shared.ACCOUNTING_ACTIONS.upsertAccount,
1985
+ require_shared.ACCOUNTING_ACTIONS.addEntries,
1986
+ require_shared.ACCOUNTING_ACTIONS.voidEntry,
1987
+ require_shared.ACCOUNTING_ACTIONS.setOpeningBalances
1988
+ ]);
1989
+ var VIEW_VISIBLE_TRAILER = "The accounting view is shown to the user.";
1990
+ var MESSAGE_BUILDERS = {
1991
+ [require_shared.ACCOUNTING_ACTIONS.openBook]: (fields) => {
1992
+ const { books, bookId } = fields;
1993
+ const booksFragment = Array.isArray(books) ? ` Books available: ${JSON.stringify(books)}.` : "";
1994
+ return `Mounted the accounting app in the canvas${typeof bookId === "string" ? ` (book id: ${bookId})` : ""}.${booksFragment}`;
1995
+ },
1996
+ [require_shared.ACCOUNTING_ACTIONS.createBook]: (fields) => {
1997
+ const book = fields.book;
1998
+ return `${book?.name ? `A new book named ${JSON.stringify(book.name)}` : "A new book"} has been created${book?.id ? ` (id: ${book.id})` : ""}. Next required step: set opening balances via setOpeningBalances — the journal-entry, ledger, and report tabs are locked until an opening (even an empty one) is saved.`;
1999
+ },
2000
+ [require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (fields) => {
2001
+ const account = fields.account;
2002
+ if (account?.code && account?.name) return `Upserted account ${account.code} ${JSON.stringify(account.name)}.`;
2003
+ return "Updated the chart of accounts.";
2004
+ },
2005
+ [require_shared.ACCOUNTING_ACTIONS.addEntries]: (fields) => {
2006
+ const entries = Array.isArray(fields.entries) ? fields.entries : [];
2007
+ if (entries.length === 0) return "Posted 0 journal entries.";
2008
+ if (entries.length === 1) {
2009
+ const [entry] = entries;
2010
+ const idFragment = entry?.id ? ` (id: ${entry.id})` : "";
2011
+ return `Posted a journal entry on ${entry?.date ?? "the requested date"}${idFragment}.`;
2012
+ }
2013
+ const summary = entries.map((entry) => `${entry?.date ?? "?"} (id: ${entry?.id ?? "?"})`).join(", ");
2014
+ return `Posted ${entries.length} journal entries: ${summary}.`;
2015
+ },
2016
+ [require_shared.ACCOUNTING_ACTIONS.voidEntry]: (fields) => {
2017
+ return `Voided the entry; a reversing pair was posted on ${fields.reverseEntry?.date ?? "today"}.`;
2018
+ },
2019
+ [require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (fields) => {
2020
+ const opening = fields.openingEntry;
2021
+ const verb = fields.replacedExisting === true ? "replaced" : "set";
2022
+ const date = opening?.date ?? "the requested date";
2023
+ const lines = Array.isArray(opening?.lines) ? opening.lines : [];
2024
+ return `Opening balances were ${verb} as of ${date}.${lines.length > 0 ? ` Lines: ${JSON.stringify(lines)}.` : ""}`;
2025
+ },
2026
+ [require_shared.ACCOUNTING_ACTIONS.deleteBook]: (fields) => {
2027
+ const bookId = fields.deletedBookId;
2028
+ const name = fields.deletedBookName;
2029
+ return `Deleted ${name ? `the book ${JSON.stringify(name)}` : "the book"}${bookId ? ` (id: ${bookId})` : ""}.`;
2030
+ },
2031
+ [require_shared.ACCOUNTING_ACTIONS.updateBook]: (fields) => {
2032
+ const book = fields.book;
2033
+ return `Updated ${book?.name ? JSON.stringify(book.name) : "the book"}${book?.country ? ` (country: ${book.country})` : ""}.`;
2034
+ }
2035
+ };
2036
+ function previewMessage(action, fields) {
2037
+ const head = Object.hasOwn(MESSAGE_BUILDERS, action) ? MESSAGE_BUILDERS[action](fields) : void 0;
2038
+ return head ? `${head} ${VIEW_VISIBLE_TRAILER}` : VIEW_VISIBLE_TRAILER;
2039
+ }
2040
+ async function dispatch(body) {
2041
+ const { action, ...rest } = body;
2042
+ if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2043
+ const handler = ACTION_HANDLERS[action];
2044
+ const result = await handler(rest);
2045
+ const handlerFields = result && typeof result === "object" ? result : { value: result };
2046
+ const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
2047
+ action,
2048
+ ...handlerFields
2049
+ } } : {};
2050
+ const messageField = (typeof handlerFields.message === "string" ? handlerFields.message : void 0) ? {} : MESSAGE_BUILDERS[action] ? { message: previewMessage(action, handlerFields) } : { message: JSON.stringify(handlerFields) };
2051
+ return {
2052
+ action,
2053
+ ...handlerFields,
2054
+ ...messageField,
2055
+ ...dataField
2056
+ };
2057
+ }
2058
+ /** Build the accounting Express router. The host injects its workspace
2059
+ * root + logger via `configureAccountingServer(...)` and pub/sub via
2060
+ * `initAccountingEventPublisher(...)`, then mounts the returned router
2061
+ * with `app.use(...)`. */
2062
+ function createAccountingRouter() {
2063
+ const router = (0, express.Router)();
2064
+ router.post("/api/accounting", asyncHandler("accounting", "accounting dispatch failed", async (req, res) => {
2065
+ const { body } = req;
2066
+ if (!body || typeof body !== "object" || typeof body.action !== "string") {
2067
+ log.warn("accounting", "POST dispatch: invalid body");
2068
+ res.status(400).json({ error: "request body must be an object with a string `action` field" });
2069
+ return;
2070
+ }
2071
+ const { action } = body;
2072
+ log.info("accounting", "POST dispatch: start", { action });
2073
+ try {
2074
+ const result = await dispatch(body);
2075
+ log.info("accounting", "POST dispatch: ok", { action });
2076
+ res.json(result);
2077
+ } catch (err) {
2078
+ if (err instanceof AccountingError) {
2079
+ log.warn("accounting", "POST dispatch: error", {
2080
+ action,
2081
+ status: err.status,
2082
+ message: err.message
2083
+ });
2084
+ res.status(err.status).json({
2085
+ error: err.message,
2086
+ details: err.details
2087
+ });
2088
+ return;
2089
+ }
2090
+ throw err;
2091
+ }
2092
+ }));
2093
+ return router;
2094
+ }
2095
+ //#endregion
2096
+ exports.configureAccountingServer = configureAccountingServer;
2097
+ exports.createAccountingRouter = createAccountingRouter;
2098
+ exports.initAccountingEventPublisher = initAccountingEventPublisher;
2099
+ exports.isValidCalendarDate = isValidCalendarDate;
2100
+
2101
+ //# sourceMappingURL=server.cjs.map