@mulmoclaude/accounting-plugin 1.0.3 → 1.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.
package/dist/server.cjs CHANGED
@@ -21,13 +21,43 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_shared = require("./shared-CcU3_qAa.cjs");
24
+ const require_shared = require("./shared-C9K9ZkfK.cjs");
25
25
  let express = require("express");
26
26
  let node_crypto = require("node:crypto");
27
27
  let node_fs = require("node:fs");
28
28
  let node_path = require("node:path");
29
29
  node_path = __toESM(node_path, 1);
30
30
  let _mulmoclaude_core_files = require("@mulmoclaude/core/files");
31
+ //#region src/server/bodyFields.ts
32
+ var optionalString = (value) => typeof value === "string" ? value : void 0;
33
+ var optionalRecord = (value) => require_shared.isRecord(value) ? value : void 0;
34
+ /** Rebuilt field by field rather than narrowed with a predicate, so the
35
+ * returned object is one this function actually proved. A half-formed
36
+ * `{ kind: "month" }` reads as absent and the caller raises its own
37
+ * "period is required" — previously it reached the report builders and
38
+ * produced an `undefined-01` date. */
39
+ var optionalReportPeriod = (value) => {
40
+ const period = optionalRecord(value);
41
+ if (period?.kind === "month" && typeof period.period === "string") return {
42
+ kind: "month",
43
+ period: period.period
44
+ };
45
+ if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string") return {
46
+ kind: "range",
47
+ from: period.from,
48
+ to: period.to
49
+ };
50
+ };
51
+ /** The two fields the addEntries narration quotes back, read out of a
52
+ * service payload that is only `unknown` to the router. */
53
+ var describeEntry = (entry) => {
54
+ const record = optionalRecord(entry);
55
+ return {
56
+ id: optionalString(record?.id),
57
+ date: optionalString(record?.date)
58
+ };
59
+ };
60
+ //#endregion
31
61
  //#region src/server/context.ts
32
62
  var deps = null;
33
63
  /** Called once by the host before the accounting router is mounted. */
@@ -310,7 +340,7 @@ async function removeBookDir(bookId, workspaceRoot) {
310
340
  * so amounts are doubles). 0.005 keeps two-decimal currency math
311
341
  * honest while accepting the floating-point noise of summing
312
342
  * many lines. */
313
- var EQUALITY_TOLERANCE$1 = .005;
343
+ var EQUALITY_TOLERANCE = .005;
314
344
  function lineHasExactlyOneSide(line) {
315
345
  return (typeof line.debit === "number" && line.debit !== 0) !== (typeof line.credit === "number" && line.credit !== 0);
316
346
  }
@@ -349,36 +379,115 @@ function netBalance(lines) {
349
379
  }
350
380
  return net;
351
381
  }
352
- /** Pure validation. Does not throw; returns a list of issues so the
353
- * REST handler can return a structured 400 instead of an opaque
354
- * 500. */
355
- function validateLine(line, idx, accountCodes, errors) {
356
- if (!line.accountCode || !accountCodes.has(line.accountCode)) errors.push({
382
+ function checkTaxRegistrationId(value, idx, errors) {
383
+ if (value === void 0) return;
384
+ if (typeof value !== "string") {
385
+ errors.push({
386
+ field: `lines[${idx}].taxRegistrationId`,
387
+ message: "must be a string"
388
+ });
389
+ return;
390
+ }
391
+ if (value.trim().length > 32) errors.push({
392
+ field: `lines[${idx}].taxRegistrationId`,
393
+ message: `must be at most 32 characters (got ${value.trim().length})`
394
+ });
395
+ }
396
+ /** One issue per bad field, so a caller fixing a line learns about all
397
+ * of them at once. Returns whether the line came through clean. */
398
+ function checkLineFields(raw, idx, errors) {
399
+ const issuesBefore = errors.length;
400
+ const { accountCode, debit, credit, memo, taxRegistrationId } = raw;
401
+ if (typeof accountCode !== "string") errors.push({
357
402
  field: `lines[${idx}].accountCode`,
358
- message: `unknown account code ${JSON.stringify(line.accountCode)}`
403
+ message: "accountCode must be a string"
359
404
  });
360
- if (line.debit !== void 0 && !isNonNegativeNumber(line.debit)) errors.push({
405
+ if (debit !== void 0 && !isNonNegativeNumber(debit)) errors.push({
361
406
  field: `lines[${idx}].debit`,
362
407
  message: "debit must be a non-negative finite number"
363
408
  });
364
- if (line.credit !== void 0 && !isNonNegativeNumber(line.credit)) errors.push({
409
+ if (credit !== void 0 && !isNonNegativeNumber(credit)) errors.push({
365
410
  field: `lines[${idx}].credit`,
366
411
  message: "credit must be a non-negative finite number"
367
412
  });
413
+ if (memo !== void 0 && typeof memo !== "string") errors.push({
414
+ field: `lines[${idx}].memo`,
415
+ message: "memo must be a string"
416
+ });
417
+ checkTaxRegistrationId(taxRegistrationId, idx, errors);
418
+ return errors.length === issuesBefore;
419
+ }
420
+ /** Re-tested rather than assigned straight through: `checkLineFields`
421
+ * proved each of these, but only a `typeof` narrows them for the
422
+ * compiler. */
423
+ function buildLine(raw) {
424
+ const { accountCode, debit, credit, memo, taxRegistrationId } = raw;
425
+ if (typeof accountCode !== "string") return null;
426
+ const line = { accountCode };
427
+ if (typeof debit === "number") line.debit = debit;
428
+ if (typeof credit === "number") line.credit = credit;
429
+ if (typeof memo === "string") line.memo = memo;
430
+ if (typeof taxRegistrationId === "string") line.taxRegistrationId = taxRegistrationId;
431
+ return line;
432
+ }
433
+ /** Narrow one wire value to a `JournalLine`, pushing an issue per bad
434
+ * field. Shape only: account existence and the debit/credit-side rule
435
+ * belong to the caller, because journal entries and opening balances
436
+ * disagree about them. Returns null when nothing usable came out —
437
+ * the caller drops the line rather than reading fields off it. */
438
+ function parseJournalLine(raw, idx, errors) {
439
+ if (!require_shared.isRecord(raw)) {
440
+ errors.push({
441
+ field: `lines[${idx}]`,
442
+ message: "each line must be an object with an accountCode and a debit or credit amount"
443
+ });
444
+ return null;
445
+ }
446
+ if (!checkLineFields(raw, idx, errors)) return null;
447
+ return buildLine(raw);
448
+ }
449
+ /** The rules a journal line answers to on top of its shape: the code
450
+ * must name a real account, and exactly one side carries an amount. */
451
+ function validateEntryLine(line, idx, accountCodes, errors) {
452
+ if (!line.accountCode || !accountCodes.has(line.accountCode)) errors.push({
453
+ field: `lines[${idx}].accountCode`,
454
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
455
+ });
368
456
  if (!lineHasExactlyOneSide(line)) errors.push({
369
457
  field: `lines[${idx}]`,
370
458
  message: "each line must set exactly one of debit or credit (and to a non-zero amount)"
371
459
  });
372
- if (line.taxRegistrationId !== void 0) {
373
- if (typeof line.taxRegistrationId !== "string") errors.push({
374
- field: `lines[${idx}].taxRegistrationId`,
375
- message: "must be a string"
376
- });
377
- else if (line.taxRegistrationId.trim().length > 32) errors.push({
378
- field: `lines[${idx}].taxRegistrationId`,
379
- message: `must be at most 32 characters (got ${line.taxRegistrationId.trim().length})`
380
- });
381
- }
460
+ }
461
+ function parseEntryLines(raw, accountCodes, errors) {
462
+ const lines = [];
463
+ raw.forEach((rawLine, idx) => {
464
+ const line = parseJournalLine(rawLine, idx, errors);
465
+ if (line === null) return;
466
+ validateEntryLine(line, idx, accountCodes, errors);
467
+ lines.push(line);
468
+ });
469
+ return lines;
470
+ }
471
+ /** Report the debit = credit imbalance — but only when every line was
472
+ * readable. A line that failed to parse contributes nothing to the sum,
473
+ * so an entry that balances perfectly would otherwise be told it
474
+ * doesn't, sending the caller off to "fix" amounts that were never
475
+ * wrong. Naming the unreadable line is the actionable message; the
476
+ * balance is worth re-checking once it's a line. */
477
+ function checkBalances(lines, expectedLineCount, subject, errors) {
478
+ if (lines.length !== expectedLineCount) return;
479
+ const net = netBalance(lines);
480
+ if (Math.abs(net) > EQUALITY_TOLERANCE) errors.push({
481
+ field: "lines",
482
+ message: `Σ debit − Σ credit = ${net.toFixed(4)}; ${subject} must balance`
483
+ });
484
+ }
485
+ function parseOptionalString(value, field, errors) {
486
+ if (value === void 0 || typeof value === "string") return value;
487
+ errors.push({
488
+ field,
489
+ message: `${field} must be a string when supplied`
490
+ });
382
491
  }
383
492
  /** Normalize a journal line before persistence: trim string fields
384
493
  * and drop empty-string optionals so the JSONL doesn't accumulate
@@ -393,13 +502,26 @@ function normalizeLine(line) {
393
502
  }
394
503
  return out;
395
504
  }
396
- function validateEntry(input) {
505
+ /** Parse one entry off the wire. Does not throw: every rejection comes
506
+ * back as a list of issues so the REST handler can return a structured
507
+ * 400 instead of an opaque 500. Parse rather than validate — the
508
+ * narrowed entry rides along on success, so `makeEntry` never has to
509
+ * take the caller's word for the shape. */
510
+ function parseEntry(raw, accounts) {
511
+ if (!require_shared.isRecord(raw)) return {
512
+ ok: false,
513
+ errors: [{
514
+ field: "entry",
515
+ message: "each entry must be an object with a date and a lines array"
516
+ }]
517
+ };
397
518
  const errors = [];
398
- if (!isValidCalendarDate(input.date)) errors.push({
519
+ const date = typeof raw.date === "string" && isValidCalendarDate(raw.date) ? raw.date : null;
520
+ if (date === null) errors.push({
399
521
  field: "date",
400
- message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.date)}`
522
+ message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(raw.date)}`
401
523
  });
402
- if (!Array.isArray(input.lines) || input.lines.length < 2) {
524
+ if (!require_shared.isUnknownArray(raw.lines) || raw.lines.length < 2) {
403
525
  errors.push({
404
526
  field: "lines",
405
527
  message: "an entry needs at least two lines (one debit, one credit)"
@@ -409,20 +531,26 @@ function validateEntry(input) {
409
531
  errors
410
532
  };
411
533
  }
412
- const accountCodes = new Set(input.accounts.map((account) => account.code));
413
- input.lines.forEach((line, idx) => validateLine(line, idx, accountCodes, errors));
414
- const net = netBalance(input.lines);
415
- if (Math.abs(net) > EQUALITY_TOLERANCE$1) errors.push({
416
- field: "lines",
417
- message: `Σ debit − Σ credit = ${net.toFixed(4)}; entry must balance`
418
- });
419
- return {
420
- ok: errors.length === 0,
534
+ const lines = parseEntryLines(raw.lines, new Set(accounts.map((account) => account.code)), errors);
535
+ checkBalances(lines, raw.lines.length, "entry", errors);
536
+ const memo = parseOptionalString(raw.memo, "memo", errors);
537
+ const replacesEntryId = parseOptionalString(raw.replacesEntryId, "replacesEntryId", errors);
538
+ if (errors.length > 0 || date === null) return {
539
+ ok: false,
421
540
  errors
422
541
  };
542
+ return {
543
+ ok: true,
544
+ entry: {
545
+ date,
546
+ lines,
547
+ memo,
548
+ replacesEntryId
549
+ }
550
+ };
423
551
  }
424
- /** Build a JournalEntry validation is the caller's responsibility
425
- * (it should have called `validateEntry` first). The id is a fresh
552
+ /** Build a JournalEntry from lines something already parsed
553
+ * (`parseEntry` / `parseOpening`). The id is a fresh
426
554
  * UUID; createdAt is the wall clock at the moment of creation.
427
555
  * Lines are normalized so optional string fields don't persist as
428
556
  * empty strings. */
@@ -508,7 +636,6 @@ function voidedIdSet(entries) {
508
636
  }
509
637
  //#endregion
510
638
  //#region src/server/openingBalances.ts
511
- var EQUALITY_TOLERANCE = .005;
512
639
  /** Find the existing opening entry for a book, if any. Multiple
513
640
  * openings shouldn't coexist (the route enforces void-then-append),
514
641
  * but if they do the most recent by `createdAt` wins so callers
@@ -523,22 +650,34 @@ function findActiveOpening(entries) {
523
650
  }
524
651
  return active;
525
652
  }
526
- function validateLineAccountTypes(input, errors) {
527
- const accountByCode = new Map(input.accounts.map((account) => [account.code, account]));
528
- input.lines.forEach((line, idx) => {
529
- const acct = accountByCode.get(line.accountCode);
530
- if (!acct) {
531
- errors.push({
532
- field: `lines[${idx}].accountCode`,
533
- message: `unknown account code ${JSON.stringify(line.accountCode)}`
534
- });
535
- return;
536
- }
537
- if (!require_shared.BALANCE_SHEET_ACCOUNT_TYPES.includes(acct.type)) errors.push({
653
+ function validateOpeningAccount(line, idx, accountByCode, errors) {
654
+ const acct = accountByCode.get(line.accountCode);
655
+ if (!acct) {
656
+ errors.push({
538
657
  field: `lines[${idx}].accountCode`,
539
- message: `account ${acct.code} is type ${acct.type}; opening balances may only reference balance-sheet accounts (asset / liability / equity)`
658
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
540
659
  });
660
+ return;
661
+ }
662
+ if (!require_shared.BALANCE_SHEET_ACCOUNT_TYPES.includes(acct.type)) errors.push({
663
+ field: `lines[${idx}].accountCode`,
664
+ message: `account ${acct.code} is type ${acct.type}; opening balances may only reference balance-sheet accounts (asset / liability / equity)`
665
+ });
666
+ }
667
+ /** Opening lines are narrowed by the same shape parser the journal
668
+ * uses, but they answer to different rules afterwards: balance-sheet
669
+ * accounts only, and no "exactly one side" requirement — the opening
670
+ * form lets a user carry both columns on one account. */
671
+ function parseOpeningLines(raw, accounts, errors) {
672
+ const accountByCode = new Map(accounts.map((account) => [account.code, account]));
673
+ const lines = [];
674
+ raw.forEach((rawLine, idx) => {
675
+ const line = parseJournalLine(rawLine, idx, errors);
676
+ if (line === null) return;
677
+ validateOpeningAccount(line, idx, accountByCode, errors);
678
+ lines.push(line);
541
679
  });
680
+ return lines;
542
681
  }
543
682
  function validateAsOfPredatesEverything(input, errors) {
544
683
  const voided = voidedIdSet(input.existingEntries);
@@ -555,20 +694,21 @@ function validateAsOfPredatesEverything(input, errors) {
555
694
  }
556
695
  }
557
696
  }
558
- /** Validate inputs for `setOpeningBalances`. Caller passes the full
559
- * list of journal entries in the book so we can check the
560
- * "asOfDate must precede every other entry" rule. An opening with
697
+ /** Parse the inputs for `setOpeningBalances`, returning the narrowed
698
+ * lines so the caller can persist what was actually checked. Caller
699
+ * passes the full list of journal entries in the book so we can check
700
+ * the "asOfDate must precede every other entry" rule. An opening with
561
701
  * zero lines is accepted as a no-op marker — it satisfies the
562
702
  * "book has an opening" gate the UI uses without committing the
563
703
  * user to specific balances on day one (they can replace it
564
704
  * later). */
565
- function validateOpening(input) {
705
+ function parseOpening(input) {
566
706
  const errors = [];
567
707
  if (!isValidCalendarDate(input.asOfDate)) errors.push({
568
708
  field: "asOfDate",
569
709
  message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.asOfDate)}`
570
710
  });
571
- if (!Array.isArray(input.lines)) {
711
+ if (!require_shared.isUnknownArray(input.lines)) {
572
712
  errors.push({
573
713
  field: "lines",
574
714
  message: "lines must be an array"
@@ -578,20 +718,64 @@ function validateOpening(input) {
578
718
  errors
579
719
  };
580
720
  }
581
- validateLineAccountTypes(input, errors);
582
- const net = netBalance(input.lines);
583
- if (Math.abs(net) > EQUALITY_TOLERANCE) errors.push({
584
- field: "lines",
585
- message: `Σ debit − Σ credit = ${net.toFixed(4)}; opening must balance`
586
- });
721
+ const lines = parseOpeningLines(input.lines, input.accounts, errors);
722
+ checkBalances(lines, input.lines.length, "opening", errors);
587
723
  validateAsOfPredatesEverything(input, errors);
588
- return {
589
- ok: errors.length === 0,
724
+ if (errors.length > 0) return {
725
+ ok: false,
590
726
  errors
591
727
  };
728
+ return {
729
+ ok: true,
730
+ lines
731
+ };
592
732
  }
593
733
  //#endregion
594
734
  //#region src/server/accountNormalize.ts
735
+ function isAccountType(value) {
736
+ return require_shared.ACCOUNT_TYPES.some((accountType) => accountType === value);
737
+ }
738
+ /** Narrow a wire payload to an `Account`. `name` and `type` are as
739
+ * required as `code`: an account persisted without a type is invisible
740
+ * to every report, which groups rows by it. */
741
+ function parseAccountInput(raw) {
742
+ if (!require_shared.isRecord(raw)) return {
743
+ ok: false,
744
+ message: "account is required — pass an object with code, name, and type"
745
+ };
746
+ const { code, name, type, note, active } = raw;
747
+ if (typeof code !== "string" || code.length === 0) return {
748
+ ok: false,
749
+ message: "account code is required"
750
+ };
751
+ if (typeof name !== "string" || name.trim() === "") return {
752
+ ok: false,
753
+ message: "account name is required"
754
+ };
755
+ if (!isAccountType(type)) return {
756
+ ok: false,
757
+ message: `account type ${JSON.stringify(type)} is invalid — must be one of: ${require_shared.ACCOUNT_TYPES.join(", ")}`
758
+ };
759
+ if (note !== void 0 && typeof note !== "string") return {
760
+ ok: false,
761
+ message: "account note must be a string when supplied"
762
+ };
763
+ if (active !== void 0 && typeof active !== "boolean") return {
764
+ ok: false,
765
+ message: "account active must be a boolean when supplied"
766
+ };
767
+ const account = {
768
+ code,
769
+ name,
770
+ type
771
+ };
772
+ if (typeof note === "string") account.note = note;
773
+ if (typeof active === "boolean") account.active = active;
774
+ return {
775
+ ok: true,
776
+ account
777
+ };
778
+ }
595
779
  function normalizeStoredAccount(input, existing) {
596
780
  const stored = {
597
781
  code: input.code,
@@ -1555,44 +1739,65 @@ async function listAccounts(input, workspaceRoot) {
1555
1739
  accounts: await readAccounts(bookId, workspaceRoot)
1556
1740
  };
1557
1741
  }
1558
- async function upsertAccount(input, workspaceRoot) {
1559
- const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1560
- if (typeof input.account?.code !== "string" || input.account.code.length === 0) throw new AccountingError(400, "account code is required");
1561
- 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)`);
1562
- const accounts = await readAccounts(bookId, workspaceRoot);
1563
- const existingIdx = accounts.findIndex((account) => account.code === input.account.code);
1742
+ /** Parse the caller's account, then apply the one rule the chart owns
1743
+ * rather than the record: codes starting with `_` are reserved for the
1744
+ * synthetic rows the report layer injects (e.g. `_currentEarnings` in
1745
+ * the Equity section). A user account there would either duplicate a
1746
+ * B/S row or hide a real account behind the synthetic label. */
1747
+ function parseUpsertAccount(raw) {
1748
+ const parsed = parseAccountInput(raw);
1749
+ if (!parsed.ok) throw new AccountingError(400, parsed.message);
1750
+ if (parsed.account.code.startsWith("_")) throw new AccountingError(400, `account code ${JSON.stringify(parsed.account.code)} is reserved (codes starting with _ are used for synthetic report rows)`);
1751
+ return parsed.account;
1752
+ }
1753
+ /** Insert or replace the account in the chart. The whitelist +
1754
+ * active-flag policy lives in normalizeStoredAccount (see
1755
+ * ./accountNormalize.ts) so it stays unit-testable in isolation.
1756
+ * `previousType` is null for a new code — callers use it to decide
1757
+ * whether aggregation across periods just changed meaning. */
1758
+ function applyAccount(accounts, account) {
1759
+ const existingIdx = accounts.findIndex((stored) => stored.code === account.code);
1760
+ const existing = existingIdx >= 0 ? accounts[existingIdx] : void 0;
1761
+ const stored = normalizeStoredAccount(account, existing);
1564
1762
  const next = [...accounts];
1565
- const oldType = existingIdx >= 0 ? accounts[existingIdx].type : null;
1566
- const stored = normalizeStoredAccount(input.account, existingIdx >= 0 ? accounts[existingIdx] : void 0);
1567
1763
  if (existingIdx >= 0) next[existingIdx] = stored;
1568
1764
  else next.push(stored);
1765
+ return {
1766
+ next,
1767
+ previousType: existing ? existing.type : null
1768
+ };
1769
+ }
1770
+ async function upsertAccount(input, workspaceRoot) {
1771
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1772
+ const account = parseUpsertAccount(input.account);
1773
+ const { next, previousType } = applyAccount(await readAccounts(bookId, workspaceRoot), account);
1569
1774
  await writeAccounts(bookId, next, workspaceRoot);
1570
- if (oldType !== null && oldType !== input.account.type) {
1775
+ if (previousType !== null && previousType !== account.type) {
1571
1776
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1572
1777
  await invalidateAllSnapshots(bookId, workspaceRoot);
1573
1778
  }
1574
1779
  publishBookChange(bookId, { kind: require_shared.BOOK_EVENT_KINDS.accounts });
1575
1780
  return {
1576
1781
  bookId,
1577
- account: { ...input.account },
1782
+ account,
1578
1783
  accounts: next
1579
1784
  };
1580
1785
  }
1581
- function collectBatchValidationFailures(items, accounts) {
1786
+ function parseBatchEntries(items, accounts) {
1582
1787
  const failures = [];
1583
- for (let idx = 0; idx < items.length; idx++) {
1584
- const item = items[idx];
1585
- const validation = validateEntry({
1586
- date: item.date,
1587
- lines: item.lines,
1588
- accounts
1788
+ const parsed = [];
1789
+ items.forEach((item, index) => {
1790
+ const result = parseEntry(item, accounts);
1791
+ if (result.ok) parsed.push(result.entry);
1792
+ else failures.push({
1793
+ index,
1794
+ errors: result.errors
1589
1795
  });
1590
- if (!validation.ok) failures.push({
1591
- index: idx,
1592
- errors: validation.errors
1593
- });
1594
- }
1595
- return failures;
1796
+ });
1797
+ return {
1798
+ failures,
1799
+ items: parsed
1800
+ };
1596
1801
  }
1597
1802
  function buildBatchEntries(items) {
1598
1803
  return items.map((item) => makeEntry({
@@ -1608,11 +1813,11 @@ function earliestPeriodOf(entries) {
1608
1813
  }
1609
1814
  async function addEntries(input, workspaceRoot) {
1610
1815
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1611
- if (!Array.isArray(input.entries) || input.entries.length === 0) throw new AccountingError(400, "addEntries: entries must be a non-empty array");
1816
+ if (!require_shared.isUnknownArray(input.entries) || input.entries.length === 0) throw new AccountingError(400, "addEntries: entries must be a non-empty array");
1612
1817
  const accounts = await readAccounts(bookId, workspaceRoot);
1613
- const failures = collectBatchValidationFailures(input.entries, accounts);
1818
+ const { failures, items } = parseBatchEntries(input.entries, accounts);
1614
1819
  if (failures.length > 0) throw new AccountingError(400, "invalid journal entries", failures);
1615
- const built = buildBatchEntries(input.entries);
1820
+ const built = buildBatchEntries(items);
1616
1821
  await appendJournalBatch(bookId, built, workspaceRoot);
1617
1822
  const earliestPeriod = earliestPeriodOf(built);
1618
1823
  scheduleRebuild(bookId, earliestPeriod, workspaceRoot);
@@ -1691,13 +1896,13 @@ async function setOpeningBalances(input, workspaceRoot) {
1691
1896
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1692
1897
  const accounts = await readAccounts(bookId, workspaceRoot);
1693
1898
  const all = await readAllEntries(bookId, workspaceRoot);
1694
- const validation = validateOpening({
1899
+ const parsed = parseOpening({
1695
1900
  asOfDate: input.asOfDate,
1696
1901
  lines: input.lines,
1697
1902
  accounts,
1698
1903
  existingEntries: all
1699
1904
  });
1700
- if (!validation.ok) throw new AccountingError(400, "invalid opening balances", validation.errors);
1905
+ if (!parsed.ok) throw new AccountingError(400, "invalid opening balances", parsed.errors);
1701
1906
  const existing = findActiveOpening(all);
1702
1907
  if (existing) {
1703
1908
  const { reverse, marker } = makeVoidEntries(existing, "replaced via setOpeningBalances", localDateString());
@@ -1706,7 +1911,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1706
1911
  }
1707
1912
  const opening = makeEntry({
1708
1913
  date: input.asOfDate,
1709
- lines: input.lines,
1914
+ lines: parsed.lines,
1710
1915
  memo: input.memo ?? "Opening balances",
1711
1916
  kind: "opening"
1712
1917
  });
@@ -1858,17 +2063,15 @@ function asyncHandler(namespace, fallbackMessage, handler) {
1858
2063
  try {
1859
2064
  await handler(req, res);
1860
2065
  } catch (err) {
1861
- const expressReq = req;
1862
- const expressRes = res;
1863
2066
  log.error(namespace, "handler threw", {
1864
- route: expressReq.path,
2067
+ route: req.path,
1865
2068
  error: require_shared.errorMessage(err)
1866
2069
  });
1867
- if (expressRes.headersSent) {
2070
+ if (res.headersSent) {
1868
2071
  next(err);
1869
2072
  return;
1870
2073
  }
1871
- expressRes.status(500).json({ error: fallbackMessage });
2074
+ res.status(500).json({ error: fallbackMessage });
1872
2075
  }
1873
2076
  };
1874
2077
  }
@@ -1888,17 +2091,18 @@ async function handleOpenBook(rest) {
1888
2091
  }
1889
2092
  async function handleGetReport(rest) {
1890
2093
  const kind = typeof rest.kind === "string" ? rest.kind : "";
1891
- const periodInput = rest.period;
1892
- const bookId = rest.bookId;
2094
+ const periodInput = optionalReportPeriod(rest.period);
2095
+ const bookId = optionalString(rest.bookId);
2096
+ const periodRequired = (label) => new AccountingError(400, `${label}: period is required — { kind: "month", period: "YYYY-MM" } or { kind: "range", from: "YYYY-MM-DD", to: "YYYY-MM-DD" }`);
1893
2097
  if (kind === "balance") {
1894
- if (!periodInput) throw new AccountingError(400, "getReport balance: period is required");
2098
+ if (!periodInput) throw periodRequired("getReport balance");
1895
2099
  return getBalanceSheetReport({
1896
2100
  bookId,
1897
2101
  period: periodInput
1898
2102
  });
1899
2103
  }
1900
2104
  if (kind === "pl") {
1901
- if (!periodInput) throw new AccountingError(400, "getReport pl: period is required");
2105
+ if (!periodInput) throw periodRequired("getReport pl");
1902
2106
  return getProfitLossReport({
1903
2107
  bookId,
1904
2108
  period: periodInput
@@ -1945,44 +2149,44 @@ var ACTION_HANDLERS = {
1945
2149
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
1946
2150
  confirm: rest.confirm === true
1947
2151
  }),
1948
- [require_shared.ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: rest.bookId }),
2152
+ [require_shared.ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: optionalString(rest.bookId) }),
1949
2153
  [require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
1950
- bookId: rest.bookId,
2154
+ bookId: optionalString(rest.bookId),
1951
2155
  account: rest.account
1952
2156
  }),
1953
2157
  [require_shared.ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
1954
- bookId: rest.bookId,
1955
- entries: rest.entries ?? []
2158
+ bookId: optionalString(rest.bookId),
2159
+ entries: rest.entries
1956
2160
  }),
1957
2161
  [require_shared.ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
1958
- bookId: rest.bookId,
2162
+ bookId: optionalString(rest.bookId),
1959
2163
  entryId: typeof rest.entryId === "string" ? rest.entryId : "",
1960
- reason: rest.reason,
1961
- voidDate: rest.voidDate
2164
+ reason: optionalString(rest.reason),
2165
+ voidDate: optionalString(rest.voidDate)
1962
2166
  }),
1963
2167
  [require_shared.ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
1964
- bookId: rest.bookId,
1965
- from: rest.from,
1966
- to: rest.to,
1967
- accountCode: rest.accountCode
2168
+ bookId: optionalString(rest.bookId),
2169
+ from: optionalString(rest.from),
2170
+ to: optionalString(rest.to),
2171
+ accountCode: optionalString(rest.accountCode)
1968
2172
  }),
1969
- [require_shared.ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: rest.bookId }),
2173
+ [require_shared.ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: optionalString(rest.bookId) }),
1970
2174
  [require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
1971
- bookId: rest.bookId,
2175
+ bookId: optionalString(rest.bookId),
1972
2176
  asOfDate: typeof rest.asOfDate === "string" ? rest.asOfDate : "",
1973
2177
  lines: rest.lines ?? [],
1974
- memo: rest.memo
2178
+ memo: optionalString(rest.memo)
1975
2179
  }),
1976
2180
  [require_shared.ACCOUNTING_ACTIONS.getReport]: handleGetReport,
1977
2181
  [require_shared.ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
1978
- bookId: rest.bookId,
2182
+ bookId: optionalString(rest.bookId),
1979
2183
  metric: rest.metric,
1980
2184
  granularity: rest.granularity,
1981
2185
  from: rest.from,
1982
2186
  to: rest.to,
1983
2187
  accountCode: rest.accountCode
1984
2188
  }),
1985
- [require_shared.ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: rest.bookId })
2189
+ [require_shared.ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: optionalString(rest.bookId) })
1986
2190
  };
1987
2191
  var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
1988
2192
  require_shared.ACCOUNTING_ACTIONS.openBook,
@@ -2001,43 +2205,50 @@ var MESSAGE_BUILDERS = {
2001
2205
  return `Mounted the accounting app in the canvas${typeof bookId === "string" ? ` (book id: ${bookId})` : ""}.${booksFragment}`;
2002
2206
  },
2003
2207
  [require_shared.ACCOUNTING_ACTIONS.createBook]: (fields) => {
2004
- const book = fields.book;
2005
- 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.`;
2208
+ const book = optionalRecord(fields.book);
2209
+ const name = optionalString(book?.name);
2210
+ const bookId = optionalString(book?.id);
2211
+ return `${name ? `A new book named ${JSON.stringify(name)}` : "A new book"} has been created${bookId ? ` (id: ${bookId})` : ""}. 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.`;
2006
2212
  },
2007
2213
  [require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (fields) => {
2008
- const account = fields.account;
2009
- if (account?.code && account?.name) return `Upserted account ${account.code} ${JSON.stringify(account.name)}.`;
2214
+ const account = optionalRecord(fields.account);
2215
+ const code = optionalString(account?.code);
2216
+ const name = optionalString(account?.name);
2217
+ if (code && name) return `Upserted account ${code} ${JSON.stringify(name)}.`;
2010
2218
  return "Updated the chart of accounts.";
2011
2219
  },
2012
2220
  [require_shared.ACCOUNTING_ACTIONS.addEntries]: (fields) => {
2013
- const entries = Array.isArray(fields.entries) ? fields.entries : [];
2221
+ const entries = (require_shared.isUnknownArray(fields.entries) ? fields.entries : []).map(describeEntry);
2014
2222
  if (entries.length === 0) return "Posted 0 journal entries.";
2015
2223
  if (entries.length === 1) {
2016
2224
  const [entry] = entries;
2017
- const idFragment = entry?.id ? ` (id: ${entry.id})` : "";
2018
- return `Posted a journal entry on ${entry?.date ?? "the requested date"}${idFragment}.`;
2225
+ const idFragment = entry.id ? ` (id: ${entry.id})` : "";
2226
+ return `Posted a journal entry on ${entry.date ?? "the requested date"}${idFragment}.`;
2019
2227
  }
2020
- const summary = entries.map((entry) => `${entry?.date ?? "?"} (id: ${entry?.id ?? "?"})`).join(", ");
2228
+ const summary = entries.map((entry) => `${entry.date ?? "?"} (id: ${entry.id ?? "?"})`).join(", ");
2021
2229
  return `Posted ${entries.length} journal entries: ${summary}.`;
2022
2230
  },
2023
2231
  [require_shared.ACCOUNTING_ACTIONS.voidEntry]: (fields) => {
2024
- return `Voided the entry; a reversing pair was posted on ${fields.reverseEntry?.date ?? "today"}.`;
2232
+ return `Voided the entry; a reversing pair was posted on ${optionalString(optionalRecord(fields.reverseEntry)?.date) ?? "today"}.`;
2025
2233
  },
2026
2234
  [require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (fields) => {
2027
- const opening = fields.openingEntry;
2235
+ const opening = optionalRecord(fields.openingEntry);
2028
2236
  const verb = fields.replacedExisting === true ? "replaced" : "set";
2029
- const date = opening?.date ?? "the requested date";
2030
- const lines = Array.isArray(opening?.lines) ? opening.lines : [];
2237
+ const date = optionalString(opening?.date) ?? "the requested date";
2238
+ const openingLines = opening?.lines;
2239
+ const lines = require_shared.isUnknownArray(openingLines) ? openingLines : [];
2031
2240
  return `Opening balances were ${verb} as of ${date}.${lines.length > 0 ? ` Lines: ${JSON.stringify(lines)}.` : ""}`;
2032
2241
  },
2033
2242
  [require_shared.ACCOUNTING_ACTIONS.deleteBook]: (fields) => {
2034
- const bookId = fields.deletedBookId;
2035
- const name = fields.deletedBookName;
2243
+ const bookId = optionalString(fields.deletedBookId);
2244
+ const name = optionalString(fields.deletedBookName);
2036
2245
  return `Deleted ${name ? `the book ${JSON.stringify(name)}` : "the book"}${bookId ? ` (id: ${bookId})` : ""}.`;
2037
2246
  },
2038
2247
  [require_shared.ACCOUNTING_ACTIONS.updateBook]: (fields) => {
2039
- const book = fields.book;
2040
- return `Updated ${book?.name ? JSON.stringify(book.name) : "the book"}${book?.country ? ` (country: ${book.country})` : ""}.`;
2248
+ const book = optionalRecord(fields.book);
2249
+ const bookName = optionalString(book?.name);
2250
+ const country = optionalString(book?.country);
2251
+ return `Updated ${bookName ? JSON.stringify(bookName) : "the book"}${country ? ` (country: ${country})` : ""}.`;
2041
2252
  }
2042
2253
  };
2043
2254
  function previewMessage(action, fields) {
@@ -2049,7 +2260,7 @@ async function dispatch(body) {
2049
2260
  if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2050
2261
  const handler = ACTION_HANDLERS[action];
2051
2262
  const result = await handler(rest);
2052
- const handlerFields = result && typeof result === "object" ? result : { value: result };
2263
+ const handlerFields = require_shared.isRecord(result) ? result : { value: result };
2053
2264
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
2054
2265
  action,
2055
2266
  ...handlerFields