@mulmoclaude/accounting-plugin 1.2.0 → 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.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-xYPtGNvH.js";
|
|
2
2
|
import { Router } from "express";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -62,254 +62,6 @@ var log = {
|
|
|
62
62
|
debug: (namespace, msg, data) => (deps?.logger ?? consoleLogger).debug(namespace, msg, data)
|
|
63
63
|
};
|
|
64
64
|
//#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
65
|
//#region src/server/journal.ts
|
|
314
66
|
/** Floating-point tolerance for the debit = credit check. Currency
|
|
315
67
|
* amounts arrive as JavaScript numbers (the on-wire format is JSON,
|
|
@@ -411,7 +163,7 @@ function buildLine(raw) {
|
|
|
411
163
|
* belong to the caller, because journal entries and opening balances
|
|
412
164
|
* disagree about them. Returns null when nothing usable came out —
|
|
413
165
|
* the caller drops the line rather than reading fields off it. */
|
|
414
|
-
function parseJournalLine(raw, idx, errors) {
|
|
166
|
+
function parseJournalLine$1(raw, idx, errors) {
|
|
415
167
|
if (!isRecord(raw)) {
|
|
416
168
|
errors.push({
|
|
417
169
|
field: `lines[${idx}]`,
|
|
@@ -437,7 +189,7 @@ function validateEntryLine(line, idx, accountCodes, errors) {
|
|
|
437
189
|
function parseEntryLines(raw, accountCodes, errors) {
|
|
438
190
|
const lines = [];
|
|
439
191
|
raw.forEach((rawLine, idx) => {
|
|
440
|
-
const line = parseJournalLine(rawLine, idx, errors);
|
|
192
|
+
const line = parseJournalLine$1(rawLine, idx, errors);
|
|
441
193
|
if (line === null) return;
|
|
442
194
|
validateEntryLine(line, idx, accountCodes, errors);
|
|
443
195
|
lines.push(line);
|
|
@@ -465,6 +217,20 @@ function parseOptionalString(value, field, errors) {
|
|
|
465
217
|
message: `${field} must be a string when supplied`
|
|
466
218
|
});
|
|
467
219
|
}
|
|
220
|
+
var isOptionalString = (value) => value === void 0 || typeof value === "string";
|
|
221
|
+
var isOptionalNumber = (value) => value === void 0 || typeof value === "number";
|
|
222
|
+
function isJournalLine(value) {
|
|
223
|
+
return hasStringProp(value, "accountCode") && isOptionalNumber(value.debit) && isOptionalNumber(value.credit) && isOptionalString(value.memo) && isOptionalString(value.taxRegistrationId);
|
|
224
|
+
}
|
|
225
|
+
/** Checks every field `JournalEntry` and `JournalLine` declare, so a value
|
|
226
|
+
* that passes really is one. Used when reading the journal JSONL back:
|
|
227
|
+
* everything this module ever wrote satisfies it (`id` / `date` / `kind` /
|
|
228
|
+
* `lines` / `createdAt` have been required since the plugin's first
|
|
229
|
+
* release), while a line that doesn't is exactly the line that takes the
|
|
230
|
+
* whole book down — `report.ts` iterates `entry.lines` unguarded. */
|
|
231
|
+
function isJournalEntry(value) {
|
|
232
|
+
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);
|
|
233
|
+
}
|
|
468
234
|
/** Normalize a journal line before persistence: trim string fields
|
|
469
235
|
* and drop empty-string optionals so the JSONL doesn't accumulate
|
|
470
236
|
* noise like `"taxRegistrationId":""`. Pure — does not mutate
|
|
@@ -611,6 +377,257 @@ function voidedIdSet(entries) {
|
|
|
611
377
|
return set;
|
|
612
378
|
}
|
|
613
379
|
//#endregion
|
|
380
|
+
//#region src/server/io.ts
|
|
381
|
+
var root = (workspaceRoot) => workspaceRoot ?? defaultWorkspaceRoot();
|
|
382
|
+
function accountingRoot(workspaceRoot) {
|
|
383
|
+
return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accounting);
|
|
384
|
+
}
|
|
385
|
+
function configPath(workspaceRoot) {
|
|
386
|
+
return path.join(accountingRoot(workspaceRoot), "config.json");
|
|
387
|
+
}
|
|
388
|
+
/** Allowed shape for a book id used as a directory name. Defense
|
|
389
|
+
* against path traversal: a crafted id like "../../config" or
|
|
390
|
+
* "/tmp/x" would otherwise let `bookRoot` escape the
|
|
391
|
+
* `data/accounting/books/` tree, since every write path joins
|
|
392
|
+
* `bookId` directly into the filesystem. The first character is
|
|
393
|
+
* alphanumeric to forbid leading dashes / underscores that some
|
|
394
|
+
* shells / docs render confusingly; `_` and `-` are allowed inside.
|
|
395
|
+
* 64 chars is plenty for any reasonable book name. */
|
|
396
|
+
var SAFE_BOOK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
397
|
+
function isSafeBookId(bookId) {
|
|
398
|
+
return typeof bookId === "string" && SAFE_BOOK_ID_RE.test(bookId);
|
|
399
|
+
}
|
|
400
|
+
function assertSafeBookId(bookId) {
|
|
401
|
+
if (!isSafeBookId(bookId)) throw new Error(`accounting: invalid bookId ${JSON.stringify(bookId)} (allowed: alphanumeric / _ / -; 1-64 chars; cannot start with _ or -)`);
|
|
402
|
+
}
|
|
403
|
+
function bookRoot(bookId, workspaceRoot) {
|
|
404
|
+
assertSafeBookId(bookId);
|
|
405
|
+
return path.join(root(workspaceRoot), ACCOUNTING_DIRS.accountingBooks, bookId);
|
|
406
|
+
}
|
|
407
|
+
function accountsPath(bookId, workspaceRoot) {
|
|
408
|
+
return path.join(bookRoot(bookId, workspaceRoot), "accounts.json");
|
|
409
|
+
}
|
|
410
|
+
function journalDir(bookId, workspaceRoot) {
|
|
411
|
+
return path.join(bookRoot(bookId, workspaceRoot), "journal");
|
|
412
|
+
}
|
|
413
|
+
function journalFileFor(bookId, period, workspaceRoot) {
|
|
414
|
+
return path.join(journalDir(bookId, workspaceRoot), `${period}.jsonl`);
|
|
415
|
+
}
|
|
416
|
+
function snapshotsDir(bookId, workspaceRoot) {
|
|
417
|
+
return path.join(bookRoot(bookId, workspaceRoot), "snapshots");
|
|
418
|
+
}
|
|
419
|
+
function snapshotFileFor(bookId, period, workspaceRoot) {
|
|
420
|
+
return path.join(snapshotsDir(bookId, workspaceRoot), `${period}.json`);
|
|
421
|
+
}
|
|
422
|
+
async function fileExists(filePath) {
|
|
423
|
+
try {
|
|
424
|
+
await promises.access(filePath);
|
|
425
|
+
return true;
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Strict variant of `readJsonOrNull` from `./json.ts`: returns null
|
|
431
|
+
* on ENOENT but RETHROWS other read errors and parse failures so a
|
|
432
|
+
* corrupted accounting journal surfaces rather than silently
|
|
433
|
+
* collapsing to "no data". `./json.ts` keeps the permissive
|
|
434
|
+
* variant for user-config files where a single bad keystroke
|
|
435
|
+
* shouldn't 500 the server. */
|
|
436
|
+
async function readJsonStrict(filePath) {
|
|
437
|
+
try {
|
|
438
|
+
const raw = await promises.readFile(filePath, "utf-8");
|
|
439
|
+
return JSON.parse(raw);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (isEnoent(err)) return null;
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
|
|
446
|
+
* to its closing-month number in memory so every downstream consumer
|
|
447
|
+
* (reports, time-series, the UI selects) sees one shape. Absent stays
|
|
448
|
+
* absent — the field is optional and resolves to the default on read;
|
|
449
|
+
* we don't stamp an explicit December onto a book that never chose one.
|
|
450
|
+
* Nothing is written back here (no auto-migrate on disk). */
|
|
451
|
+
function normalizeBookFiscalYearEnd(book) {
|
|
452
|
+
if (book.fiscalYearEnd === void 0) return book;
|
|
453
|
+
const resolved = resolveFiscalYearEnd(book.fiscalYearEnd);
|
|
454
|
+
return book.fiscalYearEnd === resolved ? book : {
|
|
455
|
+
...book,
|
|
456
|
+
fiscalYearEnd: resolved
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function readConfig(workspaceRoot) {
|
|
460
|
+
const config = await readJsonStrict(configPath(workspaceRoot));
|
|
461
|
+
if (!config) return null;
|
|
462
|
+
return {
|
|
463
|
+
...config,
|
|
464
|
+
books: config.books.map(normalizeBookFiscalYearEnd)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function writeConfig(config, workspaceRoot) {
|
|
468
|
+
await writeJsonAtomic(configPath(workspaceRoot), config);
|
|
469
|
+
}
|
|
470
|
+
async function readAccounts(bookId, workspaceRoot) {
|
|
471
|
+
return await readJsonStrict(accountsPath(bookId, workspaceRoot)) ?? [];
|
|
472
|
+
}
|
|
473
|
+
async function writeAccounts(bookId, accounts, workspaceRoot) {
|
|
474
|
+
await writeJsonAtomic(accountsPath(bookId, workspaceRoot), accounts);
|
|
475
|
+
}
|
|
476
|
+
/** Convert a YYYY-MM-DD date string to its YYYY-MM month bucket. The
|
|
477
|
+
* month bucket dictates which JSONL file the entry lives in. */
|
|
478
|
+
function periodFromDate(date) {
|
|
479
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`accounting: invalid date format ${JSON.stringify(date)} (expected YYYY-MM-DD)`);
|
|
480
|
+
return date.slice(0, 7);
|
|
481
|
+
}
|
|
482
|
+
/** Append one entry to the appropriate month's JSONL.
|
|
483
|
+
*
|
|
484
|
+
* Uses POSIX append-only semantics (`fs.appendFile` → `O_APPEND`).
|
|
485
|
+
* Two concurrent callers landing in the same month file are
|
|
486
|
+
* serialised by the kernel — neither overwrites the other, which
|
|
487
|
+
* is the bug the previous read-modify-write implementation had.
|
|
488
|
+
*
|
|
489
|
+
* Crash mid-write: an entry shorter than `PIPE_BUF` (≥ 512 bytes
|
|
490
|
+
* on every supported platform) writes atomically; a single
|
|
491
|
+
* serialised `JournalEntry` is comfortably under that. If the
|
|
492
|
+
* process is killed during the syscall the worst case is a torn
|
|
493
|
+
* trailing line, which `readJournalMonth` already tolerates by
|
|
494
|
+
* skipping unparseable lines and surfacing a `skipped` count to
|
|
495
|
+
* the caller. */
|
|
496
|
+
async function appendJournal(bookId, entry, workspaceRoot) {
|
|
497
|
+
const file = journalFileFor(bookId, periodFromDate(entry.date), workspaceRoot);
|
|
498
|
+
await promises.mkdir(path.dirname(file), { recursive: true });
|
|
499
|
+
await promises.appendFile(file, `${JSON.stringify(entry)}\n`, { encoding: "utf-8" });
|
|
500
|
+
}
|
|
501
|
+
function groupEntriesByPeriod(entries) {
|
|
502
|
+
const byPeriod = /* @__PURE__ */ new Map();
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
const period = periodFromDate(entry.date);
|
|
505
|
+
const list = byPeriod.get(period) ?? [];
|
|
506
|
+
list.push(entry);
|
|
507
|
+
byPeriod.set(period, list);
|
|
508
|
+
}
|
|
509
|
+
return byPeriod;
|
|
510
|
+
}
|
|
511
|
+
/** Append a batch of entries: same-period entries are concatenated
|
|
512
|
+
* into one `appendFile` call so the whole same-period chunk hits
|
|
513
|
+
* the kernel as a single `O_APPEND` write — small chunks (under
|
|
514
|
+
* `PIPE_BUF`, ≥ 512 bytes on every supported platform) are
|
|
515
|
+
* guaranteed atomic by POSIX, and `O_APPEND` serialises with any
|
|
516
|
+
* concurrent appender (a parallel `appendJournal` / `addEntries`
|
|
517
|
+
* call can never overwrite our write or vice versa). Cross-period
|
|
518
|
+
* batches loop one append per period; each is independently
|
|
519
|
+
* concurrency-safe but their union is not transactional across
|
|
520
|
+
* files (out of scope for the append-only JSONL design). */
|
|
521
|
+
async function appendJournalBatch(bookId, entries, workspaceRoot) {
|
|
522
|
+
if (entries.length === 0) return;
|
|
523
|
+
const byPeriod = groupEntriesByPeriod(entries);
|
|
524
|
+
for (const [period, items] of byPeriod) {
|
|
525
|
+
const file = journalFileFor(bookId, period, workspaceRoot);
|
|
526
|
+
await promises.mkdir(path.dirname(file), { recursive: true });
|
|
527
|
+
const chunk = items.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
|
528
|
+
await promises.appendFile(file, chunk, { encoding: "utf-8" });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** One JSONL line, or null when it isn't a journal entry — unparseable JSON
|
|
532
|
+
* and JSON of the wrong shape are the same failure to a caller that can only
|
|
533
|
+
* skip the line. */
|
|
534
|
+
function parseJournalLine(line) {
|
|
535
|
+
try {
|
|
536
|
+
const parsed = JSON.parse(line);
|
|
537
|
+
return isJournalEntry(parsed) ? parsed : null;
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Read a single month's JSONL. Malformed lines are skipped (logged
|
|
543
|
+
* by the caller; this layer just returns the parseable subset) so
|
|
544
|
+
* one bad line doesn't lock the user out of their book. */
|
|
545
|
+
async function readJournalMonth(bookId, period, workspaceRoot) {
|
|
546
|
+
const file = journalFileFor(bookId, period, workspaceRoot);
|
|
547
|
+
let raw;
|
|
548
|
+
try {
|
|
549
|
+
raw = await promises.readFile(file, "utf-8");
|
|
550
|
+
} catch (err) {
|
|
551
|
+
if (isEnoent(err)) return {
|
|
552
|
+
entries: [],
|
|
553
|
+
skipped: 0
|
|
554
|
+
};
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
const parsed = raw.split("\n").filter((line) => line.trim() !== "").map(parseJournalLine);
|
|
558
|
+
const entries = parsed.filter((entry) => entry !== null);
|
|
559
|
+
return {
|
|
560
|
+
entries,
|
|
561
|
+
skipped: parsed.length - entries.length
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** List the YYYY-MM periods that have a journal file on disk, sorted
|
|
565
|
+
* ascending. Useful for full-history scans (rebuilding snapshots
|
|
566
|
+
* from scratch). */
|
|
567
|
+
async function listJournalPeriods(bookId, workspaceRoot) {
|
|
568
|
+
let names;
|
|
569
|
+
try {
|
|
570
|
+
names = await promises.readdir(journalDir(bookId, workspaceRoot));
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (isEnoent(err)) return [];
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
return names.filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).map((name) => name.slice(0, 7)).sort();
|
|
576
|
+
}
|
|
577
|
+
async function readSnapshot(bookId, period, workspaceRoot) {
|
|
578
|
+
return readJsonStrict(snapshotFileFor(bookId, period, workspaceRoot));
|
|
579
|
+
}
|
|
580
|
+
async function writeSnapshot(bookId, snapshot, workspaceRoot) {
|
|
581
|
+
const file = snapshotFileFor(bookId, snapshot.period, workspaceRoot);
|
|
582
|
+
await promises.mkdir(path.dirname(file), { recursive: true });
|
|
583
|
+
await writeJsonAtomic(file, snapshot, { uniqueTmp: true });
|
|
584
|
+
}
|
|
585
|
+
/** Drop snapshot files for all periods >= `fromPeriod`. The next
|
|
586
|
+
* read regenerates them. Idempotent: missing files are silently
|
|
587
|
+
* ignored. */
|
|
588
|
+
async function invalidateSnapshotsFrom(bookId, fromPeriod, workspaceRoot) {
|
|
589
|
+
let names;
|
|
590
|
+
try {
|
|
591
|
+
names = await promises.readdir(snapshotsDir(bookId, workspaceRoot));
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (isEnoent(err)) return { removed: [] };
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
const removed = [];
|
|
597
|
+
for (const name of names) {
|
|
598
|
+
const match = /^(\d{4}-\d{2})\.json$/.exec(name);
|
|
599
|
+
if (!match) continue;
|
|
600
|
+
const [, period] = match;
|
|
601
|
+
if (period >= fromPeriod) {
|
|
602
|
+
await promises.rm(path.join(snapshotsDir(bookId, workspaceRoot), name), { force: true });
|
|
603
|
+
removed.push(period);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { removed: removed.sort() };
|
|
607
|
+
}
|
|
608
|
+
/** Drop ALL snapshots for a book — used by `rebuildSnapshots()`
|
|
609
|
+
* with no `from`. Equivalent to `invalidateSnapshotsFrom("0000-00")`
|
|
610
|
+
* but reads more clearly at call sites. */
|
|
611
|
+
async function invalidateAllSnapshots(bookId, workspaceRoot) {
|
|
612
|
+
return invalidateSnapshotsFrom(bookId, "0000-00", workspaceRoot);
|
|
613
|
+
}
|
|
614
|
+
async function bookExists(bookId, workspaceRoot) {
|
|
615
|
+
return fileExists(bookRoot(bookId, workspaceRoot));
|
|
616
|
+
}
|
|
617
|
+
async function ensureBookDir(bookId, workspaceRoot) {
|
|
618
|
+
await promises.mkdir(bookRoot(bookId, workspaceRoot), { recursive: true });
|
|
619
|
+
await promises.mkdir(journalDir(bookId, workspaceRoot), { recursive: true });
|
|
620
|
+
await promises.mkdir(snapshotsDir(bookId, workspaceRoot), { recursive: true });
|
|
621
|
+
}
|
|
622
|
+
/** Recursively delete a book's directory. Used by `deleteBook` after
|
|
623
|
+
* the config has been updated to drop the entry. */
|
|
624
|
+
async function removeBookDir(bookId, workspaceRoot) {
|
|
625
|
+
await promises.rm(bookRoot(bookId, workspaceRoot), {
|
|
626
|
+
recursive: true,
|
|
627
|
+
force: true
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
614
631
|
//#region src/server/openingBalances.ts
|
|
615
632
|
/** Find the existing opening entry for a book, if any. Multiple
|
|
616
633
|
* openings shouldn't coexist (the route enforces void-then-append),
|
|
@@ -648,7 +665,7 @@ function parseOpeningLines(raw, accounts, errors) {
|
|
|
648
665
|
const accountByCode = new Map(accounts.map((account) => [account.code, account]));
|
|
649
666
|
const lines = [];
|
|
650
667
|
raw.forEach((rawLine, idx) => {
|
|
651
|
-
const line = parseJournalLine(rawLine, idx, errors);
|
|
668
|
+
const line = parseJournalLine$1(rawLine, idx, errors);
|
|
652
669
|
if (line === null) return;
|
|
653
670
|
validateOpeningAccount(line, idx, accountByCode, errors);
|
|
654
671
|
lines.push(line);
|
|
@@ -1643,19 +1660,22 @@ function coerceFiscalYearEndInput(raw) {
|
|
|
1643
1660
|
if (!isFiscalYearEnd(month)) throw unsupportedFiscalYearEndError(raw);
|
|
1644
1661
|
return month;
|
|
1645
1662
|
}
|
|
1646
|
-
/** Boundary checks
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1651
|
-
|
|
1652
|
-
function validateUpdateBookInput(input) {
|
|
1663
|
+
/** Boundary checks for updateBook (name / country only — fiscalYearEnd is
|
|
1664
|
+
* coerced + validated separately via `coerceFiscalYearEndInput`). Throws on
|
|
1665
|
+
* the first failure so the surrounding function stays under the
|
|
1666
|
+
* cognitive-complexity threshold, and hands back the country to persist:
|
|
1667
|
+
* `undefined` = the field was omitted, `""` = explicit clear. */
|
|
1668
|
+
function parseUpdateBookInput(input) {
|
|
1653
1669
|
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
|
-
|
|
1670
|
+
const { country } = input;
|
|
1671
|
+
if (country === void 0 || country === "") return country;
|
|
1672
|
+
if (!isSupportedCountryCode(country)) throw unsupportedCountryError(country);
|
|
1673
|
+
return country;
|
|
1655
1674
|
}
|
|
1656
1675
|
async function createBook(input, workspaceRoot) {
|
|
1657
1676
|
if (typeof input.name !== "string" || input.name.trim() === "") throw new AccountingError(400, "name is required");
|
|
1658
|
-
|
|
1677
|
+
const { country } = input;
|
|
1678
|
+
if (country !== void 0 && !isSupportedCountryCode(country)) throw unsupportedCountryError(country);
|
|
1659
1679
|
const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd) ?? 12;
|
|
1660
1680
|
const config = await loadOrInitConfig(workspaceRoot);
|
|
1661
1681
|
const bookId = input.id ?? await generateBookId(config, workspaceRoot);
|
|
@@ -1666,7 +1686,7 @@ async function createBook(input, workspaceRoot) {
|
|
|
1666
1686
|
id: bookId,
|
|
1667
1687
|
name: input.name,
|
|
1668
1688
|
currency: input.currency ?? DEFAULT_CURRENCY,
|
|
1669
|
-
...
|
|
1689
|
+
...country ? { country } : {},
|
|
1670
1690
|
fiscalYearEnd,
|
|
1671
1691
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1672
1692
|
};
|
|
@@ -1680,15 +1700,15 @@ async function updateBook(input, workspaceRoot) {
|
|
|
1680
1700
|
const config = await loadOrInitConfig(workspaceRoot);
|
|
1681
1701
|
const target = findBook(config, input.bookId);
|
|
1682
1702
|
if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
|
|
1683
|
-
|
|
1703
|
+
const country = parseUpdateBookInput(input);
|
|
1684
1704
|
const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd);
|
|
1685
1705
|
const next = {
|
|
1686
1706
|
...target,
|
|
1687
1707
|
...input.name !== void 0 ? { name: input.name } : {},
|
|
1688
|
-
...
|
|
1708
|
+
...country ? { country } : {},
|
|
1689
1709
|
...fiscalYearEnd !== void 0 ? { fiscalYearEnd } : {}
|
|
1690
1710
|
};
|
|
1691
|
-
if (
|
|
1711
|
+
if (country === "") delete next.country;
|
|
1692
1712
|
await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
|
|
1693
1713
|
publishBooksChanged();
|
|
1694
1714
|
return { book: next };
|
|
@@ -1959,12 +1979,14 @@ function ensureValidYmd(label, value) {
|
|
|
1959
1979
|
return value;
|
|
1960
1980
|
}
|
|
1961
1981
|
function ensureMetric(value) {
|
|
1962
|
-
|
|
1963
|
-
|
|
1982
|
+
const metric = TIME_SERIES_METRICS.find((candidate) => candidate === value);
|
|
1983
|
+
if (metric === void 0) throw new AccountingError(400, `getTimeSeries: metric must be one of ${TIME_SERIES_METRICS.join(", ")}`);
|
|
1984
|
+
return metric;
|
|
1964
1985
|
}
|
|
1965
1986
|
function ensureGranularity(value) {
|
|
1966
|
-
|
|
1967
|
-
|
|
1987
|
+
const granularity = TIME_SERIES_GRANULARITIES.find((candidate) => candidate === value);
|
|
1988
|
+
if (granularity === void 0) throw new AccountingError(400, `getTimeSeries: granularity must be one of ${TIME_SERIES_GRANULARITIES.join(", ")}`);
|
|
1989
|
+
return granularity;
|
|
1968
1990
|
}
|
|
1969
1991
|
function resolveAccountCode(metric, raw) {
|
|
1970
1992
|
if (metric === "accountBalance") {
|