@mulmoclaude/accounting-plugin 0.2.0 → 0.3.1

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ACCOUNTING_ACTIONS, ACCOUNTING_API, ACCOUNTING_BOOKS_CHANNEL, ACCOUNTING_DIRS, BOOK_EVENT_KINDS, FISCAL_YEAR_ENDS, SUPPORTED_COUNTRY_CODES, TIME_SERIES_GRANULARITIES, TIME_SERIES_METRICS, bookChannel, errorMessage, fiscalYearEndMonth, isFiscalYearEnd, isSupportedCountryCode } from "./shared.js";
1
+ import { ACCOUNTING_ACTIONS, ACCOUNTING_API, ACCOUNTING_BOOKS_CHANNEL, ACCOUNTING_DIRS, BOOK_EVENT_KINDS, FISCAL_YEAR_END_MONTHS, SUPPORTED_COUNTRY_CODES, TIME_SERIES_GRANULARITIES, TIME_SERIES_METRICS, bookChannel, errorMessage, fiscalYearEndMonth, isFiscalYearEnd, isSupportedCountryCode, resolveFiscalYearEnd } from "./shared.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
@@ -142,8 +142,27 @@ async function readJsonStrict(filePath) {
142
142
  throw err;
143
143
  }
144
144
  }
145
+ /** Migrate a legacy calendar-quarter `fiscalYearEnd` token ("Q1".."Q4")
146
+ * to its closing-month number in memory so every downstream consumer
147
+ * (reports, time-series, the UI selects) sees one shape. Absent stays
148
+ * absent — the field is optional and resolves to the default on read;
149
+ * we don't stamp an explicit December onto a book that never chose one.
150
+ * Nothing is written back here (no auto-migrate on disk). */
151
+ function normalizeBookFiscalYearEnd(book) {
152
+ if (book.fiscalYearEnd === void 0) return book;
153
+ const resolved = resolveFiscalYearEnd(book.fiscalYearEnd);
154
+ return book.fiscalYearEnd === resolved ? book : {
155
+ ...book,
156
+ fiscalYearEnd: resolved
157
+ };
158
+ }
145
159
  async function readConfig(workspaceRoot) {
146
- return readJsonStrict(configPath(workspaceRoot));
160
+ const config = await readJsonStrict(configPath(workspaceRoot));
161
+ if (!config) return null;
162
+ return {
163
+ ...config,
164
+ books: config.books.map(normalizeBookFiscalYearEnd)
165
+ };
147
166
  }
148
167
  async function writeConfig(config, workspaceRoot) {
149
168
  await writeJsonAtomic(configPath(workspaceRoot), config);
@@ -1464,29 +1483,47 @@ function unsupportedCountryError(received) {
1464
1483
  return new AccountingError(400, `unsupported country code ${JSON.stringify(received)} — must be one of: ${SUPPORTED_COUNTRY_CODES.join(", ")}`);
1465
1484
  }
1466
1485
  function unsupportedFiscalYearEndError(received) {
1467
- return new AccountingError(400, `unsupported fiscalYearEnd ${JSON.stringify(received)} — must be one of: ${FISCAL_YEAR_ENDS.join(", ")}`);
1468
- }
1469
- /** Narrow a free-form `fiscalYearEnd` input. Empty / absent → default
1470
- * (back-compat with old callers and pre-field on-disk books); any
1471
- * other value must match the enum or 400. */
1472
- function narrowFiscalYearEnd(raw) {
1473
- if (raw === void 0 || raw === "") return "Q4";
1474
- if (!isFiscalYearEnd(raw)) throw unsupportedFiscalYearEndError(raw);
1475
- return raw;
1476
- }
1477
- /** Boundary checks shared by updateBook. Throws on the first failure
1478
- * so the surrounding function stays under the cognitive-complexity
1479
- * threshold; each rule is also unit-testable independently via the
1480
- * service entry point. */
1486
+ return new AccountingError(400, `unsupported fiscalYearEnd ${JSON.stringify(received)} — must be a closing-month number ${FISCAL_YEAR_END_MONTHS.join(", ")} (1 = January … 12 = December)`);
1487
+ }
1488
+ /** Coerce + validate a free-form `fiscalYearEnd` from any ingress path
1489
+ * (REST body, MCP tool args, direct callers). The service is the
1490
+ * validation boundary, so this is deliberately tolerant of input SHAPE
1491
+ * but strict about the resulting value:
1492
+ * - absent / null / empty string `undefined` (field omitted; the
1493
+ * caller decides default-vs-no-op);
1494
+ * - a number, or a numeric string ("8") from a hand-rolled client →
1495
+ * that month;
1496
+ * - a legacy calendar-quarter token ("Q1".."Q4") from a stale client
1497
+ * its closing month (same Q1→3 mapping the read side applies);
1498
+ * - anything else non-empty (a typo, garbage, an out-of-range or
1499
+ * non-integer number) 400, echoing the ORIGINAL value so the
1500
+ * bad payload can't be silently mistaken for the default. */
1501
+ function coerceFiscalYearEndInput(raw) {
1502
+ if (raw === void 0 || raw === null || raw === "") return void 0;
1503
+ let month = raw;
1504
+ if (typeof raw === "string") {
1505
+ const trimmed = raw.trim();
1506
+ if (trimmed === "") return void 0;
1507
+ if (/^Q[1-4]$/.test(trimmed)) return resolveFiscalYearEnd(trimmed);
1508
+ month = /^-?\d+$/.test(trimmed) ? Number(trimmed) : NaN;
1509
+ }
1510
+ if (!isFiscalYearEnd(month)) throw unsupportedFiscalYearEndError(raw);
1511
+ return month;
1512
+ }
1513
+ /** Boundary checks shared by updateBook (name / country only —
1514
+ * fiscalYearEnd is coerced + validated separately via
1515
+ * `coerceFiscalYearEndInput`). Throws on the first failure so the
1516
+ * surrounding function stays under the cognitive-complexity threshold;
1517
+ * each rule is also unit-testable independently via the service entry
1518
+ * point. */
1481
1519
  function validateUpdateBookInput(input) {
1482
1520
  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");
1483
1521
  if (input.country !== void 0 && input.country !== "" && !isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1484
- if (input.fiscalYearEnd !== void 0 && input.fiscalYearEnd !== "" && !isFiscalYearEnd(input.fiscalYearEnd)) throw unsupportedFiscalYearEndError(input.fiscalYearEnd);
1485
1522
  }
1486
1523
  async function createBook(input, workspaceRoot) {
1487
1524
  if (typeof input.name !== "string" || input.name.trim() === "") throw new AccountingError(400, "name is required");
1488
1525
  if (input.country !== void 0 && !isSupportedCountryCode(input.country)) throw unsupportedCountryError(input.country);
1489
- const fiscalYearEnd = narrowFiscalYearEnd(input.fiscalYearEnd);
1526
+ const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd) ?? 12;
1490
1527
  const config = await loadOrInitConfig(workspaceRoot);
1491
1528
  const bookId = input.id ?? await generateBookId(config, workspaceRoot);
1492
1529
  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 -)`);
@@ -1511,11 +1548,12 @@ async function updateBook(input, workspaceRoot) {
1511
1548
  const target = findBook(config, input.bookId);
1512
1549
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1513
1550
  validateUpdateBookInput(input);
1551
+ const fiscalYearEnd = coerceFiscalYearEndInput(input.fiscalYearEnd);
1514
1552
  const next = {
1515
1553
  ...target,
1516
1554
  ...input.name !== void 0 ? { name: input.name } : {},
1517
1555
  ...input.country !== void 0 && input.country !== "" ? { country: input.country } : {},
1518
- ...input.fiscalYearEnd !== void 0 && input.fiscalYearEnd !== "" ? { fiscalYearEnd: input.fiscalYearEnd } : {}
1556
+ ...fiscalYearEnd !== void 0 ? { fiscalYearEnd } : {}
1519
1557
  };
1520
1558
  if (input.country === "") delete next.country;
1521
1559
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
@@ -1800,7 +1838,7 @@ async function loadTimeSeriesBookContext(requestedBookId, workspaceRoot) {
1800
1838
  const bookId = resolveBookId(config, requestedBookId);
1801
1839
  return {
1802
1840
  bookId,
1803
- fiscalYearEnd: findBook(config, bookId)?.fiscalYearEnd ?? "Q4",
1841
+ fiscalYearEnd: resolveFiscalYearEnd(findBook(config, bookId)?.fiscalYearEnd),
1804
1842
  accounts: await readAccounts(bookId, workspaceRoot)
1805
1843
  };
1806
1844
  }
@@ -1911,7 +1949,7 @@ var ACTION_HANDLERS = {
1911
1949
  name: String(rest.name ?? ""),
1912
1950
  currency: typeof rest.currency === "string" ? rest.currency : void 0,
1913
1951
  country: typeof rest.country === "string" ? rest.country : void 0,
1914
- fiscalYearEnd: typeof rest.fiscalYearEnd === "string" ? rest.fiscalYearEnd : void 0
1952
+ fiscalYearEnd: rest.fiscalYearEnd
1915
1953
  });
1916
1954
  return {
1917
1955
  bookId: result.book.id,
@@ -1923,7 +1961,7 @@ var ACTION_HANDLERS = {
1923
1961
  bookId: String(rest.bookId ?? ""),
1924
1962
  name: typeof rest.name === "string" ? rest.name : void 0,
1925
1963
  country: typeof rest.country === "string" ? rest.country : void 0,
1926
- fiscalYearEnd: typeof rest.fiscalYearEnd === "string" ? rest.fiscalYearEnd : void 0
1964
+ fiscalYearEnd: rest.fiscalYearEnd
1927
1965
  });
1928
1966
  return {
1929
1967
  bookId: result.book.id,