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