@mulmoclaude/accounting-plugin 2.0.0 → 2.2.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.
Files changed (50) hide show
  1. package/README.md +37 -0
  2. package/dist/server/context.d.ts +45 -3
  3. package/dist/server/context.d.ts.map +1 -1
  4. package/dist/server/eventPublisher.d.ts +9 -4
  5. package/dist/server/eventPublisher.d.ts.map +1 -1
  6. package/dist/server/index.d.ts +1 -0
  7. package/dist/server/index.d.ts.map +1 -1
  8. package/dist/server/router.d.ts +27 -3
  9. package/dist/server/router.d.ts.map +1 -1
  10. package/dist/server/snapshotCache.d.ts +3 -3
  11. package/dist/server/snapshotCache.d.ts.map +1 -1
  12. package/dist/server.cjs +152 -82
  13. package/dist/server.cjs.map +1 -1
  14. package/dist/server.js +151 -81
  15. package/dist/server.js.map +1 -1
  16. package/dist/shared/api.d.ts +12 -0
  17. package/dist/shared/api.d.ts.map +1 -1
  18. package/dist/shared/channels.d.ts +22 -2
  19. package/dist/shared/channels.d.ts.map +1 -1
  20. package/dist/{shared-BZAAF-I4.js → shared-BccLo0Ux.js} +39 -5
  21. package/dist/shared-BccLo0Ux.js.map +1 -0
  22. package/dist/{shared-BpFVwa6Y.cjs → shared-Dgt-i23T.cjs} +50 -4
  23. package/dist/shared-Dgt-i23T.cjs.map +1 -0
  24. package/dist/shared.cjs +3 -1
  25. package/dist/shared.js +2 -2
  26. package/dist/style.css +19 -10
  27. package/dist/vue/View.vue.d.ts +5 -0
  28. package/dist/vue/View.vue.d.ts.map +1 -1
  29. package/dist/vue/api.d.ts +132 -100
  30. package/dist/vue/api.d.ts.map +1 -1
  31. package/dist/vue/components/AccountsModal.vue.d.ts.map +1 -1
  32. package/dist/vue/components/BalanceSheet.vue.d.ts.map +1 -1
  33. package/dist/vue/components/BookSettings.vue.d.ts.map +1 -1
  34. package/dist/vue/components/JournalEntryForm.vue.d.ts.map +1 -1
  35. package/dist/vue/components/JournalList.vue.d.ts.map +1 -1
  36. package/dist/vue/components/Ledger.vue.d.ts.map +1 -1
  37. package/dist/vue/components/NewBookForm.vue.d.ts.map +1 -1
  38. package/dist/vue/components/OpeningBalancesForm.vue.d.ts.map +1 -1
  39. package/dist/vue/components/ProfitLoss.vue.d.ts.map +1 -1
  40. package/dist/vue/hostContext.d.ts +15 -0
  41. package/dist/vue/hostContext.d.ts.map +1 -1
  42. package/dist/vue/useAccountingChannel.d.ts +13 -3
  43. package/dist/vue/useAccountingChannel.d.ts.map +1 -1
  44. package/dist/vue.cjs +229 -99
  45. package/dist/vue.cjs.map +1 -1
  46. package/dist/vue.js +230 -100
  47. package/dist/vue.js.map +1 -1
  48. package/package.json +5 -5
  49. package/dist/shared-BZAAF-I4.js.map +0 -1
  50. package/dist/shared-BpFVwa6Y.cjs.map +0 -1
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as resolveFiscalYearEnd, B as BALANCE_SHEET_ACCOUNT_TYPES, D as isFiscalYearEnd, F as isUnknownArray, H as ACCOUNTING_API, I as ACCOUNTING_BOOKS_CHANNEL, L as BOOK_EVENT_KINDS, M as errorMessage, N as hasStringProp, P as isRecord, R as bookChannel, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, U as ACCOUNTING_ACTIONS, V as JOURNAL_ENTRY_KINDS, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as ACCOUNT_TYPES } from "./shared-BZAAF-I4.js";
1
+ import { A as resolveFiscalYearEnd, B as ACCOUNT_TYPES, D as isFiscalYearEnd, F as isUnknownArray, G as ACCOUNTING_ACTIONS, H as JOURNAL_ENTRY_KINDS, L as BOOK_EVENT_KINDS, M as errorMessage, N as hasStringProp, P as isRecord, R as bookChannel, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, U as ACCOUNTING_API, V as BALANCE_SHEET_ACCOUNT_TYPES, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as booksChannel } from "./shared-BccLo0Ux.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
@@ -60,11 +60,41 @@ function configureAccountingServer(context) {
60
60
  }
61
61
  /** Default workspace root for io calls that don't pass one explicitly.
62
62
  * Throws if the host never configured the server — a real wiring bug
63
- * (unit tests always pass an explicit root, so they never hit this). */
63
+ * (unit tests always pass an explicit root, so they never hit this)
64
+ * and also under STRICT mode (`workspaceRoot: null`), where there is no
65
+ * ambient root to fall back to and a missing option is a bug rather
66
+ * than a default. */
64
67
  function defaultWorkspaceRoot() {
65
68
  if (!deps) throw new Error("@mulmoclaude/accounting-plugin: configureAccountingServer() must be called before serving accounting requests");
69
+ if (deps.workspaceRoot === null) throw new Error("@mulmoclaude/accounting-plugin: this host is configured with workspaceRoot: null (explicit-root mode), so every call must pass a workspaceRoot. A call reached the engine without one.");
66
70
  return deps.workspaceRoot;
67
71
  }
72
+ /** The opaque channel scope for a root, or `null` when the host declared
73
+ * none (single-root hosts, and a multi-root host's default root). Used
74
+ * by the event publisher; never contains a path.
75
+ *
76
+ * A missing root throws under explicit-root mode, exactly as
77
+ * `defaultWorkspaceRoot()` does. Returning `null` there would be worse
78
+ * than the io-path bug it mirrors: the event would go out on the
79
+ * UNSCOPED channel name, which every project's default-scope
80
+ * subscriber receives. The publisher catches it and logs, so a dropped
81
+ * root costs one event and a warning rather than a silent cross-project
82
+ * notification. */
83
+ function channelScopeFor(workspaceRoot) {
84
+ if (!deps?.channelScopeForRoot) return null;
85
+ return deps.channelScopeForRoot(workspaceRoot ?? defaultWorkspaceRoot());
86
+ }
87
+ /** Whether the host declared project scoping at all.
88
+ *
89
+ * The difference matters where a card records the project it belongs
90
+ * to: on a scoped host, "no scope" is a REAL answer (the default root)
91
+ * and must be recorded as `null`, not left out — an omitted field means
92
+ * "ask the host what is active now", which is precisely the drift a
93
+ * pinned card exists to prevent. On an unscoped host the field is left
94
+ * out entirely and every payload stays byte-identical to today's. */
95
+ function isProjectScopedHost() {
96
+ return Boolean(deps?.channelScopeForRoot);
97
+ }
68
98
  var consoleLogger = {
69
99
  error: (namespace, msg, data) => console.error(`[${namespace}] ${msg}`, data ?? ""),
70
100
  warn: (namespace, msg, data) => console.warn(`[${namespace}] ${msg}`, data ?? ""),
@@ -1141,9 +1171,16 @@ var pubsub = null;
1141
1171
  function initAccountingEventPublisher(instance) {
1142
1172
  pubsub = instance;
1143
1173
  }
1144
- function safePublish(channel, payload) {
1174
+ /** `channelFor` is called INSIDE the guard, not before it: resolving the
1175
+ * scope can throw (a write that reached the publisher with no root
1176
+ * under explicit-root mode), and a lost event with a warning beats
1177
+ * either crashing the write or publishing to the unscoped channel that
1178
+ * every project can hear. */
1179
+ function safePublish(channelFor, payload) {
1145
1180
  if (!pubsub) return;
1181
+ let channel = "(unresolved)";
1146
1182
  try {
1183
+ channel = channelFor();
1147
1184
  pubsub.publish(channel, payload);
1148
1185
  } catch (err) {
1149
1186
  log.warn("accounting", "publish failed; subscribers will miss this event", {
@@ -1154,15 +1191,20 @@ function safePublish(channel, payload) {
1154
1191
  }
1155
1192
  /** Per-book change notification. `period` should be the entry's
1156
1193
  * YYYY-MM bucket (or the earliest invalidated month for snapshot
1157
- * events). */
1158
- function publishBookChange(bookId, payload) {
1159
- safePublish(bookChannel(bookId), payload);
1194
+ * events).
1195
+ *
1196
+ * `workspaceRoot` is the root the write happened under; the channel
1197
+ * name is namespaced by the host's opaque scope for it (see
1198
+ * `channelScopeFor`). Omit it — as a single-root host's service calls
1199
+ * do — and the name is what it has always been. */
1200
+ function publishBookChange(bookId, payload, workspaceRoot) {
1201
+ safePublish(() => bookChannel(bookId, channelScopeFor(workspaceRoot)), payload);
1160
1202
  }
1161
1203
  /** Fired when the *list* of books changes (createBook, deleteBook).
1162
1204
  * Payload is intentionally empty — subscribers refetch from
1163
- * /api/accounting. */
1164
- function publishBooksChanged() {
1165
- safePublish(ACCOUNTING_BOOKS_CHANNEL, {});
1205
+ * /api/accounting. Scoped by root like `publishBookChange`. */
1206
+ function publishBooksChanged(workspaceRoot) {
1207
+ safePublish(() => booksChannel(channelScopeFor(workspaceRoot)), {});
1166
1208
  }
1167
1209
  //#endregion
1168
1210
  //#region src/server/snapshotCache.ts
@@ -1231,18 +1273,39 @@ async function rebuildAllSnapshots(bookId, workspaceRoot) {
1231
1273
  return { rebuilt: periods };
1232
1274
  }
1233
1275
  var rebuildQueues = /* @__PURE__ */ new Map();
1276
+ /** Canonical key for the root a rebuild belongs to. An absent root
1277
+ * resolves to the host's configured one, so a call that passes it
1278
+ * explicitly and one that relies on the default share a queue. Under
1279
+ * explicit-root mode there is no default and `defaultWorkspaceRoot()`
1280
+ * throws — deliberately loud: a `cancelRebuild` / `awaitRebuildIdle`
1281
+ * that quietly keyed a queue nobody scheduled would return while the
1282
+ * real rebuild is still writing, which is how `deleteBook` races a
1283
+ * `writeSnapshot` back into existence.
1284
+ *
1285
+ * Lexical resolution only — symlinks are deliberately NOT resolved,
1286
+ * matching `canonicalRoot` in `@mulmoclaude/core`'s collection engine,
1287
+ * so one project has one identity across both packages. A host that
1288
+ * hands the engine a real path in one call and a symlink alias in the
1289
+ * next would split the queue; the fix belongs in the host's own root
1290
+ * resolution, which is where the two packages agree it lives. */
1291
+ function rootKey(workspaceRoot) {
1292
+ return path.resolve(workspaceRoot ?? defaultWorkspaceRoot());
1293
+ }
1294
+ function queueKey(bookId, workspaceRoot) {
1295
+ return `${rootKey(workspaceRoot)}\u0000${bookId}`;
1296
+ }
1234
1297
  function minPeriod(lhs, rhs) {
1235
1298
  if (lhs === null) return rhs;
1236
1299
  return lhs < rhs ? lhs : rhs;
1237
1300
  }
1238
- function isInvalidatedDuringRebuild(bookId, period) {
1239
- const queue = rebuildQueues.get(bookId);
1301
+ function isInvalidatedDuringRebuild(key, period) {
1302
+ const queue = rebuildQueues.get(key);
1240
1303
  return queue !== void 0 && queue.pendingFromPeriod !== null && period >= queue.pendingFromPeriod;
1241
1304
  }
1242
- function isCancelled(bookId) {
1243
- return rebuildQueues.get(bookId)?.cancelled === true;
1305
+ function isCancelled(key) {
1306
+ return rebuildQueues.get(key)?.cancelled === true;
1244
1307
  }
1245
- async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1308
+ async function runRebuild(key, bookId, fromPeriod, workspaceRoot) {
1246
1309
  const startedAt = Date.now();
1247
1310
  log.info("accounting", "snapshot rebuild started", {
1248
1311
  bookId,
@@ -1251,25 +1314,25 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1251
1314
  publishBookChange(bookId, {
1252
1315
  kind: BOOK_EVENT_KINDS.snapshotsRebuilding,
1253
1316
  period: fromPeriod
1254
- });
1317
+ }, workspaceRoot);
1255
1318
  const targets = (await listJournalPeriods(bookId, workspaceRoot)).filter((monthKey) => monthKey >= fromPeriod);
1256
1319
  let written = 0;
1257
1320
  for (const monthKey of targets) {
1258
- if (isCancelled(bookId)) break;
1259
- if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1321
+ if (isCancelled(key)) break;
1322
+ if (isInvalidatedDuringRebuild(key, monthKey)) break;
1260
1323
  const balances = await balancesAtEndOf(bookId, monthKey, workspaceRoot);
1261
- if (isCancelled(bookId)) break;
1262
- if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1324
+ if (isCancelled(key)) break;
1325
+ if (isInvalidatedDuringRebuild(key, monthKey)) break;
1263
1326
  await writeSnapshot(bookId, {
1264
1327
  period: monthKey,
1265
1328
  balances,
1266
1329
  builtAt: (/* @__PURE__ */ new Date()).toISOString()
1267
1330
  }, workspaceRoot);
1268
- if (isCancelled(bookId)) {
1331
+ if (isCancelled(key)) {
1269
1332
  await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1270
1333
  break;
1271
1334
  }
1272
- if (isInvalidatedDuringRebuild(bookId, monthKey)) {
1335
+ if (isInvalidatedDuringRebuild(key, monthKey)) {
1273
1336
  await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1274
1337
  break;
1275
1338
  }
@@ -1277,7 +1340,7 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1277
1340
  publishBookChange(bookId, {
1278
1341
  kind: BOOK_EVENT_KINDS.snapshotsReady,
1279
1342
  period: monthKey
1280
- });
1343
+ }, workspaceRoot);
1281
1344
  }
1282
1345
  log.info("accounting", "snapshot rebuild done", {
1283
1346
  bookId,
@@ -1285,7 +1348,7 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1285
1348
  durationMs: Date.now() - startedAt
1286
1349
  });
1287
1350
  }
1288
- function startRebuild(bookId, fromPeriod, workspaceRoot) {
1351
+ function startRebuild(key, bookId, fromPeriod, workspaceRoot) {
1289
1352
  const entry = {
1290
1353
  running: Promise.resolve(),
1291
1354
  pendingFromPeriod: null,
@@ -1294,27 +1357,27 @@ function startRebuild(bookId, fromPeriod, workspaceRoot) {
1294
1357
  runningFromPeriod: fromPeriod,
1295
1358
  cancelled: false
1296
1359
  };
1297
- entry.running = runRebuild(bookId, fromPeriod, workspaceRoot).catch((err) => {
1360
+ entry.running = runRebuild(key, bookId, fromPeriod, workspaceRoot).catch((err) => {
1298
1361
  log.error("accounting", "snapshot rebuild failed", {
1299
1362
  bookId,
1300
1363
  fromPeriod,
1301
1364
  error: errorMessage(err)
1302
1365
  });
1303
1366
  }).then(() => {
1304
- const current = rebuildQueues.get(bookId);
1367
+ const current = rebuildQueues.get(key);
1305
1368
  if (!current) return;
1306
1369
  if (current.cancelled) {
1307
- rebuildQueues.delete(bookId);
1370
+ rebuildQueues.delete(key);
1308
1371
  return;
1309
1372
  }
1310
1373
  if (current.pendingFromPeriod !== null) {
1311
1374
  const nextFrom = current.pendingFromPeriod;
1312
1375
  const nextRoot = current.pendingWorkspaceRoot;
1313
1376
  const carriedCount = current.coalescedWriteCount;
1314
- const successor = startRebuild(bookId, nextFrom, nextRoot);
1377
+ const successor = startRebuild(key, bookId, nextFrom, nextRoot);
1315
1378
  successor.coalescedWriteCount += carriedCount;
1316
- rebuildQueues.set(bookId, successor);
1317
- } else rebuildQueues.delete(bookId);
1379
+ rebuildQueues.set(key, successor);
1380
+ } else rebuildQueues.delete(key);
1318
1381
  });
1319
1382
  return entry;
1320
1383
  }
@@ -1323,9 +1386,10 @@ function startRebuild(bookId, fromPeriod, workspaceRoot) {
1323
1386
  * follow-up rebuild that covers the minimum `fromPeriod` seen.
1324
1387
  * Returns immediately — the rebuild runs on its own promise chain. */
1325
1388
  function scheduleRebuild(bookId, fromPeriod, workspaceRoot) {
1326
- const existing = rebuildQueues.get(bookId);
1389
+ const key = queueKey(bookId, workspaceRoot);
1390
+ const existing = rebuildQueues.get(key);
1327
1391
  if (!existing) {
1328
- rebuildQueues.set(bookId, startRebuild(bookId, fromPeriod, workspaceRoot));
1392
+ rebuildQueues.set(key, startRebuild(key, bookId, fromPeriod, workspaceRoot));
1329
1393
  return;
1330
1394
  }
1331
1395
  existing.pendingFromPeriod = minPeriod(existing.pendingFromPeriod, fromPeriod);
@@ -1336,9 +1400,10 @@ function scheduleRebuild(bookId, fromPeriod, workspaceRoot) {
1336
1400
  * `bookId`. Also called by `deleteBook` after `cancelRebuild` to
1337
1401
  * ensure a previously running rebuild has fully stopped before the
1338
1402
  * caller removes the book's directory on disk. */
1339
- async function awaitRebuildIdle(bookId) {
1340
- while (rebuildQueues.has(bookId)) {
1341
- const entry = rebuildQueues.get(bookId);
1403
+ async function awaitRebuildIdle(bookId, workspaceRoot) {
1404
+ const key = queueKey(bookId, workspaceRoot);
1405
+ while (rebuildQueues.has(key)) {
1406
+ const entry = rebuildQueues.get(key);
1342
1407
  if (!entry) return;
1343
1408
  await entry.running;
1344
1409
  }
@@ -1348,8 +1413,8 @@ async function awaitRebuildIdle(bookId) {
1348
1413
  * `removeBookDir` cannot race with a `writeSnapshot` that would
1349
1414
  * re-create the directory tree. Pair with `awaitRebuildIdle(bookId)`
1350
1415
  * to wait for the in-flight rebuild to finish bailing. */
1351
- function cancelRebuild(bookId) {
1352
- const entry = rebuildQueues.get(bookId);
1416
+ function cancelRebuild(bookId, workspaceRoot) {
1417
+ const entry = rebuildQueues.get(queueKey(bookId, workspaceRoot));
1353
1418
  if (!entry) return;
1354
1419
  entry.cancelled = true;
1355
1420
  entry.pendingFromPeriod = null;
@@ -1703,7 +1768,7 @@ async function createBook(input, workspaceRoot) {
1703
1768
  await ensureBookDir(bookId, workspaceRoot);
1704
1769
  await writeAccounts(bookId, [...DEFAULT_ACCOUNTS], workspaceRoot);
1705
1770
  await writeConfig({ books: [...config.books, book] }, workspaceRoot);
1706
- publishBooksChanged();
1771
+ publishBooksChanged(workspaceRoot);
1707
1772
  return { book };
1708
1773
  }
1709
1774
  async function updateBook(input, workspaceRoot) {
@@ -1720,7 +1785,7 @@ async function updateBook(input, workspaceRoot) {
1720
1785
  };
1721
1786
  if (country === "") delete next.country;
1722
1787
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1723
- publishBooksChanged();
1788
+ publishBooksChanged(workspaceRoot);
1724
1789
  return { book: next };
1725
1790
  }
1726
1791
  async function deleteBook(input, workspaceRoot) {
@@ -1728,11 +1793,11 @@ async function deleteBook(input, workspaceRoot) {
1728
1793
  const config = await loadOrInitConfig(workspaceRoot);
1729
1794
  const target = findBook(config, input.bookId);
1730
1795
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1731
- cancelRebuild(input.bookId);
1732
- await awaitRebuildIdle(input.bookId);
1796
+ cancelRebuild(input.bookId, workspaceRoot);
1797
+ await awaitRebuildIdle(input.bookId, workspaceRoot);
1733
1798
  await removeBookDir(input.bookId, workspaceRoot);
1734
1799
  await writeConfig({ books: config.books.filter((book) => book.id !== input.bookId) }, workspaceRoot);
1735
- publishBooksChanged();
1800
+ publishBooksChanged(workspaceRoot);
1736
1801
  return {
1737
1802
  deletedBookId: input.bookId,
1738
1803
  deletedBookName: target.name
@@ -1782,7 +1847,7 @@ async function upsertAccount(input, workspaceRoot) {
1782
1847
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1783
1848
  await invalidateAllSnapshots(bookId, workspaceRoot);
1784
1849
  }
1785
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.accounts });
1850
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.accounts }, workspaceRoot);
1786
1851
  return {
1787
1852
  bookId,
1788
1853
  account,
@@ -1831,7 +1896,7 @@ async function addEntries(input, workspaceRoot) {
1831
1896
  publishBookChange(bookId, {
1832
1897
  kind: BOOK_EVENT_KINDS.journal,
1833
1898
  period: earliestPeriod
1834
- });
1899
+ }, workspaceRoot);
1835
1900
  return {
1836
1901
  bookId,
1837
1902
  entries: built
@@ -1860,7 +1925,7 @@ async function voidEntry(input, workspaceRoot) {
1860
1925
  publishBookChange(bookId, {
1861
1926
  kind: BOOK_EVENT_KINDS.journal,
1862
1927
  period: fromPeriod
1863
- });
1928
+ }, workspaceRoot);
1864
1929
  return {
1865
1930
  bookId,
1866
1931
  reverseEntry: reverse,
@@ -1924,7 +1989,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1924
1989
  await appendJournal(bookId, opening, workspaceRoot);
1925
1990
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1926
1991
  await invalidateAllSnapshots(bookId, workspaceRoot);
1927
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.opening });
1992
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.opening }, workspaceRoot);
1928
1993
  return {
1929
1994
  bookId,
1930
1995
  openingEntry: opening,
@@ -2022,9 +2087,10 @@ function validateTimeSeriesInput(input) {
2022
2087
  async function loadTimeSeriesBookContext(requestedBookId, workspaceRoot) {
2023
2088
  const config = await loadOrInitConfig(workspaceRoot);
2024
2089
  const bookId = resolveBookId(config, requestedBookId);
2090
+ const book = findBook(config, bookId);
2025
2091
  return {
2026
2092
  bookId,
2027
- fiscalYearEnd: resolveFiscalYearEnd(findBook(config, bookId)?.fiscalYearEnd),
2093
+ fiscalYearEnd: resolveFiscalYearEnd(book?.fiscalYearEnd),
2028
2094
  accounts: await readAccounts(bookId, workspaceRoot)
2029
2095
  };
2030
2096
  }
@@ -2058,7 +2124,7 @@ async function getTimeSeriesReport(input, workspaceRoot) {
2058
2124
  async function rebuildSnapshots(input, workspaceRoot) {
2059
2125
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
2060
2126
  const result = await rebuildAllSnapshots(bookId, workspaceRoot);
2061
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.snapshotsReady });
2127
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.snapshotsReady }, workspaceRoot);
2062
2128
  return {
2063
2129
  bookId,
2064
2130
  rebuilt: result.rebuilt
@@ -2085,19 +2151,21 @@ function asyncHandler(namespace, fallbackMessage, handler) {
2085
2151
  }
2086
2152
  //#endregion
2087
2153
  //#region src/server/router.ts
2088
- async function handleOpenBook(rest) {
2154
+ async function handleOpenBook(rest, root) {
2089
2155
  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.");
2090
- const list = await listBooks();
2156
+ const list = await listBooks(root);
2091
2157
  if (!list.books.some((book) => book.id === rest.bookId)) throw new AccountingError(404, `openBook: book ${JSON.stringify(rest.bookId)} not found`);
2092
2158
  const initialTab = typeof rest.initialTab === "string" ? rest.initialTab : void 0;
2159
+ const scopeField = isProjectScopedHost() ? { scope: channelScopeFor(root) } : {};
2093
2160
  return {
2094
2161
  kind: "accounting-app",
2095
2162
  bookId: rest.bookId,
2096
2163
  initialTab,
2097
- books: list.books
2164
+ books: list.books,
2165
+ ...scopeField
2098
2166
  };
2099
2167
  }
2100
- async function handleGetReport(rest) {
2168
+ async function handleGetReport(rest, root) {
2101
2169
  const kind = typeof rest.kind === "string" ? rest.kind : "";
2102
2170
  const periodInput = optionalReportPeriod(rest.period);
2103
2171
  const bookId = optionalString(rest.bookId);
@@ -2108,14 +2176,14 @@ async function handleGetReport(rest) {
2108
2176
  return getBalanceSheetReport({
2109
2177
  bookId,
2110
2178
  period: periodInput
2111
- });
2179
+ }, root);
2112
2180
  }
2113
2181
  if (kind === "pl") {
2114
2182
  if (!periodInput) throw periodRequired("getReport pl");
2115
2183
  return getProfitLossReport({
2116
2184
  bookId,
2117
2185
  period: periodInput
2118
- });
2186
+ }, root);
2119
2187
  }
2120
2188
  if (kind === "ledger") {
2121
2189
  if (typeof rest.accountCode !== "string" || rest.accountCode === "") throw new AccountingError(400, "getReport ledger: accountCode is required");
@@ -2123,79 +2191,79 @@ async function handleGetReport(rest) {
2123
2191
  bookId,
2124
2192
  accountCode: rest.accountCode,
2125
2193
  period: periodInput
2126
- });
2194
+ }, root);
2127
2195
  }
2128
2196
  throw new AccountingError(400, `getReport: unknown kind ${JSON.stringify(kind)}`);
2129
2197
  }
2130
2198
  var ACTION_HANDLERS = {
2131
2199
  [ACCOUNTING_ACTIONS.openBook]: handleOpenBook,
2132
- [ACCOUNTING_ACTIONS.getBooks]: () => listBooks(),
2133
- [ACCOUNTING_ACTIONS.createBook]: async (rest) => {
2200
+ [ACCOUNTING_ACTIONS.getBooks]: (_rest, root) => listBooks(root),
2201
+ [ACCOUNTING_ACTIONS.createBook]: async (rest, root) => {
2134
2202
  const result = await createBook({
2135
2203
  name: typeof rest.name === "string" ? rest.name : "",
2136
2204
  currency: typeof rest.currency === "string" ? rest.currency : void 0,
2137
2205
  country: typeof rest.country === "string" ? rest.country : void 0,
2138
2206
  fiscalYearEnd: rest.fiscalYearEnd
2139
- });
2207
+ }, root);
2140
2208
  return {
2141
2209
  bookId: result.book.id,
2142
2210
  ...result
2143
2211
  };
2144
2212
  },
2145
- [ACCOUNTING_ACTIONS.updateBook]: async (rest) => {
2213
+ [ACCOUNTING_ACTIONS.updateBook]: async (rest, root) => {
2146
2214
  const result = await updateBook({
2147
2215
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
2148
2216
  name: typeof rest.name === "string" ? rest.name : void 0,
2149
2217
  country: typeof rest.country === "string" ? rest.country : void 0,
2150
2218
  fiscalYearEnd: rest.fiscalYearEnd
2151
- });
2219
+ }, root);
2152
2220
  return {
2153
2221
  bookId: result.book.id,
2154
2222
  ...result
2155
2223
  };
2156
2224
  },
2157
- [ACCOUNTING_ACTIONS.deleteBook]: (rest) => deleteBook({
2225
+ [ACCOUNTING_ACTIONS.deleteBook]: (rest, root) => deleteBook({
2158
2226
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
2159
2227
  confirm: rest.confirm === true
2160
- }),
2161
- [ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: optionalString(rest.bookId) }),
2162
- [ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
2228
+ }, root),
2229
+ [ACCOUNTING_ACTIONS.getAccounts]: (rest, root) => listAccounts({ bookId: optionalString(rest.bookId) }, root),
2230
+ [ACCOUNTING_ACTIONS.upsertAccount]: (rest, root) => upsertAccount({
2163
2231
  bookId: optionalString(rest.bookId),
2164
2232
  account: rest.account
2165
- }),
2166
- [ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
2233
+ }, root),
2234
+ [ACCOUNTING_ACTIONS.addEntries]: (rest, root) => addEntries({
2167
2235
  bookId: optionalString(rest.bookId),
2168
2236
  entries: rest.entries
2169
- }),
2170
- [ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
2237
+ }, root),
2238
+ [ACCOUNTING_ACTIONS.voidEntry]: (rest, root) => voidEntry({
2171
2239
  bookId: optionalString(rest.bookId),
2172
2240
  entryId: typeof rest.entryId === "string" ? rest.entryId : "",
2173
2241
  reason: optionalString(rest.reason),
2174
2242
  voidDate: optionalString(rest.voidDate)
2175
- }),
2176
- [ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
2243
+ }, root),
2244
+ [ACCOUNTING_ACTIONS.getJournalEntries]: (rest, root) => listEntries({
2177
2245
  bookId: optionalString(rest.bookId),
2178
2246
  from: optionalString(rest.from),
2179
2247
  to: optionalString(rest.to),
2180
2248
  accountCode: optionalString(rest.accountCode)
2181
- }),
2182
- [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: optionalString(rest.bookId) }),
2183
- [ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
2249
+ }, root),
2250
+ [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest, root) => getOpeningBalances({ bookId: optionalString(rest.bookId) }, root),
2251
+ [ACCOUNTING_ACTIONS.setOpeningBalances]: (rest, root) => setOpeningBalances({
2184
2252
  bookId: optionalString(rest.bookId),
2185
2253
  asOfDate: typeof rest.asOfDate === "string" ? rest.asOfDate : "",
2186
2254
  lines: rest.lines ?? [],
2187
2255
  memo: optionalString(rest.memo)
2188
- }),
2256
+ }, root),
2189
2257
  [ACCOUNTING_ACTIONS.getReport]: handleGetReport,
2190
- [ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
2258
+ [ACCOUNTING_ACTIONS.getTimeSeries]: (rest, root) => getTimeSeriesReport({
2191
2259
  bookId: optionalString(rest.bookId),
2192
2260
  metric: rest.metric,
2193
2261
  granularity: rest.granularity,
2194
2262
  from: rest.from,
2195
2263
  to: rest.to,
2196
2264
  accountCode: rest.accountCode
2197
- }),
2198
- [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: optionalString(rest.bookId) })
2265
+ }, root),
2266
+ [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest, root) => rebuildSnapshots({ bookId: optionalString(rest.bookId) }, root)
2199
2267
  };
2200
2268
  var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
2201
2269
  ACCOUNTING_ACTIONS.openBook,
@@ -2272,11 +2340,11 @@ function previewMessage(action, fields) {
2272
2340
  const head = ownEntry(MESSAGE_BUILDERS, action)?.(fields);
2273
2341
  return head ? `${head} ${VIEW_VISIBLE_TRAILER}` : VIEW_VISIBLE_TRAILER;
2274
2342
  }
2275
- async function dispatch(body) {
2343
+ async function dispatch(body, root) {
2276
2344
  const { action, ...rest } = body;
2277
2345
  const handler = ownEntry(ACTION_HANDLERS, action);
2278
2346
  if (!handler) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2279
- const result = await handler(rest);
2347
+ const result = await handler(rest, root);
2280
2348
  const handlerFields = isRecord(result) ? result : { value: result };
2281
2349
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
2282
2350
  action,
@@ -2293,8 +2361,9 @@ async function dispatch(body) {
2293
2361
  /** Build the accounting Express router. The host injects its workspace
2294
2362
  * root + logger via `configureAccountingServer(...)` and pub/sub via
2295
2363
  * `initAccountingEventPublisher(...)`, then mounts the returned router
2296
- * with `app.use(...)`. */
2297
- function createAccountingRouter() {
2364
+ * with `app.use(...)`. Pass `resolveWorkspaceRoot` to serve more than
2365
+ * one root; omit it and every request uses the configured one. */
2366
+ function createAccountingRouter(options = {}) {
2298
2367
  const router = Router();
2299
2368
  router.post(ACCOUNTING_API.dispatch.path, asyncHandler("accounting", "accounting dispatch failed", async (req, res) => {
2300
2369
  const { body } = req;
@@ -2306,7 +2375,8 @@ function createAccountingRouter() {
2306
2375
  const { action } = body;
2307
2376
  log.info("accounting", "POST dispatch: start", { action });
2308
2377
  try {
2309
- const result = await dispatch(body);
2378
+ const root = options.resolveWorkspaceRoot?.(req);
2379
+ const result = await dispatch(body, root);
2310
2380
  log.info("accounting", "POST dispatch: ok", { action });
2311
2381
  res.json(result);
2312
2382
  } catch (err) {