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