@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.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
|
-
import { A as resolveFiscalYearEnd, D as isFiscalYearEnd, F as
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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:
|
|
379
|
+
message: "accountCode must be a string"
|
|
335
380
|
});
|
|
336
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
498
|
+
message: `expected YYYY-MM-DD calendar date, got ${JSON.stringify(raw.date)}`
|
|
377
499
|
});
|
|
378
|
-
if (!
|
|
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
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
401
|
-
* (
|
|
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
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
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
|
-
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
558
|
-
|
|
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:
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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 (
|
|
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
|
|
1758
|
+
account,
|
|
1554
1759
|
accounts: next
|
|
1555
1760
|
};
|
|
1556
1761
|
}
|
|
1557
|
-
function
|
|
1762
|
+
function parseBatchEntries(items, accounts) {
|
|
1558
1763
|
const failures = [];
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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 (!
|
|
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 =
|
|
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(
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1992
|
-
return `Posted a journal entry on ${entry
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2239
|
+
const handlerFields = isRecord(result) ? result : { value: result };
|
|
2027
2240
|
const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
|
|
2028
2241
|
action,
|
|
2029
2242
|
...handlerFields
|