@diy-accounting-uk/diya-gl 1.1.1 → 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 +2 -2
- package/bin/diya-gl-write-workbook.js +1 -1
- package/dist/app/bin/export.js +5 -5
- package/dist/app/bin/write-workbook.js +2 -2
- package/dist/app/data/releases.json +52 -0
- package/dist/app/lib/anchors/se.js +1 -1
- package/dist/app/lib/app-resources.js +5 -1
- package/dist/app/lib/calculators/se-derivations.js +521 -0
- package/dist/app/lib/diya-gl-calculator.js +1 -0
- package/dist/app/lib/diya-gl-canonical.js +2 -2
- package/dist/app/lib/{books-interchange.js → diya-gl-interchange.js} +15 -6
- package/dist/app/lib/diya-gl-loader.js +1 -1
- package/dist/app/lib/diya-gl-schema.js +1 -1
- package/dist/app/lib/mcp/diya-gl-tools.js +3 -3
- package/dist/app/lib/overtype-sidecar.js +1 -1
- package/dist/app/lib/product-workbook.js +2 -2
- package/dist/app/lib/provenance-data.js +3 -3
- package/dist/app/lib/provenance.js +1 -1
- package/dist/app/lib/report-serializer.js +1 -1
- package/dist/app/lib/xlsx-exporter.js +1 -1
- package/dist/app/products/se.js +67 -2
- package/dist/web/spreadsheets.diyaccounting.co.uk/public/schema/diya-gl-book-v2.schema.json +1 -1
- package/package.json +1 -1
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
|
|
53
|
-
|
|
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
|
|
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
|
|
package/dist/app/bin/export.js
CHANGED
|
@@ -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
|
|
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/
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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/
|
|
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
|
|
|
@@ -181,6 +181,58 @@
|
|
|
181
181
|
"scorecard": "101279 passed, 0 warnings, 0 failed"
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"tag": "diya-gl-v1.1.1",
|
|
187
|
+
"date": "2026-09-09",
|
|
188
|
+
"formatVersion": "diya-gl-books/1",
|
|
189
|
+
"engineVersion": "1.1.1+49d53885673a",
|
|
190
|
+
"taxDataHash": "6a9c036b5f9c",
|
|
191
|
+
"reconciledCommit": "",
|
|
192
|
+
"templates": {
|
|
193
|
+
"bst": {
|
|
194
|
+
"hash": "0d003e10e00c",
|
|
195
|
+
"scorecard": "810 passed, 0 warnings, 0 failed"
|
|
196
|
+
},
|
|
197
|
+
"taxi": {
|
|
198
|
+
"hash": "1ff69654d64d",
|
|
199
|
+
"scorecard": "686 passed, 0 warnings, 0 failed"
|
|
200
|
+
},
|
|
201
|
+
"se": {
|
|
202
|
+
"hash": "c6a50059cc90",
|
|
203
|
+
"scorecard": "7679 passed, 0 warnings, 0 failed"
|
|
204
|
+
},
|
|
205
|
+
"ltd": {
|
|
206
|
+
"hash": "0c18ceeaced4",
|
|
207
|
+
"scorecard": "101279 passed, 0 warnings, 0 failed"
|
|
208
|
+
}
|
|
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
|
+
}
|
|
184
236
|
}
|
|
185
237
|
]
|
|
186
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-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
* (
|
|
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-
|
|
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
|
-
// (
|
|
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
|
|
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 "../
|
|
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
|
|
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 (
|
|
87
|
-
//
|
|
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
|
|
15
|
-
"engineVersion": "1.
|
|
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
|
|
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
|
-
*
|
|
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
|
package/dist/app/products/se.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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",
|