@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/accountNormalize.d.ts +11 -0
- package/dist/server/accountNormalize.d.ts.map +1 -1
- package/dist/server/bodyFields.d.ts +16 -0
- package/dist/server/bodyFields.d.ts.map +1 -0
- package/dist/server/journal.d.ts +36 -10
- package/dist/server/journal.d.ts.map +1 -1
- package/dist/server/openingBalances.d.ts +13 -9
- package/dist/server/openingBalances.d.ts.map +1 -1
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/service.d.ts +4 -10
- package/dist/server/service.d.ts.map +1 -1
- package/dist/server.cjs +341 -128
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +341 -128
- package/dist/server.js.map +1 -1
- package/dist/{shared-CcU3_qAa.cjs → shared-C9K9ZkfK.cjs} +19 -1
- package/dist/{shared-CcU3_qAa.cjs.map → shared-C9K9ZkfK.cjs.map} +1 -1
- package/dist/{shared-MOpJ1kUa.js → shared-qp9j-GTD.js} +8 -2
- package/dist/{shared-MOpJ1kUa.js.map → shared-qp9j-GTD.js.map} +1 -1
- package/dist/shared.cjs +1 -1
- package/dist/shared.js +1 -1
- package/dist/vue.cjs +1 -1
- package/dist/vue.js +1 -1
- package/package.json +4 -4
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-
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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:
|
|
403
|
+
message: "accountCode must be a string"
|
|
359
404
|
});
|
|
360
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
522
|
+
message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(raw.date)}`
|
|
401
523
|
});
|
|
402
|
-
if (!
|
|
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
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
425
|
-
* (
|
|
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
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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
|
-
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
582
|
-
|
|
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:
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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 (
|
|
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
|
|
1782
|
+
account,
|
|
1578
1783
|
accounts: next
|
|
1579
1784
|
};
|
|
1580
1785
|
}
|
|
1581
|
-
function
|
|
1786
|
+
function parseBatchEntries(items, accounts) {
|
|
1582
1787
|
const failures = [];
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
const
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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 (!
|
|
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 =
|
|
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(
|
|
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
|
|
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 (!
|
|
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:
|
|
1914
|
+
lines: parsed.lines,
|
|
1710
1915
|
memo: input.memo ?? "Opening balances",
|
|
1711
1916
|
kind: "opening"
|
|
1712
1917
|
});
|
|
@@ -1886,17 +2091,18 @@ async function handleOpenBook(rest) {
|
|
|
1886
2091
|
}
|
|
1887
2092
|
async function handleGetReport(rest) {
|
|
1888
2093
|
const kind = typeof rest.kind === "string" ? rest.kind : "";
|
|
1889
|
-
const periodInput = rest.period;
|
|
1890
|
-
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" }`);
|
|
1891
2097
|
if (kind === "balance") {
|
|
1892
|
-
if (!periodInput) throw
|
|
2098
|
+
if (!periodInput) throw periodRequired("getReport balance");
|
|
1893
2099
|
return getBalanceSheetReport({
|
|
1894
2100
|
bookId,
|
|
1895
2101
|
period: periodInput
|
|
1896
2102
|
});
|
|
1897
2103
|
}
|
|
1898
2104
|
if (kind === "pl") {
|
|
1899
|
-
if (!periodInput) throw
|
|
2105
|
+
if (!periodInput) throw periodRequired("getReport pl");
|
|
1900
2106
|
return getProfitLossReport({
|
|
1901
2107
|
bookId,
|
|
1902
2108
|
period: periodInput
|
|
@@ -1943,44 +2149,44 @@ var ACTION_HANDLERS = {
|
|
|
1943
2149
|
bookId: typeof rest.bookId === "string" ? rest.bookId : "",
|
|
1944
2150
|
confirm: rest.confirm === true
|
|
1945
2151
|
}),
|
|
1946
|
-
[require_shared.ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: rest.bookId }),
|
|
2152
|
+
[require_shared.ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: optionalString(rest.bookId) }),
|
|
1947
2153
|
[require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
|
|
1948
|
-
bookId: rest.bookId,
|
|
2154
|
+
bookId: optionalString(rest.bookId),
|
|
1949
2155
|
account: rest.account
|
|
1950
2156
|
}),
|
|
1951
2157
|
[require_shared.ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
|
|
1952
|
-
bookId: rest.bookId,
|
|
1953
|
-
entries: rest.entries
|
|
2158
|
+
bookId: optionalString(rest.bookId),
|
|
2159
|
+
entries: rest.entries
|
|
1954
2160
|
}),
|
|
1955
2161
|
[require_shared.ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
|
|
1956
|
-
bookId: rest.bookId,
|
|
2162
|
+
bookId: optionalString(rest.bookId),
|
|
1957
2163
|
entryId: typeof rest.entryId === "string" ? rest.entryId : "",
|
|
1958
|
-
reason: rest.reason,
|
|
1959
|
-
voidDate: rest.voidDate
|
|
2164
|
+
reason: optionalString(rest.reason),
|
|
2165
|
+
voidDate: optionalString(rest.voidDate)
|
|
1960
2166
|
}),
|
|
1961
2167
|
[require_shared.ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
|
|
1962
|
-
bookId: rest.bookId,
|
|
1963
|
-
from: rest.from,
|
|
1964
|
-
to: rest.to,
|
|
1965
|
-
accountCode: rest.accountCode
|
|
2168
|
+
bookId: optionalString(rest.bookId),
|
|
2169
|
+
from: optionalString(rest.from),
|
|
2170
|
+
to: optionalString(rest.to),
|
|
2171
|
+
accountCode: optionalString(rest.accountCode)
|
|
1966
2172
|
}),
|
|
1967
|
-
[require_shared.ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: rest.bookId }),
|
|
2173
|
+
[require_shared.ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: optionalString(rest.bookId) }),
|
|
1968
2174
|
[require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
|
|
1969
|
-
bookId: rest.bookId,
|
|
2175
|
+
bookId: optionalString(rest.bookId),
|
|
1970
2176
|
asOfDate: typeof rest.asOfDate === "string" ? rest.asOfDate : "",
|
|
1971
2177
|
lines: rest.lines ?? [],
|
|
1972
|
-
memo: rest.memo
|
|
2178
|
+
memo: optionalString(rest.memo)
|
|
1973
2179
|
}),
|
|
1974
2180
|
[require_shared.ACCOUNTING_ACTIONS.getReport]: handleGetReport,
|
|
1975
2181
|
[require_shared.ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
|
|
1976
|
-
bookId: rest.bookId,
|
|
2182
|
+
bookId: optionalString(rest.bookId),
|
|
1977
2183
|
metric: rest.metric,
|
|
1978
2184
|
granularity: rest.granularity,
|
|
1979
2185
|
from: rest.from,
|
|
1980
2186
|
to: rest.to,
|
|
1981
2187
|
accountCode: rest.accountCode
|
|
1982
2188
|
}),
|
|
1983
|
-
[require_shared.ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: rest.bookId })
|
|
2189
|
+
[require_shared.ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: optionalString(rest.bookId) })
|
|
1984
2190
|
};
|
|
1985
2191
|
var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
|
|
1986
2192
|
require_shared.ACCOUNTING_ACTIONS.openBook,
|
|
@@ -1999,43 +2205,50 @@ var MESSAGE_BUILDERS = {
|
|
|
1999
2205
|
return `Mounted the accounting app in the canvas${typeof bookId === "string" ? ` (book id: ${bookId})` : ""}.${booksFragment}`;
|
|
2000
2206
|
},
|
|
2001
2207
|
[require_shared.ACCOUNTING_ACTIONS.createBook]: (fields) => {
|
|
2002
|
-
const book = fields.book;
|
|
2003
|
-
|
|
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.`;
|
|
2004
2212
|
},
|
|
2005
2213
|
[require_shared.ACCOUNTING_ACTIONS.upsertAccount]: (fields) => {
|
|
2006
|
-
const account = fields.account;
|
|
2007
|
-
|
|
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)}.`;
|
|
2008
2218
|
return "Updated the chart of accounts.";
|
|
2009
2219
|
},
|
|
2010
2220
|
[require_shared.ACCOUNTING_ACTIONS.addEntries]: (fields) => {
|
|
2011
|
-
const entries =
|
|
2221
|
+
const entries = (require_shared.isUnknownArray(fields.entries) ? fields.entries : []).map(describeEntry);
|
|
2012
2222
|
if (entries.length === 0) return "Posted 0 journal entries.";
|
|
2013
2223
|
if (entries.length === 1) {
|
|
2014
2224
|
const [entry] = entries;
|
|
2015
|
-
const idFragment = entry
|
|
2016
|
-
return `Posted a journal entry on ${entry
|
|
2225
|
+
const idFragment = entry.id ? ` (id: ${entry.id})` : "";
|
|
2226
|
+
return `Posted a journal entry on ${entry.date ?? "the requested date"}${idFragment}.`;
|
|
2017
2227
|
}
|
|
2018
|
-
const summary = entries.map((entry) => `${entry
|
|
2228
|
+
const summary = entries.map((entry) => `${entry.date ?? "?"} (id: ${entry.id ?? "?"})`).join(", ");
|
|
2019
2229
|
return `Posted ${entries.length} journal entries: ${summary}.`;
|
|
2020
2230
|
},
|
|
2021
2231
|
[require_shared.ACCOUNTING_ACTIONS.voidEntry]: (fields) => {
|
|
2022
|
-
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"}.`;
|
|
2023
2233
|
},
|
|
2024
2234
|
[require_shared.ACCOUNTING_ACTIONS.setOpeningBalances]: (fields) => {
|
|
2025
|
-
const opening = fields.openingEntry;
|
|
2235
|
+
const opening = optionalRecord(fields.openingEntry);
|
|
2026
2236
|
const verb = fields.replacedExisting === true ? "replaced" : "set";
|
|
2027
|
-
const date = opening?.date ?? "the requested date";
|
|
2028
|
-
const
|
|
2237
|
+
const date = optionalString(opening?.date) ?? "the requested date";
|
|
2238
|
+
const openingLines = opening?.lines;
|
|
2239
|
+
const lines = require_shared.isUnknownArray(openingLines) ? openingLines : [];
|
|
2029
2240
|
return `Opening balances were ${verb} as of ${date}.${lines.length > 0 ? ` Lines: ${JSON.stringify(lines)}.` : ""}`;
|
|
2030
2241
|
},
|
|
2031
2242
|
[require_shared.ACCOUNTING_ACTIONS.deleteBook]: (fields) => {
|
|
2032
|
-
const bookId = fields.deletedBookId;
|
|
2033
|
-
const name = fields.deletedBookName;
|
|
2243
|
+
const bookId = optionalString(fields.deletedBookId);
|
|
2244
|
+
const name = optionalString(fields.deletedBookName);
|
|
2034
2245
|
return `Deleted ${name ? `the book ${JSON.stringify(name)}` : "the book"}${bookId ? ` (id: ${bookId})` : ""}.`;
|
|
2035
2246
|
},
|
|
2036
2247
|
[require_shared.ACCOUNTING_ACTIONS.updateBook]: (fields) => {
|
|
2037
|
-
const book = fields.book;
|
|
2038
|
-
|
|
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})` : ""}.`;
|
|
2039
2252
|
}
|
|
2040
2253
|
};
|
|
2041
2254
|
function previewMessage(action, fields) {
|
|
@@ -2047,7 +2260,7 @@ async function dispatch(body) {
|
|
|
2047
2260
|
if (!Object.hasOwn(ACTION_HANDLERS, action)) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
|
|
2048
2261
|
const handler = ACTION_HANDLERS[action];
|
|
2049
2262
|
const result = await handler(rest);
|
|
2050
|
-
const handlerFields = result
|
|
2263
|
+
const handlerFields = require_shared.isRecord(result) ? result : { value: result };
|
|
2051
2264
|
const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
|
|
2052
2265
|
action,
|
|
2053
2266
|
...handlerFields
|