@mulmoclaude/accounting-plugin 1.1.0 → 1.2.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,9 +1,39 @@
1
- import { A as resolveFiscalYearEnd, D as isFiscalYearEnd, F as bookChannel, L as BALANCE_SHEET_ACCOUNT_TYPES, M as errorMessage, N as ACCOUNTING_BOOKS_CHANNEL, P as BOOK_EVENT_KINDS, R as ACCOUNTING_API, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as ACCOUNTING_ACTIONS } from "./shared-MOpJ1kUa.js";
1
+ import { A as resolveFiscalYearEnd, B as ACCOUNTING_API, D as isFiscalYearEnd, F as ACCOUNTING_BOOKS_CHANNEL, I as BOOK_EVENT_KINDS, L as bookChannel, M as errorMessage, N as isRecord, P as isUnknownArray, R as ACCOUNT_TYPES, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, V as ACCOUNTING_ACTIONS, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as BALANCE_SHEET_ACCOUNT_TYPES } from "./shared-qp9j-GTD.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { isEnoent, writeJsonAtomic } from "@mulmoclaude/core/files";
7
+ //#region src/server/bodyFields.ts
8
+ var optionalString = (value) => typeof value === "string" ? value : void 0;
9
+ var optionalRecord = (value) => isRecord(value) ? value : void 0;
10
+ /** Rebuilt field by field rather than narrowed with a predicate, so the
11
+ * returned object is one this function actually proved. A half-formed
12
+ * `{ kind: "month" }` reads as absent and the caller raises its own
13
+ * "period is required" — previously it reached the report builders and
14
+ * produced an `undefined-01` date. */
15
+ var optionalReportPeriod = (value) => {
16
+ const period = optionalRecord(value);
17
+ if (period?.kind === "month" && typeof period.period === "string") return {
18
+ kind: "month",
19
+ period: period.period
20
+ };
21
+ if (period?.kind === "range" && typeof period.from === "string" && typeof period.to === "string") return {
22
+ kind: "range",
23
+ from: period.from,
24
+ to: period.to
25
+ };
26
+ };
27
+ /** The two fields the addEntries narration quotes back, read out of a
28
+ * service payload that is only `unknown` to the router. */
29
+ var describeEntry = (entry) => {
30
+ const record = optionalRecord(entry);
31
+ return {
32
+ id: optionalString(record?.id),
33
+ date: optionalString(record?.date)
34
+ };
35
+ };
36
+ //#endregion
7
37
  //#region src/server/context.ts
8
38
  var deps = null;
9
39
  /** Called once by the host before the accounting router is mounted. */
@@ -286,7 +316,7 @@ async function removeBookDir(bookId, workspaceRoot) {
286
316
  * so amounts are doubles). 0.005 keeps two-decimal currency math
287
317
  * honest while accepting the floating-point noise of summing
288
318
  * many lines. */
289
- var EQUALITY_TOLERANCE$1 = .005;
319
+ var EQUALITY_TOLERANCE = .005;
290
320
  function lineHasExactlyOneSide(line) {
291
321
  return (typeof line.debit === "number" && line.debit !== 0) !== (typeof line.credit === "number" && line.credit !== 0);
292
322
  }
@@ -325,36 +355,115 @@ function netBalance(lines) {
325
355
  }
326
356
  return net;
327
357
  }
328
- /** Pure validation. Does not throw; returns a list of issues so the
329
- * REST handler can return a structured 400 instead of an opaque
330
- * 500. */
331
- function validateLine(line, idx, accountCodes, errors) {
332
- if (!line.accountCode || !accountCodes.has(line.accountCode)) errors.push({
358
+ function checkTaxRegistrationId(value, idx, errors) {
359
+ if (value === void 0) return;
360
+ if (typeof value !== "string") {
361
+ errors.push({
362
+ field: `lines[${idx}].taxRegistrationId`,
363
+ message: "must be a string"
364
+ });
365
+ return;
366
+ }
367
+ if (value.trim().length > 32) errors.push({
368
+ field: `lines[${idx}].taxRegistrationId`,
369
+ message: `must be at most 32 characters (got ${value.trim().length})`
370
+ });
371
+ }
372
+ /** One issue per bad field, so a caller fixing a line learns about all
373
+ * of them at once. Returns whether the line came through clean. */
374
+ function checkLineFields(raw, idx, errors) {
375
+ const issuesBefore = errors.length;
376
+ const { accountCode, debit, credit, memo, taxRegistrationId } = raw;
377
+ if (typeof accountCode !== "string") errors.push({
333
378
  field: `lines[${idx}].accountCode`,
334
- message: `unknown account code ${JSON.stringify(line.accountCode)}`
379
+ message: "accountCode must be a string"
335
380
  });
336
- if (line.debit !== void 0 && !isNonNegativeNumber(line.debit)) errors.push({
381
+ if (debit !== void 0 && !isNonNegativeNumber(debit)) errors.push({
337
382
  field: `lines[${idx}].debit`,
338
383
  message: "debit must be a non-negative finite number"
339
384
  });
340
- if (line.credit !== void 0 && !isNonNegativeNumber(line.credit)) errors.push({
385
+ if (credit !== void 0 && !isNonNegativeNumber(credit)) errors.push({
341
386
  field: `lines[${idx}].credit`,
342
387
  message: "credit must be a non-negative finite number"
343
388
  });
389
+ if (memo !== void 0 && typeof memo !== "string") errors.push({
390
+ field: `lines[${idx}].memo`,
391
+ message: "memo must be a string"
392
+ });
393
+ checkTaxRegistrationId(taxRegistrationId, idx, errors);
394
+ return errors.length === issuesBefore;
395
+ }
396
+ /** Re-tested rather than assigned straight through: `checkLineFields`
397
+ * proved each of these, but only a `typeof` narrows them for the
398
+ * compiler. */
399
+ function buildLine(raw) {
400
+ const { accountCode, debit, credit, memo, taxRegistrationId } = raw;
401
+ if (typeof accountCode !== "string") return null;
402
+ const line = { accountCode };
403
+ if (typeof debit === "number") line.debit = debit;
404
+ if (typeof credit === "number") line.credit = credit;
405
+ if (typeof memo === "string") line.memo = memo;
406
+ if (typeof taxRegistrationId === "string") line.taxRegistrationId = taxRegistrationId;
407
+ return line;
408
+ }
409
+ /** Narrow one wire value to a `JournalLine`, pushing an issue per bad
410
+ * field. Shape only: account existence and the debit/credit-side rule
411
+ * belong to the caller, because journal entries and opening balances
412
+ * disagree about them. Returns null when nothing usable came out —
413
+ * the caller drops the line rather than reading fields off it. */
414
+ function parseJournalLine(raw, idx, errors) {
415
+ if (!isRecord(raw)) {
416
+ errors.push({
417
+ field: `lines[${idx}]`,
418
+ message: "each line must be an object with an accountCode and a debit or credit amount"
419
+ });
420
+ return null;
421
+ }
422
+ if (!checkLineFields(raw, idx, errors)) return null;
423
+ return buildLine(raw);
424
+ }
425
+ /** The rules a journal line answers to on top of its shape: the code
426
+ * must name a real account, and exactly one side carries an amount. */
427
+ function validateEntryLine(line, idx, accountCodes, errors) {
428
+ if (!line.accountCode || !accountCodes.has(line.accountCode)) errors.push({
429
+ field: `lines[${idx}].accountCode`,
430
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
431
+ });
344
432
  if (!lineHasExactlyOneSide(line)) errors.push({
345
433
  field: `lines[${idx}]`,
346
434
  message: "each line must set exactly one of debit or credit (and to a non-zero amount)"
347
435
  });
348
- if (line.taxRegistrationId !== void 0) {
349
- if (typeof line.taxRegistrationId !== "string") errors.push({
350
- field: `lines[${idx}].taxRegistrationId`,
351
- message: "must be a string"
352
- });
353
- else if (line.taxRegistrationId.trim().length > 32) errors.push({
354
- field: `lines[${idx}].taxRegistrationId`,
355
- message: `must be at most 32 characters (got ${line.taxRegistrationId.trim().length})`
356
- });
357
- }
436
+ }
437
+ function parseEntryLines(raw, accountCodes, errors) {
438
+ const lines = [];
439
+ raw.forEach((rawLine, idx) => {
440
+ const line = parseJournalLine(rawLine, idx, errors);
441
+ if (line === null) return;
442
+ validateEntryLine(line, idx, accountCodes, errors);
443
+ lines.push(line);
444
+ });
445
+ return lines;
446
+ }
447
+ /** Report the debit = credit imbalance — but only when every line was
448
+ * readable. A line that failed to parse contributes nothing to the sum,
449
+ * so an entry that balances perfectly would otherwise be told it
450
+ * doesn't, sending the caller off to "fix" amounts that were never
451
+ * wrong. Naming the unreadable line is the actionable message; the
452
+ * balance is worth re-checking once it's a line. */
453
+ function checkBalances(lines, expectedLineCount, subject, errors) {
454
+ if (lines.length !== expectedLineCount) return;
455
+ const net = netBalance(lines);
456
+ if (Math.abs(net) > EQUALITY_TOLERANCE) errors.push({
457
+ field: "lines",
458
+ message: `Σ debit − Σ credit = ${net.toFixed(4)}; ${subject} must balance`
459
+ });
460
+ }
461
+ function parseOptionalString(value, field, errors) {
462
+ if (value === void 0 || typeof value === "string") return value;
463
+ errors.push({
464
+ field,
465
+ message: `${field} must be a string when supplied`
466
+ });
358
467
  }
359
468
  /** Normalize a journal line before persistence: trim string fields
360
469
  * and drop empty-string optionals so the JSONL doesn't accumulate
@@ -369,13 +478,26 @@ function normalizeLine(line) {
369
478
  }
370
479
  return out;
371
480
  }
372
- function validateEntry(input) {
481
+ /** Parse one entry off the wire. Does not throw: every rejection comes
482
+ * back as a list of issues so the REST handler can return a structured
483
+ * 400 instead of an opaque 500. Parse rather than validate — the
484
+ * narrowed entry rides along on success, so `makeEntry` never has to
485
+ * take the caller's word for the shape. */
486
+ function parseEntry(raw, accounts) {
487
+ if (!isRecord(raw)) return {
488
+ ok: false,
489
+ errors: [{
490
+ field: "entry",
491
+ message: "each entry must be an object with a date and a lines array"
492
+ }]
493
+ };
373
494
  const errors = [];
374
- if (!isValidCalendarDate(input.date)) errors.push({
495
+ const date = typeof raw.date === "string" && isValidCalendarDate(raw.date) ? raw.date : null;
496
+ if (date === null) errors.push({
375
497
  field: "date",
376
- message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.date)}`
498
+ message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(raw.date)}`
377
499
  });
378
- if (!Array.isArray(input.lines) || input.lines.length < 2) {
500
+ if (!isUnknownArray(raw.lines) || raw.lines.length < 2) {
379
501
  errors.push({
380
502
  field: "lines",
381
503
  message: "an entry needs at least two lines (one debit, one credit)"
@@ -385,20 +507,26 @@ function validateEntry(input) {
385
507
  errors
386
508
  };
387
509
  }
388
- const accountCodes = new Set(input.accounts.map((account) => account.code));
389
- input.lines.forEach((line, idx) => validateLine(line, idx, accountCodes, errors));
390
- const net = netBalance(input.lines);
391
- if (Math.abs(net) > EQUALITY_TOLERANCE$1) errors.push({
392
- field: "lines",
393
- message: `Σ debit − Σ credit = ${net.toFixed(4)}; entry must balance`
394
- });
395
- return {
396
- ok: errors.length === 0,
510
+ const lines = parseEntryLines(raw.lines, new Set(accounts.map((account) => account.code)), errors);
511
+ checkBalances(lines, raw.lines.length, "entry", errors);
512
+ const memo = parseOptionalString(raw.memo, "memo", errors);
513
+ const replacesEntryId = parseOptionalString(raw.replacesEntryId, "replacesEntryId", errors);
514
+ if (errors.length > 0 || date === null) return {
515
+ ok: false,
397
516
  errors
398
517
  };
518
+ return {
519
+ ok: true,
520
+ entry: {
521
+ date,
522
+ lines,
523
+ memo,
524
+ replacesEntryId
525
+ }
526
+ };
399
527
  }
400
- /** Build a JournalEntry validation is the caller's responsibility
401
- * (it should have called `validateEntry` first). The id is a fresh
528
+ /** Build a JournalEntry from lines something already parsed
529
+ * (`parseEntry` / `parseOpening`). The id is a fresh
402
530
  * UUID; createdAt is the wall clock at the moment of creation.
403
531
  * Lines are normalized so optional string fields don't persist as
404
532
  * empty strings. */
@@ -484,7 +612,6 @@ function voidedIdSet(entries) {
484
612
  }
485
613
  //#endregion
486
614
  //#region src/server/openingBalances.ts
487
- var EQUALITY_TOLERANCE = .005;
488
615
  /** Find the existing opening entry for a book, if any. Multiple
489
616
  * openings shouldn't coexist (the route enforces void-then-append),
490
617
  * but if they do the most recent by `createdAt` wins so callers
@@ -499,22 +626,34 @@ function findActiveOpening(entries) {
499
626
  }
500
627
  return active;
501
628
  }
502
- function validateLineAccountTypes(input, errors) {
503
- const accountByCode = new Map(input.accounts.map((account) => [account.code, account]));
504
- input.lines.forEach((line, idx) => {
505
- const acct = accountByCode.get(line.accountCode);
506
- if (!acct) {
507
- errors.push({
508
- field: `lines[${idx}].accountCode`,
509
- message: `unknown account code ${JSON.stringify(line.accountCode)}`
510
- });
511
- return;
512
- }
513
- if (!BALANCE_SHEET_ACCOUNT_TYPES.includes(acct.type)) errors.push({
629
+ function validateOpeningAccount(line, idx, accountByCode, errors) {
630
+ const acct = accountByCode.get(line.accountCode);
631
+ if (!acct) {
632
+ errors.push({
514
633
  field: `lines[${idx}].accountCode`,
515
- message: `account ${acct.code} is type ${acct.type}; opening balances may only reference balance-sheet accounts (asset / liability / equity)`
634
+ message: `unknown account code ${JSON.stringify(line.accountCode)}`
516
635
  });
636
+ return;
637
+ }
638
+ if (!BALANCE_SHEET_ACCOUNT_TYPES.includes(acct.type)) errors.push({
639
+ field: `lines[${idx}].accountCode`,
640
+ message: `account ${acct.code} is type ${acct.type}; opening balances may only reference balance-sheet accounts (asset / liability / equity)`
641
+ });
642
+ }
643
+ /** Opening lines are narrowed by the same shape parser the journal
644
+ * uses, but they answer to different rules afterwards: balance-sheet
645
+ * accounts only, and no "exactly one side" requirement — the opening
646
+ * form lets a user carry both columns on one account. */
647
+ function parseOpeningLines(raw, accounts, errors) {
648
+ const accountByCode = new Map(accounts.map((account) => [account.code, account]));
649
+ const lines = [];
650
+ raw.forEach((rawLine, idx) => {
651
+ const line = parseJournalLine(rawLine, idx, errors);
652
+ if (line === null) return;
653
+ validateOpeningAccount(line, idx, accountByCode, errors);
654
+ lines.push(line);
517
655
  });
656
+ return lines;
518
657
  }
519
658
  function validateAsOfPredatesEverything(input, errors) {
520
659
  const voided = voidedIdSet(input.existingEntries);
@@ -531,20 +670,21 @@ function validateAsOfPredatesEverything(input, errors) {
531
670
  }
532
671
  }
533
672
  }
534
- /** Validate inputs for `setOpeningBalances`. Caller passes the full
535
- * list of journal entries in the book so we can check the
536
- * "asOfDate must precede every other entry" rule. An opening with
673
+ /** Parse the inputs for `setOpeningBalances`, returning the narrowed
674
+ * lines so the caller can persist what was actually checked. Caller
675
+ * passes the full list of journal entries in the book so we can check
676
+ * the "asOfDate must precede every other entry" rule. An opening with
537
677
  * zero lines is accepted as a no-op marker — it satisfies the
538
678
  * "book has an opening" gate the UI uses without committing the
539
679
  * user to specific balances on day one (they can replace it
540
680
  * later). */
541
- function validateOpening(input) {
681
+ function parseOpening(input) {
542
682
  const errors = [];
543
683
  if (!isValidCalendarDate(input.asOfDate)) errors.push({
544
684
  field: "asOfDate",
545
685
  message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(input.asOfDate)}`
546
686
  });
547
- if (!Array.isArray(input.lines)) {
687
+ if (!isUnknownArray(input.lines)) {
548
688
  errors.push({
549
689
  field: "lines",
550
690
  message: "lines must be an array"
@@ -554,20 +694,64 @@ function validateOpening(input) {
554
694
  errors
555
695
  };
556
696
  }
557
- validateLineAccountTypes(input, errors);
558
- const net = netBalance(input.lines);
559
- if (Math.abs(net) > EQUALITY_TOLERANCE) errors.push({
560
- field: "lines",
561
- message: `Σ debit − Σ credit = ${net.toFixed(4)}; opening must balance`
562
- });
697
+ const lines = parseOpeningLines(input.lines, input.accounts, errors);
698
+ checkBalances(lines, input.lines.length, "opening", errors);
563
699
  validateAsOfPredatesEverything(input, errors);
564
- return {
565
- ok: errors.length === 0,
700
+ if (errors.length > 0) return {
701
+ ok: false,
566
702
  errors
567
703
  };
704
+ return {
705
+ ok: true,
706
+ lines
707
+ };
568
708
  }
569
709
  //#endregion
570
710
  //#region src/server/accountNormalize.ts
711
+ function isAccountType(value) {
712
+ return ACCOUNT_TYPES.some((accountType) => accountType === value);
713
+ }
714
+ /** Narrow a wire payload to an `Account`. `name` and `type` are as
715
+ * required as `code`: an account persisted without a type is invisible
716
+ * to every report, which groups rows by it. */
717
+ function parseAccountInput(raw) {
718
+ if (!isRecord(raw)) return {
719
+ ok: false,
720
+ message: "account is required — pass an object with code, name, and type"
721
+ };
722
+ const { code, name, type, note, active } = raw;
723
+ if (typeof code !== "string" || code.length === 0) return {
724
+ ok: false,
725
+ message: "account code is required"
726
+ };
727
+ if (typeof name !== "string" || name.trim() === "") return {
728
+ ok: false,
729
+ message: "account name is required"
730
+ };
731
+ if (!isAccountType(type)) return {
732
+ ok: false,
733
+ message: `account type ${JSON.stringify(type)} is invalid — must be one of: ${ACCOUNT_TYPES.join(", ")}`
734
+ };
735
+ if (note !== void 0 && typeof note !== "string") return {
736
+ ok: false,
737
+ message: "account note must be a string when supplied"
738
+ };
739
+ if (active !== void 0 && typeof active !== "boolean") return {
740
+ ok: false,
741
+ message: "account active must be a boolean when supplied"
742
+ };
743
+ const account = {
744
+ code,
745
+ name,
746
+ type
747
+ };
748
+ if (typeof note === "string") account.note = note;
749
+ if (typeof active === "boolean") account.active = active;
750
+ return {
751
+ ok: true,
752
+ account
753
+ };
754
+ }
571
755
  function normalizeStoredAccount(input, existing) {
572
756
  const stored = {
573
757
  code: input.code,
@@ -1531,44 +1715,65 @@ async function listAccounts(input, workspaceRoot) {
1531
1715
  accounts: await readAccounts(bookId, workspaceRoot)
1532
1716
  };
1533
1717
  }
1534
- async function upsertAccount(input, workspaceRoot) {
1535
- const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1536
- if (typeof input.account?.code !== "string" || input.account.code.length === 0) throw new AccountingError(400, "account code is required");
1537
- 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)`);
1538
- const accounts = await readAccounts(bookId, workspaceRoot);
1539
- const existingIdx = accounts.findIndex((account) => account.code === input.account.code);
1718
+ /** Parse the caller's account, then apply the one rule the chart owns
1719
+ * rather than the record: codes starting with `_` are reserved for the
1720
+ * synthetic rows the report layer injects (e.g. `_currentEarnings` in
1721
+ * the Equity section). A user account there would either duplicate a
1722
+ * B/S row or hide a real account behind the synthetic label. */
1723
+ function parseUpsertAccount(raw) {
1724
+ const parsed = parseAccountInput(raw);
1725
+ if (!parsed.ok) throw new AccountingError(400, parsed.message);
1726
+ 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)`);
1727
+ return parsed.account;
1728
+ }
1729
+ /** Insert or replace the account in the chart. The whitelist +
1730
+ * active-flag policy lives in normalizeStoredAccount (see
1731
+ * ./accountNormalize.ts) so it stays unit-testable in isolation.
1732
+ * `previousType` is null for a new code — callers use it to decide
1733
+ * whether aggregation across periods just changed meaning. */
1734
+ function applyAccount(accounts, account) {
1735
+ const existingIdx = accounts.findIndex((stored) => stored.code === account.code);
1736
+ const existing = existingIdx >= 0 ? accounts[existingIdx] : void 0;
1737
+ const stored = normalizeStoredAccount(account, existing);
1540
1738
  const next = [...accounts];
1541
- const oldType = existingIdx >= 0 ? accounts[existingIdx].type : null;
1542
- const stored = normalizeStoredAccount(input.account, existingIdx >= 0 ? accounts[existingIdx] : void 0);
1543
1739
  if (existingIdx >= 0) next[existingIdx] = stored;
1544
1740
  else next.push(stored);
1741
+ return {
1742
+ next,
1743
+ previousType: existing ? existing.type : null
1744
+ };
1745
+ }
1746
+ async function upsertAccount(input, workspaceRoot) {
1747
+ const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1748
+ const account = parseUpsertAccount(input.account);
1749
+ const { next, previousType } = applyAccount(await readAccounts(bookId, workspaceRoot), account);
1545
1750
  await writeAccounts(bookId, next, workspaceRoot);
1546
- if (oldType !== null && oldType !== input.account.type) {
1751
+ if (previousType !== null && previousType !== account.type) {
1547
1752
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1548
1753
  await invalidateAllSnapshots(bookId, workspaceRoot);
1549
1754
  }
1550
1755
  publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.accounts });
1551
1756
  return {
1552
1757
  bookId,
1553
- account: { ...input.account },
1758
+ account,
1554
1759
  accounts: next
1555
1760
  };
1556
1761
  }
1557
- function collectBatchValidationFailures(items, accounts) {
1762
+ function parseBatchEntries(items, accounts) {
1558
1763
  const failures = [];
1559
- for (let idx = 0; idx < items.length; idx++) {
1560
- const item = items[idx];
1561
- const validation = validateEntry({
1562
- date: item.date,
1563
- lines: item.lines,
1564
- accounts
1764
+ const parsed = [];
1765
+ items.forEach((item, index) => {
1766
+ const result = parseEntry(item, accounts);
1767
+ if (result.ok) parsed.push(result.entry);
1768
+ else failures.push({
1769
+ index,
1770
+ errors: result.errors
1565
1771
  });
1566
- if (!validation.ok) failures.push({
1567
- index: idx,
1568
- errors: validation.errors
1569
- });
1570
- }
1571
- return failures;
1772
+ });
1773
+ return {
1774
+ failures,
1775
+ items: parsed
1776
+ };
1572
1777
  }
1573
1778
  function buildBatchEntries(items) {
1574
1779
  return items.map((item) => makeEntry({
@@ -1584,11 +1789,11 @@ function earliestPeriodOf(entries) {
1584
1789
  }
1585
1790
  async function addEntries(input, workspaceRoot) {
1586
1791
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1587
- if (!Array.isArray(input.entries) || input.entries.length === 0) throw new AccountingError(400, "addEntries: entries must be a non-empty array");
1792
+ if (!isUnknownArray(input.entries) || input.entries.length === 0) throw new AccountingError(400, "addEntries: entries must be a non-empty array");
1588
1793
  const accounts = await readAccounts(bookId, workspaceRoot);
1589
- const failures = collectBatchValidationFailures(input.entries, accounts);
1794
+ const { failures, items } = parseBatchEntries(input.entries, accounts);
1590
1795
  if (failures.length > 0) throw new AccountingError(400, "invalid journal entries", failures);
1591
- const built = buildBatchEntries(input.entries);
1796
+ const built = buildBatchEntries(items);
1592
1797
  await appendJournalBatch(bookId, built, workspaceRoot);
1593
1798
  const earliestPeriod = earliestPeriodOf(built);
1594
1799
  scheduleRebuild(bookId, earliestPeriod, workspaceRoot);
@@ -1667,13 +1872,13 @@ async function setOpeningBalances(input, workspaceRoot) {
1667
1872
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
1668
1873
  const accounts = await readAccounts(bookId, workspaceRoot);
1669
1874
  const all = await readAllEntries(bookId, workspaceRoot);
1670
- const validation = validateOpening({
1875
+ const parsed = parseOpening({
1671
1876
  asOfDate: input.asOfDate,
1672
1877
  lines: input.lines,
1673
1878
  accounts,
1674
1879
  existingEntries: all
1675
1880
  });
1676
- if (!validation.ok) throw new AccountingError(400, "invalid opening balances", validation.errors);
1881
+ if (!parsed.ok) throw new AccountingError(400, "invalid opening balances", parsed.errors);
1677
1882
  const existing = findActiveOpening(all);
1678
1883
  if (existing) {
1679
1884
  const { reverse, marker } = makeVoidEntries(existing, "replaced via setOpeningBalances", localDateString());
@@ -1682,7 +1887,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1682
1887
  }
1683
1888
  const opening = makeEntry({
1684
1889
  date: input.asOfDate,
1685
- lines: input.lines,
1890
+ lines: parsed.lines,
1686
1891
  memo: input.memo ?? "Opening balances",
1687
1892
  kind: "opening"
1688
1893
  });
@@ -1862,17 +2067,18 @@ async function handleOpenBook(rest) {
1862
2067
  }
1863
2068
  async function handleGetReport(rest) {
1864
2069
  const kind = typeof rest.kind === "string" ? rest.kind : "";
1865
- const periodInput = rest.period;
1866
- const bookId = rest.bookId;
2070
+ const periodInput = optionalReportPeriod(rest.period);
2071
+ const bookId = optionalString(rest.bookId);
2072
+ 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" }`);
1867
2073
  if (kind === "balance") {
1868
- if (!periodInput) throw new AccountingError(400, "getReport balance: period is required");
2074
+ if (!periodInput) throw periodRequired("getReport balance");
1869
2075
  return getBalanceSheetReport({
1870
2076
  bookId,
1871
2077
  period: periodInput
1872
2078
  });
1873
2079
  }
1874
2080
  if (kind === "pl") {
1875
- if (!periodInput) throw new AccountingError(400, "getReport pl: period is required");
2081
+ if (!periodInput) throw periodRequired("getReport pl");
1876
2082
  return getProfitLossReport({
1877
2083
  bookId,
1878
2084
  period: periodInput
@@ -1919,44 +2125,44 @@ var ACTION_HANDLERS = {
1919
2125
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
1920
2126
  confirm: rest.confirm === true
1921
2127
  }),
1922
- [ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: rest.bookId }),
2128
+ [ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: optionalString(rest.bookId) }),
1923
2129
  [ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
1924
- bookId: rest.bookId,
2130
+ bookId: optionalString(rest.bookId),
1925
2131
  account: rest.account
1926
2132
  }),
1927
2133
  [ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
1928
- bookId: rest.bookId,
1929
- entries: rest.entries ?? []
2134
+ bookId: optionalString(rest.bookId),
2135
+ entries: rest.entries
1930
2136
  }),
1931
2137
  [ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
1932
- bookId: rest.bookId,
2138
+ bookId: optionalString(rest.bookId),
1933
2139
  entryId: typeof rest.entryId === "string" ? rest.entryId : "",
1934
- reason: rest.reason,
1935
- voidDate: rest.voidDate
2140
+ reason: optionalString(rest.reason),
2141
+ voidDate: optionalString(rest.voidDate)
1936
2142
  }),
1937
2143
  [ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
1938
- bookId: rest.bookId,
1939
- from: rest.from,
1940
- to: rest.to,
1941
- accountCode: rest.accountCode
2144
+ bookId: optionalString(rest.bookId),
2145
+ from: optionalString(rest.from),
2146
+ to: optionalString(rest.to),
2147
+ accountCode: optionalString(rest.accountCode)
1942
2148
  }),
1943
- [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: rest.bookId }),
2149
+ [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: optionalString(rest.bookId) }),
1944
2150
  [ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
1945
- bookId: rest.bookId,
2151
+ bookId: optionalString(rest.bookId),
1946
2152
  asOfDate: typeof rest.asOfDate === "string" ? rest.asOfDate : "",
1947
2153
  lines: rest.lines ?? [],
1948
- memo: rest.memo
2154
+ memo: optionalString(rest.memo)
1949
2155
  }),
1950
2156
  [ACCOUNTING_ACTIONS.getReport]: handleGetReport,
1951
2157
  [ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
1952
- bookId: rest.bookId,
2158
+ bookId: optionalString(rest.bookId),
1953
2159
  metric: rest.metric,
1954
2160
  granularity: rest.granularity,
1955
2161
  from: rest.from,
1956
2162
  to: rest.to,
1957
2163
  accountCode: rest.accountCode
1958
2164
  }),
1959
- [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: rest.bookId })
2165
+ [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: optionalString(rest.bookId) })
1960
2166
  };
1961
2167
  var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
1962
2168
  ACCOUNTING_ACTIONS.openBook,
@@ -1975,43 +2181,50 @@ var MESSAGE_BUILDERS = {
1975
2181
  return `Mounted the accounting app in the canvas${typeof bookId === "string" ? ` (book id: ${bookId})` : ""}.${booksFragment}`;
1976
2182
  },
1977
2183
  [ACCOUNTING_ACTIONS.createBook]: (fields) => {
1978
- const book = fields.book;
1979
- 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.`;
2184
+ const book = optionalRecord(fields.book);
2185
+ const name = optionalString(book?.name);
2186
+ const bookId = optionalString(book?.id);
2187
+ 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.`;
1980
2188
  },
1981
2189
  [ACCOUNTING_ACTIONS.upsertAccount]: (fields) => {
1982
- const account = fields.account;
1983
- if (account?.code && account?.name) return `Upserted account ${account.code} ${JSON.stringify(account.name)}.`;
2190
+ const account = optionalRecord(fields.account);
2191
+ const code = optionalString(account?.code);
2192
+ const name = optionalString(account?.name);
2193
+ if (code && name) return `Upserted account ${code} ${JSON.stringify(name)}.`;
1984
2194
  return "Updated the chart of accounts.";
1985
2195
  },
1986
2196
  [ACCOUNTING_ACTIONS.addEntries]: (fields) => {
1987
- const entries = Array.isArray(fields.entries) ? fields.entries : [];
2197
+ const entries = (isUnknownArray(fields.entries) ? fields.entries : []).map(describeEntry);
1988
2198
  if (entries.length === 0) return "Posted 0 journal entries.";
1989
2199
  if (entries.length === 1) {
1990
2200
  const [entry] = entries;
1991
- const idFragment = entry?.id ? ` (id: ${entry.id})` : "";
1992
- return `Posted a journal entry on ${entry?.date ?? "the requested date"}${idFragment}.`;
2201
+ const idFragment = entry.id ? ` (id: ${entry.id})` : "";
2202
+ return `Posted a journal entry on ${entry.date ?? "the requested date"}${idFragment}.`;
1993
2203
  }
1994
- const summary = entries.map((entry) => `${entry?.date ?? "?"} (id: ${entry?.id ?? "?"})`).join(", ");
2204
+ const summary = entries.map((entry) => `${entry.date ?? "?"} (id: ${entry.id ?? "?"})`).join(", ");
1995
2205
  return `Posted ${entries.length} journal entries: ${summary}.`;
1996
2206
  },
1997
2207
  [ACCOUNTING_ACTIONS.voidEntry]: (fields) => {
1998
- return `Voided the entry; a reversing pair was posted on ${fields.reverseEntry?.date ?? "today"}.`;
2208
+ return `Voided the entry; a reversing pair was posted on ${optionalString(optionalRecord(fields.reverseEntry)?.date) ?? "today"}.`;
1999
2209
  },
2000
2210
  [ACCOUNTING_ACTIONS.setOpeningBalances]: (fields) => {
2001
- const opening = fields.openingEntry;
2211
+ const opening = optionalRecord(fields.openingEntry);
2002
2212
  const verb = fields.replacedExisting === true ? "replaced" : "set";
2003
- const date = opening?.date ?? "the requested date";
2004
- const lines = Array.isArray(opening?.lines) ? opening.lines : [];
2213
+ const date = optionalString(opening?.date) ?? "the requested date";
2214
+ const openingLines = opening?.lines;
2215
+ const lines = isUnknownArray(openingLines) ? openingLines : [];
2005
2216
  return `Opening balances were ${verb} as of ${date}.${lines.length > 0 ? ` Lines: ${JSON.stringify(lines)}.` : ""}`;
2006
2217
  },
2007
2218
  [ACCOUNTING_ACTIONS.deleteBook]: (fields) => {
2008
- const bookId = fields.deletedBookId;
2009
- const name = fields.deletedBookName;
2219
+ const bookId = optionalString(fields.deletedBookId);
2220
+ const name = optionalString(fields.deletedBookName);
2010
2221
  return `Deleted ${name ? `the book ${JSON.stringify(name)}` : "the book"}${bookId ? ` (id: ${bookId})` : ""}.`;
2011
2222
  },
2012
2223
  [ACCOUNTING_ACTIONS.updateBook]: (fields) => {
2013
- const book = fields.book;
2014
- return `Updated ${book?.name ? JSON.stringify(book.name) : "the book"}${book?.country ? ` (country: ${book.country})` : ""}.`;
2224
+ const book = optionalRecord(fields.book);
2225
+ const bookName = optionalString(book?.name);
2226
+ const country = optionalString(book?.country);
2227
+ return `Updated ${bookName ? JSON.stringify(bookName) : "the book"}${country ? ` (country: ${country})` : ""}.`;
2015
2228
  }
2016
2229
  };
2017
2230
  function previewMessage(action, fields) {
@@ -2023,7 +2236,7 @@ async function dispatch(body) {
2023
2236
  if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2024
2237
  const handler = ACTION_HANDLERS[action];
2025
2238
  const result = await handler(rest);
2026
- const handlerFields = result && typeof result === "object" ? result : { value: result };
2239
+ const handlerFields = isRecord(result) ? result : { value: result };
2027
2240
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
2028
2241
  action,
2029
2242
  ...handlerFields