@diy-accounting-uk/diya-gl 1.2.44 → 1.2.46
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/README.md +6 -3
- package/bin/diya-gl-mcp.js +3 -2
- package/dist/app/bin/diya-gl-mcp.js +2 -1
- package/dist/app/data/releases.json +55 -3
- package/dist/app/lib/book-checks/ltd.js +3 -5
- package/dist/app/lib/book-checks.js +10 -6
- package/dist/app/lib/book-queries.js +297 -0
- package/dist/app/lib/cached-values.js +1813 -0
- package/dist/app/lib/calculators/ltd.js +17 -8
- package/dist/app/lib/derivations/vat-return.js +5 -4
- package/dist/app/lib/diya-gl-edits.js +25 -0
- package/dist/app/lib/diya-gl-loader.js +2 -0
- package/dist/app/lib/generator.js +11 -1
- package/dist/app/lib/link-caches.js +4 -0
- package/dist/app/lib/ltd-layout.js +43 -0
- package/dist/app/lib/mcp/diya-gl-tools.js +95 -7
- package/dist/app/lib/mcp/server.js +5 -1
- package/dist/app/lib/provenance-data.js +4 -4
- package/dist/app/lib/report-generator.js +35 -6
- package/dist/app/lib/report-serializer.js +30 -13
- package/dist/app/lib/scenario-extractor.js +37 -20
- package/dist/app/lib/spreadsheet-runner.js +30 -4
- package/dist/app/lib/xlsx-exporter.js +22 -7
- package/dist/app/products/ltd.js +90 -31
- package/dist/app/products/se.js +89 -25
- package/dist/web/spreadsheets.diyaccounting.co.uk/public/schema/diya-gl-docs.md +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,9 +67,12 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
|
|
|
67
67
|
another site. The book stays in the browser (the part of the address after `#` is never sent
|
|
68
68
|
anywhere). The server stays up until Ctrl-C. The workbook templates come from
|
|
69
69
|
spreadsheets.diyaccounting.co.uk the first time a page action needs one.
|
|
70
|
-
- **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with
|
|
71
|
-
`extract_book`, `report`, `edit_lines`, `save_workbook
|
|
72
|
-
`
|
|
70
|
+
- **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with eight tools:
|
|
71
|
+
`extract_book`, `report`, `edit_lines`, `save_workbook`, and four that read every detail of
|
|
72
|
+
the loaded book: `lines` (filter and group every transaction, e.g. best customer or spend on
|
|
73
|
+
fuel in June), `chart` (the accounts and the report rows each feeds), `book` (the business,
|
|
74
|
+
period, tax settings and registers) and `checks` (every book check with the entryNumbers
|
|
75
|
+
behind it). Point an MCP client at `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
|
|
73
76
|
|
|
74
77
|
## API
|
|
75
78
|
|
package/bin/diya-gl-mcp.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
// Copyright (C) 2006-2026 DIY Accounting Limited
|
|
4
4
|
//
|
|
5
|
-
// diya-gl-mcp.js — the stdio MCP server
|
|
6
|
-
//
|
|
5
|
+
// diya-gl-mcp.js — the stdio MCP server over a loaded diya-gl book:
|
|
6
|
+
// extract_book, report, edit_lines, save_workbook, lines, chart, book and
|
|
7
|
+
// checks. See
|
|
7
8
|
// app/lib/mcp/server.js in the source repository for the method table.
|
|
8
9
|
|
|
9
10
|
import { runDistBin } from "./run-dist-bin.js";
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Copyright (C) 2006-2026 DIY Accounting Limited
|
|
4
4
|
//
|
|
5
5
|
// diya-gl-mcp.js — a stdio MCP server exposing the diya-gl pipeline as
|
|
6
|
-
//
|
|
6
|
+
// tools: extract_book, report, edit_lines, save_workbook, lines, chart,
|
|
7
|
+
// book and checks, over any of
|
|
7
8
|
// the four products (Basic Sole Trader, Taxi Driver, Self Employed, Company).
|
|
8
9
|
//
|
|
9
10
|
// Transport: hand-rolled newline-delimited JSON-RPC 2.0
|
|
@@ -1304,7 +1304,7 @@
|
|
|
1304
1304
|
"tag": "diya-gl-v1.2.41",
|
|
1305
1305
|
"date": "2026-10-01",
|
|
1306
1306
|
"formatVersion": "diya-gl/1",
|
|
1307
|
-
"engineVersion": "1.2.
|
|
1307
|
+
"engineVersion": "1.2.41+5b1fc93c5",
|
|
1308
1308
|
"taxDataHash": "89424bd36c0d",
|
|
1309
1309
|
"reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
|
|
1310
1310
|
"templates": {
|
|
@@ -1330,7 +1330,7 @@
|
|
|
1330
1330
|
"tag": "diya-gl-v1.2.42",
|
|
1331
1331
|
"date": "2026-10-03",
|
|
1332
1332
|
"formatVersion": "diya-gl/1",
|
|
1333
|
-
"engineVersion": "1.2.
|
|
1333
|
+
"engineVersion": "1.2.42+ed6c7809b",
|
|
1334
1334
|
"taxDataHash": "89424bd36c0d",
|
|
1335
1335
|
"reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
|
|
1336
1336
|
"templates": {
|
|
@@ -1356,7 +1356,59 @@
|
|
|
1356
1356
|
"tag": "diya-gl-v1.2.43",
|
|
1357
1357
|
"date": "2026-10-03",
|
|
1358
1358
|
"formatVersion": "diya-gl/1",
|
|
1359
|
-
"engineVersion": "1.2.
|
|
1359
|
+
"engineVersion": "1.2.43+971ca45ad",
|
|
1360
|
+
"taxDataHash": "89424bd36c0d",
|
|
1361
|
+
"reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
|
|
1362
|
+
"templates": {
|
|
1363
|
+
"bst": {
|
|
1364
|
+
"hash": "0d003e10e00c",
|
|
1365
|
+
"scorecard": "812 passed, 0 warnings, 0 failed"
|
|
1366
|
+
},
|
|
1367
|
+
"taxi": {
|
|
1368
|
+
"hash": "1ff69654d64d",
|
|
1369
|
+
"scorecard": "700 passed, 0 warnings, 0 failed"
|
|
1370
|
+
},
|
|
1371
|
+
"se": {
|
|
1372
|
+
"hash": "064605ae76ed",
|
|
1373
|
+
"scorecard": "8014 passed, 0 warnings, 0 failed"
|
|
1374
|
+
},
|
|
1375
|
+
"ltd": {
|
|
1376
|
+
"hash": "5bb7be9b6d97",
|
|
1377
|
+
"scorecard": "101605 passed, 0 warnings, 0 failed"
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
"tag": "diya-gl-v1.2.44",
|
|
1383
|
+
"date": "2026-10-03",
|
|
1384
|
+
"formatVersion": "diya-gl/1",
|
|
1385
|
+
"engineVersion": "1.2.44+4b8f90020",
|
|
1386
|
+
"taxDataHash": "89424bd36c0d",
|
|
1387
|
+
"reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
|
|
1388
|
+
"templates": {
|
|
1389
|
+
"bst": {
|
|
1390
|
+
"hash": "0d003e10e00c",
|
|
1391
|
+
"scorecard": "812 passed, 0 warnings, 0 failed"
|
|
1392
|
+
},
|
|
1393
|
+
"taxi": {
|
|
1394
|
+
"hash": "1ff69654d64d",
|
|
1395
|
+
"scorecard": "700 passed, 0 warnings, 0 failed"
|
|
1396
|
+
},
|
|
1397
|
+
"se": {
|
|
1398
|
+
"hash": "064605ae76ed",
|
|
1399
|
+
"scorecard": "8014 passed, 0 warnings, 0 failed"
|
|
1400
|
+
},
|
|
1401
|
+
"ltd": {
|
|
1402
|
+
"hash": "5bb7be9b6d97",
|
|
1403
|
+
"scorecard": "101605 passed, 0 warnings, 0 failed"
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
"tag": "diya-gl-v1.2.45",
|
|
1409
|
+
"date": "2026-10-04",
|
|
1410
|
+
"formatVersion": "diya-gl/1",
|
|
1411
|
+
"engineVersion": "1.2.45+3801b0c2",
|
|
1360
1412
|
"taxDataHash": "89424bd36c0d",
|
|
1361
1413
|
"reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
|
|
1362
1414
|
"templates": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { changeLinePostingDate } from "../diya-gl-edits.js";
|
|
17
17
|
import { LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP } from "../scenario-extractor.js";
|
|
18
|
-
import { BANK_ACCOUNT_FILES, BANK_TRANSFER_CODES,
|
|
18
|
+
import { BANK_ACCOUNT_FILES, BANK_TRANSFER_CODES, ltdBankPlacement } from "../ltd-layout.js";
|
|
19
19
|
|
|
20
20
|
// The bank account each transfer letter names: Currentaccount is BB, so a
|
|
21
21
|
// BB-coded line on any other workbook is a transfer to or from the current
|
|
@@ -161,13 +161,11 @@ export const LTD_CHECK_SPECS = [
|
|
|
161
161
|
const fileName = bankFileOf(line);
|
|
162
162
|
if (!fileName) return true;
|
|
163
163
|
if (line.debitCreditCode !== "D" && line.debitCreditCode !== "C") return false;
|
|
164
|
-
|
|
165
|
-
const analysed = line.debitCreditCode === "D" ? layout.receiptCodes : layout.paymentCodes;
|
|
166
|
-
return !analysed.includes(line["diya-gl:bankCode"]);
|
|
164
|
+
return !ltdBankPlacement(fileName, line.debitCreditCode === "D", line["diya-gl:bankCode"], line.amount).analysed;
|
|
167
165
|
});
|
|
168
166
|
},
|
|
169
167
|
consequence: function () {
|
|
170
|
-
return "Each bank workbook analyses its own list of codes, and the four workbooks do not share one list -- the cash book has no receipt column for RV, RC or X.
|
|
168
|
+
return "Each bank workbook analyses its own list of codes, and the four workbooks do not share one list -- the cash book has no receipt column for RV, RC or X. A refund coded DR on a payment or CR on a receipt lands in the opposite block as a negative entry under its own code. Any other entry coded outside its workbook's list has no column to land in, and the package cannot be written at all.";
|
|
171
169
|
},
|
|
172
170
|
buildHelper: null,
|
|
173
171
|
apply: null,
|
|
@@ -684,9 +684,13 @@ function bankEntries(lines, bankCode, side) {
|
|
|
684
684
|
// straddling invoice is returned on a VAT period outside that year and is
|
|
685
685
|
// banked in a book this one does not hold, so it neither needs a settlement
|
|
686
686
|
// of its own nor takes up a bank line another invoice is waiting for.
|
|
687
|
-
|
|
687
|
+
// A credit note runs the other way from an invoice: its money goes back out
|
|
688
|
+
// as a refund (DR on a payment, CR on a receipt), or it is offset or written
|
|
689
|
+
// off and never reaches the bank. It neither wants a receipt or a payment
|
|
690
|
+
// the way an invoice does nor takes up one an invoice is waiting for.
|
|
691
|
+
function invoiceEntries(lines, journal) {
|
|
688
692
|
return lines.filter(function (line) {
|
|
689
|
-
return line.sourceJournalID === journal && !isStraddlingLine(line);
|
|
693
|
+
return line.sourceJournalID === journal && line.documentType !== "credit-note" && !isStraddlingLine(line);
|
|
690
694
|
});
|
|
691
695
|
}
|
|
692
696
|
|
|
@@ -769,7 +773,7 @@ const SETTLEMENT_KINDS = [
|
|
|
769
773
|
actionLabel: "Add the sale",
|
|
770
774
|
what: "sale",
|
|
771
775
|
sources: function (ctx) {
|
|
772
|
-
return unpaired(bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"),
|
|
776
|
+
return unpaired(bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"), invoiceEntries(ctx.lines, "sales"));
|
|
773
777
|
},
|
|
774
778
|
account: function (ctx) {
|
|
775
779
|
return ctx.chart.sales[0] || null;
|
|
@@ -785,7 +789,7 @@ const SETTLEMENT_KINDS = [
|
|
|
785
789
|
actionLabel: "Add the purchase",
|
|
786
790
|
what: "purchase",
|
|
787
791
|
sources: function (ctx) {
|
|
788
|
-
return unpaired(bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"),
|
|
792
|
+
return unpaired(bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"), invoiceEntries(ctx.lines, "purchases"));
|
|
789
793
|
},
|
|
790
794
|
account: function (ctx) {
|
|
791
795
|
return repostAccount(ctx, "purchases");
|
|
@@ -801,7 +805,7 @@ const SETTLEMENT_KINDS = [
|
|
|
801
805
|
actionLabel: "Add the receipt",
|
|
802
806
|
what: "receipt",
|
|
803
807
|
sources: function (ctx) {
|
|
804
|
-
return unpaired(
|
|
808
|
+
return unpaired(invoiceEntries(ctx.lines, "sales"), bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"));
|
|
805
809
|
},
|
|
806
810
|
account: settlementBankAccount,
|
|
807
811
|
build: function (ctx, source, account, entryNumber) {
|
|
@@ -815,7 +819,7 @@ const SETTLEMENT_KINDS = [
|
|
|
815
819
|
actionLabel: "Add the payment",
|
|
816
820
|
what: "payment",
|
|
817
821
|
sources: function (ctx) {
|
|
818
|
-
return unpaired(
|
|
822
|
+
return unpaired(invoiceEntries(ctx.lines, "purchases"), bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"));
|
|
819
823
|
},
|
|
820
824
|
account: settlementBankAccount,
|
|
821
825
|
build: function (ctx, source, account, entryNumber) {
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2006-2026 DIY Accounting Limited
|
|
3
|
+
//
|
|
4
|
+
// book-queries.js — read-only questions over a diya-gl book: which lines
|
|
5
|
+
// match a filter and what they add up to, the chart of accounts with the
|
|
6
|
+
// report rows each account feeds, the book's own profile, and the book
|
|
7
|
+
// checks with the entryNumbers behind each verdict. Pure functions over
|
|
8
|
+
// (book, lines) and the documents the engine already builds, so the MCP
|
|
9
|
+
// server and any other caller read the same answers.
|
|
10
|
+
|
|
11
|
+
import { isStraddlingLine } from "./scenario-extractor.js";
|
|
12
|
+
|
|
13
|
+
function isoDate(value) {
|
|
14
|
+
if (!value) return "";
|
|
15
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
16
|
+
return String(value).slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A TOML date parses to a Date; JSON would print it with a midnight time the
|
|
20
|
+
// book never stated, so a date-only value goes out as YYYY-MM-DD.
|
|
21
|
+
function plainValue(value) {
|
|
22
|
+
if (value instanceof Date) {
|
|
23
|
+
const iso = value.toISOString();
|
|
24
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) return value.map(plainValue);
|
|
27
|
+
if (value && typeof value === "object") {
|
|
28
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, plainValue(inner)]));
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asSet(filter) {
|
|
34
|
+
if (filter === undefined || filter === null || filter === "") return null;
|
|
35
|
+
return new Set((Array.isArray(filter) ? filter : [filter]).map(String));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A line's amount in pence with the sign a total wants: a credit note
|
|
40
|
+
* reverses its sale or purchase, and a credit on the bank or the journal
|
|
41
|
+
* (debitCreditCode "C", money out of a bank account) is negative.
|
|
42
|
+
*
|
|
43
|
+
* A credit note on the bad debts account (4005) or the fixed asset sale
|
|
44
|
+
* account (4006) is negative here too. Both templates book a written-off
|
|
45
|
+
* debt as a negative sale: the Basic Sole Trader sales tabs have one gross
|
|
46
|
+
* column feeding turnover, and the Limited Company sales tabs' "Bad Debts
|
|
47
|
+
* written off" column T reaches the P&L negated (TrialBalance row 81 =
|
|
48
|
+
* -Sales!T1, MnthP&L B34), so only a negative entry raises the expense. A
|
|
49
|
+
* customer's total therefore falls by the debt written off against them.
|
|
50
|
+
* @param {Object} line
|
|
51
|
+
* @returns {number}
|
|
52
|
+
*/
|
|
53
|
+
export function signedPence(line) {
|
|
54
|
+
const reverses = (line.sourceJournalID === "sales" || line.sourceJournalID === "purchases") && line.documentType === "credit-note";
|
|
55
|
+
const pence = Math.round((Number(line.amount) || 0) * 100);
|
|
56
|
+
return reverses || line.debitCreditCode === "C" ? -pence : pence;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const GROUP_KEYS = {
|
|
60
|
+
detailComment: (line) => line.detailComment ?? "",
|
|
61
|
+
accountMainID: (line) => String(line.accountMainID ?? ""),
|
|
62
|
+
month: (line) => isoDate(line.postingDate).slice(0, 7),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The fields `lines` can group by. */
|
|
66
|
+
export const LINE_GROUPINGS = Object.keys(GROUP_KEYS);
|
|
67
|
+
|
|
68
|
+
function accountNames(book) {
|
|
69
|
+
const names = {};
|
|
70
|
+
for (const accounts of Object.values(book?.accounts || {})) {
|
|
71
|
+
for (const [id, account] of Object.entries(accounts || {})) names[id] = account?.accountMainDescription;
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byLargest = (a, b) => Math.abs(b.totalPence) - Math.abs(a.totalPence) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The lines matching every filter given, their count and signed total in
|
|
80
|
+
* pence, and either the lines themselves or their groups.
|
|
81
|
+
*
|
|
82
|
+
* Grouped by detailComment or accountMainID, groups come largest total
|
|
83
|
+
* first; by month, in date order. Ungrouped with `top`, lines come largest
|
|
84
|
+
* amount first; ungrouped without it, in book order.
|
|
85
|
+
* @param {Object} book
|
|
86
|
+
* @param {Array} lines
|
|
87
|
+
* @param {Object} [filters]
|
|
88
|
+
* @param {string|string[]} [filters.journal] - sourceJournalID
|
|
89
|
+
* @param {string|string[]} [filters.accountMainID]
|
|
90
|
+
* @param {string} [filters.from] - YYYY-MM-DD, inclusive
|
|
91
|
+
* @param {string} [filters.to] - YYYY-MM-DD, inclusive
|
|
92
|
+
* @param {string} [filters.text] - case-insensitive, in detailComment or lineItemComment
|
|
93
|
+
* @param {string} [filters.documentReference] - case-insensitive, in documentReference
|
|
94
|
+
* @param {string} [filters.groupBy] - one of LINE_GROUPINGS
|
|
95
|
+
* @param {number} [filters.top] - at most this many groups (or lines)
|
|
96
|
+
* @returns {Object}
|
|
97
|
+
*/
|
|
98
|
+
export function queryLines(book, lines, filters = {}) {
|
|
99
|
+
const { journal, accountMainID, from, to, text, documentReference, groupBy, top } = filters;
|
|
100
|
+
if (groupBy !== undefined && !GROUP_KEYS[groupBy]) {
|
|
101
|
+
throw new Error(`groupBy must be one of ${LINE_GROUPINGS.join(", ")}, got "${groupBy}"`);
|
|
102
|
+
}
|
|
103
|
+
if (top !== undefined && (!Number.isInteger(top) || top < 1)) throw new Error(`top must be a whole number above zero, got "${top}"`);
|
|
104
|
+
const journals = asSet(journal);
|
|
105
|
+
const accounts = asSet(accountMainID);
|
|
106
|
+
const needle = text ? String(text).toLowerCase() : null;
|
|
107
|
+
const reference = documentReference ? String(documentReference).toLowerCase() : null;
|
|
108
|
+
|
|
109
|
+
const matched = lines.filter((line) => {
|
|
110
|
+
if (journals && !journals.has(String(line.sourceJournalID))) return false;
|
|
111
|
+
if (accounts && !accounts.has(String(line.accountMainID))) return false;
|
|
112
|
+
const date = isoDate(line.postingDate);
|
|
113
|
+
if (from && date < from) return false;
|
|
114
|
+
if (to && date > to) return false;
|
|
115
|
+
if (needle && !`${line.detailComment ?? ""}\n${line.lineItemComment ?? ""}`.toLowerCase().includes(needle)) return false;
|
|
116
|
+
if (
|
|
117
|
+
reference &&
|
|
118
|
+
!String(line.documentReference ?? "")
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.includes(reference)
|
|
121
|
+
)
|
|
122
|
+
return false;
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const answer = { count: matched.length, totalPence: matched.reduce((sum, line) => sum + signedPence(line), 0) };
|
|
127
|
+
|
|
128
|
+
if (!groupBy) {
|
|
129
|
+
const ordered = top
|
|
130
|
+
? matched
|
|
131
|
+
.slice()
|
|
132
|
+
.sort((a, b) => Math.abs(signedPence(b)) - Math.abs(signedPence(a)))
|
|
133
|
+
.slice(0, top)
|
|
134
|
+
: matched;
|
|
135
|
+
return { ...answer, lines: ordered };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const keyOf = GROUP_KEYS[groupBy];
|
|
139
|
+
const groups = new Map();
|
|
140
|
+
for (const line of matched) {
|
|
141
|
+
const key = keyOf(line);
|
|
142
|
+
if (!groups.has(key)) groups.set(key, { key, count: 0, totalPence: 0, entryNumbers: [] });
|
|
143
|
+
const group = groups.get(key);
|
|
144
|
+
group.count += 1;
|
|
145
|
+
group.totalPence += signedPence(line);
|
|
146
|
+
group.entryNumbers.push(line.entryNumber);
|
|
147
|
+
}
|
|
148
|
+
const names = groupBy === "accountMainID" ? accountNames(book) : {};
|
|
149
|
+
let ordered = [...groups.values()].map((group) => (names[group.key] ? { ...group, name: names[group.key] } : group));
|
|
150
|
+
ordered.sort(groupBy === "month" ? (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0) : byLargest);
|
|
151
|
+
if (top) ordered = ordered.slice(0, top);
|
|
152
|
+
return { ...answer, groupBy, groups: ordered };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A reconciliation table, not a line of the accounts: its rows restate
|
|
156
|
+
// journal totals the account rows already carry.
|
|
157
|
+
const NOT_A_REPORT_ROW = "section/journal-category-vat-netting/";
|
|
158
|
+
|
|
159
|
+
// The rows with the least else in them, among those a test admits.
|
|
160
|
+
function smallestRows(rows, admits) {
|
|
161
|
+
let best = [];
|
|
162
|
+
let bestSize = Infinity;
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
if (!admits(row)) continue;
|
|
165
|
+
const size = row.entries.size;
|
|
166
|
+
if (size < bestSize) {
|
|
167
|
+
best = [row];
|
|
168
|
+
bestSize = size;
|
|
169
|
+
} else if (size === bestSize) {
|
|
170
|
+
best.push(row);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return best;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The report rows an account feeds: of the rows built from whole accounts
|
|
177
|
+
// (every year line of each account they touch, so a month's or a debtor's
|
|
178
|
+
// slice of an account does not count), the smallest that holds every year
|
|
179
|
+
// line of this one. An account the engine splits across rows (a bank
|
|
180
|
+
// account whose opening line sits apart from its movements) feeds instead
|
|
181
|
+
// the smallest of the rows holding the most of its lines. Read off the
|
|
182
|
+
// engine's own attribution rather than restated here. A VAT-straddling line
|
|
183
|
+
// belongs to a return outside the year and reaches no row of the year's
|
|
184
|
+
// accounts.
|
|
185
|
+
function rowsFed(account, rows, entriesOfAccount, accountOfEntry) {
|
|
186
|
+
const own = entriesOfAccount.get(account);
|
|
187
|
+
if (!own || own.size === 0) return [];
|
|
188
|
+
const holdsAll = (row) => [...own].every((entry) => row.entries.has(entry));
|
|
189
|
+
const wholeAccounts = (row) =>
|
|
190
|
+
[...row.entries].every((entry) => {
|
|
191
|
+
const other = entriesOfAccount.get(accountOfEntry.get(entry));
|
|
192
|
+
return !other || [...other].every((otherEntry) => row.entries.has(otherEntry));
|
|
193
|
+
});
|
|
194
|
+
let best = smallestRows(rows, (row) => holdsAll(row) && wholeAccounts(row));
|
|
195
|
+
if (best.length === 0) {
|
|
196
|
+
const held = (row) => [...own].filter((entry) => row.entries.has(entry)).length;
|
|
197
|
+
const most = Math.max(0, ...rows.map(held));
|
|
198
|
+
if (most > 0) best = smallestRows(rows, (row) => held(row) === most);
|
|
199
|
+
}
|
|
200
|
+
return best.map((row) => ({ key: row.key, label: row.label }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The book's chart of accounts: each account's id, name, group (the
|
|
205
|
+
* book.toml table it sits in), its other declared fields, how many lines
|
|
206
|
+
* post to it and their signed total in pence, and the report rows it feeds.
|
|
207
|
+
* @param {Object} book
|
|
208
|
+
* @param {Array} lines
|
|
209
|
+
* @param {Object} attributedReport - R built with entryNumbers on every figure
|
|
210
|
+
* @returns {Array<Object>}
|
|
211
|
+
*/
|
|
212
|
+
export function chartOfAccounts(book, lines, attributedReport) {
|
|
213
|
+
const rows = attributedReport.values
|
|
214
|
+
.filter((entry) => entry.key.startsWith("section/") && !entry.key.startsWith(NOT_A_REPORT_ROW) && entry.entryNumbers?.length > 0)
|
|
215
|
+
.map((entry) => ({ key: entry.key, label: entry.label, entries: new Set(entry.entryNumbers) }));
|
|
216
|
+
const accountOfEntry = new Map();
|
|
217
|
+
const entriesOfAccount = new Map();
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
if (isStraddlingLine(line)) continue;
|
|
220
|
+
const account = String(line.accountMainID);
|
|
221
|
+
accountOfEntry.set(line.entryNumber, account);
|
|
222
|
+
if (!entriesOfAccount.has(account)) entriesOfAccount.set(account, new Set());
|
|
223
|
+
entriesOfAccount.get(account).add(line.entryNumber);
|
|
224
|
+
}
|
|
225
|
+
const chart = [];
|
|
226
|
+
for (const [group, accounts] of Object.entries(book?.accounts || {})) {
|
|
227
|
+
for (const [id, account] of Object.entries(accounts || {})) {
|
|
228
|
+
const { accountMainDescription, ...declared } = account || {};
|
|
229
|
+
const posted = lines.filter((line) => String(line.accountMainID) === id);
|
|
230
|
+
chart.push({
|
|
231
|
+
id,
|
|
232
|
+
name: accountMainDescription ?? null,
|
|
233
|
+
group,
|
|
234
|
+
...plainValue(declared),
|
|
235
|
+
lineCount: posted.length,
|
|
236
|
+
totalPence: posted.reduce((sum, line) => sum + signedPence(line), 0),
|
|
237
|
+
feeds: rowsFed(id, rows, entriesOfAccount, accountOfEntry),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return chart;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The book's own profile: product, period, the business, its tax settings,
|
|
246
|
+
* its bank accounts, the journals its lines are posted on, and every other
|
|
247
|
+
* table book.toml carries (registers, opening balances, stock).
|
|
248
|
+
* @param {Object} book
|
|
249
|
+
* @param {Array} lines
|
|
250
|
+
* @param {string} product - bst, taxi, se or ltd
|
|
251
|
+
* @returns {Object}
|
|
252
|
+
*/
|
|
253
|
+
export function bookProfile(book, lines, product) {
|
|
254
|
+
const { documentInfo, entityInformation, tax, accounts, ...registers } = book || {};
|
|
255
|
+
const journals = new Map();
|
|
256
|
+
for (const line of lines) {
|
|
257
|
+
const id = line.sourceJournalID;
|
|
258
|
+
const date = isoDate(line.postingDate);
|
|
259
|
+
if (!journals.has(id)) journals.set(id, { journal: id, count: 0, firstDate: date, lastDate: date });
|
|
260
|
+
const entry = journals.get(id);
|
|
261
|
+
entry.count += 1;
|
|
262
|
+
if (date < entry.firstDate) entry.firstDate = date;
|
|
263
|
+
if (date > entry.lastDate) entry.lastDate = date;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
product,
|
|
267
|
+
period: { start: isoDate(documentInfo?.periodCoveredStart), end: isoDate(documentInfo?.periodCoveredEnd) },
|
|
268
|
+
documentInfo: plainValue(documentInfo ?? {}),
|
|
269
|
+
entity: plainValue(entityInformation ?? {}),
|
|
270
|
+
tax: plainValue(tax ?? {}),
|
|
271
|
+
bankAccounts: Object.entries(accounts?.bank || {}).map(([id, account]) => ({
|
|
272
|
+
id,
|
|
273
|
+
name: account?.accountMainDescription ?? null,
|
|
274
|
+
...plainValue(Object.fromEntries(Object.entries(account || {}).filter(([key]) => key !== "accountMainDescription"))),
|
|
275
|
+
})),
|
|
276
|
+
journals: [...journals.values()].sort((a, b) => (a.journal < b.journal ? -1 : 1)),
|
|
277
|
+
...plainValue(registers),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The book checks and warnings with each one's verdict and the entryNumbers
|
|
283
|
+
* of the lines that fail it.
|
|
284
|
+
* @param {{results: Array, summary: Object}} bookChecks - runBookChecks() output
|
|
285
|
+
* @returns {{summary: Object, checks: Array<Object>}}
|
|
286
|
+
*/
|
|
287
|
+
export function checksWithEntries(bookChecks) {
|
|
288
|
+
return {
|
|
289
|
+
summary: bookChecks.summary,
|
|
290
|
+
checks: bookChecks.results.map(({ result, offenders, ...rest }) => ({
|
|
291
|
+
...rest,
|
|
292
|
+
verdict: result,
|
|
293
|
+
entryNumbers: (offenders || []).map((offender) => offender.entryNumber).filter((entry) => entry !== undefined),
|
|
294
|
+
offenders: offenders || [],
|
|
295
|
+
})),
|
|
296
|
+
};
|
|
297
|
+
}
|