@diy-accounting-uk/diya-gl 1.2.42 → 1.2.44

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
@@ -38,11 +38,12 @@ diya-gl recalc --package bst --data my-book --years se-2025-2026 --output-dir ou
38
38
  diya-gl read-workbook --file my-workbook.xlsx --output-dir out
39
39
  diya-gl write-workbook --data my-book --output-dir out
40
40
  diya-gl link my-book
41
+ diya-gl view my-book
41
42
  diya-gl mcp
42
43
  ```
43
44
 
44
45
  Each subcommand is also its own command, if you only want one on your `PATH`:
45
- `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-link`, `diya-gl-mcp`.
46
+ `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-link`, `diya-gl-view`, `diya-gl-mcp`.
46
47
 
47
48
  - **recalc** takes a diya-gl book (`--data`) or a populated workbook (`--source-dir`,
48
49
  `--mode saved|recalculate`) and writes `report.json`: every figure, report section and
@@ -57,10 +58,91 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
57
58
  opens it, the whole book carried in the fragment so nothing is uploaded. `--base <url>`
58
59
  replaces the origin. The fragment length goes to stderr, with a warning past 64 KB (about
59
60
  2,000 lines), where chat, email and QR codes start to cut links off.
61
+ - **view** takes the same book sources as **link**, serves the full diya-gl pages on
62
+ `127.0.0.1` (`--port <number>`, default a free port) and opens the book in your default
63
+ browser (`--no-open` prints the address only). The pages are not in this package: the local
64
+ server fetches each file from https://diya-gl.co.uk on first use and caches it under the
65
+ system temporary directory, one directory per package version, so the first run needs the
66
+ network and later runs of the same version reuse the cache. `--origin <url>` fetches from
67
+ another site. The book stays in the browser (the part of the address after `#` is never sent
68
+ anywhere). The server stays up until Ctrl-C. The workbook templates come from
69
+ spreadsheets.diyaccounting.co.uk the first time a page action needs one.
60
70
  - **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with four tools:
61
71
  `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
62
72
  `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
63
73
 
74
+ ## API
75
+
76
+ The package root exports three functions that turn a book into the figures HMRC's MTD APIs
77
+ take. Each takes the parsed book, its lines and a parameters object, and returns a promise.
78
+ Nothing is sent anywhere: the answer is the request body to send, plus what fed it.
79
+
80
+ Load a book first:
81
+
82
+ ```js
83
+ import { readFileSync } from "node:fs";
84
+ import { parse } from "smol-toml";
85
+
86
+ function loadBook(dir) {
87
+ const book = parse(readFileSync(`${dir}/book.toml`, "utf8"));
88
+ const lines = readFileSync(`${dir}/lines.jsonl`, "utf8")
89
+ .split("\n")
90
+ .filter(Boolean)
91
+ .map((line) => JSON.parse(line));
92
+ return { book, lines };
93
+ }
94
+ ```
95
+
96
+ ### `deriveVatReturn(book, lines, { periodEnd, periodStart?, periodKey? })`
97
+
98
+ The nine VAT return boxes for the three-month period ending `periodEnd` (YYYY-MM-DD), from a
99
+ Limited Company book that declares `diya-gl:vatRegistered = true`. `periodStart`, when given,
100
+ must open that quarter; `periodKey` is echoed back. The answer carries `boxes` (box1 to box9),
101
+ `hmrc` (the same figures under HMRC's field names, boxes 1 to 5 to the penny and 6 to 9 in
102
+ whole pounds), `months`, `dueDate`, `scheme`, and `lines`: the journal lines behind boxes 1, 4,
103
+ 6 and 7, each with what it contributes. A book whose lines do not reconcile with its VAT
104
+ interface is refused.
105
+
106
+ ```js
107
+ import { deriveVatReturn } from "@diy-accounting-uk/diya-gl";
108
+
109
+ const { book, lines } = loadBook("my-company-book");
110
+ const answer = await deriveVatReturn(book, lines, { periodStart: "2025-07-01", periodEnd: "2025-09-30" });
111
+ console.log(answer.hmrc); // { vatDueSales, vatDueAcquisitions, totalVatDue, ..., totalAcquisitionsExVAT }
112
+ ```
113
+
114
+ ### `deriveItsaQuarterlyUpdate(book, lines, { periodEndDate?, quarterlyPeriodType?, taxYear? })`
115
+
116
+ One period of HMRC's Self Employment Business API from a Self Employed book. In 2023-24 and
117
+ 2024-25 each period carries its own figures; from 2025-26 each carries the running total from
118
+ 6 April. `periodEndDate` picks the period among the year's four; without it every period is
119
+ answered. `quarterlyPeriodType` is `standard` (6 April quarters, the default) or `calendar`.
120
+ `taxYear` (as `2025-26`) overrides the year the book's dates imply. Each period lists the fields
121
+ the book cannot source under `omitted`; those are left out of the body, never sent as zero.
122
+
123
+ ```js
124
+ import { deriveItsaQuarterlyUpdate } from "@diy-accounting-uk/diya-gl";
125
+
126
+ const { book, lines } = loadBook("my-self-employed-book");
127
+ const answer = await deriveItsaQuarterlyUpdate(book, lines, { periodEndDate: "2025-10-05" });
128
+ const [period] = answer.periods; // { periodDates, periodIncome, periodExpenses, ..., omitted, covers }
129
+ ```
130
+
131
+ ### `deriveItsaAnnualSubmission(book, lines, { taxYear? })`
132
+
133
+ The year's allowances and adjustments for the same API's annual submission, from a Self
134
+ Employed book, restricted to the fields HMRC accepts for that tax year. `taxYear` (as
135
+ `2025-26`) overrides the year the book's dates imply. The answer carries `allowances`,
136
+ `adjustments`, `omitted` and `warnings`.
137
+
138
+ ```js
139
+ import { deriveItsaAnnualSubmission } from "@diy-accounting-uk/diya-gl";
140
+
141
+ const { book, lines } = loadBook("my-self-employed-book");
142
+ const answer = await deriveItsaAnnualSubmission(book, lines, { taxYear: "2025-26" });
143
+ console.log(answer.allowances.annualInvestmentAllowance);
144
+ ```
145
+
64
146
  ## The format
65
147
 
66
148
  A diya-gl book's fields are drawn from the XBRL Global Ledger Taxonomy Framework 2015 and
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // diya-gl-view.js — open a book in the full diya-gl pages, served locally
6
+ // from this package.
7
+
8
+ import { runDistBin } from "./run-dist-bin.js";
9
+ runDistBin("app/bin/view.js", process.argv.slice(2));
package/bin/diya-gl.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Copyright (C) 2006-2026 DIY Accounting Limited
4
4
  //
5
- // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|link|mcp> [args].
5
+ // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|link|view|mcp> [args].
6
6
  // Each subcommand is also its own bin (diya-gl-recalc, and so on), for a
7
7
  // caller that wants one command on its PATH without the others.
8
8
 
@@ -17,6 +17,7 @@ const SUBCOMMANDS = {
17
17
  "read-workbook": "app/bin/export.js",
18
18
  "write-workbook": "app/bin/write-workbook.js",
19
19
  "link": "app/bin/link.js",
20
+ "view": "app/bin/view.js",
20
21
  "mcp": "app/bin/diya-gl-mcp.js",
21
22
  };
22
23
 
@@ -55,13 +55,17 @@ async function readBookText(source) {
55
55
  return { toml: canonicalBookToml(book), lines: canonicalLinesJsonl(lines), book };
56
56
  }
57
57
 
58
- export async function bookLink(source, base = DEFAULT_BASE) {
58
+ export async function readBookFragment(source) {
59
59
  const { toml, lines, book } = await readBookText(source);
60
60
  const declared = book.entityInformation && book.entityInformation["diya-gl:product"];
61
61
  const product = productIdOf(declared);
62
62
  if (!product)
63
63
  throw new Error(`The book declares no product with a page: entityInformation."diya-gl:product" is ${JSON.stringify(declared)}.`);
64
- const { fragment, length, warning } = encodeBookFragment({ toml, lines });
64
+ return { product, ...encodeBookFragment({ toml, lines }) };
65
+ }
66
+
67
+ export async function bookLink(source, base = DEFAULT_BASE) {
68
+ const { product, fragment, length, warning } = await readBookFragment(source);
65
69
  return { url: `${base}/${product}.html#${FRAGMENT_KEY}=${fragment}`, length, warning };
66
70
  }
67
71
 
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // view.js — open a book in the full diya-gl pages, served on 127.0.0.1 by a
6
+ // caching proxy that fetches each page file from diya-gl.co.uk on first use.
7
+ //
8
+ // Usage:
9
+ // node app/bin/view.js examples/basic-taxi-driver
10
+ // node app/bin/view.js my-book-diya-gl.zip --port 8080 --no-open
11
+ //
12
+ // The book is any source link.js reads: a directory holding book.toml and
13
+ // lines.jsonl, or a diya-gl zip, JSON or workbook file. The server stays up
14
+ // until Ctrl-C. Fetched files are cached under the system temporary directory,
15
+ // one directory per package version and origin host.
16
+
17
+ import { spawn } from "child_process";
18
+ import { existsSync, readFileSync } from "fs";
19
+ import { tmpdir } from "os";
20
+ import { dirname, join, resolve } from "path";
21
+ import { fileURLToPath } from "url";
22
+ import { readBookFragment } from "./link.js";
23
+ import { FRAGMENT_KEY } from "../lib/diya-gl-link.js";
24
+ import { DEFAULT_ORIGIN, startViewServer } from "../lib/diya-gl-view-server.js";
25
+
26
+ const USAGE =
27
+ "Usage: diya-gl view <book directory | diya-gl zip | diya-gl JSON file | workbook> [--port <number>] [--origin <url>] [--no-open]";
28
+
29
+ // The nearest package.json above this file: the package's own in dist/, the
30
+ // repository's in a checkout. Both carry the version the cache is keyed on.
31
+ function packageVersion() {
32
+ let directory = dirname(fileURLToPath(import.meta.url));
33
+ for (;;) {
34
+ const candidate = join(directory, "package.json");
35
+ if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8")).version;
36
+ const parent = dirname(directory);
37
+ if (parent === directory) throw new Error("No package.json found above " + import.meta.url);
38
+ directory = parent;
39
+ }
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const args = argv.slice(2);
44
+ let port = 0;
45
+ let origin = DEFAULT_ORIGIN;
46
+ let open = true;
47
+ const positional = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ if (args[i] === "--port") {
50
+ const value = args[++i];
51
+ port = Number(value);
52
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
53
+ throw new Error(`--port needs a number from 0 to 65535, got "${value}". ${USAGE}`);
54
+ } else if (args[i] === "--origin") {
55
+ origin = args[++i];
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(origin);
59
+ } catch {
60
+ throw new Error(`--origin needs an http or https URL, got "${origin}". ${USAGE}`);
61
+ }
62
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
63
+ throw new Error(`--origin needs an http or https URL, got "${origin}". ${USAGE}`);
64
+ } else if (args[i] === "--no-open") {
65
+ open = false;
66
+ } else {
67
+ positional.push(args[i]);
68
+ }
69
+ }
70
+ if (positional.length !== 1) throw new Error(USAGE);
71
+ return { source: positional[0], port, origin, open };
72
+ }
73
+
74
+ function openInBrowser(url) {
75
+ const [command, args] =
76
+ process.platform === "darwin"
77
+ ? ["open", [url]]
78
+ : process.platform === "win32"
79
+ ? ["cmd", ["/c", "start", "", url]]
80
+ : ["xdg-open", [url]];
81
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
82
+ child.on("error", (error) => console.error(`Could not open a browser (${error.message}). Open the address above.`));
83
+ child.unref();
84
+ }
85
+
86
+ async function main() {
87
+ const { source, port, origin, open } = parseArgs(process.argv);
88
+ const { product, fragment, length, warning } = await readBookFragment(source);
89
+ const cacheDir = join(tmpdir(), "diya-gl-view", packageVersion(), new URL(origin).host.replace(/[^A-Za-z0-9.-]/g, "_"));
90
+ const running = await startViewServer({ cacheDir, origin, port });
91
+ const url = `http://${running.host}:${running.port}/${product}.html#${FRAGMENT_KEY}=${fragment}`;
92
+ console.log(url);
93
+ console.error(`Fragment length: ${length} characters`);
94
+ if (warning) console.error(`Warning: ${warning}`);
95
+ console.error(`Fetching pages from ${running.origin} on first use.`);
96
+ console.error(`Cache directory: ${running.cacheDir}`);
97
+ console.error("Serving on 127.0.0.1 until you press Ctrl-C.");
98
+ if (open) openInBrowser(url);
99
+
100
+ process.on("SIGINT", () => {
101
+ running.close().then(() => process.exit(0));
102
+ });
103
+ }
104
+
105
+ main().catch((error) => {
106
+ console.error(error.message);
107
+ process.exit(1);
108
+ });
@@ -1325,6 +1325,58 @@
1325
1325
  "scorecard": "101605 passed, 0 warnings, 0 failed"
1326
1326
  }
1327
1327
  }
1328
+ },
1329
+ {
1330
+ "tag": "diya-gl-v1.2.42",
1331
+ "date": "2026-10-03",
1332
+ "formatVersion": "diya-gl/1",
1333
+ "engineVersion": "1.2.40+57de8f55b",
1334
+ "taxDataHash": "89424bd36c0d",
1335
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1336
+ "templates": {
1337
+ "bst": {
1338
+ "hash": "0d003e10e00c",
1339
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1340
+ },
1341
+ "taxi": {
1342
+ "hash": "1ff69654d64d",
1343
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1344
+ },
1345
+ "se": {
1346
+ "hash": "064605ae76ed",
1347
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1348
+ },
1349
+ "ltd": {
1350
+ "hash": "5bb7be9b6d97",
1351
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1352
+ }
1353
+ }
1354
+ },
1355
+ {
1356
+ "tag": "diya-gl-v1.2.43",
1357
+ "date": "2026-10-03",
1358
+ "formatVersion": "diya-gl/1",
1359
+ "engineVersion": "1.2.40+57de8f55b",
1360
+ "taxDataHash": "89424bd36c0d",
1361
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1362
+ "templates": {
1363
+ "bst": {
1364
+ "hash": "0d003e10e00c",
1365
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1366
+ },
1367
+ "taxi": {
1368
+ "hash": "1ff69654d64d",
1369
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1370
+ },
1371
+ "se": {
1372
+ "hash": "064605ae76ed",
1373
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1374
+ },
1375
+ "ltd": {
1376
+ "hash": "5bb7be9b6d97",
1377
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1378
+ }
1379
+ }
1328
1380
  }
1329
1381
  ]
1330
1382
  }
@@ -60,7 +60,7 @@ import { attributionWriter, entriesOf, entryOf, fieldEntriesOf, unionOf } from "
60
60
 
61
61
  const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
62
62
  const MONTH_COLS = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N"];
63
- const VAT_RATE = 0.2;
63
+ export const VAT_RATE = 0.2;
64
64
 
65
65
  // Sales and Purchases analysis columns, code letter by column letter, in the
66
66
  // order row 5 of each month tab tests them. Column AK of a Purchases month
@@ -26,7 +26,7 @@ let cachedMapping = null;
26
26
 
27
27
  // Resolved on first use, not at import: the engine bundle loads in the browser,
28
28
  // where import.meta.url is an http URL and fileURLToPath throws.
29
- function loadMapping() {
29
+ export function loadMapping() {
30
30
  if (!cachedMapping) {
31
31
  const here = dirname(fileURLToPath(import.meta.url));
32
32
  const mappingPath = resolve(here, "..", "..", "data", "hmrc", "sa103-mtd-mapping.json");
@@ -50,7 +50,7 @@ function boxEntry(boxes, boxNumber) {
50
50
  // first is the one this template's cell actually answers -- section 8 of the
51
51
  // design records which, box by box -- so a caller asking for "the field this
52
52
  // box answers" gets that one unless told to reach past it.
53
- function fieldsOf(entry) {
53
+ export function fieldsOf(entry) {
54
54
  return entry.field.split("|").map((field) => field.trim());
55
55
  }
56
56
 
@@ -63,11 +63,15 @@ function primaryField(boxes, boxNumber) {
63
63
  // rather than the object this call meant to build. Every path here comes from
64
64
  // sa103-mtd-mapping.json's own "field" column, so a segment like this is a
65
65
  // defect in that mapping file and must fail loudly rather than land quietly
66
- // on some other object. The check sits inside the walk, as literal
67
- // comparisons, and each intermediate node is only reused when it is the
68
- // object's own property: the shape static analysis recognises as guarded.
66
+ // on some other object. The whole path is refused before anything is
67
+ // written, so a refused path leaves the target untouched. The check is
68
+ // repeated inside the walk, as literal comparisons, and each intermediate
69
+ // node is only reused when it is the object's own property: the shape static
70
+ // analysis recognises as guarded.
69
71
  export function setPath(target, path, value) {
70
72
  const parts = path.split(".");
73
+ const refused = parts.find((part) => part === "__proto__" || part === "constructor" || part === "prototype");
74
+ if (refused !== undefined) throw new Error(`setPath: "${refused}" is not a field name, in path "${path}"`);
71
75
  let node = target;
72
76
  for (let i = 0; i < parts.length; i++) {
73
77
  const part = parts[i];
@@ -491,7 +495,7 @@ const NO_SOURCE_ANNUAL_BOXES = [
491
495
  // 2026-27 additions, gated behind HMRC test flags) is not treated as a
492
496
  // live field here -- neither has a cell in this template regardless, so
493
497
  // the payload does not change, only whether the gap is worth warning about.
494
- function annualFieldsUnavailableForYear(taxYear) {
498
+ export function annualFieldsUnavailableForYear(taxYear) {
495
499
  const years = loadMapping().api.years;
496
500
  const order = Object.keys(years);
497
501
  const index = order.indexOf(taxYear);
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // index.js — the diya-gl package's public module: the filing figures a book
5
+ // answers, imported as `@diy-accounting-uk/diya-gl`.
6
+
7
+ export { deriveVatReturn } from "./vat-return.js";
8
+ export { deriveItsaQuarterlyUpdate, deriveItsaAnnualSubmission } from "./itsa.js";
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // itsa.js — the two ITSA answers HMRC's Self Employment Business API takes
5
+ // from a self-employed (se) book. deriveItsaQuarterlyUpdate answers one
6
+ // period: the period's own figures in a tax year filed as dated period
7
+ // summaries, a running total from 6 April in a year filed as cumulative
8
+ // period summaries. deriveItsaAnnualSubmission answers the year's allowances
9
+ // and adjustments. Both call calculators/se-derivations.js and add no
10
+ // arithmetic beyond summing quarters.
11
+ //
12
+ // The field set HMRC accepts changes by tax year (sa103-mtd-mapping.json's
13
+ // api.years: allowances gone from 2025-26, an adjustment gone from 2026-27,
14
+ // two test-only additions), and every answer here is filtered to the year's
15
+ // live fields. A field the template cannot source is omitted, never sent as
16
+ // a zero.
17
+
18
+ import {
19
+ annualFieldsUnavailableForYear,
20
+ buildSelfEmploymentAnnualSubmission,
21
+ buildSelfEmploymentQuarterlyUpdates,
22
+ fieldsOf,
23
+ loadMapping,
24
+ setPath,
25
+ } from "../calculators/se-derivations.js";
26
+ import { loadTaxDataForBook, productOf } from "../product-workbook.js";
27
+
28
+ export const QUARTERLY_PERIOD_TYPES = ["standard", "calendar"];
29
+
30
+ const TAX_YEAR_LABEL = /^(\d{4})-(\d{2})$/;
31
+
32
+ function requireSelfEmployedBook(book, lines, caller) {
33
+ if (!book || !lines) throw new Error(`${caller} requires a book and its lines`);
34
+ const product = productOf(book);
35
+ if (product !== "se") {
36
+ throw new Error(`The book is a "${product}" book; the ITSA derivations read a self-employed (se) book`);
37
+ }
38
+ }
39
+
40
+ // "2025-26" names the package's se-2025-2026.toml; a label the mapping's
41
+ // api.years does not carry is refused before any figure is derived, because
42
+ // the field set for that year is unknown.
43
+ function taxYearFileNameFor(label) {
44
+ const match = TAX_YEAR_LABEL.exec(label ?? "");
45
+ if (!match) throw new Error(`taxYear must look like 2025-26, not "${label}"`);
46
+ const start = Number(match[1]);
47
+ const end = start + 1;
48
+ if (String(end).slice(-2) !== match[2]) throw new Error(`taxYear "${label}" does not name two consecutive years`);
49
+ return `se-${start}-${end}`;
50
+ }
51
+
52
+ async function taxDataFor(book, taxYear) {
53
+ const options = taxYear ? { taxYearName: taxYearFileNameFor(taxYear) } : {};
54
+ const taxData = await loadTaxDataForBook(book, options);
55
+ const label = taxData?.tax_year?.label;
56
+ if (!label) throw new Error("the tax year file declares no tax_year.label");
57
+ if (!loadMapping().api.years[label]) {
58
+ throw new Error(`sa103-mtd-mapping.json's api.years carries no entry for tax year "${label}"`);
59
+ }
60
+ return taxData;
61
+ }
62
+
63
+ function boxesOnRoute(route) {
64
+ return loadMapping().boxes.filter((entry) => entry.form === "SA103F" && entry.route === route && entry.field);
65
+ }
66
+
67
+ /**
68
+ * The quarterly field slots HMRC's period summary carries, from the mapping:
69
+ * every quarterly-route field (a box shared by two fields counts both) plus
70
+ * the consolidated-expenses election the box 31 caveat names. The mapping
71
+ * varies none of these by tax year; the year decides the shape, not the set.
72
+ * @returns {string[]}
73
+ */
74
+ export function quarterlyFieldSlots() {
75
+ const slots = new Set();
76
+ for (const entry of boxesOnRoute("quarterly")) for (const field of fieldsOf(entry)) slots.add(field);
77
+ slots.add("periodExpenses.consolidatedExpenses");
78
+ return [...slots].sort();
79
+ }
80
+
81
+ /**
82
+ * The annual field slots HMRC's schema accepts for one tax year: every
83
+ * annual-route field in the mapping, less those api.years says are gone by
84
+ * that year, less those added only in a later year, less the test-only
85
+ * additions (a fieldsAdded entry carrying a status).
86
+ * @param {string} taxYear - a label such as "2025-26"
87
+ * @returns {string[]}
88
+ */
89
+ export function annualFieldSlotsForTaxYear(taxYear) {
90
+ const unavailable = annualFieldsUnavailableForYear(taxYear);
91
+ for (const year of Object.values(loadMapping().api.years)) {
92
+ for (const added of year.fieldsAdded || []) {
93
+ if (typeof added !== "string") unavailable.add(added.field);
94
+ }
95
+ }
96
+ const slots = new Set();
97
+ for (const entry of boxesOnRoute("annual")) for (const field of fieldsOf(entry)) slots.add(field);
98
+ return [...slots].filter((field) => !unavailable.has(field)).sort();
99
+ }
100
+
101
+ function readPath(target, path) {
102
+ let node = target;
103
+ for (const part of path.split(".")) {
104
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, part)) return undefined;
105
+ node = node[part];
106
+ }
107
+ return node;
108
+ }
109
+
110
+ function round2(value) {
111
+ return Math.round(value * 100) / 100;
112
+ }
113
+
114
+ function sumPeriods(periods, slots) {
115
+ const total = {};
116
+ for (const slot of slots) {
117
+ let sum = null;
118
+ for (const period of periods) {
119
+ const value = readPath(period, slot);
120
+ if (typeof value === "number") sum = (sum ?? 0) + value;
121
+ }
122
+ if (sum !== null) setPath(total, slot, round2(sum));
123
+ }
124
+ return total;
125
+ }
126
+
127
+ // Rebuilds a payload from the slots the year accepts, in the mapping's own
128
+ // order, so a field the derivation never set stays absent and a field the
129
+ // year no longer accepts is dropped. Answers the payload and the slots left
130
+ // out of it.
131
+ function restrictToSlots(payload, slots) {
132
+ const kept = {};
133
+ const omitted = [];
134
+ for (const slot of slots) {
135
+ const value = readPath(payload, slot);
136
+ if (value === undefined) omitted.push(slot);
137
+ else setPath(kept, slot, value);
138
+ }
139
+ return { kept, omitted };
140
+ }
141
+
142
+ function periodLabel(period) {
143
+ return `${period.periodDates.periodStartDate} to ${period.periodDates.periodEndDate}`;
144
+ }
145
+
146
+ /**
147
+ * One period of the Self Employment Business API from a self-employed book.
148
+ * In a tax year HMRC files as dated period summaries (2023-24, 2024-25) the
149
+ * answer is that period's own figures; in a year filed as cumulative period
150
+ * summaries (2025-26 on) it is the running total from the year's first
151
+ * period through the one named. The period is picked by periodEndDate among
152
+ * the year's four; with no periodEndDate every period is answered.
153
+ * @param {Object} book - parsed book.toml
154
+ * @param {Array} lines - parsed lines.jsonl entries
155
+ * @param {{periodEndDate?: string, quarterlyPeriodType?: "standard"|"calendar", taxYear?: string}} [params] -
156
+ * quarterlyPeriodType defaults to standard (6 April quarters); taxYear (as 2025-26) overrides the year the
157
+ * book's dates imply
158
+ * @returns {Promise<Object>} { taxYear, shape, quarterlyPeriodType, fieldSlots, periods, warnings }
159
+ */
160
+ export async function deriveItsaQuarterlyUpdate(book, lines, { periodEndDate, quarterlyPeriodType, taxYear } = {}) {
161
+ requireSelfEmployedBook(book, lines, "deriveItsaQuarterlyUpdate");
162
+ const taxData = await taxDataFor(book, taxYear);
163
+ const label = taxData.tax_year.label;
164
+ const shape = loadMapping().api.years[label].quarterly;
165
+ const periodType = quarterlyPeriodType || "standard";
166
+ if (!QUARTERLY_PERIOD_TYPES.includes(periodType)) {
167
+ throw new Error(`quarterlyPeriodType must be one of ${QUARTERLY_PERIOD_TYPES.join(", ")}, not "${periodType}"`);
168
+ }
169
+
170
+ const derived = buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, { quarterlyPeriodType: periodType });
171
+ const slots = quarterlyFieldSlots();
172
+ const periods = derived.periods;
173
+
174
+ let wanted = periods;
175
+ if (periodEndDate) {
176
+ const index = periods.findIndex((period) => period.periodDates.periodEndDate === periodEndDate);
177
+ if (index === -1) {
178
+ throw new Error(
179
+ `No ${periodType} period of ${label} ends on ${periodEndDate}; the periods end on ${periods.map((p) => p.periodDates.periodEndDate).join(", ")}`,
180
+ );
181
+ }
182
+ wanted = [periods[index]];
183
+ }
184
+
185
+ const answers = wanted.map((period) => {
186
+ const index = periods.indexOf(period);
187
+ const covered = shape === "cumulative-period-summary" ? periods.slice(0, index + 1) : [period];
188
+ const figures = shape === "cumulative-period-summary" ? sumPeriods(covered, slots) : period;
189
+ const { kept, omitted } = restrictToSlots(figures, slots);
190
+ return {
191
+ periodDates: {
192
+ periodStartDate: covered[0].periodDates.periodStartDate,
193
+ periodEndDate: period.periodDates.periodEndDate,
194
+ },
195
+ ...kept,
196
+ omitted,
197
+ covers: covered.map(periodLabel),
198
+ };
199
+ });
200
+
201
+ return {
202
+ taxYear: label,
203
+ shape,
204
+ quarterlyPeriodType: periodType,
205
+ fieldSlots: slots,
206
+ periods: answers,
207
+ warnings: derived.warnings,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * The year's allowances and adjustments for the Self Employment Business
213
+ * API's annual submission, from a self-employed book, restricted to the
214
+ * fields HMRC's schema accepts for that tax year.
215
+ * @param {Object} book - parsed book.toml
216
+ * @param {Array} lines - parsed lines.jsonl entries
217
+ * @param {{taxYear?: string}} [params] - taxYear (as 2025-26) overrides the year the book's dates imply
218
+ * @returns {Promise<Object>} { taxYear, fieldSlots, allowances, adjustments, omitted, warnings }
219
+ */
220
+ export async function deriveItsaAnnualSubmission(book, lines, { taxYear } = {}) {
221
+ requireSelfEmployedBook(book, lines, "deriveItsaAnnualSubmission");
222
+ const taxData = await taxDataFor(book, taxYear);
223
+ const label = taxData.tax_year.label;
224
+ const slots = annualFieldSlotsForTaxYear(label);
225
+
226
+ const derived = buildSelfEmploymentAnnualSubmission(book, lines, taxData);
227
+ const { kept, omitted } = restrictToSlots(derived, slots);
228
+
229
+ return {
230
+ taxYear: label,
231
+ fieldSlots: slots,
232
+ ...kept,
233
+ omitted,
234
+ warnings: derived.warnings,
235
+ };
236
+ }
@@ -0,0 +1,238 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // vat-return.js — the nine VAT boxes for one obligation period, read from the
5
+ // Ltd engine's own Vatreturns.xlsx!Vatinterface results, with HMRC's field
6
+ // names, HMRC's rounding, and the journal lines that fed each box. Nothing
7
+ // here computes VAT the engine has not already computed: the boxes are the
8
+ // interface's quarter columns, and the lines are attributed with the same
9
+ // split the journal rows use, then reconciled against those columns.
10
+
11
+ import { calculateFromDiyaGl } from "../diya-gl-calculator.js";
12
+ import { diyaGlToScenario } from "../diya-gl-loader.js";
13
+ import { loadTaxDataForBook, productOf } from "../product-workbook.js";
14
+ import { isStraddlingLine, LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP } from "../scenario-extractor.js";
15
+ import { dateFromExcelSerial } from "../calculators/shared.js";
16
+ import { VAT_RATE } from "../calculators/ltd.js";
17
+ import { splitVat, VATINTERFACE_FIRST_MONTH_ROW, VATINTERFACE_FIRST_ROW, VATINTERFACE_LAST_ROW } from "../tax/vat.js";
18
+
19
+ const INTERFACE_SHEET = "Vatreturns.xlsx!Vatinterface";
20
+ const MONTHS_IN_QUARTER = 3;
21
+
22
+ /**
23
+ * The ISO day an Excel serial names.
24
+ * @param {number} serial
25
+ * @returns {string}
26
+ */
27
+ export function isoFromSerial(serial) {
28
+ return dateFromExcelSerial(serial).toISOString().slice(0, 10);
29
+ }
30
+
31
+ function isoDate(value) {
32
+ if (value === undefined || value === null) return null;
33
+ return new Date(value).toISOString().slice(0, 10);
34
+ }
35
+
36
+ function firstOfMonthsBefore(isoMonthEnd, monthsBefore) {
37
+ const [year, month] = isoMonthEnd.split("-").map(Number);
38
+ return new Date(Date.UTC(year, month - 1 - monthsBefore, 1)).toISOString().slice(0, 10);
39
+ }
40
+
41
+ function monthKey(isoDay) {
42
+ return isoDay.slice(0, 7);
43
+ }
44
+
45
+ function pence(value) {
46
+ return Math.round(value * 100) / 100;
47
+ }
48
+
49
+ function pounds(value) {
50
+ return Math.round(value);
51
+ }
52
+
53
+ function splitGross(gross) {
54
+ return splitVat(gross, VAT_RATE);
55
+ }
56
+
57
+ /**
58
+ * The interface rows the engine built for a book: one per period end, with
59
+ * the month figures and, from the first accounting month on, the quarter sums
60
+ * the return boxes read.
61
+ * @param {Object} results - calculateFromDiyaGl() output for a Company (ltd) book
62
+ * @returns {Array<{row: number, periodEnd: string, dueDate: string, month: Object, quarter: Object|null, flatRate: number}>}
63
+ */
64
+ export function interfaceRows(results) {
65
+ const sheet = results[INTERFACE_SHEET];
66
+ if (!sheet) throw new Error("The book's product carries no VAT interface; only a Company (ltd) book answers a VAT return");
67
+ const rows = [];
68
+ for (let row = VATINTERFACE_FIRST_ROW; row <= VATINTERFACE_LAST_ROW; row++) {
69
+ const hasQuarter = row >= VATINTERFACE_FIRST_MONTH_ROW;
70
+ rows.push({
71
+ row,
72
+ periodEnd: isoFromSerial(sheet[`B${row}`]),
73
+ dueDate: isoFromSerial(sheet[`C${row}`]),
74
+ month: {
75
+ salesNet: sheet[`D${row}`] || 0,
76
+ salesVat: sheet[`F${row}`] || 0,
77
+ purchasesNet: sheet[`H${row}`] || 0,
78
+ purchasesVat: sheet[`J${row}`] || 0,
79
+ },
80
+ quarter: hasQuarter
81
+ ? {
82
+ salesNet: sheet[`E${row}`] || 0,
83
+ salesVat: sheet[`G${row}`] || 0,
84
+ purchasesNet: sheet[`I${row}`] || 0,
85
+ purchasesVat: sheet[`K${row}`] || 0,
86
+ }
87
+ : null,
88
+ flatRate: sheet[`M${row}`] || 0,
89
+ });
90
+ }
91
+ return rows;
92
+ }
93
+
94
+ function lineContribution(line, box) {
95
+ const { vat, net } = splitGross(line.amount);
96
+ return {
97
+ entryNumber: line.entryNumber,
98
+ lineNumber: line.lineNumber ?? null,
99
+ date: line.postingDate,
100
+ account: line.accountMainID,
101
+ reference: line.documentReference ?? null,
102
+ counterparty: line.detailComment ?? null,
103
+ gross: pence(line.amount),
104
+ contributes: pence(box === "vat" ? vat : net),
105
+ };
106
+ }
107
+
108
+ // The journal lines behind one quarter, bucketed the way the engine's month
109
+ // tabs and straddling sheets take them: a line without diya-gl:vatPeriodEnd
110
+ // lands on the month its postingDate names; a line with it lands on the
111
+ // interface row carrying that period end.
112
+ function linesForQuarter(lines, quarterRows) {
113
+ const monthEnds = new Set(quarterRows.map((r) => r.periodEnd));
114
+ const months = new Set(quarterRows.map((r) => monthKey(r.periodEnd)));
115
+ const inQuarter = (line) => {
116
+ if (isStraddlingLine(line)) return monthEnds.has(isoDate(line["diya-gl:vatPeriodEnd"]));
117
+ return months.has(monthKey(line.postingDate));
118
+ };
119
+ const sales = lines.filter((l) => l.sourceJournalID === "sales" && LTD_SALES_CODE_MAP[l.accountMainID] && inQuarter(l));
120
+ const purchases = lines.filter((l) => l.sourceJournalID === "purchases" && LTD_PURCHASE_CODE_MAP[l.accountMainID] && inQuarter(l));
121
+ return { sales, purchases };
122
+ }
123
+
124
+ function sumContributions(lines, part) {
125
+ return lines.reduce((total, line) => total + splitGross(line.amount)[part], 0);
126
+ }
127
+
128
+ function reconcile(label, attributed, engine) {
129
+ if (Math.abs(attributed - engine) > 0.005) {
130
+ throw new Error(
131
+ `The lines attributed to ${label} sum to ${pence(attributed)} but the book's VAT interface carries ${pence(engine)}; ` +
132
+ "a line is dated outside the accounting year or posts to a month the interface does not carry, so this return cannot be derived",
133
+ );
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The nine VAT return boxes for the quarter ending on periodEnd, from a
139
+ * Company (ltd) book that declares itself VAT registered.
140
+ * @param {Object} book - parsed book.toml
141
+ * @param {Array} lines - parsed lines.jsonl entries
142
+ * @param {{periodEnd: string, periodStart?: string, periodKey?: string}} params - periodEnd is the
143
+ * obligation's period end (YYYY-MM-DD); periodStart, when given, must open that quarter; periodKey is
144
+ * echoed back untouched
145
+ * @returns {Promise<Object>} { vatRegistrationNumber, periodKey, periodStart, periodEnd, dueDate, scheme,
146
+ * months, boxes, hmrc, lines, notes }
147
+ */
148
+ export async function deriveVatReturn(book, lines, { periodEnd, periodStart, periodKey } = {}) {
149
+ if (!book || !lines) throw new Error("deriveVatReturn requires a book and its lines");
150
+ if (!periodEnd) throw new Error("deriveVatReturn requires periodEnd (the obligation's period end, YYYY-MM-DD)");
151
+ const entity = book.entityInformation ?? {};
152
+ if (entity["diya-gl:vatRegistered"] !== true) {
153
+ throw new Error(
154
+ "The book does not declare the company VAT registered (entityInformation diya-gl:vatRegistered), so it carries no VAT return",
155
+ );
156
+ }
157
+
158
+ const product = productOf(book);
159
+ const taxData = await loadTaxDataForBook(book);
160
+ const results = calculateFromDiyaGl(book, lines, product, taxData, diyaGlToScenario(book, lines, product));
161
+ const rows = interfaceRows(results);
162
+ const wantedEnd = isoDate(periodEnd);
163
+ const index = rows.findIndex((r) => r.periodEnd === wantedEnd);
164
+ if (index < 0) {
165
+ throw new Error(
166
+ `The book carries no VAT period ending ${wantedEnd}; it answers periods ending ${rows.map((r) => r.periodEnd).join(", ")}`,
167
+ );
168
+ }
169
+ const row = rows[index];
170
+ if (!row.quarter) {
171
+ throw new Error(
172
+ `The period ending ${wantedEnd} is before the book's accounting year; the earliest quarter this book answers ends ${rows[VATINTERFACE_FIRST_MONTH_ROW - VATINTERFACE_FIRST_ROW].periodEnd}`,
173
+ );
174
+ }
175
+ const expectedStart = firstOfMonthsBefore(wantedEnd, MONTHS_IN_QUARTER - 1);
176
+ if (periodStart && isoDate(periodStart) !== expectedStart) {
177
+ throw new Error(
178
+ `The book answers three-month periods ending on a month end; ${isoDate(periodStart)} to ${wantedEnd} is not one, the quarter ending ${wantedEnd} starts ${expectedStart}`,
179
+ );
180
+ }
181
+
182
+ const quarterRows = rows.slice(index - (MONTHS_IN_QUARTER - 1), index + 1);
183
+ const { sales, purchases } = linesForQuarter(lines, quarterRows);
184
+ reconcile("box 1", sumContributions(sales, "vat"), row.quarter.salesVat);
185
+ reconcile("box 4", sumContributions(purchases, "vat"), row.quarter.purchasesVat);
186
+ reconcile("box 6", sumContributions(sales, "net"), row.quarter.salesNet);
187
+ reconcile("box 7", sumContributions(purchases, "net"), row.quarter.purchasesNet);
188
+
189
+ const flatRate = row.flatRate > 0;
190
+ const box1 = pence(row.quarter.salesVat);
191
+ const box2 = 0;
192
+ const box3 = pence(box1 + box2);
193
+ const box4 = pence(row.quarter.purchasesVat);
194
+ const box5 = pence(box3 - box4);
195
+ const box6 = pounds(row.quarter.salesNet + (flatRate ? row.quarter.salesVat : 0));
196
+ const box7 = pounds(row.quarter.purchasesNet);
197
+ const box8 = 0;
198
+ const box9 = 0;
199
+
200
+ return {
201
+ vatRegistrationNumber: entity["diya-gl:vatNumber"] ?? null,
202
+ periodKey: periodKey ?? null,
203
+ periodStart: expectedStart,
204
+ periodEnd: wantedEnd,
205
+ dueDate: row.dueDate,
206
+ scheme: flatRate ? "flat-rate" : "standard",
207
+ months: quarterRows.map((r) => ({
208
+ periodEnd: r.periodEnd,
209
+ salesNet: pence(r.month.salesNet),
210
+ salesVat: pence(r.month.salesVat),
211
+ purchasesNet: pence(r.month.purchasesNet),
212
+ purchasesVat: pence(r.month.purchasesVat),
213
+ })),
214
+ boxes: { box1, box2, box3, box4, box5, box6, box7, box8, box9 },
215
+ hmrc: {
216
+ vatDueSales: box1,
217
+ vatDueAcquisitions: box2,
218
+ totalVatDue: box3,
219
+ vatReclaimedCurrPeriod: box4,
220
+ netVatDue: box5,
221
+ totalValueSalesExVAT: box6,
222
+ totalValuePurchasesExVAT: box7,
223
+ totalValueGoodsSuppliedExVAT: box8,
224
+ totalAcquisitionsExVAT: box9,
225
+ },
226
+ lines: {
227
+ box1: sales.map((l) => lineContribution(l, "vat")),
228
+ box4: purchases.map((l) => lineContribution(l, "vat")),
229
+ box6: sales.map((l) => lineContribution(l, "net")),
230
+ box7: purchases.map((l) => lineContribution(l, "net")),
231
+ },
232
+ notes: [
233
+ `The engine applies ${VAT_RATE * 100}% to every sales and purchases journal line; the lines' own taxCode and taxRate are not read.`,
234
+ "Boxes 2, 8 and 9 are nil: the book carries no EU acquisitions or supplies.",
235
+ "Boxes 1 to 5 are to the penny and boxes 6 to 9 whole pounds; box 5 is the rounded box 3 less the rounded box 4.",
236
+ ],
237
+ };
238
+ }
@@ -0,0 +1,203 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // diya-gl-view-server.js — a read-only caching proxy on 127.0.0.1 for the
5
+ // diya-gl pages. A GET or HEAD for /<path> is answered from the cache
6
+ // directory when it holds the file; otherwise the file is fetched from the
7
+ // origin site, stored with its content type, and served. A non-2xx answer
8
+ // from the origin passes through with its status and is not stored.
9
+ //
10
+ // The pages are not part of the npm package: they are fetched from the site
11
+ // on first use. The service worker script is answered 404, so the browser
12
+ // never layers a second cache over this one.
13
+ //
14
+ // The workbook templates are fetched from the template source (the same site
15
+ // the engine's own template loader uses) and streamed back unchanged.
16
+
17
+ import { createServer } from "http";
18
+ import { mkdir, readFile, realpath, rename, writeFile } from "fs/promises";
19
+ import { extname, resolve, sep } from "path";
20
+
21
+ export const DEFAULT_ORIGIN = "https://diya-gl.co.uk";
22
+ export const DEFAULT_TEMPLATE_SOURCE = "https://spreadsheets.diyaccounting.co.uk/diya-gl/assets";
23
+ const TEMPLATE_URL_PREFIX = "/assets/templates/";
24
+ // A template is "meta.toml" or "<product>/<file>" with a workbook or TOML extension; nothing else
25
+ // is proxied, so the upstream URL is always the template source plus one of these names.
26
+ const TEMPLATE_NAME = /^(?:[a-z]+\/)?[A-Za-z0-9][A-Za-z0-9._-]*\.(?:xlsx|toml)$/;
27
+
28
+ const CONTENT_TYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".js": "text/javascript; charset=utf-8",
31
+ ".mjs": "text/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".json": "application/json; charset=utf-8",
34
+ ".jsonl": "application/x-ndjson; charset=utf-8",
35
+ ".toml": "text/plain; charset=utf-8",
36
+ ".txt": "text/plain; charset=utf-8",
37
+ ".svg": "image/svg+xml",
38
+ ".png": "image/png",
39
+ ".ico": "image/x-icon",
40
+ ".webmanifest": "application/manifest+json",
41
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
42
+ ".zip": "application/zip",
43
+ };
44
+
45
+ function send(response, status, body, contentType = "text/plain; charset=utf-8") {
46
+ response.writeHead(status, { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
47
+ response.end(body);
48
+ }
49
+
50
+ const SERVICE_WORKER_PATH = "/sw.js";
51
+
52
+ /**
53
+ * The cache entry paths for a URL path, or null when the path would leave the
54
+ * cache directory. The body and its content type are stored side by side.
55
+ */
56
+ function cacheEntryFor(cacheReal, urlPath) {
57
+ let decoded;
58
+ try {
59
+ decoded = decodeURIComponent(urlPath);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (decoded.includes("\0") || decoded.includes("\\")) return null;
64
+ const segments = decoded.split("/").filter((segment) => segment !== "");
65
+ if (segments.some((segment) => segment === "." || segment === "..")) return null;
66
+ if (decoded.endsWith("/") || segments.length === 0) segments.push("index.html");
67
+ const body = resolve(cacheReal, "body", ...segments);
68
+ if (!body.startsWith(resolve(cacheReal, "body") + sep)) return null;
69
+ return { body, type: resolve(cacheReal, "type", ...segments) };
70
+ }
71
+
72
+ async function readIfPresent(path, cacheReal) {
73
+ try {
74
+ const real = await realpath(path);
75
+ if (!real.startsWith(cacheReal + sep)) return null;
76
+ return await readFile(real);
77
+ } catch (error) {
78
+ if (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "EISDIR") return null;
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ async function writeAtomically(path, data) {
84
+ await mkdir(resolve(path, ".."), { recursive: true });
85
+ const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
86
+ await writeFile(temporary, data);
87
+ await rename(temporary, path);
88
+ }
89
+
90
+ async function serveFromOrigin(request, response, { origin, cacheReal, urlPath, search }) {
91
+ const entry = cacheEntryFor(cacheReal, urlPath);
92
+ if (!entry) {
93
+ send(response, 404, "Not found\n");
94
+ return;
95
+ }
96
+ const cachedBody = await readIfPresent(entry.body, cacheReal);
97
+ const cachedType = cachedBody ? await readIfPresent(entry.type, cacheReal) : null;
98
+ if (cachedBody && cachedType) {
99
+ response.writeHead(200, {
100
+ "Content-Type": cachedType.toString("utf8"),
101
+ "Cache-Control": "no-store",
102
+ "X-Content-Type-Options": "nosniff",
103
+ });
104
+ response.end(request.method === "HEAD" ? undefined : cachedBody);
105
+ return;
106
+ }
107
+ const url = `${origin}${urlPath}${search}`;
108
+ let upstream;
109
+ try {
110
+ upstream = await fetch(url);
111
+ } catch (cause) {
112
+ send(response, 502, `${url} could not be fetched: ${cause.message}\n`);
113
+ return;
114
+ }
115
+ const body = Buffer.from(await upstream.arrayBuffer());
116
+ const contentType = upstream.headers.get("content-type") ?? "application/octet-stream";
117
+ if (!upstream.ok) {
118
+ send(response, upstream.status, request.method === "HEAD" ? undefined : body, contentType);
119
+ return;
120
+ }
121
+ await writeAtomically(entry.body, body);
122
+ await writeAtomically(entry.type, contentType);
123
+ send(response, 200, request.method === "HEAD" ? undefined : body, contentType);
124
+ }
125
+
126
+ async function proxyTemplate(response, templateSource, urlPath) {
127
+ const requested = urlPath.slice(TEMPLATE_URL_PREFIX.length);
128
+ const base = `${templateSource.replace(/\/$/, "")}/templates/`;
129
+ const url = TEMPLATE_NAME.test(requested) ? new URL(requested, base) : null;
130
+ if (!url || url.origin !== new URL(base).origin || !url.href.startsWith(base)) {
131
+ send(response, 404, "Not found\n");
132
+ return;
133
+ }
134
+ let upstream;
135
+ try {
136
+ upstream = await fetch(url);
137
+ } catch (cause) {
138
+ send(response, 502, `The workbook template could not be fetched from ${url.href}: ${cause.message}\n`);
139
+ return;
140
+ }
141
+ if (!upstream.ok) {
142
+ send(response, upstream.status === 404 ? 404 : 502, `${url.href} returned ${upstream.status}\n`);
143
+ return;
144
+ }
145
+ send(response, 200, Buffer.from(await upstream.arrayBuffer()), CONTENT_TYPES[extname(url.pathname)] ?? "application/octet-stream");
146
+ }
147
+
148
+ /**
149
+ * @param {Object} options
150
+ * @param {string} options.cacheDir - the directory fetched files are stored in; created when absent
151
+ * @param {string} [options.origin] - the site pages are fetched from
152
+ * @param {number} [options.port] - 0 or absent lets the OS choose a free port
153
+ * @param {string} [options.templateSource] - where /assets/templates/ requests are fetched from
154
+ * @returns {Promise<{server: import("http").Server, port: number, host: string, origin: string, cacheDir: string, close: () => Promise<void>}>}
155
+ */
156
+ export async function startViewServer({ cacheDir, origin = DEFAULT_ORIGIN, port = 0, templateSource = DEFAULT_TEMPLATE_SOURCE }) {
157
+ await mkdir(cacheDir, { recursive: true });
158
+ const cacheReal = await realpath(cacheDir);
159
+ const host = "127.0.0.1";
160
+ const originBase = origin.replace(/\/$/, "");
161
+
162
+ const server = createServer(async (request, response) => {
163
+ try {
164
+ if (request.method !== "GET" && request.method !== "HEAD") {
165
+ send(response, 405, "Method not allowed\n");
166
+ return;
167
+ }
168
+ // The path as sent: new URL() would collapse dot segments before the escape check sees them.
169
+ const queryAt = request.url.indexOf("?");
170
+ const urlPath = queryAt === -1 ? request.url : request.url.slice(0, queryAt);
171
+ const search = queryAt === -1 ? "" : request.url.slice(queryAt);
172
+ if (!urlPath.startsWith("/") || urlPath === SERVICE_WORKER_PATH) {
173
+ send(response, 404, "Not found\n");
174
+ return;
175
+ }
176
+ if (urlPath.startsWith(TEMPLATE_URL_PREFIX)) {
177
+ await proxyTemplate(response, templateSource, urlPath);
178
+ return;
179
+ }
180
+ await serveFromOrigin(request, response, { origin: originBase, cacheReal, urlPath, search });
181
+ } catch (error) {
182
+ send(response, 500, `${error.message}\n`);
183
+ }
184
+ });
185
+
186
+ await new Promise((resolveListening, rejectListening) => {
187
+ server.once("error", rejectListening);
188
+ server.listen(port, host, resolveListening);
189
+ });
190
+
191
+ return {
192
+ server,
193
+ host,
194
+ origin: originBase,
195
+ cacheDir: cacheReal,
196
+ port: server.address().port,
197
+ close: () =>
198
+ new Promise((resolveClosed) => {
199
+ server.close(() => resolveClosed());
200
+ server.closeAllConnections();
201
+ }),
202
+ };
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.42",
3
+ "version": "1.2.44",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",
@@ -17,8 +17,14 @@
17
17
  "diya-gl-read-workbook": "bin/diya-gl-read-workbook.js",
18
18
  "diya-gl-write-workbook": "bin/diya-gl-write-workbook.js",
19
19
  "diya-gl-link": "bin/diya-gl-link.js",
20
+ "diya-gl-view": "bin/diya-gl-view.js",
20
21
  "diya-gl-mcp": "bin/diya-gl-mcp.js"
21
22
  },
23
+ "exports": {
24
+ ".": "./dist/app/lib/derivations/index.js",
25
+ "./dist/*": "./dist/*",
26
+ "./package.json": "./package.json"
27
+ },
22
28
  "files": ["bin/", "dist/", "README.md", "NOTICE"],
23
29
  "scripts": {
24
30
  "prepack": "node scripts/prepack.mjs"