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