@diy-accounting-uk/diya-gl 1.1.2 → 1.2.0

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
@@ -49,8 +49,8 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
49
49
  - **read-workbook** takes an `.xlsx`, a package zip, or a diya-gl zip or JSON file
50
50
  (`--file`), and writes `book.toml`, `lines.jsonl`, `report.json` and `bookchecks.json`.
51
51
  - **write-workbook** takes a diya-gl book (`--data` or `--file`) and writes the Excel
52
- package its product composes onto its template — the same client-side path the site's
53
- books page uses, so it never needs LibreOffice. Add `--zip` for the package as one zip.
52
+ package its product composes onto its template — the same client-side path the DIYA-GL
53
+ pages use, so it never needs LibreOffice. Add `--zip` for the package as one zip.
54
54
  - **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with four tools:
55
55
  `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
56
56
  `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // diya-gl-write-workbook.js — writes the Excel package a diya-gl book
6
6
  // composes onto its product's template, with no LibreOffice: the same
7
- // client-side compose path the books page uses. See
7
+ // client-side compose path the DIYA-GL pages use. See
8
8
  // app/bin/write-workbook.js in the source repository for the full option
9
9
  // list (--data or --file, plus --zip for the package download).
10
10
 
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // --source-dir reads a package the pipeline already unpacked into its own
15
15
  // directory, and always needs --package to say which product's extractors
16
- // to run. --file takes any of the kinds books-interchange.js reads -- a
16
+ // to run. --file takes any of the kinds diya-gl-interchange.js reads -- a
17
17
  // customer's own download, not a directory this repo laid out -- and writes
18
18
  // beside that input unless --output-dir says otherwise; --package is
19
19
  // optional there, since the file itself says which product it is (the
@@ -59,7 +59,7 @@ import {
59
59
  InvalidDiyaGlJsonError,
60
60
  PackagePartError,
61
61
  ProductNotAvailableError,
62
- } from "../lib/books-interchange.js";
62
+ } from "../lib/diya-gl-interchange.js";
63
63
  import { PRODUCTS } from "../lib/products.js";
64
64
 
65
65
  // A book read through --file whose sniffed product disagrees with the
@@ -74,7 +74,7 @@ export class PackageMismatchError extends Error {
74
74
 
75
75
  // Every error a book source can refuse a read with, named rather than a
76
76
  // stack trace: --file prints the message and exits, exactly as the anchor
77
- // guard already did before books-interchange.js grew four more ways a file
77
+ // guard already did before diya-gl-interchange.js grew four more ways a file
78
78
  // can be rejected.
79
79
  const NAMED_BOOK_SOURCE_ERRORS = [
80
80
  AnchorError,
@@ -192,13 +192,13 @@ async function writeBookChecksJson(outputDir, book, lines) {
192
192
  console.log(` bookchecks.json: ${results.length} rules, ${failing} failing`);
193
193
  }
194
194
 
195
- // The whole --file extraction: books-interchange.js sniffs the input and
195
+ // The whole --file extraction: diya-gl-interchange.js sniffs the input and
196
196
  // turns it into D (a workbook or a package zip runs the anchor guard and
197
197
  // the extractors over a workbook set; a diya-gl zip, a JSON file or that
198
198
  // JSON zipped validate straight against the published schemas), then this
199
199
  // builds R from that D. --source-dir's CLI path and the MCP server's
200
200
  // extract_book tool both call this rather than each reaching into
201
- // books-interchange.js on their own, so a change to how D or R are produced
201
+ // diya-gl-interchange.js on their own, so a change to how D or R are produced
202
202
  // can only ever happen in one place.
203
203
  //
204
204
  // The product is whichever of the four the file sniffs as; a caller that
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // write-workbook.js — diya-gl data in, an Excel package out. The other
6
6
  // direction from export.js: this takes a book.toml + lines.jsonl (or any of
7
- // the kinds books-interchange.js reads) and writes the workbook(s) the
7
+ // the kinds diya-gl-interchange.js reads) and writes the workbook(s) the
8
8
  // template composes them onto, through product-workbook.js's saveWorkbook
9
9
  // path. No LibreOffice: the writes land as cached formula results the same
10
10
  // way the books page's client-side export does, never a recalculation pass.
@@ -17,7 +17,7 @@
17
17
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
18
18
  import { resolve } from "path";
19
19
  import { loadDiyaGlData } from "../lib/diya-gl-loader.js";
20
- import { readBookSource } from "../lib/books-interchange.js";
20
+ import { readBookSource } from "../lib/diya-gl-interchange.js";
21
21
  import { saveWorkbook, saveWorkbookFiles, savePackageZip, productOf } from "../lib/product-workbook.js";
22
22
  import { productModule, PRODUCTS } from "../lib/products.js";
23
23
 
@@ -207,6 +207,32 @@
207
207
  "scorecard": "101279 passed, 0 warnings, 0 failed"
208
208
  }
209
209
  }
210
+ },
211
+ {
212
+ "tag": "diya-gl-v1.1.2",
213
+ "date": "2026-09-09",
214
+ "formatVersion": "diya-gl-books/1",
215
+ "engineVersion": "1.1.2+25fe6af937aa",
216
+ "taxDataHash": "6a9c036b5f9c",
217
+ "reconciledCommit": "",
218
+ "templates": {
219
+ "bst": {
220
+ "hash": "0d003e10e00c",
221
+ "scorecard": "810 passed, 0 warnings, 0 failed"
222
+ },
223
+ "taxi": {
224
+ "hash": "1ff69654d64d",
225
+ "scorecard": "686 passed, 0 warnings, 0 failed"
226
+ },
227
+ "se": {
228
+ "hash": "c6a50059cc90",
229
+ "scorecard": "7679 passed, 0 warnings, 0 failed"
230
+ },
231
+ "ltd": {
232
+ "hash": "0c18ceeaced4",
233
+ "scorecard": "101279 passed, 0 warnings, 0 failed"
234
+ }
235
+ }
210
236
  }
211
237
  ]
212
238
  }
@@ -445,7 +445,7 @@ export function isSeInputCell(file, sheet, cellRef) {
445
445
  * baseline from, one per file SE_ANCHORS names. Resolved lazily: path and
446
446
  * url are Node-only, so this module only reaches them when a caller actually
447
447
  * asks for the paths, keeping SE_ANCHORS and isSeInputCell safe to import
448
- * under the books bundle's node-absent stubs (see build-books-bundle.mjs).
448
+ * under the books bundle's node-absent stubs (see build-diya-gl-bundle.mjs).
449
449
  * @returns {Promise<Object>} { [file]: templatePath }
450
450
  */
451
451
  export async function seTemplatePaths() {
@@ -30,8 +30,12 @@ const SCHEMA_ROOT_FROM_APP = ["..", "web", "spreadsheets.diyaccounting.co.uk", "
30
30
  // so the published npm package and the image leave them out. A repository
31
31
  // checkout has app/templates and reads it straight off disk. A packaged install
32
32
  // has not, so the loader takes the same file from the site, which already
33
- // serves the templates the books page uses, and keeps it in a user cache so
33
+ // serves the templates the DIYA-GL pages use, and keeps it in a user cache so
34
34
  // every later run works with no network.
35
+ //
36
+ // This stays on /books/ while the site still serves the pages there. Every
37
+ // published version fetches this URL, so it moves only once the live site
38
+ // redirects it, and the redirect keeps the older versions working after that.
35
39
  const TEMPLATE_PREFIX = "templates/";
36
40
  const DEFAULT_TEMPLATE_SOURCE = "https://spreadsheets.diyaccounting.co.uk/books/assets/";
37
41
 
@@ -0,0 +1,521 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // se-derivations.js — the two named ITSA derivations for the Self Employed
5
+ // product: the quarterly update HMRC's Self Employment Business API expects
6
+ // four times a year, and the annual submission that closes the year. Both
7
+ // sit beside the Ltd derivations in this directory and follow the same
8
+ // shape: read the engine's own cells, add no arithmetic beyond summing and
9
+ // picking boxes, and let a caller with only a book and lines get an answer.
10
+ //
11
+ // Every HMRC field name comes from app/data/hmrc/sa103-mtd-mapping.json --
12
+ // this module never restates a box's API field name in code, so a mapping
13
+ // correction there reaches both derivations with no code change.
14
+
15
+ import { readFileSync } from "fs";
16
+ import { resolve, dirname } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { diyaGlToScenario } from "../diya-gl-loader.js";
19
+ import { calculateSeCells } from "./se.js";
20
+ import { SE_YEAR_END_MONTH } from "../../products/se.js";
21
+ import { extractTaxYearStart, parseDate } from "../scenario-loader.js";
22
+ import { shiftMonths, periodShiftMonths } from "../period-shift.js";
23
+ import { splitVat } from "../tax/vat.js";
24
+
25
+ let cachedMapping = null;
26
+
27
+ // Resolved on first use, not at import: the engine bundle loads in the browser,
28
+ // where import.meta.url is an http URL and fileURLToPath throws.
29
+ function loadMapping() {
30
+ if (!cachedMapping) {
31
+ const here = dirname(fileURLToPath(import.meta.url));
32
+ const mappingPath = resolve(here, "..", "..", "data", "hmrc", "sa103-mtd-mapping.json");
33
+ cachedMapping = JSON.parse(readFileSync(mappingPath, "utf8"));
34
+ }
35
+ return cachedMapping;
36
+ }
37
+
38
+ function sa103fBoxes() {
39
+ return loadMapping().boxes.filter((entry) => entry.form === "SA103F");
40
+ }
41
+
42
+ function boxEntry(boxes, boxNumber) {
43
+ const entry = boxes.find((candidate) => candidate.box === boxNumber);
44
+ if (!entry) throw new Error(`sa103-mtd-mapping.json carries no SA103F box ${boxNumber}`);
45
+ if (!entry.field) throw new Error(`SA103F box ${boxNumber} carries no API field in sa103-mtd-mapping.json`);
46
+ return entry;
47
+ }
48
+
49
+ // A box HMRC's own mapping shares between two fields lists them "a | b". The
50
+ // first is the one this template's cell actually answers -- section 8 of the
51
+ // design records which, box by box -- so a caller asking for "the field this
52
+ // box answers" gets that one unless told to reach past it.
53
+ function fieldsOf(entry) {
54
+ return entry.field.split("|").map((field) => field.trim());
55
+ }
56
+
57
+ function primaryField(boxes, boxNumber) {
58
+ return fieldsOf(boxEntry(boxes, boxNumber))[0];
59
+ }
60
+
61
+ function setPath(target, path, value) {
62
+ const parts = path.split(".");
63
+ let node = target;
64
+ for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]] ||= {};
65
+ node[parts[parts.length - 1]] = value;
66
+ }
67
+
68
+ function round2(value) {
69
+ return Math.round(value * 100) / 100;
70
+ }
71
+
72
+ // Every quarter is rounded to the penny and the fourth carries whatever the
73
+ // first three left behind, so the four always sum to the annual box exactly
74
+ // -- the invariant the reconciliation tests below hold the derivation to.
75
+ function quartersWithResidue(values, annual) {
76
+ const q1 = round2(values[0]);
77
+ const q2 = round2(values[1]);
78
+ const q3 = round2(values[2]);
79
+ const q4 = round2(round2(annual) - q1 - q2 - q3);
80
+ return [q1, q2, q3, q4];
81
+ }
82
+
83
+ function vatRateFor(scenario) {
84
+ return scenario?.metadata?.vat_registered === false ? 0 : 0.2;
85
+ }
86
+
87
+ function isoDate(year, month, day) {
88
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
89
+ }
90
+
91
+ // HMRC's standard quarterly periods, published guidance (the phase 2 plan
92
+ // names these). Confirmed.
93
+ function standardQuarterBoundaries(startYear) {
94
+ return [
95
+ { start: isoDate(startYear, 4, 6), end: isoDate(startYear, 7, 5) },
96
+ { start: isoDate(startYear, 7, 6), end: isoDate(startYear, 10, 5) },
97
+ { start: isoDate(startYear, 10, 6), end: isoDate(startYear + 1, 1, 5) },
98
+ { start: isoDate(startYear + 1, 1, 6), end: isoDate(startYear + 1, 4, 5) },
99
+ ];
100
+ }
101
+
102
+ // The calendar election's exact dates are understood but not confirmed
103
+ // against a live filing (no spec in this repository states them). Isolated
104
+ // here so a confirmed change is a one-line edit. They only affect the dates
105
+ // reported back to the caller -- the calendar quarters are computed as three
106
+ // whole P&L month columns regardless of the exact day either boundary falls
107
+ // on, so a correction here changes no figure.
108
+ function calendarQuarterBoundaries(startYear) {
109
+ return [
110
+ { start: isoDate(startYear, 4, 6), end: isoDate(startYear, 6, 30) },
111
+ { start: isoDate(startYear, 7, 1), end: isoDate(startYear, 9, 30) },
112
+ { start: isoDate(startYear, 10, 1), end: isoDate(startYear, 12, 31) },
113
+ { start: isoDate(startYear + 1, 1, 1), end: isoDate(startYear + 1, 4, 5) },
114
+ ];
115
+ }
116
+
117
+ // A book's own first and last postings can fall a few days either side of
118
+ // the nominal 6 April / 5 April boundary (the fixtures carry lines dated 1
119
+ // to 5 April). The first quarter's lower edge and the last quarter's upper
120
+ // edge are widened to unbounded so those postings always land inside the
121
+ // year rather than being dropped as before-the-period or after-it; the two
122
+ // boundaries in between stay exactly as given.
123
+ function widenOuterEdges(boundaries) {
124
+ return boundaries.map((boundary, index) => ({
125
+ start: index === 0 ? null : boundary.start,
126
+ end: index === boundaries.length - 1 ? null : boundary.end,
127
+ }));
128
+ }
129
+
130
+ function withinBoundary(dateIso, boundary) {
131
+ if (boundary.start !== null && dateIso < boundary.start) return false;
132
+ if (boundary.end !== null && dateIso > boundary.end) return false;
133
+ return true;
134
+ }
135
+
136
+ function quarterIndexFor(dateIso, boundaries) {
137
+ const index = boundaries.findIndex((boundary) => withinBoundary(dateIso, boundary));
138
+ if (index === -1) throw new Error(`no quarter boundary covers ${dateIso}`);
139
+ return index;
140
+ }
141
+
142
+ function flatten(journal) {
143
+ return Object.values(journal || {}).flat();
144
+ }
145
+
146
+ function taxYearLabel(taxData, book) {
147
+ if (taxData?.tax_year?.label) return taxData.tax_year.label;
148
+ const periodEnd = book?.documentInfo?.periodCoveredEnd;
149
+ if (!periodEnd) throw new Error("no tax year: taxData.tax_year.label is absent and book.documentInfo.periodCoveredEnd is missing");
150
+ const year = parseDate(periodEnd).getUTCFullYear();
151
+ return `${year - 1}-${String(year).slice(-2)}`;
152
+ }
153
+
154
+ const MONTH_COLS = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N"];
155
+
156
+ function quarterSumOverRows(pl, rows, quarterIndex) {
157
+ const columns = MONTH_COLS.slice(quarterIndex * 3, quarterIndex * 3 + 3);
158
+ let total = 0;
159
+ for (const column of columns) for (const row of rows) total += pl[`${column}${row}`] || 0;
160
+ return total;
161
+ }
162
+
163
+ // The calendar election is three whole P&L month columns a quarter, so the
164
+ // quarterly figures come straight off the monthly grid the engine already
165
+ // computes -- the same grid app/test/se-anchors.test.js and the committed
166
+ // reports check against the Excel package. This path carries every mixed
167
+ // row's own addback (the payroll addition to row 21, the mileage claim in
168
+ // row 25, the stock movement in row 14's March cell) automatically, because
169
+ // the engine already folded each into the month it belongs to.
170
+ function quarterlyFromMonthlyGrid(pl) {
171
+ const perQuarter = (rows) => [0, 1, 2, 3].map((quarter) => quarterSumOverRows(pl, rows, quarter));
172
+ return {
173
+ turnover: perQuarter([5, 6, 7, 8]),
174
+ other: perQuarter([38]),
175
+ costOfGoods: perQuarter([14, 16]),
176
+ paymentsToSubcontractors: perQuarter([15]),
177
+ wagesAndStaffCosts: perQuarter([21]),
178
+ carVanTravelExpenses: perQuarter([25, 26]),
179
+ premisesRunningCosts: perQuarter([22]),
180
+ maintenanceCosts: perQuarter([23]),
181
+ adminCosts: perQuarter([24]),
182
+ advertisingCosts: perQuarter([27]),
183
+ interestOnBankOtherLoans: perQuarter([30]),
184
+ financeCharges: perQuarter([31]),
185
+ irrecoverableDebts: perQuarter([29]),
186
+ professionalFees: perQuarter([28]),
187
+ depreciation: perQuarter([33, 34]),
188
+ otherExpenses: perQuarter([32]),
189
+ depreciationDisallowable: perQuarter([34]),
190
+ };
191
+ }
192
+
193
+ // Sales and purchases code letters that feed a P&L row with no other
194
+ // adjustment mixed in, read off app/lib/scenario-extractor.js's own account
195
+ // maps by way of the code letters scenario transactions already carry.
196
+ const SALES_QUARTERLY_ROW = { a: 5, b: 6, c: 7, d: 8 };
197
+ const PURCHASES_QUARTERLY_ROW = { s: 14, c: 15, o: 16, w: 21, p: 22, m: 23, g: 24, v: 25, h: 26, a: 27, l: 28, y: 32 };
198
+
199
+ const MONTH_KEYS = ["apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", "jan", "feb", "mar"];
200
+ const MONTH_SHEET_NAMES = {
201
+ apr: "Apr",
202
+ may: "May",
203
+ jun: "Jun",
204
+ jul: "Jul",
205
+ aug: "Aug",
206
+ sep: "Sep",
207
+ oct: "Oct",
208
+ nov: "Nov",
209
+ dec: "Dec",
210
+ jan: "Jan",
211
+ feb: "Feb",
212
+ mar: "Mar",
213
+ };
214
+
215
+ // HMRC's standard periods cut three month tabs mid-month, so no cell in the
216
+ // package holds either half of a cut month and the quarterly figures come
217
+ // off the dated lines instead: each line goes through the same code-to-row
218
+ // map the engine uses, splits VAT at the book's own rate, and lands in
219
+ // whichever quarter its (period-shifted) posting date falls in. Two things
220
+ // the lines cannot carry are added separately: the depreciation pair, which
221
+ // the schedule spreads evenly across the year regardless of period type, and
222
+ // the stock movement, which sits wholly in the quarter holding the
223
+ // accounting period end. The mileage claim is banded monthly rather than
224
+ // per line, so its month's whole claim is bucketed by the calendar quarter
225
+ // its month falls in either way, the same routing the calendar election uses.
226
+ function quarterlyFromLines(scenario, rawResults, rate, monthOffset, boundaries) {
227
+ const rows = {};
228
+ for (const row of [5, 6, 7, 8, 14, 15, 16, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 38]) rows[row] = [0, 0, 0, 0];
229
+
230
+ const shiftedIso = (dateValue) => {
231
+ const shifted = shiftMonths(parseDate(dateValue), monthOffset);
232
+ return isoDate(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, shifted.getUTCDate());
233
+ };
234
+
235
+ for (const tx of flatten(scenario.sales)) {
236
+ if (tx.mileage) continue;
237
+ const net = splitVat(tx.amount, rate).net;
238
+ const quarter = quarterIndexFor(shiftedIso(tx.date), boundaries);
239
+ if (tx.code in SALES_QUARTERLY_ROW) rows[SALES_QUARTERLY_ROW[tx.code]][quarter] += net;
240
+ else if (tx.code === "o") rows[29][quarter] -= net;
241
+ }
242
+ for (const tx of flatten(scenario.purchases)) {
243
+ if (tx.mileage) continue;
244
+ const row = PURCHASES_QUARTERLY_ROW[tx.code];
245
+ if (!row) continue;
246
+ const net = splitVat(tx.amount, rate).net;
247
+ const quarter = quarterIndexFor(shiftedIso(tx.date), boundaries);
248
+ rows[row][quarter] += net;
249
+ }
250
+ for (const entry of flatten(scenario.payroll)) {
251
+ const quarter = quarterIndexFor(shiftedIso(entry.date), boundaries);
252
+ rows[21][quarter] += (entry.grossPay || 0) + (entry.employerNI || 0);
253
+ }
254
+ for (const tx of flatten(scenario.bank)) {
255
+ if (tx.code === "BC") continue;
256
+ const quarter = quarterIndexFor(shiftedIso(tx.date), boundaries);
257
+ const isCash = tx.account === "1220";
258
+ if (!isCash && tx.direction === "out" && tx.code === "J") rows[30][quarter] += tx.amount;
259
+ if (!isCash && tx.direction === "out" && tx.code === "B") rows[31][quarter] += tx.amount;
260
+ if (isCash && tx.direction === "out" && tx.code === "J") rows[31][quarter] += tx.amount;
261
+ if (!isCash && tx.direction === "in" && tx.code === "K") rows[38][quarter] += tx.amount;
262
+ }
263
+
264
+ const mileageByQuarter = [0, 0, 0, 0];
265
+ MONTH_KEYS.forEach((key, index) => {
266
+ const claim = rawResults[`Purchases.xlsx!${MONTH_SHEET_NAMES[key]}`]?.G2 || 0;
267
+ mileageByQuarter[Math.floor(index / 3)] += claim;
268
+ });
269
+
270
+ // The stock movement sits only in the closing month's own cell, which
271
+ // carries no code of its own to bucket by line. Isolating it as the gap
272
+ // between the engine's own annual row 14 and this quarter table's own
273
+ // code-s total keeps it out of the per-line loop above and lets it land
274
+ // wholly on the quarter that holds the accounting period end.
275
+ const annualCodeSNet = rows[14].reduce((total, value) => total + value, 0);
276
+ const stockMovement = rawResults["Profit & Loss Account"].B14 - annualCodeSNet;
277
+ rows[14][3] += stockMovement;
278
+
279
+ const depreciationQuarter = rawResults["SE Full"].D114 / 4;
280
+ const depreciationDisallowableQuarter = rawResults["SE Full"].O114 / 4;
281
+
282
+ return {
283
+ turnover: [0, 1, 2, 3].map((q) => rows[5][q] + rows[6][q] + rows[7][q] + rows[8][q]),
284
+ other: rows[38],
285
+ costOfGoods: [0, 1, 2, 3].map((q) => rows[14][q] + rows[16][q]),
286
+ paymentsToSubcontractors: rows[15],
287
+ wagesAndStaffCosts: rows[21],
288
+ carVanTravelExpenses: [0, 1, 2, 3].map((q) => rows[25][q] + rows[26][q] + mileageByQuarter[q]),
289
+ premisesRunningCosts: rows[22],
290
+ maintenanceCosts: rows[23],
291
+ adminCosts: rows[24],
292
+ advertisingCosts: rows[27],
293
+ interestOnBankOtherLoans: rows[30],
294
+ financeCharges: rows[31],
295
+ irrecoverableDebts: rows[29],
296
+ professionalFees: rows[28],
297
+ depreciation: [depreciationQuarter, depreciationQuarter, depreciationQuarter, depreciationQuarter],
298
+ otherExpenses: rows[32],
299
+ depreciationDisallowable: [
300
+ depreciationDisallowableQuarter,
301
+ depreciationDisallowableQuarter,
302
+ depreciationDisallowableQuarter,
303
+ depreciationDisallowableQuarter,
304
+ ],
305
+ };
306
+ }
307
+
308
+ /**
309
+ * The four quarterly period summaries HMRC's Self Employment Business API
310
+ * expects, built off the same engine the reconciliation runs.
311
+ *
312
+ * @param {Object} book - parsed book.toml
313
+ * @param {Array} lines - parsed lines.jsonl entries
314
+ * @param {Object} taxData - the tax year file (or extractTaxDataFromBook's
315
+ * result) the package is generated against
316
+ * @param {Object} [options]
317
+ * @param {Object} [options.scenario] - a scenario already built by
318
+ * diyaGlToScenario; built from book and lines when absent
319
+ * @param {"calendar"|"standard"} [options.quarterlyPeriodType] - which
320
+ * quarter boundaries to fall back to when options.periods is absent.
321
+ * Defaults to "calendar".
322
+ * @param {Array<{periodStartDate: string, periodEndDate: string}>} [options.periods] -
323
+ * the four periods HMRC's own obligations response named. Overrides
324
+ * quarterlyPeriodType.
325
+ */
326
+ export function buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, options = {}) {
327
+ const scenario = options.scenario || diyaGlToScenario(book, lines, "se");
328
+ const rate = vatRateFor(scenario);
329
+ const startYear = taxData?.tax_year?.start ? new Date(taxData.tax_year.start).getUTCFullYear() : extractTaxYearStart(scenario);
330
+ if (!startYear) throw new Error("buildSelfEmploymentQuarterlyUpdates: no tax year could be read from taxData or scenario");
331
+ const monthOffset = periodShiftMonths(scenario, startYear, SE_YEAR_END_MONTH);
332
+
333
+ const rawResults = calculateSeCells(book, lines, taxData, scenario);
334
+ const pl = rawResults["Profit & Loss Account"];
335
+ const seFull = rawResults["SE Full"];
336
+ const boxes = sa103fBoxes();
337
+
338
+ const explicitPeriods = options.periods ? options.periods.map((p) => ({ start: p.periodStartDate, end: p.periodEndDate })) : null;
339
+ const quarterlyPeriodType = options.quarterlyPeriodType || "calendar";
340
+ const nominalBoundaries =
341
+ explicitPeriods || (quarterlyPeriodType === "standard" ? standardQuarterBoundaries(startYear) : calendarQuarterBoundaries(startYear));
342
+
343
+ const usesMonthlyGrid = !explicitPeriods && quarterlyPeriodType === "calendar";
344
+ const quarterly = usesMonthlyGrid
345
+ ? quarterlyFromMonthlyGrid(pl)
346
+ : quarterlyFromLines(scenario, rawResults, rate, monthOffset, widenOuterEdges(nominalBoundaries));
347
+
348
+ const annualAnchor = {
349
+ turnover: seFull.D55,
350
+ other: seFull.O55,
351
+ costOfGoods: seFull.D66,
352
+ paymentsToSubcontractors: seFull.D70,
353
+ wagesAndStaffCosts: seFull.D74,
354
+ carVanTravelExpenses: seFull.D78,
355
+ premisesRunningCosts: seFull.D82,
356
+ maintenanceCosts: seFull.D86,
357
+ adminCosts: seFull.D90,
358
+ advertisingCosts: seFull.D94,
359
+ interestOnBankOtherLoans: seFull.D98,
360
+ financeCharges: seFull.D102,
361
+ irrecoverableDebts: seFull.D106,
362
+ professionalFees: seFull.D110,
363
+ depreciation: seFull.D114,
364
+ otherExpenses: seFull.D118,
365
+ depreciationDisallowable: seFull.O114,
366
+ };
367
+
368
+ const quarters = {};
369
+ for (const key of Object.keys(annualAnchor)) quarters[key] = quartersWithResidue(quarterly[key], annualAnchor[key]);
370
+
371
+ const incomeField = { turnover: primaryField(boxes, "15"), other: primaryField(boxes, "16") };
372
+ const expenseField = {
373
+ costOfGoods: primaryField(boxes, "17"),
374
+ paymentsToSubcontractors: primaryField(boxes, "18"),
375
+ wagesAndStaffCosts: primaryField(boxes, "19"),
376
+ carVanTravelExpenses: primaryField(boxes, "20"),
377
+ premisesRunningCosts: primaryField(boxes, "21"),
378
+ maintenanceCosts: primaryField(boxes, "22"),
379
+ adminCosts: primaryField(boxes, "23"),
380
+ advertisingCosts: primaryField(boxes, "24"),
381
+ interestOnBankOtherLoans: primaryField(boxes, "25"),
382
+ financeCharges: primaryField(boxes, "26"),
383
+ irrecoverableDebts: primaryField(boxes, "27"),
384
+ professionalFees: primaryField(boxes, "28"),
385
+ depreciation: primaryField(boxes, "29"),
386
+ otherExpenses: primaryField(boxes, "30"),
387
+ };
388
+ const disallowableField = primaryField(boxes, "44");
389
+
390
+ const periods = nominalBoundaries.map((boundary, index) => {
391
+ // Each mapping field name already carries its own top-level branch
392
+ // ("periodIncome.turnover", "periodExpenses.costOfGoods", ...), so every
393
+ // field for this quarter lands on one shared root and the three
394
+ // branches fall out of that root rather than being built separately and
395
+ // then walked into again.
396
+ const period = { periodDates: { periodStartDate: boundary.start, periodEndDate: boundary.end } };
397
+ setPath(period, incomeField.turnover, quarters.turnover[index]);
398
+ setPath(period, incomeField.other, quarters.other[index]);
399
+ for (const [key, field] of Object.entries(expenseField)) setPath(period, field, quarters[key][index]);
400
+ setPath(period, disallowableField, quarters.depreciationDisallowable[index]);
401
+ return period;
402
+ });
403
+
404
+ const warnings = [];
405
+ const box24 = boxEntry(boxes, "24");
406
+ warnings.push({
407
+ field: fieldsOf(box24)[1],
408
+ reason: "SA103F box 24 combines advertising and business entertainment in one Profit & Loss Account row; the template cannot separate them.",
409
+ });
410
+ warnings.push({
411
+ field: "periodExpenses.consolidatedExpenses",
412
+ reason: "a customer election for turnover at or below the VAT threshold, offered on the filing page; never derived from the books.",
413
+ });
414
+ warnings.push({
415
+ field: disallowableField,
416
+ reason:
417
+ 'SA103F box 44 is Profit & Loss Account row 34 alone; a loss on disposal (row 33) stays inside box 29\'s allowable total, ' +
418
+ "which is not an allowable deduction, with nothing moved to a disallowable box.",
419
+ handComputed: round2(pl.B33 || 0),
420
+ });
421
+ for (const entry of boxes) {
422
+ if (entry.route !== "quarterly" || !entry.field) continue;
423
+ if (!entry.field.includes("periodDisallowableExpenses.")) continue;
424
+ if (entry.box === "44") continue;
425
+ for (const field of fieldsOf(entry)) {
426
+ warnings.push({
427
+ field,
428
+ reason: `SA103F box ${entry.box} is printed with no formula behind it ("Not captured in DIY Accounting" on the VitalTax sheet); no book field carries it.`,
429
+ });
430
+ }
431
+ }
432
+
433
+ return {
434
+ taxYear: taxYearLabel(taxData, book),
435
+ quarterlyPeriodType: explicitPeriods ? options.quarterlyPeriodType : quarterlyPeriodType,
436
+ periods,
437
+ warnings,
438
+ };
439
+ }
440
+
441
+ // Every annual field this book can source, and a warning naming the box for
442
+ // every one it cannot -- section 8 of the design records why each is out of
443
+ // reach today: no cell in the template, a cell the customer fills by hand,
444
+ // or a box the schema has no field for.
445
+ const NO_SOURCE_ANNUAL_BOXES = [
446
+ { box: "51", pick: 0 }, // allowances.capitalAllowanceSpecialRatePool
447
+ { box: "51", pick: 1 }, // allowances.capitalAllowanceSingleAssetPool (shared with box 50)
448
+ { box: "52.1", pick: 0 }, // allowances.zeroEmissionsCarAllowance
449
+ { box: "53", pick: 0 }, // allowances.structuredBuildingAllowance
450
+ { box: "53.1", pick: 0 }, // allowances.enhancedStructuredBuildingAllowance
451
+ { box: "55", pick: 1 }, // allowances.businessPremisesRenovationAllowance (shared with enhancedCapitalAllowance)
452
+ { box: "59", pick: 1 }, // adjustments.balancingChargeBpra (shared with balancingChargeOther)
453
+ { box: "62", pick: 0 }, // adjustments.includedNonTaxableProfits
454
+ { box: "68", pick: 0 }, // adjustments.basisAdjustment
455
+ { box: "71", pick: 0 }, // adjustments.accountingAdjustment
456
+ { box: "73.3", pick: 0 }, // adjustments.transitionProfitAmount
457
+ { box: "73.3", pick: 1 }, // adjustments.transitionProfitAccelerationAmount
458
+ ];
459
+
460
+ /**
461
+ * The annual submission that closes the year, built off the same engine the
462
+ * reconciliation runs.
463
+ *
464
+ * @param {Object} book - parsed book.toml
465
+ * @param {Array} lines - parsed lines.jsonl entries
466
+ * @param {Object} taxData - the tax year file (or extractTaxDataFromBook's
467
+ * result) the package is generated against
468
+ * @param {Object} [options]
469
+ * @param {Object} [options.scenario] - a scenario already built by
470
+ * diyaGlToScenario; built from book and lines when absent
471
+ */
472
+ export function buildSelfEmploymentAnnualSubmission(book, lines, taxData, options = {}) {
473
+ const scenario = options.scenario || diyaGlToScenario(book, lines, "se");
474
+ const rawResults = calculateSeCells(book, lines, taxData, scenario);
475
+ const seFull = rawResults["SE Full"];
476
+ const boxes = sa103fBoxes();
477
+
478
+ // Each mapping field name already carries its own top-level branch
479
+ // ("allowances.annualInvestmentAllowance", "adjustments.balancingChargeOther",
480
+ // ...), so both branches land on one shared root and fall out of it rather
481
+ // than being built separately and then walked into again.
482
+ const root = {};
483
+ setPath(root, primaryField(boxes, "49"), round2(seFull.D139));
484
+ setPath(root, primaryField(boxes, "50"), round2(seFull.D144));
485
+ setPath(root, primaryField(boxes, "55"), round2(seFull.O144));
486
+ setPath(root, primaryField(boxes, "56"), round2(seFull.O149));
487
+
488
+ setPath(root, primaryField(boxes, "59"), round2(seFull.O160));
489
+ const ownUseField = primaryField(boxes, "60");
490
+ setPath(root, ownUseField, round2(seFull.D169));
491
+ setPath(root, primaryField(boxes, "75"), round2(seFull.O204));
492
+
493
+ const allowances = root.allowances || {};
494
+ const adjustments = root.adjustments || {};
495
+
496
+ const enhancedCapitalAllowanceField = primaryField(boxes, "55");
497
+ const warnings = [
498
+ {
499
+ field: ownUseField,
500
+ reason: "Business Details!O50 is a customer input cell the book schema has no field for; the template always carries nil here.",
501
+ },
502
+ {
503
+ field: enhancedCapitalAllowanceField,
504
+ reason:
505
+ "SA103F box 55 is the small pools write-off (Schedule S1, once the pool balance and the writing down allowance together are under £1,000), not a genuine 100% or enhanced capital allowance; HMRC's own field mapping still files it here.",
506
+ },
507
+ ];
508
+ for (const { box: boxNumber, pick } of NO_SOURCE_ANNUAL_BOXES) {
509
+ const entry = boxEntry(boxes, boxNumber);
510
+ warnings.push({
511
+ field: fieldsOf(entry)[pick],
512
+ reason: `SA103F box ${boxNumber} has no cell the template computes; the customer fills it in by hand.`,
513
+ });
514
+ }
515
+
516
+ const result = { taxYear: taxYearLabel(taxData, book) };
517
+ if (Object.keys(allowances).length > 0) result.allowances = allowances;
518
+ if (Object.keys(adjustments).length > 0) result.adjustments = adjustments;
519
+ result.warnings = warnings;
520
+ return result;
521
+ }
@@ -16,6 +16,7 @@ import { calculateSeCells, calculateSeResults } from "./calculators/se.js";
16
16
  import { calculateLtdCells, calculateLtdResults } from "./calculators/ltd.js";
17
17
 
18
18
  export { aggregateByAccountAndMonth, annualTotal, sumValues, aggregateByCode } from "./calculators/shared.js";
19
+ export { buildSelfEmploymentQuarterlyUpdates, buildSelfEmploymentAnnualSubmission } from "./calculators/se-derivations.js";
19
20
 
20
21
  /**
21
22
  * Main entry point. Calculate financial reports from diya-gl data.
@@ -182,7 +182,7 @@ export function canonicalLinesJsonl(lines) {
182
182
 
183
183
  /**
184
184
  * One line's own fields reordered to the schema's declared order, values
185
- * untouched -- the shape writeBookJson (books-interchange.js) serialises a
185
+ * untouched -- the shape writeBookJson (diya-gl-interchange.js) serialises a
186
186
  * line as. Any field the schema does not declare is kept, after the
187
187
  * declared ones, so an unrecognised key is lost to validation rather than
188
188
  * to this reordering.
@@ -336,7 +336,7 @@ export function canonicalBookToml(book) {
336
336
 
337
337
  /**
338
338
  * The book's top-level tables reordered to the schema's declared order,
339
- * values untouched -- the shape writeBookJson (books-interchange.js)
339
+ * values untouched -- the shape writeBookJson (diya-gl-interchange.js)
340
340
  * serialises a book as. Any table the schema does not declare is kept,
341
341
  * after the declared ones.
342
342
  * @param {Object} book - parsed book.toml
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) 2006-2026 DIY Accounting Limited
3
3
  //
4
- // books-interchange.js — what a byte array handed to the CLI, the MCP
4
+ // diya-gl-interchange.js — what a byte array handed to the CLI, the MCP
5
5
  // server or the books page actually is, and the one place that turns it
6
6
  // into D (a diya-gl book and its lines) or back into bytes. Every caller
7
7
  // sniffs and reads through detectBookSource/readBookSource, so a file that
@@ -49,9 +49,15 @@ import * as bst from "../products/bst.js";
49
49
 
50
50
  export { AnchorError };
51
51
 
52
- const JSON_FORMAT = "diya-gl-books";
52
+ const JSON_FORMAT = "diya-gl";
53
53
  const JSON_VERSION = 1;
54
54
 
55
+ // A file written before the format's name changed from "diya-gl-books" to
56
+ // "diya-gl" carries the old name in its "format" field. The version number
57
+ // alone did not change, so a legacy file still reads once its format name is
58
+ // recognised here -- the writer only ever emits JSON_FORMAT.
59
+ const LEGACY_JSON_FORMATS = ["diya-gl-books"];
60
+
55
61
  // The workbook every multi-file package is built around; its siblings say
56
62
  // which product the package is.
57
63
  const PACKAGE_HUB = "Financialaccounts.xlsx";
@@ -104,7 +110,7 @@ export class InvalidDiyaGlBookError extends Error {
104
110
  }
105
111
  }
106
112
 
107
- /** A JSON file that is not this module's own diya-gl-books format. */
113
+ /** A JSON file that is not this module's own diya-gl format. */
108
114
  export class InvalidDiyaGlJsonError extends Error {
109
115
  constructor(why) {
110
116
  super(`This JSON file is not a diya-gl book: ${why}`);
@@ -401,7 +407,9 @@ function undeclaredProduct(book) {
401
407
  );
402
408
  }
403
409
 
404
- // { "format": "diya-gl-books", "version": 1, "product": "bst", "book": {...}, "lines": [...] }
410
+ // { "format": "diya-gl", "version": 1, "product": "bst", "book": {...}, "lines": [...] }
411
+ // A file saved before the format's rename still reads: its "format" is one
412
+ // of LEGACY_JSON_FORMATS rather than JSON_FORMAT.
405
413
  function parseJsonDocument(text) {
406
414
  let document;
407
415
  try {
@@ -409,8 +417,9 @@ function parseJsonDocument(text) {
409
417
  } catch (cause) {
410
418
  throw new InvalidDiyaGlJsonError(`the text does not parse as JSON (${cause.message})`);
411
419
  }
412
- if (document.format !== JSON_FORMAT) {
413
- throw new InvalidDiyaGlJsonError(`expected "format": ${JSON.stringify(JSON_FORMAT)}, found ${JSON.stringify(document.format)}`);
420
+ if (document.format !== JSON_FORMAT && !LEGACY_JSON_FORMATS.includes(document.format)) {
421
+ const expected = [JSON_FORMAT, ...LEGACY_JSON_FORMATS].map((f) => JSON.stringify(f)).join(" or ");
422
+ throw new InvalidDiyaGlJsonError(`expected "format": ${expected}, found ${JSON.stringify(document.format)}`);
414
423
  }
415
424
  if (document.version !== JSON_VERSION) {
416
425
  throw new InvalidDiyaGlJsonError(`expected "version": ${JSON_VERSION}, found ${JSON.stringify(document.version)}`);
@@ -658,7 +658,7 @@ export function diyaGlToScenario(book, lines, product) {
658
658
  *
659
659
  * Resolves app/data's path from import.meta.url lazily, on the first call,
660
660
  * rather than at module load: this module is bundled into the books page
661
- * (books-engine.js re-exports diyaGlToScenario from it), where url and path
661
+ * (diya-gl-engine.js re-exports diyaGlToScenario from it), where url and path
662
662
  * are stubs that throw when called -- extractTaxDataFromBook itself is
663
663
  * Node-only and never reached from the bundle, but a module-scope call
664
664
  * would run for every importer, browser included.
@@ -47,7 +47,7 @@ export function useSchemas(bookSchema, linesSchema) {
47
47
  * Security Policy with no `unsafe-eval` forbids the `new Function` inside
48
48
  * ajv.compile, which useSchemas() and loadSchemasFrom() both reach. The
49
49
  * books bundle takes a different route to the same result (see
50
- * scripts/build-books-bundle.mjs: it resolves ajv's own imports to functions
50
+ * scripts/build-diya-gl-bundle.mjs: it resolves ajv's own imports to functions
51
51
  * generateStandaloneValidatorSource() built ahead of time, so useSchemas()
52
52
  * and loadSchemasFrom() keep working unchanged); this function is the direct
53
53
  * seam for a caller - a test proving the two validator sources agree, or a
@@ -3,10 +3,10 @@
3
3
  //
4
4
  // diya-gl-tools.js — the four MCP tools, each a thin call into a function
5
5
  // phase 1 already tests: extract_book wraps export.js's --file pipeline
6
- // (books-interchange.js underneath, so every kind it reads loads here too),
6
+ // (diya-gl-interchange.js underneath, so every kind it reads loads here too),
7
7
  // report and edit_lines wrap the diya-gl-calculator/report-serializer loop
8
8
  // and diya-gl-edits.js, save_workbook wraps product-workbook.js for a workbook
9
- // or package zip and books-interchange.js for the two diya-gl formats. No
9
+ // or package zip and diya-gl-interchange.js for the two diya-gl formats. No
10
10
  // engine code lives here.
11
11
  //
12
12
  // extract_book and report both carry a bookChecks field alongside report --
@@ -30,7 +30,7 @@ import { resolve as resolvePath } from "path";
30
30
  import { extractBookFromFile, buildFileReportDocument, calculatedResultsFor } from "../../bin/export.js";
31
31
  import { canonicalBookToml, canonicalLinesJsonl } from "../diya-gl-canonical.js";
32
32
  import { stampBook } from "../provenance.js";
33
- import { writeDiyaGlZip, writeBookJson } from "../books-interchange.js";
33
+ import { writeDiyaGlZip, writeBookJson } from "../diya-gl-interchange.js";
34
34
  import { saveWorkbook, savePackageZip, loadTaxDataForBook, productOf } from "../product-workbook.js";
35
35
  import { productModule } from "../products.js";
36
36
  import {
@@ -93,7 +93,7 @@ function attribute(file, sheet, cellRef, extractionMap, reportLabels) {
93
93
  // process. A BST or SE template is a file in this repo that never changes
94
94
  // while a process runs, so the path itself is the cache key; Taxi's own
95
95
  // baseline is a workbook the generator builds fresh for the book's own tax
96
- // year (see readWorkbookSource in books-interchange.js), which has no path
96
+ // year (see readWorkbookSource in diya-gl-interchange.js), which has no path
97
97
  // on disk to key on, so its entry in options.templates carries its own cache
98
98
  // key (a string like "taxi:se-2025-2026") alongside a loader that is only
99
99
  // called on a cache miss.
@@ -83,8 +83,8 @@ export function productOf(book) {
83
83
  return product;
84
84
  }
85
85
 
86
- // Re-exported for product-workbook.js's own callers (books-engine.js,
87
- // books-interchange.js, the workbook tests); its home is tax-year.js so
86
+ // Re-exported for product-workbook.js's own callers (diya-gl-engine.js,
87
+ // diya-gl-interchange.js, the workbook tests); its home is tax-year.js so
88
88
  // diya-gl-loader.js can share it without importing this module.
89
89
  export { taxYearFileName };
90
90
 
@@ -11,10 +11,10 @@
11
11
  // stamped by any of the three surfaces carries the same values.
12
12
 
13
13
  export const PROVENANCE_DATA = {
14
- "formatVersion": "diya-gl-books/1",
15
- "engineVersion": "1.1.2+25fe6af937aa",
14
+ "formatVersion": "diya-gl/1",
15
+ "engineVersion": "1.2.0+a39469f30c42",
16
16
  "taxDataHash": "6a9c036b5f9c",
17
- "reconciledCommit": "",
17
+ "reconciledCommit": "320b4b8a1585e16fa170c2d312c14df38d0bcf5b",
18
18
  "templates": {
19
19
  "bst": {
20
20
  "hash": "0d003e10e00c",
@@ -18,7 +18,7 @@ import { PROVENANCE_DATA } from "./provenance-data.js";
18
18
 
19
19
  // The same schema-name-to-product-id mapping xlsx-exporter.js's
20
20
  // productIdOf() carries, kept as a small map of its own rather than an
21
- // import: report-serializer.js and books-interchange.js both reach this
21
+ // import: report-serializer.js and diya-gl-interchange.js both reach this
22
22
  // module from one side of a cycle through xlsx-exporter.js (which itself
23
23
  // imports scenario-extractor.js), and a static import back into
24
24
  // xlsx-exporter.js from here closes that cycle -- a genuine circular ESM
@@ -299,7 +299,7 @@ function checkEntries(checks) {
299
299
  const entry = {
300
300
  key: `check/${check.name}${seen > 1 ? `#${seen}` : ""}`,
301
301
  unit: "verdict",
302
- value: check.pass ? "pass" : "fail",
302
+ value: check.pass ? "pass" : check.severity === "warning" ? "warn" : "fail",
303
303
  };
304
304
  const expected = canonicalValue(check.expected);
305
305
  const actual = canonicalValue(check.actual);
@@ -2737,7 +2737,7 @@ function transactionRegionIndex() {
2737
2737
  *
2738
2738
  * bstExtractionMap() is the BST caller's own name for this factory, kept so
2739
2739
  * the books page (which calls it directly, by this name, through
2740
- * books-engine.js) does not have to change here; the four multi-file
2740
+ * diya-gl-engine.js) does not have to change here; the four multi-file
2741
2741
  * extractors below build the same shape for SE and Ltd.
2742
2742
  * @param {string} [bookFieldProduct] - which product's bookFieldCells() the
2743
2743
  * field-lookup half answers from; defaults to "bst", this factory's first
@@ -1339,6 +1339,16 @@ export function standardReads() {
1339
1339
  // months that traded and tie the projected year to the actual one.
1340
1340
  9,
1341
1341
  38,
1342
+ // Materials after the stock adjustment, wages and staff costs, bank
1343
+ // interest paid and finance charges: the only rows an ITSA quarterly
1344
+ // update needs a monthly split for that the netting table above does
1345
+ // not already carry (row 14 mixes a stock movement into its own
1346
+ // closing-month cell, row 21 mixes a payroll addback, and rows 30/31
1347
+ // come off the bank books rather than a purchases analysis column).
1348
+ 14,
1349
+ 21,
1350
+ 30,
1351
+ 31,
1342
1352
  ]),
1343
1353
  ];
1344
1354
  reads["Profit & Loss Account"] = reads["Profit & Loss Account"] || [];
@@ -1878,9 +1888,14 @@ export function categoryNetting(results, scenario) {
1878
1888
  export function checkCompliance(results, expected, taxData, calculateExpectedTax, packageYearEnd) {
1879
1889
  const checks = [];
1880
1890
 
1881
- function check(name, actual, expectedVal, tolerance = 1) {
1891
+ // A fourth, optional argument marks a check "warning": the shipped
1892
+ // template genuinely diverges from the figure named here and no fixture
1893
+ // closes the gap, so the report is asked to carry the divergence rather
1894
+ // than fail outright. The diff column is what a hand computation would
1895
+ // call the size of that gap.
1896
+ function check(name, actual, expectedVal, tolerance = 1, severity) {
1882
1897
  const pass = Math.abs(actual - expectedVal) <= tolerance;
1883
- checks.push({ name, actual, expected: expectedVal, pass, diff: actual - expectedVal, tolerance });
1898
+ checks.push({ name, actual, expected: expectedVal, pass, diff: actual - expectedVal, tolerance, severity });
1884
1899
  }
1885
1900
 
1886
1901
  // The same whole-year shift cellWrites applies before writing a posting
@@ -2243,6 +2258,56 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2243
2258
  check(`SA103F ${caption} (${cell}) = the profit and loss account`, num(seFull[cell]), plFigure);
2244
2259
  }
2245
2260
 
2261
+ // Box 44 is row 34 alone, so a loss on the disposal of an asset (row 33)
2262
+ // stays inside box 29's allowable total with nothing moved to a
2263
+ // disallowable box, even though a loss on disposal is not an allowable
2264
+ // deduction: the balancing allowance in box 56 is what relieves it. The
2265
+ // template has no fix available -- this warns of the gap rather than
2266
+ // failing on it, carrying the loss itself as the size of what box 44
2267
+ // leaves out.
2268
+ check(
2269
+ "SA103F box 44 disallowable depreciation (O114) leaves the loss on disposal (row 33) out of the disallowable total that box 29 (D114) carries",
2270
+ num(seFull.O114),
2271
+ num(seFull.D114),
2272
+ 0.01,
2273
+ "warning",
2274
+ );
2275
+
2276
+ // VitalTax pools rows 5 to 7 for its own annual sales figure; box 15
2277
+ // pools rows 5 to 8, the extra row being sales coded "d" ("Other
2278
+ // Income" on the P&L, despite the name a turnover line). A trader
2279
+ // reading VitalTax as a running total of the year's turnover would
2280
+ // undercount it by that row. The shipped template has no fix
2281
+ // available -- this warns of the gap rather than failing, carrying
2282
+ // the excluded row as the size of the gap.
2283
+ if (vt) {
2284
+ check(
2285
+ "VitalTax annual sales (G5) excludes the Other Income sales that SA103F box 15 (D55) includes",
2286
+ num(vt.G5),
2287
+ num(seFull.D55),
2288
+ 0.01,
2289
+ "warning",
2290
+ );
2291
+
2292
+ // VitalTax's own other-income row sums three P&L rows together --
2293
+ // Other Income (turnover), Investment Grants and Interest received --
2294
+ // where SA103F keeps them apart: the grants reach box 75 (O204), not
2295
+ // box 16 (O55). A trader reading VitalTax's other-income row as a
2296
+ // quarterly "other business income" figure would carry the grants
2297
+ // there too, double-counting them against the annual return. The
2298
+ // Other Income row's own contribution is anchored on the scenario's
2299
+ // own sales coded "d" rather than on the P&L cell it feeds, so a
2300
+ // wrong Other Income line cannot hide inside this check.
2301
+ const otherIncomeSales = journalTotalsByCode(expected.sales, rate, "a").net.d || 0;
2302
+ check(
2303
+ "VitalTax other income (rows 8, 11 and 38 folded together) treats Investment Grants as ordinary other income, while SA103F reports them apart at box 75 (O204) rather than box 16 (O55)",
2304
+ num(pl.B8) + num(pl.B11) + num(pl.B38),
2305
+ num(seFull.O55) + otherIncomeSales,
2306
+ 0.01,
2307
+ "warning",
2308
+ );
2309
+ }
2310
+
2246
2311
  // The form's own arithmetic, each total against the boxes it adds up.
2247
2312
  check(
2248
2313
  "SA103F box 57 total capital allowances (O154) = boxes 49 to 56",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "diya-gl:formatVersion": {
77
77
  "type": "string",
78
- "description": "diya-gl: extension — The diya-gl-books format name and version this file is written as, e.g. \"diya-gl-books/1\"."
78
+ "description": "diya-gl: extension — The diya-gl format name and version this file is written as, e.g. \"diya-gl/1\"."
79
79
  },
80
80
  "diya-gl:engineVersion": {
81
81
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",