@diy-accounting-uk/diya-gl 1.2.43 → 1.2.45

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 CHANGED
@@ -67,9 +67,84 @@ 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 four tools:
71
- `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
72
- `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
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.
76
+
77
+ ## API
78
+
79
+ The package root exports three functions that turn a book into the figures HMRC's MTD APIs
80
+ take. Each takes the parsed book, its lines and a parameters object, and returns a promise.
81
+ Nothing is sent anywhere: the answer is the request body to send, plus what fed it.
82
+
83
+ Load a book first:
84
+
85
+ ```js
86
+ import { readFileSync } from "node:fs";
87
+ import { parse } from "smol-toml";
88
+
89
+ function loadBook(dir) {
90
+ const book = parse(readFileSync(`${dir}/book.toml`, "utf8"));
91
+ const lines = readFileSync(`${dir}/lines.jsonl`, "utf8")
92
+ .split("\n")
93
+ .filter(Boolean)
94
+ .map((line) => JSON.parse(line));
95
+ return { book, lines };
96
+ }
97
+ ```
98
+
99
+ ### `deriveVatReturn(book, lines, { periodEnd, periodStart?, periodKey? })`
100
+
101
+ The nine VAT return boxes for the three-month period ending `periodEnd` (YYYY-MM-DD), from a
102
+ Limited Company book that declares `diya-gl:vatRegistered = true`. `periodStart`, when given,
103
+ must open that quarter; `periodKey` is echoed back. The answer carries `boxes` (box1 to box9),
104
+ `hmrc` (the same figures under HMRC's field names, boxes 1 to 5 to the penny and 6 to 9 in
105
+ whole pounds), `months`, `dueDate`, `scheme`, and `lines`: the journal lines behind boxes 1, 4,
106
+ 6 and 7, each with what it contributes. A book whose lines do not reconcile with its VAT
107
+ interface is refused.
108
+
109
+ ```js
110
+ import { deriveVatReturn } from "@diy-accounting-uk/diya-gl";
111
+
112
+ const { book, lines } = loadBook("my-company-book");
113
+ const answer = await deriveVatReturn(book, lines, { periodStart: "2025-07-01", periodEnd: "2025-09-30" });
114
+ console.log(answer.hmrc); // { vatDueSales, vatDueAcquisitions, totalVatDue, ..., totalAcquisitionsExVAT }
115
+ ```
116
+
117
+ ### `deriveItsaQuarterlyUpdate(book, lines, { periodEndDate?, quarterlyPeriodType?, taxYear? })`
118
+
119
+ One period of HMRC's Self Employment Business API from a Self Employed book. In 2023-24 and
120
+ 2024-25 each period carries its own figures; from 2025-26 each carries the running total from
121
+ 6 April. `periodEndDate` picks the period among the year's four; without it every period is
122
+ answered. `quarterlyPeriodType` is `standard` (6 April quarters, the default) or `calendar`.
123
+ `taxYear` (as `2025-26`) overrides the year the book's dates imply. Each period lists the fields
124
+ the book cannot source under `omitted`; those are left out of the body, never sent as zero.
125
+
126
+ ```js
127
+ import { deriveItsaQuarterlyUpdate } from "@diy-accounting-uk/diya-gl";
128
+
129
+ const { book, lines } = loadBook("my-self-employed-book");
130
+ const answer = await deriveItsaQuarterlyUpdate(book, lines, { periodEndDate: "2025-10-05" });
131
+ const [period] = answer.periods; // { periodDates, periodIncome, periodExpenses, ..., omitted, covers }
132
+ ```
133
+
134
+ ### `deriveItsaAnnualSubmission(book, lines, { taxYear? })`
135
+
136
+ The year's allowances and adjustments for the same API's annual submission, from a Self
137
+ Employed book, restricted to the fields HMRC accepts for that tax year. `taxYear` (as
138
+ `2025-26`) overrides the year the book's dates imply. The answer carries `allowances`,
139
+ `adjustments`, `omitted` and `warnings`.
140
+
141
+ ```js
142
+ import { deriveItsaAnnualSubmission } from "@diy-accounting-uk/diya-gl";
143
+
144
+ const { book, lines } = loadBook("my-self-employed-book");
145
+ const answer = await deriveItsaAnnualSubmission(book, lines, { taxYear: "2025-26" });
146
+ console.log(answer.allowances.annualInvestmentAllowance);
147
+ ```
73
148
 
74
149
  ## The format
75
150
 
@@ -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, four tools over a loaded diya-gl
6
- // book: extract_book, report, edit_lines, save_workbook. See
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
- // four tools: extract_book, report, edit_lines, save_workbook, over any of
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
@@ -1351,6 +1351,58 @@
1351
1351
  "scorecard": "101605 passed, 0 warnings, 0 failed"
1352
1352
  }
1353
1353
  }
1354
+ },
1355
+ {
1356
+ "tag": "diya-gl-v1.2.43",
1357
+ "date": "2026-10-03",
1358
+ "formatVersion": "diya-gl/1",
1359
+ "engineVersion": "1.2.40+57de8f55b",
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.40+57de8f55b",
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
+ }
1354
1406
  }
1355
1407
  ]
1356
1408
  }
@@ -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
+ }