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