@diy-accounting-uk/diya-gl 1.0.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.
Files changed (157) hide show
  1. package/README.md +63 -0
  2. package/bin/diya-gl-mcp.js +10 -0
  3. package/bin/diya-gl-read-workbook.js +11 -0
  4. package/bin/diya-gl-recalc.js +10 -0
  5. package/bin/diya-gl-write-workbook.js +12 -0
  6. package/bin/diya-gl.js +24 -0
  7. package/bin/run-dist-bin.js +23 -0
  8. package/dist/app/bin/build-diya-gl-spec.js +680 -0
  9. package/dist/app/bin/build-packages.js +149 -0
  10. package/dist/app/bin/build-reconciliation-pages.js +1052 -0
  11. package/dist/app/bin/build-sitemaps.js +51 -0
  12. package/dist/app/bin/compliance-report.js +125 -0
  13. package/dist/app/bin/cross-package-reconciliation.js +92 -0
  14. package/dist/app/bin/diya-gl-mcp.js +27 -0
  15. package/dist/app/bin/export.js +318 -0
  16. package/dist/app/bin/extract-scenarios.js +1348 -0
  17. package/dist/app/bin/generate.js +353 -0
  18. package/dist/app/bin/judge-reconciliation.js +649 -0
  19. package/dist/app/bin/reconcile.js +247 -0
  20. package/dist/app/bin/report.js +291 -0
  21. package/dist/app/bin/verify-roundtrip.js +941 -0
  22. package/dist/app/bin/verify-stability.js +429 -0
  23. package/dist/app/bin/write-workbook.js +106 -0
  24. package/dist/app/data/filing/ct-computation-v1.1.toml +1033 -0
  25. package/dist/app/data/filing/ct600-v3.toml +2329 -0
  26. package/dist/app/data/filing/frs105-formats.toml +244 -0
  27. package/dist/app/data/fixture-master-gaps.json +247 -0
  28. package/dist/app/data/hmrc/SOURCE.md +14 -0
  29. package/dist/app/data/hmrc/form-layouts/bst.json +116 -0
  30. package/dist/app/data/hmrc/form-layouts/ltd.json +452 -0
  31. package/dist/app/data/hmrc/form-layouts/se.json +485 -0
  32. package/dist/app/data/hmrc/form-layouts/taxi.json +117 -0
  33. package/dist/app/data/hmrc/sa103-mtd-mapping.json +1078 -0
  34. package/dist/app/data/hmrc/sa103f_mapping_v3.csv +111 -0
  35. package/dist/app/data/judge-rubric.md +52 -0
  36. package/dist/app/data/ltd-2020.toml +49 -0
  37. package/dist/app/data/ltd-2021.toml +49 -0
  38. package/dist/app/data/ltd-2022.toml +49 -0
  39. package/dist/app/data/ltd-2023.toml +49 -0
  40. package/dist/app/data/ltd-2024.toml +49 -0
  41. package/dist/app/data/ltd-2025.toml +49 -0
  42. package/dist/app/data/ltd-2026.toml +53 -0
  43. package/dist/app/data/ltd-2027.toml +51 -0
  44. package/dist/app/data/render-unrepresentable/bst.json +24 -0
  45. package/dist/app/data/render-unrepresentable/ltd.json +863 -0
  46. package/dist/app/data/render-unrepresentable/se.json +455 -0
  47. package/dist/app/data/render-unrepresentable/taxi.json +16 -0
  48. package/dist/app/data/roundtrip-budget.json +38 -0
  49. package/dist/app/data/roundtrip-matrix-budget.json +26 -0
  50. package/dist/app/data/roundtrip-unrepresentable.json +401 -0
  51. package/dist/app/data/se-2020-2021.toml +52 -0
  52. package/dist/app/data/se-2021-2022.toml +52 -0
  53. package/dist/app/data/se-2022-2023.toml +55 -0
  54. package/dist/app/data/se-2023-2024.toml +54 -0
  55. package/dist/app/data/se-2024-2025.toml +51 -0
  56. package/dist/app/data/se-2025-2026.toml +51 -0
  57. package/dist/app/data/se-2026-2027.toml +51 -0
  58. package/dist/app/data/volatile-cells.json +16 -0
  59. package/dist/app/lib/anchors/bst.js +79 -0
  60. package/dist/app/lib/anchors/ltd.js +626 -0
  61. package/dist/app/lib/anchors/run.js +100 -0
  62. package/dist/app/lib/anchors/se.js +456 -0
  63. package/dist/app/lib/anchors/taxi.js +164 -0
  64. package/dist/app/lib/app-resources.js +80 -0
  65. package/dist/app/lib/book-checks/ltd.js +408 -0
  66. package/dist/app/lib/book-checks/se.js +687 -0
  67. package/dist/app/lib/book-checks/taxi.js +215 -0
  68. package/dist/app/lib/book-checks.js +872 -0
  69. package/dist/app/lib/books-engine.js +197 -0
  70. package/dist/app/lib/books-interchange.js +533 -0
  71. package/dist/app/lib/calculators/bst.js +381 -0
  72. package/dist/app/lib/calculators/ltd.js +1938 -0
  73. package/dist/app/lib/calculators/se.js +1239 -0
  74. package/dist/app/lib/calculators/shared.js +131 -0
  75. package/dist/app/lib/calculators/taxi.js +461 -0
  76. package/dist/app/lib/canonical-report-value.js +77 -0
  77. package/dist/app/lib/compliance-report.js +341 -0
  78. package/dist/app/lib/diya-gl-calculator.js +52 -0
  79. package/dist/app/lib/diya-gl-canonical.js +355 -0
  80. package/dist/app/lib/diya-gl-edits-ltd.js +144 -0
  81. package/dist/app/lib/diya-gl-edits.js +361 -0
  82. package/dist/app/lib/diya-gl-loader.js +774 -0
  83. package/dist/app/lib/diya-gl-schema.js +212 -0
  84. package/dist/app/lib/generator.js +1912 -0
  85. package/dist/app/lib/guide.js +33 -0
  86. package/dist/app/lib/headlines.js +293 -0
  87. package/dist/app/lib/link-caches.js +421 -0
  88. package/dist/app/lib/ltd-layout.js +139 -0
  89. package/dist/app/lib/mcp/diya-gl-tools.js +330 -0
  90. package/dist/app/lib/mcp/jsonrpc-stdio.js +66 -0
  91. package/dist/app/lib/mcp/server.js +65 -0
  92. package/dist/app/lib/overtype-sidecar.js +180 -0
  93. package/dist/app/lib/package-builder.js +202 -0
  94. package/dist/app/lib/payslips-date-chain.js +481 -0
  95. package/dist/app/lib/payslips-layout.js +235 -0
  96. package/dist/app/lib/period-shift.js +111 -0
  97. package/dist/app/lib/product-workbook.js +317 -0
  98. package/dist/app/lib/products.js +27 -0
  99. package/dist/app/lib/provenance-data.js +36 -0
  100. package/dist/app/lib/provenance.js +92 -0
  101. package/dist/app/lib/report-generator.js +374 -0
  102. package/dist/app/lib/report-indicators.js +468 -0
  103. package/dist/app/lib/report-serializer.js +392 -0
  104. package/dist/app/lib/scenario-extractor.js +1403 -0
  105. package/dist/app/lib/scenario-loader.js +84 -0
  106. package/dist/app/lib/sitemap-builder.js +59 -0
  107. package/dist/app/lib/spreadsheet-runner.js +566 -0
  108. package/dist/app/lib/tax/capital-allowances.js +77 -0
  109. package/dist/app/lib/tax/corporation-tax.js +144 -0
  110. package/dist/app/lib/tax/income-tax.js +122 -0
  111. package/dist/app/lib/tax/mileage.js +69 -0
  112. package/dist/app/lib/tax/national-insurance.js +31 -0
  113. package/dist/app/lib/tax/vat.js +188 -0
  114. package/dist/app/lib/tax-year.js +31 -0
  115. package/dist/app/lib/template-formula-map.js +253 -0
  116. package/dist/app/lib/workbook-set.js +153 -0
  117. package/dist/app/lib/xlsx-exporter.js +2815 -0
  118. package/dist/app/lib/xlsx-parts.js +111 -0
  119. package/dist/app/lib/xlsx-reader.js +84 -0
  120. package/dist/app/products/bst.js +727 -0
  121. package/dist/app/products/ltd.js +4270 -0
  122. package/dist/app/products/se.js +3403 -0
  123. package/dist/app/products/taxi.js +912 -0
  124. package/dist/app/templates/bst/bst-excel.xlsx +0 -0
  125. package/dist/app/templates/bst/meta.toml +27 -0
  126. package/dist/app/templates/ltd/Cashaccount.xlsx +0 -0
  127. package/dist/app/templates/ltd/Companysecretary.xlsx +0 -0
  128. package/dist/app/templates/ltd/Creditcardaccount.xlsx +0 -0
  129. package/dist/app/templates/ltd/Currentaccount.xlsx +0 -0
  130. package/dist/app/templates/ltd/Dividend Voucher.docx +0 -0
  131. package/dist/app/templates/ltd/Financialaccounts.xlsx +0 -0
  132. package/dist/app/templates/ltd/Fixedassets.xlsx +0 -0
  133. package/dist/app/templates/ltd/Payslips.xlsx +0 -0
  134. package/dist/app/templates/ltd/Purchases.xlsx +0 -0
  135. package/dist/app/templates/ltd/Sales.xlsx +0 -0
  136. package/dist/app/templates/ltd/Salesinvoice.xlsx +0 -0
  137. package/dist/app/templates/ltd/Savingaccount.xlsx +0 -0
  138. package/dist/app/templates/ltd/Vatreturns.xlsx +0 -0
  139. package/dist/app/templates/ltd/expensesform.xlsx +0 -0
  140. package/dist/app/templates/ltd/meta.toml +54 -0
  141. package/dist/app/templates/meta.toml +12 -0
  142. package/dist/app/templates/se/Bank.xlsx +0 -0
  143. package/dist/app/templates/se/Cash.xlsx +0 -0
  144. package/dist/app/templates/se/Financialaccounts.xlsx +0 -0
  145. package/dist/app/templates/se/Fixedassets.xlsx +0 -0
  146. package/dist/app/templates/se/Payslips.xlsx +0 -0
  147. package/dist/app/templates/se/Purchases.xlsx +0 -0
  148. package/dist/app/templates/se/Sales.xlsx +0 -0
  149. package/dist/app/templates/se/Salesinvoice.xlsx +0 -0
  150. package/dist/app/templates/se/Vat.xlsx +0 -0
  151. package/dist/app/templates/se/meta.toml +43 -0
  152. package/dist/app/templates/taxi/meta.toml +43 -0
  153. package/dist/app/templates/taxi/taxi-excel.xlsx +0 -0
  154. package/dist/web/spreadsheets.diyaccounting.co.uk/public/schema/diya-gl-book-v2.schema.json +713 -0
  155. package/dist/web/spreadsheets.diyaccounting.co.uk/public/schema/diya-gl-docs.md +930 -0
  156. package/dist/web/spreadsheets.diyaccounting.co.uk/public/schema/diya-gl-lines-v2.schema.json +220 -0
  157. package/package.json +37 -0
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2025-2026 DIY Accounting Ltd
4
+ //
5
+ // build-sitemaps.js — Generate sitemap.xml for spreadsheets site
6
+ //
7
+ // Usage:
8
+ // node app/bin/build-sitemaps.js
9
+ //
10
+ // Reads: web/spreadsheets.diyaccounting.co.uk/public/knowledge-base.toml
11
+ // web/spreadsheets.diyaccounting.co.uk/public/catalogue.toml
12
+ // Writes: web/spreadsheets.diyaccounting.co.uk/public/sitemap.xml
13
+
14
+ import { parse as parseTOML } from "smol-toml";
15
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
16
+ import { resolve, dirname } from "path";
17
+ import { fileURLToPath } from "url";
18
+ import { buildSitemapXml } from "../lib/sitemap-builder.js";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const ROOT = resolve(__dirname, "..", "..");
22
+ const PUBLIC_DIR = resolve(ROOT, "web", "spreadsheets.diyaccounting.co.uk", "public");
23
+ const KB_TOML = resolve(PUBLIC_DIR, "knowledge-base.toml");
24
+ const CATALOGUE_TOML = resolve(PUBLIC_DIR, "catalogue.toml");
25
+ const SITEMAP_PATH = resolve(PUBLIC_DIR, "sitemap.xml");
26
+
27
+ let articles = [];
28
+ if (existsSync(KB_TOML)) {
29
+ const kb = parseTOML(readFileSync(KB_TOML, "utf8"));
30
+ articles = kb.article || [];
31
+ }
32
+
33
+ let products = [];
34
+ if (existsSync(CATALOGUE_TOML)) {
35
+ const cat = parseTOML(readFileSync(CATALOGUE_TOML, "utf8"));
36
+ products = cat.products || [];
37
+ }
38
+
39
+ // Reconciliation pages, discovered from the metadata files the page builder writes
40
+ const RECONCILIATION_DIR = resolve(PUBLIC_DIR, "reconciliation");
41
+ let reconciliationPages = [];
42
+ if (existsSync(RECONCILIATION_DIR)) {
43
+ reconciliationPages = readdirSync(RECONCILIATION_DIR)
44
+ .filter((f) => f.endsWith(".json"))
45
+ .map((f) => f.replace(/\.json$/, ""))
46
+ .sort();
47
+ }
48
+
49
+ const { xml, urlCount } = buildSitemapXml(products, articles, reconciliationPages);
50
+ writeFileSync(SITEMAP_PATH, xml, "utf8");
51
+ console.log(`Spreadsheets sitemap: ${SITEMAP_PATH} (${urlCount} URLs)`);
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 DIY Accounting Ltd
4
+ //
5
+ // compliance-report.js — Generate markdown compliance report from existing test reports.
6
+ // Does NOT run tests — expects reports to already exist.
7
+ //
8
+ // Usage:
9
+ // node app/bin/compliance-report.js --target URL [--output FILE]
10
+
11
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
12
+ import { join, resolve, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ parseNpmAudit,
16
+ parseEslintSecurity,
17
+ parsePa11yReport,
18
+ parseAxeResults,
19
+ parseLighthouseResults,
20
+ parseTextSpacingResults,
21
+ parseRetireResults,
22
+ generateReport,
23
+ } from "../lib/compliance-report.js";
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const projectRoot = resolve(__dirname, "..", "..");
27
+
28
+ const args = process.argv.slice(2);
29
+ const getArg = (name, defaultValue) => {
30
+ const idx = args.indexOf(name);
31
+ if (idx !== -1 && args[idx + 1]) return args[idx + 1];
32
+ return defaultValue;
33
+ };
34
+
35
+ const targetUrl = getArg("--target", null);
36
+ const outputFile = getArg("--output", "REPORT_ACCESSIBILITY_PENETRATION.md");
37
+
38
+ if (!targetUrl) {
39
+ console.error("Error: --target URL is required");
40
+ console.error("Usage: node app/bin/compliance-report.js --target URL [--output FILE]");
41
+ process.exit(1);
42
+ }
43
+
44
+ const targetDir = join(projectRoot, "web/spreadsheets.diyaccounting.co.uk/public/tests");
45
+ const accessibilityDir = join(targetDir, "accessibility");
46
+ const penetrationDir = join(targetDir, "penetration");
47
+
48
+ function readJsonFile(path) {
49
+ try {
50
+ if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
51
+ } catch (error) {
52
+ console.warn(`Warning: Could not parse ${path}: ${error.message}`);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function readTextFile(path) {
58
+ try {
59
+ if (existsSync(path)) return readFileSync(path, "utf8");
60
+ } catch (error) {
61
+ console.warn(`Warning: Could not read ${path}: ${error.message}`);
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function getPackageVersion() {
67
+ const pkg = readJsonFile(join(projectRoot, "package.json"));
68
+ return pkg?.version || "unknown";
69
+ }
70
+
71
+ function main() {
72
+ console.log("Compliance Report Generator");
73
+ console.log("===========================");
74
+ console.log(`Target: ${targetUrl}`);
75
+ console.log(`Output: ${outputFile}`);
76
+ console.log("");
77
+
78
+ const reportFiles = [
79
+ join(penetrationDir, "npm-audit.json"),
80
+ join(penetrationDir, "eslint-security.txt"),
81
+ join(penetrationDir, "retire.json"),
82
+ join(accessibilityDir, "pa11y-report.txt"),
83
+ join(accessibilityDir, "axe-results.json"),
84
+ join(accessibilityDir, "axe-wcag22-results.json"),
85
+ join(accessibilityDir, "lighthouse-results.json"),
86
+ join(accessibilityDir, "text-spacing-results.json"),
87
+ ];
88
+
89
+ const sourceFiles = [];
90
+ let foundCount = 0;
91
+ for (const file of reportFiles) {
92
+ const exists = existsSync(file);
93
+ const relativePath = file.replace(projectRoot + "/", "");
94
+ console.log(` ${exists ? "+" : "-"} ${relativePath}`);
95
+ sourceFiles.push({ path: relativePath, exists });
96
+ if (exists) foundCount++;
97
+ }
98
+ console.log(`\nFound ${foundCount}/${reportFiles.length} report files.`);
99
+
100
+ if (foundCount === 0) {
101
+ console.error("\nError: No report files found. Run compliance tests first:");
102
+ console.error(" npm run compliance:ci-report");
103
+ process.exit(1);
104
+ }
105
+
106
+ console.log("\nGenerating compliance report...");
107
+ const report = generateReport({
108
+ targetUrl,
109
+ version: getPackageVersion(),
110
+ sourceFiles,
111
+ npmAudit: parseNpmAudit(readJsonFile(join(penetrationDir, "npm-audit.json"))),
112
+ eslint: parseEslintSecurity(readTextFile(join(penetrationDir, "eslint-security.txt"))),
113
+ pa11y: parsePa11yReport(readTextFile(join(accessibilityDir, "pa11y-report.txt"))),
114
+ axe: parseAxeResults(readJsonFile(join(accessibilityDir, "axe-results.json"))),
115
+ axeWcag22: parseAxeResults(readJsonFile(join(accessibilityDir, "axe-wcag22-results.json"))),
116
+ lighthouse: parseLighthouseResults(readJsonFile(join(accessibilityDir, "lighthouse-results.json"))),
117
+ textSpacing: parseTextSpacingResults(readJsonFile(join(accessibilityDir, "text-spacing-results.json"))),
118
+ retire: parseRetireResults(readJsonFile(join(penetrationDir, "retire.json"))),
119
+ });
120
+ const outputPath = join(projectRoot, outputFile);
121
+ writeFileSync(outputPath, report);
122
+ console.log(`\nReport written to: ${outputPath}`);
123
+ }
124
+
125
+ main();
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 DIY Accounting Ltd
4
+ //
5
+ // cross-package-reconciliation.js — Check that values which should be
6
+ // consistent between packages are consistent across reconciliation reports.
7
+ //
8
+ // For a non-VAT scenario with the same transactions, pre-tax profit should
9
+ // be identical in BST, SE, and Ltd (since amounts are face value, no VAT split).
10
+ //
11
+ // Usage:
12
+ // node app/bin/cross-package-reconciliation.js
13
+ // npm run cross-package-reconciliation
14
+
15
+ import { readFileSync, readdirSync, existsSync } from "fs";
16
+ import { resolve, dirname } from "path";
17
+ import { fileURLToPath } from "url";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const REPORTS_DIR = resolve(__dirname, "..", "..", "reports");
21
+
22
+ if (!existsSync(REPORTS_DIR)) {
23
+ console.error("No reports/ directory. Run reconciliation first.");
24
+ process.exit(1);
25
+ }
26
+
27
+ const reports = readdirSync(REPORTS_DIR)
28
+ .filter((f) => f.endsWith(".md"))
29
+ .map((f) => {
30
+ const content = readFileSync(resolve(REPORTS_DIR, f), "utf-8");
31
+ return { name: f, content };
32
+ });
33
+
34
+ console.log(`Found ${reports.length} reconciliation reports`);
35
+
36
+ // Extract scenario name and key values from each report
37
+ function extractValues(content) {
38
+ const scenario = content.match(/Scenario: (.+)/)?.[1] || "";
39
+ const status = content.match(/Status: (.+)/)?.[1] || "";
40
+
41
+ // Extract cell values from appendix
42
+ const values = {};
43
+ const cellMatches = [...content.matchAll(/\| ([A-Z]+\d+) \| .+? \| ([^\|]+?) \|/g)];
44
+ for (const m of cellMatches) {
45
+ const val = parseFloat(m[2].trim());
46
+ if (!isNaN(val)) values[m[1]] = val;
47
+ }
48
+
49
+ return { scenario, status, values };
50
+ }
51
+
52
+ // Group reports by scenario
53
+ const byScenario = {};
54
+ for (const r of reports) {
55
+ const { scenario, status, values } = extractValues(r.content);
56
+ if (!byScenario[scenario]) byScenario[scenario] = [];
57
+
58
+ // Detect product from filename
59
+ let product = "unknown";
60
+ if (r.name.includes("Basic_Sole_Trader")) product = "bst";
61
+ else if (r.name.includes("Self_Employed")) product = "se";
62
+ else if (r.name.includes("Company")) product = "ltd";
63
+ else if (r.name.includes("Taxi")) product = "taxi";
64
+
65
+ byScenario[scenario].push({ name: r.name, product, status, values });
66
+ }
67
+
68
+ let failures = 0;
69
+
70
+ // For each scenario that appears in multiple products, check consistency
71
+ for (const [scenario, entries] of Object.entries(byScenario)) {
72
+ if (entries.length < 2) continue;
73
+
74
+ console.log(`\nScenario: ${scenario} (${entries.length} reports)`);
75
+ for (const e of entries) {
76
+ console.log(` ${e.product}: ${e.status}`);
77
+ }
78
+
79
+ // Check: all reports should RECONCILE
80
+ const anomalies = entries.filter((e) => e.status.includes("ANOMALY"));
81
+ if (anomalies.length > 0) {
82
+ console.log(` CROSS-CHECK FAIL: ${anomalies.length} anomalies detected`);
83
+ failures++;
84
+ }
85
+ }
86
+
87
+ if (failures > 0) {
88
+ console.log(`\n=== ${failures} cross-package failures ===`);
89
+ process.exit(1);
90
+ } else {
91
+ console.log(`\n=== All cross-package checks passed ===`);
92
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 DIY Accounting Ltd
4
+ //
5
+ // diya-gl-mcp.js — a stdio MCP server exposing the diya-gl pipeline as
6
+ // four tools: extract_book, report, edit_lines, save_workbook, over any of
7
+ // the four products (Basic Sole Trader, Taxi Driver, Self Employed, Company).
8
+ //
9
+ // Transport: hand-rolled newline-delimited JSON-RPC 2.0
10
+ // (app/lib/mcp/jsonrpc-stdio.js), not @modelcontextprotocol/sdk. The SDK's
11
+ // Node package pulls in express, hono, cors, jose and zod -- an HTTP
12
+ // server framework and an HTTP-transport dependency set -- to reach the
13
+ // three JSON-RPC methods a stdio-only server actually receives here
14
+ // (initialize, tools/list, tools/call). This repo carries no server
15
+ // framework at all (see CLAUDE.md's "what this repo does NOT have"): adding
16
+ // one to speak three methods over stdin/stdout would be exactly the heavy
17
+ // dependency the plan's own escape hatch names. The protocol surface this
18
+ // server implements is small enough to write directly and test without a
19
+ // mock of someone else's transport.
20
+ //
21
+ // Run directly: node app/bin/diya-gl-mcp.js
22
+ // Registered for this repo's own Claude Code sessions in .mcp.json.
23
+
24
+ import { createMethods } from "../lib/mcp/server.js";
25
+ import { serveJsonRpc } from "../lib/mcp/jsonrpc-stdio.js";
26
+
27
+ serveJsonRpc({ input: process.stdin, output: process.stdout, methods: createMethods() });
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 DIY Accounting Ltd
4
+ //
5
+ // export.js — Extract diya-gl data from a populated Excel package.
6
+ //
7
+ // Usage:
8
+ // node app/bin/export.js --package bst --source-dir examples/bst-latest --output-dir /tmp/exported
9
+ // node app/bin/export.js --package se --source-dir examples/se-latest --output-dir /tmp/exported
10
+ // node app/bin/export.js --file my-file.xlsx
11
+ // node app/bin/export.js --file my-package.zip --output-dir /tmp/exported
12
+ // node app/bin/export.js --package se --file my-package.zip --output-dir /tmp/exported
13
+ //
14
+ // --source-dir reads a package the pipeline already unpacked into its own
15
+ // directory, and always needs --package to say which product's extractors
16
+ // to run. --file takes any of the kinds books-interchange.js reads -- a
17
+ // customer's own download, not a directory this repo laid out -- and writes
18
+ // beside that input unless --output-dir says otherwise; --package is
19
+ // optional there, since the file itself says which product it is (the
20
+ // anchor guard for a single workbook, the sheets a package zip carries for
21
+ // the rest). Given anyway, --package is checked against what the file
22
+ // sniffs as and a disagreement is refused by name.
23
+ //
24
+ // book.toml and lines.jsonl are written through app/lib/diya-gl-canonical.js,
25
+ // the one form D is compared in, so a re-ordered line, a re-ordered field or
26
+ // a formatting difference can never register as a data difference. The
27
+ // exported book is validated against the published v2 schemas before it is
28
+ // written. --file also emits report.json: R, computed by the JS engine from
29
+ // the D just extracted, through the same modules report.js --data uses, so
30
+ // one run is the whole extract-recalculate-report loop. Alongside it goes
31
+ // overtyped.json, every sum the template computes that this copy of it
32
+ // carries as a typed value instead -- present only when the input carried an
33
+ // actual workbook to compare against a computed value in the first place.
34
+ // --file also emits bookchecks.json: the book checks and warnings
35
+ // app/lib/book-checks.js runs over D, for whichever of the five kinds the
36
+ // input sniffed as -- the tax data behind the VAT threshold warning comes
37
+ // from loadTaxDataForBook, the book's own declared year read straight off
38
+ // app/data/, so the figure is real rather than a stand-in constant.
39
+
40
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
41
+ import { resolve, dirname, basename } from "path";
42
+ import { extractBook, extractLines } from "../lib/xlsx-exporter.js";
43
+ import { workbookSetFromDirectory } from "../lib/workbook-set.js";
44
+ import { canonicalBookToml, canonicalLinesJsonl } from "../lib/diya-gl-canonical.js";
45
+ import { stampBook } from "../lib/provenance.js";
46
+ import { validateBook, validateLines } from "../lib/diya-gl-schema.js";
47
+ import { extractTaxDataFromBook, diyaGlToScenario } from "../lib/diya-gl-loader.js";
48
+ import { calculateFromDiyaGl } from "../lib/diya-gl-calculator.js";
49
+ import { calculateExpectedTax } from "../lib/tax/income-tax.js";
50
+ import { buildReportDocument, serializeReportDocument } from "../lib/report-serializer.js";
51
+ import { runBookChecks, bookChecksJson } from "../lib/book-checks.js";
52
+ import { loadTaxDataForBook, productOf } from "../lib/product-workbook.js";
53
+ import {
54
+ readBookSource,
55
+ AnchorError,
56
+ XlsBookSourceError,
57
+ UnknownBookSourceError,
58
+ InvalidDiyaGlBookError,
59
+ InvalidDiyaGlJsonError,
60
+ PackagePartError,
61
+ ProductNotAvailableError,
62
+ } from "../lib/books-interchange.js";
63
+ import { PRODUCTS } from "../lib/products.js";
64
+
65
+ // A book read through --file whose sniffed product disagrees with the
66
+ // --package it was given, so the CLI reports it by name rather than
67
+ // building R for the wrong product.
68
+ export class PackageMismatchError extends Error {
69
+ constructor(given, actualProductName) {
70
+ super(`--package ${given} was given but the file is a ${actualProductName} workbook`);
71
+ this.name = "PackageMismatchError";
72
+ }
73
+ }
74
+
75
+ // Every error a book source can refuse a read with, named rather than a
76
+ // stack trace: --file prints the message and exits, exactly as the anchor
77
+ // guard already did before books-interchange.js grew four more ways a file
78
+ // can be rejected.
79
+ const NAMED_BOOK_SOURCE_ERRORS = [
80
+ AnchorError,
81
+ XlsBookSourceError,
82
+ UnknownBookSourceError,
83
+ InvalidDiyaGlBookError,
84
+ InvalidDiyaGlJsonError,
85
+ PackagePartError,
86
+ ProductNotAvailableError,
87
+ PackageMismatchError,
88
+ ];
89
+
90
+ function isNamedBookSourceError(err) {
91
+ return NAMED_BOOK_SOURCE_ERRORS.some((ErrorClass) => err instanceof ErrorClass);
92
+ }
93
+
94
+ function parseArgs(argv) {
95
+ const args = argv.slice(2);
96
+ const getArg = (name) => {
97
+ const idx = args.indexOf(name);
98
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
99
+ };
100
+
101
+ const packageName = getArg("--package");
102
+ const sourceDir = getArg("--source-dir");
103
+ const file = getArg("--file");
104
+ const outputDir = getArg("--output-dir");
105
+
106
+ const usage =
107
+ "Usage: node app/bin/export.js --package <bst|taxi|se|ltd> --source-dir <path> --output-dir <path>\n" +
108
+ " or: node app/bin/export.js --file <path.xlsx|path.zip> [--package <bst|taxi|se|ltd>] [--output-dir <path>]";
109
+
110
+ if (!sourceDir && !file) {
111
+ console.error(usage);
112
+ process.exit(1);
113
+ }
114
+ if (sourceDir && file) {
115
+ console.error("Error: --source-dir and --file are mutually exclusive");
116
+ process.exit(1);
117
+ }
118
+ if (sourceDir && !packageName) {
119
+ console.error(usage);
120
+ process.exit(1);
121
+ }
122
+ if (sourceDir && !outputDir) {
123
+ console.error("Error: --output-dir is required with --source-dir");
124
+ process.exit(1);
125
+ }
126
+
127
+ return { packageName, sourceDir, file, outputDir };
128
+ }
129
+
130
+ // R for a (book, lines) pair: the JS engine's own D -> R loop --
131
+ // diyaGlToScenario -> calculateFromDiyaGl -> checkCompliance ->
132
+ // buildReportDocument -- the same loop report.js's --data mode runs, so the
133
+ // CLI's --file mode, report.js and the MCP server's tools never diverge on
134
+ // how R is built from the same D. Pure: no disk access, no console output.
135
+ export function buildFileReportDocument(book, lines, packageName, productMod) {
136
+ const taxData = extractTaxDataFromBook(book, packageName);
137
+ const scenario = diyaGlToScenario(book, lines, packageName);
138
+ const results = calculateFromDiyaGl(book, lines, packageName, taxData, scenario);
139
+ const mergedScenario = { ...scenario, ...scenario.expected };
140
+ const periodEnd = book.documentInfo?.periodCoveredEnd;
141
+ const yearEnd = periodEnd ? new Date(periodEnd).toISOString().slice(0, 10) : null;
142
+ const checks =
143
+ typeof productMod.checkCompliance === "function"
144
+ ? productMod.checkCompliance({ ...results }, mergedScenario, taxData, calculateExpectedTax, yearEnd)
145
+ : [];
146
+ return buildReportDocument({
147
+ packageName,
148
+ engine: "js",
149
+ results,
150
+ productMod,
151
+ scenario: mergedScenario,
152
+ checks,
153
+ scenarioName: book.documentInfo?.entriesComment,
154
+ yearEnd,
155
+ });
156
+ }
157
+
158
+ function writeReportJson(outputDir, document) {
159
+ writeFileSync(resolve(outputDir, "report.json"), serializeReportDocument(document));
160
+ console.log(` report.json: ${document.values.length} values`);
161
+ }
162
+
163
+ function writeOvertypedJson(outputDir, overtyped) {
164
+ writeFileSync(resolve(outputDir, "overtyped.json"), `${JSON.stringify(overtyped, null, 2)}\n`);
165
+ const count = Object.keys(overtyped).length;
166
+ console.log(` overtyped.json: ${count} ${count === 1 ? "cell" : "cells"} typed over a template formula`);
167
+ }
168
+
169
+ // R's calculated cell map alone, the same D -> R half buildFileReportDocument
170
+ // runs before it builds a whole report document -- for a caller (the book
171
+ // checks, which only read a handful of R's cells for a warning like Ltd's
172
+ // distributable-profits one) that has no use for the rest of it.
173
+ export function calculatedResultsFor(book, lines, taxData) {
174
+ const packageName = productOf(book);
175
+ const scenario = diyaGlToScenario(book, lines, packageName);
176
+ return calculateFromDiyaGl(book, lines, packageName, taxData, scenario);
177
+ }
178
+
179
+ // The book checks and warnings, run over D with the book's own tax year's
180
+ // data behind the VAT threshold warning -- loadTaxDataForBook resolves that
181
+ // year from book.documentInfo alone, so this runs the same way whichever of
182
+ // the five kinds --file read the book from. Also carries R, so a
183
+ // product's own warning that reads the calculated accounts (Ltd's dividend
184
+ // warning against distributable profits) sees them here too, not just
185
+ // through the report.
186
+ async function writeBookChecksJson(outputDir, book, lines) {
187
+ const taxData = await loadTaxDataForBook(book);
188
+ const calculatedResults = calculatedResultsFor(book, lines, taxData);
189
+ const { results } = runBookChecks({ book, lines, taxData, results: calculatedResults });
190
+ writeFileSync(resolve(outputDir, "bookchecks.json"), bookChecksJson(results));
191
+ const failing = results.filter((r) => r.result === "fail").length;
192
+ console.log(` bookchecks.json: ${results.length} rules, ${failing} failing`);
193
+ }
194
+
195
+ // The whole --file extraction: books-interchange.js sniffs the input and
196
+ // turns it into D (a workbook or a package zip runs the anchor guard and
197
+ // the extractors over a workbook set; a diya-gl zip, a JSON file or that
198
+ // JSON zipped validate straight against the published schemas), then this
199
+ // builds R from that D. --source-dir's CLI path and the MCP server's
200
+ // extract_book tool both call this rather than each reaching into
201
+ // books-interchange.js on their own, so a change to how D or R are produced
202
+ // can only ever happen in one place.
203
+ //
204
+ // The product is whichever of the four the file sniffs as; a caller that
205
+ // already knows which product it expects (--package, or the MCP tool's own
206
+ // argument) passes it as `product` and gets a named refusal when the file
207
+ // disagrees, rather than silently reading it as something it is not.
208
+ export async function extractBookFromFile(filePath, { product } = {}) {
209
+ const resolvedFile = resolve(filePath);
210
+ const bytes = readFileSync(resolvedFile);
211
+ const { product: sniffedProduct, book, lines, overtyped } = await readBookSource(bytes, basename(resolvedFile), { products: PRODUCTS });
212
+ if (product && product !== sniffedProduct) {
213
+ throw new PackageMismatchError(product, PRODUCTS[sniffedProduct].PRODUCT.name);
214
+ }
215
+ const productMod = PRODUCTS[sniffedProduct];
216
+ const document = buildFileReportDocument(book, lines, sniffedProduct, productMod);
217
+ return { product: sniffedProduct, book, lines, document, overtyped };
218
+ }
219
+
220
+ // The v2 schema validation, then book.toml + lines.jsonl written through
221
+ // diya-gl-canonical.js, shared by both --source-dir and --file so the two
222
+ // can only ever differ in how they got to (book, lines), never in how that
223
+ // pair reaches disk -- which is what the byte-for-byte verification rests on.
224
+ function writeDiyaGlData(outputDir, book, lines) {
225
+ const stamped = stampBook(book);
226
+ const bookErrors = validateBook(stamped);
227
+ if (!bookErrors.valid) {
228
+ console.error(`The exported book does not conform to the published v2 book schema:`);
229
+ for (const error of bookErrors.errors) console.error(` ${error}`);
230
+ process.exit(1);
231
+ }
232
+ const lineErrors = validateLines(lines, stamped);
233
+ if (!lineErrors.valid) {
234
+ console.error(`The exported lines do not conform to the published v2 lines schema:`);
235
+ for (const error of lineErrors.errors.slice(0, 20)) console.error(` ${error}`);
236
+ if (lineErrors.errors.length > 20) console.error(` ... and ${lineErrors.errors.length - 20} more`);
237
+ process.exit(1);
238
+ }
239
+
240
+ mkdirSync(outputDir, { recursive: true });
241
+ writeFileSync(resolve(outputDir, "lines.jsonl"), canonicalLinesJsonl(lines));
242
+ writeFileSync(resolve(outputDir, "book.toml"), canonicalBookToml(stamped));
243
+
244
+ console.log(` lines.jsonl: ${lines.length} entries`);
245
+ console.log(` book.toml: ${Object.keys(stamped).length} tables`);
246
+ }
247
+
248
+ async function runFileMode(filePath, outputDirArg, packageName) {
249
+ const resolvedFile = resolve(filePath);
250
+ const resolvedOutput = resolve(outputDirArg || dirname(resolvedFile));
251
+
252
+ console.log(`=== export.js ===`);
253
+ console.log(`File: ${resolvedFile}`);
254
+ console.log(`Output: ${resolvedOutput}`);
255
+
256
+ let extracted;
257
+ try {
258
+ extracted = await extractBookFromFile(resolvedFile, { product: packageName });
259
+ } catch (err) {
260
+ if (isNamedBookSourceError(err)) {
261
+ console.error(err.message);
262
+ process.exit(1);
263
+ }
264
+ throw err;
265
+ }
266
+
267
+ const { product, book, lines, document, overtyped } = extracted;
268
+ console.log(`Package: ${product}`);
269
+ writeDiyaGlData(resolvedOutput, book, lines);
270
+ writeReportJson(resolvedOutput, document);
271
+ if (overtyped) writeOvertypedJson(resolvedOutput, overtyped);
272
+ await writeBookChecksJson(resolvedOutput, book, lines);
273
+
274
+ console.log(`\nExported ${lines.length} transactions to ${resolvedOutput}`);
275
+ }
276
+
277
+ async function main() {
278
+ const { packageName, sourceDir, file, outputDir } = parseArgs(process.argv);
279
+ if (packageName && !PRODUCTS[packageName]) {
280
+ console.error(`Unknown package: ${packageName}. Available: ${Object.keys(PRODUCTS).join(", ")}`);
281
+ process.exit(1);
282
+ }
283
+
284
+ if (file) {
285
+ await runFileMode(file, outputDir, packageName);
286
+ return;
287
+ }
288
+
289
+ const productMod = PRODUCTS[packageName];
290
+ const resolvedSource = resolve(sourceDir);
291
+ const resolvedOutput = resolve(outputDir);
292
+
293
+ console.log(`=== export.js ===`);
294
+ console.log(`Package: ${packageName}`);
295
+ console.log(`Source: ${resolvedSource}`);
296
+ console.log(`Output: ${resolvedOutput}`);
297
+
298
+ const set = await workbookSetFromDirectory(resolvedSource);
299
+ if (set.names().length === 0) {
300
+ console.error(`No xlsx file found in ${resolvedSource}`);
301
+ process.exit(1);
302
+ }
303
+
304
+ const lines = await extractLines(set, packageName);
305
+ const book = await extractBook(set, packageName, lines, productMod.CELL_MAP);
306
+ writeDiyaGlData(resolvedOutput, book, lines);
307
+ console.log(`\nExported ${lines.length} transactions to ${resolvedOutput}`);
308
+ }
309
+
310
+ // Only run as a CLI when invoked directly; a caller importing
311
+ // extractBookFromFile or buildFileReportDocument (the MCP server does both)
312
+ // must never trigger a second, argv-driven run of this file's own main().
313
+ if (import.meta.url === `file://${process.argv[1]}`) {
314
+ main().catch((err) => {
315
+ console.error(err);
316
+ process.exit(1);
317
+ });
318
+ }