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