@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,1052 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright (C) 2026 DIY Accounting Ltd
4
+ //
5
+ // build-reconciliation-pages.js — Build the published reconciliation pages.
6
+ //
7
+ // Usage:
8
+ // node app/bin/build-reconciliation-pages.js --product ltd
9
+ // node app/bin/build-reconciliation-pages.js --product all --no-screenshots
10
+ // node app/bin/build-reconciliation-pages.js --index-only
11
+ // node app/bin/build-reconciliation-pages.js --record diya-gl-v1.0.0
12
+ //
13
+ // Reads: reports/*.md
14
+ // app/test/fixtures/<scenario>.toml
15
+ // examples/<product>-latest/*.xlsx
16
+ // app/data/releases.json
17
+ // Writes: web/spreadsheets.diyaccounting.co.uk/public/reconciliation/<product>.html
18
+ // web/spreadsheets.diyaccounting.co.uk/public/reconciliation/<product>.json
19
+ // web/spreadsheets.diyaccounting.co.uk/public/reconciliation/screenshots/*.png
20
+ // web/spreadsheets.diyaccounting.co.uk/public/reconciliation/index.html
21
+ // web/spreadsheets.diyaccounting.co.uk/public/reconciliation/releases.html
22
+ // web/spreadsheets.diyaccounting.co.uk/public/reconciliation/releases.json
23
+ // app/data/releases.json (only with --record)
24
+
25
+ import { execFileSync } from "child_process";
26
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
27
+ import { tmpdir } from "os";
28
+ import { basename, dirname, join, resolve } from "path";
29
+ import { fileURLToPath } from "url";
30
+ import JSZip from "jszip";
31
+ import { loadScenario, MONTH_SHEETS } from "../lib/scenario-loader.js";
32
+ import { getLibreOffice } from "../lib/spreadsheet-runner.js";
33
+ import { PROVENANCE_DATA } from "../lib/provenance-data.js";
34
+
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ const ROOT = resolve(__dirname, "..", "..");
37
+ const REPORTS_DIR = resolve(ROOT, "reports");
38
+ const FIXTURES_DIR = resolve(ROOT, "app", "test", "fixtures");
39
+ const EXAMPLES_DIR = resolve(ROOT, "examples");
40
+ const OUT_DIR = resolve(ROOT, "web", "spreadsheets.diyaccounting.co.uk", "public", "reconciliation");
41
+ const RELEASES_FILE = resolve(ROOT, "app", "data", "releases.json");
42
+
43
+ // ── Product configuration ───────────────────────────────────────────────────
44
+
45
+ const PRODUCTS = {
46
+ taxi: {
47
+ name: "Taxi Driver",
48
+ reportPrefix: "GB_Accounts_Taxi_Driver",
49
+ featuredScenario: "taxi-scenario-basic",
50
+ examples: "taxi-latest",
51
+ catalogue: "GB Accounts Taxi Driver",
52
+ screenshots: [
53
+ { file: "GB_Accounts_Taxi_Driver.xlsx", sheet: "Profit & Loss Acc", caption: "Profit and loss account" },
54
+ { file: "GB_Accounts_Taxi_Driver.xlsx", sheet: "Draft Tax calculation", caption: "Draft tax calculation" },
55
+ { file: "GB_Accounts_Taxi_Driver.xlsx", sheet: "SE Short", caption: "Self assessment short pages" },
56
+ ],
57
+ },
58
+ bst: {
59
+ name: "Basic Sole Trader",
60
+ reportPrefix: "GB_Accounts_Basic_Sole_Trader",
61
+ featuredScenario: "bst-scenario-basic",
62
+ examples: "bst-latest",
63
+ catalogue: "GB Accounts Basic Sole Trader",
64
+ screenshots: [
65
+ { file: "GB_Accounts_Basic_Sole_Trader.xlsx", sheet: "Profit & Loss Acc", caption: "Profit and loss account" },
66
+ { file: "GB_Accounts_Basic_Sole_Trader.xlsx", sheet: "Income Tax", caption: "Income tax calculation" },
67
+ { file: "GB_Accounts_Basic_Sole_Trader.xlsx", sheet: "SE Short", caption: "Self assessment short pages" },
68
+ { file: "GB_Accounts_Basic_Sole_Trader.xlsx", sheet: "Debtors & Creditors", caption: "Debtors and creditors" },
69
+ ],
70
+ },
71
+ se: {
72
+ name: "Self Employed",
73
+ reportPrefix: "GB_Accounts_Self_Employed",
74
+ featuredScenario: "se-scenario-advanced",
75
+ examples: "se-latest",
76
+ catalogue: "GB Accounts Self Employed",
77
+ screenshots: [
78
+ { file: "Financialaccounts.xlsx", sheet: "Profit & Loss Account", caption: "Profit and loss account" },
79
+ { file: "Financialaccounts.xlsx", sheet: "Income Tax", caption: "Income tax calculation" },
80
+ { file: "Financialaccounts.xlsx", sheet: "SE Short", caption: "Self assessment short pages" },
81
+ { file: "Vat.xlsx", sheet: "VATQtr1", caption: "VAT return, quarter 1" },
82
+ { file: "Fixedassets.xlsx", sheet: "Schedule", caption: "Fixed asset schedule" },
83
+ ],
84
+ },
85
+ ltd: {
86
+ name: "Limited Company",
87
+ reportPrefix: "GB_Accounts_Company",
88
+ featuredScenario: "ltd-scenario-full",
89
+ examples: "ltd-latest",
90
+ catalogue: "GB Accounts Company",
91
+ screenshots: [
92
+ { file: "Financialaccounts.xlsx", sheet: "MnthP&L", caption: "Monthly profit and loss" },
93
+ { file: "Financialaccounts.xlsx", sheet: "PubP&L", caption: "Published profit and loss" },
94
+ { file: "Financialaccounts.xlsx", sheet: "PubBalSht", caption: "Published balance sheet" },
95
+ { file: "Financialaccounts.xlsx", sheet: "CorporationTax", caption: "Corporation tax computation" },
96
+ { file: "Vatreturns.xlsx", sheet: "VATQtr1", caption: "VAT return, quarter 1" },
97
+ { file: "Fixedassets.xlsx", sheet: "Schedule", caption: "Fixed asset schedule" },
98
+ ],
99
+ },
100
+ };
101
+
102
+ const PRODUCT_ORDER = ["taxi", "bst", "se", "ltd"];
103
+
104
+ // The six page sections, in the order every product page follows.
105
+ const PAGE_SECTIONS = [
106
+ { id: "summary", title: "Summary" },
107
+ { id: "reconciliation-checks", title: "Reconciliation checks" },
108
+ { id: "input-transactions", title: "Input transactions" },
109
+ { id: "screenshots", title: "Screenshots" },
110
+ { id: "accounting-statements", title: "Accounting statements" },
111
+ { id: "tax-review", title: "Tax review" },
112
+ ];
113
+
114
+ // Report sections that belong under Tax review rather than Accounting statements.
115
+ const TAX_SECTION_PATTERNS = [/corporation tax/i, /income tax/i, /draft tax/i, /self assessment/i];
116
+
117
+ // The feature ladder shown on the index matrix, in a fixed order for every product.
118
+ const FEATURES = [
119
+ { key: "checks", label: "Reconciliation checks" },
120
+ { key: "sales", label: "Sales journal" },
121
+ { key: "purchases", label: "Purchase journal" },
122
+ { key: "bank", label: "Bank and cash" },
123
+ { key: "payroll", label: "Payroll" },
124
+ { key: "openingBalances", label: "Opening balances" },
125
+ { key: "fixedAssets", label: "Fixed assets" },
126
+ { key: "profitAndLoss", label: "Profit and loss" },
127
+ { key: "balanceSheet", label: "Balance sheet" },
128
+ { key: "stock", label: "Stock" },
129
+ { key: "debtorsCreditors", label: "Debtors and creditors" },
130
+ { key: "vat", label: "VAT quarters" },
131
+ { key: "trialBalance", label: "Trial balance check" },
132
+ { key: "corporationTax", label: "Corporation tax" },
133
+ { key: "incomeTax", label: "Income tax" },
134
+ { key: "selfAssessment", label: "Self assessment" },
135
+ { key: "screenshots", label: "Sheet screenshots" },
136
+ ];
137
+
138
+ // ── Small helpers ───────────────────────────────────────────────────────────
139
+
140
+ function escapeHtml(text) {
141
+ return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
142
+ }
143
+
144
+ // Report cells carry &nbsp; indents and **bold** markers. Everything else is literal text.
145
+ function inlineMarkdown(text) {
146
+ return escapeHtml(text)
147
+ .replace(/&amp;nbsp;/g, "&nbsp;")
148
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
149
+ }
150
+
151
+ function slug(text) {
152
+ return String(text)
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9]+/g, "-")
155
+ .replace(/^-+|-+$/g, "");
156
+ }
157
+
158
+ const numberFormat = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
159
+
160
+ function formatAmount(value) {
161
+ return typeof value === "number" ? numberFormat.format(value) : String(value);
162
+ }
163
+
164
+ function formatCell(value) {
165
+ if (value === null || value === undefined) return "";
166
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
167
+ if (typeof value === "number") return numberFormat.format(value);
168
+ if (typeof value === "boolean") return value ? "yes" : "no";
169
+ const text = String(value);
170
+ return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
171
+ }
172
+
173
+ function titleCase(key) {
174
+ const words = String(key).replace(/_/g, " ");
175
+ return words.charAt(0).toUpperCase() + words.slice(1);
176
+ }
177
+
178
+ // ── Report markdown parsing ─────────────────────────────────────────────────
179
+
180
+ const REPORT_FILE_PATTERN = /^(.+?)_(\d{4})_(\d{2})_(\d{2})__([A-Za-z0-9]+)__Excel_2007_(.+)\.md$/;
181
+
182
+ function parseTableRow(line) {
183
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
184
+ return trimmed.split("|").map((cell) => cell.trim());
185
+ }
186
+
187
+ function isSeparatorRow(line) {
188
+ return /^\|[\s:|-]+\|$/.test(line.trim());
189
+ }
190
+
191
+ function alignmentsFrom(separatorLine) {
192
+ return parseTableRow(separatorLine).map((cell) => (cell.endsWith(":") ? "right" : "left"));
193
+ }
194
+
195
+ // Parses the committed report markdown by structure: the Status line, ## headings and pipe tables.
196
+ export function parseReport(path) {
197
+ const lines = readFileSync(path, "utf8").split("\n");
198
+ const report = { title: "", scenario: "", status: "", frontMatter: [], sections: [] };
199
+ let section = null;
200
+
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const line = lines[i];
203
+ if (line.startsWith("# ")) {
204
+ report.title = line.slice(2).trim();
205
+ continue;
206
+ }
207
+ if (line.startsWith("Scenario:")) {
208
+ report.scenario = line.slice("Scenario:".length).trim();
209
+ continue;
210
+ }
211
+ if (line.startsWith("Status:")) {
212
+ report.status = line.slice("Status:".length).trim();
213
+ continue;
214
+ }
215
+ if (line.startsWith("## ") || line.startsWith("### ")) {
216
+ const level = line.startsWith("### ") ? 3 : 2;
217
+ section = { name: line.replace(/^#+\s*/, "").trim(), level, blocks: [] };
218
+ report.sections.push(section);
219
+ continue;
220
+ }
221
+ if (line.trim().startsWith("|") && section) {
222
+ const head = parseTableRow(line);
223
+ const align = isSeparatorRow(lines[i + 1] ?? "") ? alignmentsFrom(lines[++i]) : head.map(() => "left");
224
+ const rows = [];
225
+ while (i + 1 < lines.length && lines[i + 1].trim().startsWith("|")) {
226
+ rows.push(parseTableRow(lines[++i]));
227
+ }
228
+ section.blocks.push({ type: "table", head, align, rows });
229
+ continue;
230
+ }
231
+ if (line.trim() && !line.startsWith("---")) {
232
+ if (section) section.blocks.push({ type: "text", text: line.trim() });
233
+ else report.frontMatter.push(line.trim());
234
+ }
235
+ }
236
+ return report;
237
+ }
238
+
239
+ function loadProductReports(reportsDir, product) {
240
+ const config = PRODUCTS[product];
241
+ const runs = [];
242
+ for (const file of readdirSync(reportsDir).sort()) {
243
+ if (!file.endsWith(".md") || !file.startsWith(`${config.reportPrefix}_`)) continue;
244
+ const match = REPORT_FILE_PATTERN.exec(file);
245
+ if (!match) continue;
246
+ const [, , year, month, day, label, scenario] = match;
247
+ const path = join(reportsDir, file);
248
+ const status = (
249
+ readFileSync(path, "utf8")
250
+ .split("\n")
251
+ .find((l) => l.startsWith("Status:")) ?? ""
252
+ )
253
+ .slice(7)
254
+ .trim();
255
+ runs.push({ file, path, yearEnd: `${year}-${month}-${day}`, label, scenario, status });
256
+ }
257
+ runs.sort((a, b) => b.yearEnd.localeCompare(a.yearEnd));
258
+ return runs;
259
+ }
260
+
261
+ // ── Scenario fixture rendering ──────────────────────────────────────────────
262
+
263
+ const JOURNAL_COLUMN_ORDER = ["date", "customer", "supplier", "account", "source", "description", "invoice", "code", "amount", "vat"];
264
+
265
+ function journalColumns(rows) {
266
+ const keys = new Set();
267
+ for (const row of rows) for (const key of Object.keys(row)) keys.add(key);
268
+ const ordered = JOURNAL_COLUMN_ORDER.filter((key) => keys.has(key));
269
+ for (const key of keys) if (!ordered.includes(key)) ordered.push(key);
270
+ return ordered;
271
+ }
272
+
273
+ function journalTotal(rows) {
274
+ return rows.reduce((total, row) => total + (typeof row.amount === "number" ? row.amount : 0), 0);
275
+ }
276
+
277
+ function monthsOf(journal) {
278
+ return Object.keys(MONTH_SHEETS).filter((month) => Array.isArray(journal?.[month]) && journal[month].length > 0);
279
+ }
280
+
281
+ function renderJournalTable(rows) {
282
+ const columns = journalColumns(rows);
283
+ const head = columns.map(titleCase);
284
+ const align = columns.map((key) => (key === "amount" ? "right" : "left"));
285
+ const body = rows.map((row) => columns.map((key) => formatCell(row[key])));
286
+ return renderTable({ head, align, rows: body });
287
+ }
288
+
289
+ function renderJournal(journal, heading, id) {
290
+ const months = monthsOf(journal);
291
+ if (months.length === 0) return "";
292
+ const total = months.reduce((sum, month) => sum + journalTotal(journal[month]), 0);
293
+ const count = months.reduce((sum, month) => sum + journal[month].length, 0);
294
+ const parts = [
295
+ `<details class="recon-details" id="${id}">`,
296
+ `<summary>${escapeHtml(heading)}: ${count} entries, ${escapeHtml(formatAmount(total))}</summary>`,
297
+ ];
298
+ for (const month of months) {
299
+ parts.push(`<h5>${escapeHtml(MONTH_SHEETS[month])}</h5>`);
300
+ parts.push(renderJournalTable(journal[month]));
301
+ }
302
+ parts.push("</details>");
303
+ return parts.join("\n");
304
+ }
305
+
306
+ function renderKeyValueTable(object) {
307
+ const rows = Object.entries(object).map(([key, value]) => [titleCase(key), formatCell(value)]);
308
+ return renderTable({ head: ["Item", "Value"], align: ["left", "right"], rows });
309
+ }
310
+
311
+ function renderRecordTable(records) {
312
+ const columns = journalColumns(records);
313
+ return renderTable({
314
+ head: columns.map(titleCase),
315
+ align: columns.map((key) => (key === "amount" || key === "cost" || key === "acc_dep" || key === "grossPay" ? "right" : "left")),
316
+ rows: records.map((record) => columns.map((key) => formatCell(record[key]))),
317
+ });
318
+ }
319
+
320
+ function renderMonthlySummary(scenario) {
321
+ const journals = [
322
+ ["Sales", scenario.sales],
323
+ ["Purchases", scenario.purchases],
324
+ ["Bank", scenario.bank],
325
+ ].filter(([, journal]) => journal);
326
+ if (journals.length === 0) return "";
327
+ const head = ["Month", ...journals.flatMap(([name]) => [`${name} entries`, `${name} total`])];
328
+ const align = head.map((_, index) => (index === 0 ? "left" : "right"));
329
+ const rows = [];
330
+ for (const month of Object.keys(MONTH_SHEETS)) {
331
+ const cells = [MONTH_SHEETS[month]];
332
+ let any = false;
333
+ for (const [, journal] of journals) {
334
+ const entries = journal[month] ?? [];
335
+ if (entries.length) any = true;
336
+ cells.push(String(entries.length), formatAmount(journalTotal(entries)));
337
+ }
338
+ if (any) rows.push(cells);
339
+ }
340
+ const totals = ["Year"];
341
+ for (const [, journal] of journals) {
342
+ const all = Object.keys(MONTH_SHEETS).flatMap((month) => journal[month] ?? []);
343
+ totals.push(String(all.length), `**${formatAmount(journalTotal(all))}**`);
344
+ }
345
+ rows.push(totals);
346
+ return renderTable({ head, align, rows });
347
+ }
348
+
349
+ function renderInputTransactions(scenario) {
350
+ const parts = [];
351
+ if (scenario.business) {
352
+ parts.push("<h4>Business details</h4>");
353
+ parts.push(renderKeyValueTable(scenario.business));
354
+ }
355
+ const summary = renderMonthlySummary(scenario);
356
+ if (summary) {
357
+ parts.push("<h4>Month by month</h4>");
358
+ parts.push(summary);
359
+ }
360
+ const journals = [
361
+ ["Sales journal", scenario.sales, "sales-journal"],
362
+ ["Purchase journal", scenario.purchases, "purchase-journal"],
363
+ ["Bank and cash entries", scenario.bank, "bank-journal"],
364
+ ];
365
+ const rendered = journals.map(([heading, journal, id]) => renderJournal(journal, heading, id)).filter(Boolean);
366
+ if (rendered.length) {
367
+ parts.push("<h4>Every entry</h4>");
368
+ parts.push(...rendered);
369
+ }
370
+ if (Array.isArray(scenario.employees) && scenario.employees.length) {
371
+ parts.push("<h4>Payroll</h4>");
372
+ parts.push(renderRecordTable(scenario.employees));
373
+ }
374
+ const openings = [
375
+ ["Opening debtors", scenario.opening_debtors],
376
+ ["Closing debtors", scenario.closing_debtors],
377
+ ["Opening creditors", scenario.opening_creditors],
378
+ ["Closing creditors", scenario.closing_creditors],
379
+ ["Opening fixed assets", scenario.opening_fixed_assets],
380
+ ].filter(([, records]) => Array.isArray(records) && records.length);
381
+ if (openings.length || scenario.opening_balance || scenario.stock) {
382
+ parts.push("<h4>Opening and closing balances</h4>");
383
+ if (scenario.opening_balance) {
384
+ parts.push("<h5>Opening balance</h5>");
385
+ parts.push(renderKeyValueTable(scenario.opening_balance));
386
+ }
387
+ if (scenario.stock) {
388
+ parts.push("<h5>Stock</h5>");
389
+ parts.push(renderKeyValueTable(scenario.stock));
390
+ }
391
+ for (const [heading, records] of openings) {
392
+ parts.push(`<h5>${escapeHtml(heading)}</h5>`);
393
+ parts.push(renderRecordTable(records));
394
+ }
395
+ }
396
+ return parts.join("\n");
397
+ }
398
+
399
+ // ── Screenshots ─────────────────────────────────────────────────────────────
400
+
401
+ function decodeXmlAttribute(text) {
402
+ return text
403
+ .replace(/&lt;/g, "<")
404
+ .replace(/&gt;/g, ">")
405
+ .replace(/&quot;/g, '"')
406
+ .replace(/&apos;/g, "'")
407
+ .replace(/&amp;/g, "&");
408
+ }
409
+
410
+ async function sheetPartPath(zip, relationshipId) {
411
+ const relsFile = zip.file("xl/_rels/workbook.xml.rels");
412
+ if (!relsFile) throw new Error("No xl/_rels/workbook.xml.rels");
413
+ const rels = await relsFile.async("string");
414
+ const pattern = new RegExp(`<Relationship[^>]*Id="${relationshipId}"[^>]*Target="([^"]*)"`);
415
+ const match = pattern.exec(rels) ?? new RegExp(`<Relationship[^>]*Target="([^"]*)"[^>]*Id="${relationshipId}"`).exec(rels);
416
+ if (!match) throw new Error(`No workbook relationship ${relationshipId}`);
417
+ return `xl/${match[1].replace(/^\/?xl\//, "").replace(/^\//, "")}`;
418
+ }
419
+
420
+ // LibreOffice prints the file name, page number and today's date unless the sheet
421
+ // defines its own header and footer. Empty ones keep the image the same run to run.
422
+ function blankHeaderAndFooter(sheetXml) {
423
+ const blank = "<headerFooter><oddHeader></oddHeader><oddFooter></oddFooter></headerFooter>";
424
+ if (/<headerFooter[\s>]/.test(sheetXml)) {
425
+ return sheetXml.replace(/<headerFooter\b[^>]*(?:\/>|>[\s\S]*?<\/headerFooter>)/, blank);
426
+ }
427
+ for (const anchor of [/<pageSetup\b[^>]*\/>/, /<\/pageSetup>/, /<pageMargins\b[^>]*\/>/]) {
428
+ const match = anchor.exec(sheetXml);
429
+ if (match) return sheetXml.replace(match[0], `${match[0]}${blank}`);
430
+ }
431
+ return sheetXml.replace("</worksheet>", `${blank}</worksheet>`);
432
+ }
433
+
434
+ // Hide every sheet but one, so LibreOffice's PDF export contains that sheet alone.
435
+ async function isolateSheet(xlsxPath, sheetName) {
436
+ const zip = await JSZip.loadAsync(readFileSync(xlsxPath));
437
+ const workbookFile = zip.file("xl/workbook.xml");
438
+ if (!workbookFile) throw new Error(`No xl/workbook.xml in ${xlsxPath}`);
439
+ let xml = await workbookFile.async("string");
440
+ const tags = xml.match(/<sheet\b[^>]*?\/>/g) ?? [];
441
+ let targetIndex = -1;
442
+ let targetRelationship = null;
443
+
444
+ tags.forEach((tag, index) => {
445
+ const nameMatch = /name="([^"]*)"/.exec(tag);
446
+ if (!nameMatch) return;
447
+ const name = decodeXmlAttribute(nameMatch[1]);
448
+ let updated;
449
+ if (name === sheetName) {
450
+ targetIndex = index;
451
+ targetRelationship = /r:id="([^"]*)"/.exec(tag)?.[1] ?? null;
452
+ updated = tag.replace(/\s+state="[^"]*"/, "");
453
+ } else if (/\sstate="/.test(tag)) {
454
+ updated = tag.replace(/state="[^"]*"/, 'state="hidden"');
455
+ } else {
456
+ updated = tag.replace(/\/>$/, ' state="hidden"/>');
457
+ }
458
+ xml = xml.replace(tag, () => updated);
459
+ });
460
+
461
+ if (targetIndex < 0) throw new Error(`Sheet "${sheetName}" not found in ${basename(xlsxPath)}`);
462
+ xml = xml.replace(/activeTab="\d+"/, `activeTab="${targetIndex}"`);
463
+ zip.file("xl/workbook.xml", xml);
464
+
465
+ if (targetRelationship) {
466
+ const path = await sheetPartPath(zip, targetRelationship);
467
+ const sheetFile = zip.file(path);
468
+ if (sheetFile) zip.file(path, blankHeaderAndFooter(await sheetFile.async("string")));
469
+ }
470
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
471
+ }
472
+
473
+ async function renderSheetScreenshot(xlsxPath, sheetName, outPath) {
474
+ const workDir = mkdtempSync(join(tmpdir(), "recon-shot-"));
475
+ try {
476
+ const isolated = await isolateSheet(xlsxPath, sheetName);
477
+ // The workbook name reaches the PDF page footer, so keep the real one.
478
+ const workbookName = basename(xlsxPath);
479
+ const sheetFile = join(workDir, workbookName);
480
+ writeFileSync(sheetFile, isolated);
481
+ execFileSync(
482
+ getLibreOffice(),
483
+ [
484
+ "--headless",
485
+ "--norestore",
486
+ "--calc",
487
+ `-env:UserInstallation=file://${join(workDir, "lo_profile")}`,
488
+ "--convert-to",
489
+ "pdf",
490
+ "--outdir",
491
+ workDir,
492
+ sheetFile,
493
+ ],
494
+ { stdio: "pipe", timeout: 180000 },
495
+ );
496
+ const pdfPath = join(workDir, workbookName.replace(/\.xlsx$/, ".pdf"));
497
+ if (!existsSync(pdfPath)) throw new Error(`LibreOffice produced no PDF for ${basename(xlsxPath)} ${sheetName}`);
498
+ execFileSync("pdftoppm", ["-png", "-r", "100", "-f", "1", "-l", "1", "-singlefile", pdfPath, join(workDir, "shot")], {
499
+ stdio: "pipe",
500
+ timeout: 120000,
501
+ });
502
+ const pngPath = join(workDir, "shot.png");
503
+ if (!existsSync(pngPath)) throw new Error(`pdftoppm produced no PNG for ${basename(xlsxPath)} ${sheetName}`);
504
+ mkdirSync(dirname(outPath), { recursive: true });
505
+ writeFileSync(outPath, readFileSync(pngPath));
506
+ } finally {
507
+ rmSync(workDir, { recursive: true, force: true });
508
+ }
509
+ }
510
+
511
+ async function buildScreenshots(product, outDir) {
512
+ const config = PRODUCTS[product];
513
+ const sourceDir = join(EXAMPLES_DIR, config.examples);
514
+ const shots = [];
515
+ if (!existsSync(sourceDir)) {
516
+ console.log(` no ${config.examples} workbooks, skipping screenshots`);
517
+ return shots;
518
+ }
519
+ for (const shot of config.screenshots) {
520
+ const xlsxPath = join(sourceDir, shot.file);
521
+ if (!existsSync(xlsxPath)) {
522
+ console.log(` no ${shot.file}, skipping ${shot.sheet}`);
523
+ continue;
524
+ }
525
+ const name = `${product}-${slug(shot.file.replace(/\.xlsx$/, ""))}-${slug(shot.sheet)}.png`;
526
+ const outPath = join(outDir, "screenshots", name);
527
+ await renderSheetScreenshot(xlsxPath, shot.sheet, outPath);
528
+ console.log(` screenshot ${shot.file} ${shot.sheet} -> ${name}`);
529
+ shots.push({ ...shot, image: `screenshots/${name}` });
530
+ }
531
+ return shots;
532
+ }
533
+
534
+ // ── HTML rendering ──────────────────────────────────────────────────────────
535
+
536
+ // Cells are plain text unless given as { html }, which is emitted as written.
537
+ function cellContent(cell) {
538
+ return cell !== null && typeof cell === "object" && "html" in cell ? cell.html : inlineMarkdown(cell);
539
+ }
540
+
541
+ function renderTable({ head, align, rows, className = "recon-table" }) {
542
+ const headCells = head.map((cell, index) => `<th${align[index] === "right" ? ' class="num"' : ""}>${cellContent(cell)}</th>`).join("");
543
+ const bodyRows = rows
544
+ .map((row) => {
545
+ const cells = row.map((cell, index) => `<td${align[index] === "right" ? ' class="num"' : ""}>${cellContent(cell)}</td>`).join("");
546
+ return `<tr>${cells}</tr>`;
547
+ })
548
+ .join("\n");
549
+ return `<div class="recon-scroll"><table class="${className}">\n<thead><tr>${headCells}</tr></thead>\n<tbody>\n${bodyRows}\n</tbody>\n</table></div>`;
550
+ }
551
+
552
+ function renderBlocks(blocks) {
553
+ return blocks.map((block) => (block.type === "table" ? renderTable(block) : `<p>${inlineMarkdown(block.text)}</p>`)).join("\n");
554
+ }
555
+
556
+ // The scenario description and trade line the report carries ahead of its first
557
+ // heading. Rendered as plain paragraphs above the table of contents.
558
+ export function renderFrontMatter(report) {
559
+ return report.frontMatter.map((line) => ` <p>${inlineMarkdown(line)}</p>`).join("\n");
560
+ }
561
+
562
+ function renderReportSections(sections, headingLevel = 4) {
563
+ return sections
564
+ .map((section) => `<h${headingLevel}>${escapeHtml(section.name)}</h${headingLevel}>\n${renderBlocks(section.blocks)}`)
565
+ .join("\n");
566
+ }
567
+
568
+ function pageShell({ title, description, canonical, body }) {
569
+ return `<!doctype html>
570
+ <html lang="en">
571
+ <head>
572
+ <meta charset="UTF-8" />
573
+ <title>${escapeHtml(title)}</title>
574
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
575
+ <meta name="description" content="${escapeHtml(description)}" />
576
+ <link rel="icon" type="image/svg+xml" href="../favicon.svg" />
577
+ <link rel="icon" type="image/x-icon" href="../favicon.ico" />
578
+ <meta property="og:title" content="${escapeHtml(title)}" />
579
+ <meta property="og:description" content="${escapeHtml(description)}" />
580
+ <meta property="og:url" content="${escapeHtml(canonical)}" />
581
+ <meta property="og:site_name" content="DIY Accounting" />
582
+ <meta property="og:type" content="website" />
583
+ <link rel="canonical" href="${escapeHtml(canonical)}" />
584
+ <link rel="stylesheet" href="../spreadsheets.css" />
585
+ <style>
586
+ .recon-scroll { overflow-x: auto; }
587
+ .recon-table { border-collapse: collapse; width: 100%; margin: 0.8em 0; font-size: 0.9em; }
588
+ .recon-table th, .recon-table td { border: 1px solid #ddd; padding: 0.35em 0.6em; text-align: left; vertical-align: top; }
589
+ .recon-table th { background: #f5f5f5; font-weight: 600; }
590
+ .recon-table th.num, .recon-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
591
+ .recon-details { margin: 0.8em 0; border: 1px solid #ddd; border-radius: 4px; padding: 0.4em 0.8em; }
592
+ .recon-details > summary { cursor: pointer; font-weight: 600; padding: 0.3em 0; }
593
+ .recon-shot { margin: 1.2em 0; }
594
+ .recon-shot img { max-width: 100%; border: 1px solid #ddd; }
595
+ .recon-shot figcaption { font-size: 0.9em; color: #555; margin-top: 0.4em; }
596
+ .recon-toc { margin: 1em 0 2em; padding: 0.8em 1em; background: #f7f7f7; border-radius: 4px; }
597
+ .recon-toc ol { margin: 0; padding-left: 1.4em; }
598
+ .recon-section { margin-top: 2.5em; }
599
+ .recon-facts { list-style: none; padding: 0; }
600
+ .recon-facts li { margin-bottom: 0.3em; }
601
+ .recon-verdict { margin: 1.2em 0; padding: 0.8em 1em; border: 1px solid #ddd; border-left-width: 5px; border-radius: 4px; background: #fafafa; }
602
+ .recon-verdict-pass { border-left-color: #2e7d32; }
603
+ .recon-verdict-fail { border-left-color: #c62828; }
604
+ .recon-verdict h4 { margin-top: 0; }
605
+ .recon-verdict-meta { font-size: 0.85em; color: #555; margin-bottom: 0; }
606
+ </style>
607
+ <script src="../lib/analytics.js"></script>
608
+ </head>
609
+ <body>
610
+ <a href="#mainContent" class="skip-link">Skip to main content</a>
611
+
612
+ <nav class="top-nav" aria-label="Main navigation">
613
+ <a href="../index.html">Products</a>
614
+ <a href="../download.html">Download</a>
615
+ <a href="../knowledge-base.html">Knowledge Base</a>
616
+ <a href="../community.html">Community</a>
617
+ <a href="https://submit.diyaccounting.co.uk">Submit VAT MTD</a>
618
+ <a href="../donate.html">Donate</a>
619
+ </nav>
620
+
621
+ <header>
622
+ <h1>DIY Accounting Spreadsheets</h1>
623
+ <p class="subtitle">Excel bookkeeping and accounting software for UK small businesses</p>
624
+ </header>
625
+
626
+ <main id="mainContent">
627
+ ${body}
628
+ </main>
629
+
630
+ <footer>
631
+ <div class="footer-content">
632
+ <div class="footer-left">
633
+ <a href="https://diyaccounting.co.uk">diyaccounting.co.uk</a>
634
+ <a href="../knowledge-base.html">knowledge base</a>
635
+ <a href="index.html">reconciliation</a>
636
+ <a href="https://submit.diyaccounting.co.uk/privacy.html">privacy</a>
637
+ <a href="https://submit.diyaccounting.co.uk/terms.html">terms</a>
638
+ <a href="https://submit.diyaccounting.co.uk/accessibility.html">accessibility</a>
639
+ </div>
640
+ <div class="footer-center">
641
+ <p>&copy; 2025-2026 DIY Accounting Limited</p>
642
+ </div>
643
+ </div>
644
+ </footer>
645
+ </body>
646
+ </html>
647
+ `;
648
+ }
649
+
650
+ // ── Judge verdict ───────────────────────────────────────────────────────────
651
+
652
+ // Written by app/bin/judge-reconciliation.js when the judge runs. Absent otherwise.
653
+ function loadVerdict(reportsDir, product) {
654
+ const path = join(reportsDir, `judge-verdict-${product}.json`);
655
+ if (!existsSync(path)) return null;
656
+ try {
657
+ return JSON.parse(readFileSync(path, "utf8"));
658
+ } catch (error) {
659
+ console.log(` judge verdict unreadable: ${error.message}`);
660
+ return null;
661
+ }
662
+ }
663
+
664
+ function renderConcern(concern) {
665
+ if (typeof concern === "string") return `<li>${escapeHtml(concern)}</li>`;
666
+ const where = concern.where ? ` (${concern.where})` : "";
667
+ const severity = concern.severity ? ` [${concern.severity}]` : "";
668
+ return `<li><strong>${escapeHtml(concern.figure ?? "")}</strong>${escapeHtml(where)}: ${escapeHtml(concern.why ?? "")}${escapeHtml(severity)}</li>`;
669
+ }
670
+
671
+ function renderVerdict(verdict) {
672
+ if (!verdict) return "";
673
+ const state = verdict.verdict === "fail" ? "fail" : "pass";
674
+ const parts = [
675
+ ` <div class="recon-verdict recon-verdict-${state}">`,
676
+ ` <h4>Plausibility review: ${escapeHtml(state)}</h4>`,
677
+ ` <p>${escapeHtml(verdict.summary ?? "")}</p>`,
678
+ ];
679
+ if (Array.isArray(verdict.concerns) && verdict.concerns.length > 0) {
680
+ parts.push(" <ul>");
681
+ parts.push(...verdict.concerns.map((concern) => ` ${renderConcern(concern)}`));
682
+ parts.push(" </ul>");
683
+ }
684
+ const stamp = [verdict.model, verdict.timestamp?.slice(0, 10)].filter(Boolean).join(", ");
685
+ parts.push(
686
+ ` <p class="recon-verdict-meta">Read back by ${escapeHtml(stamp)}. The arithmetic checks below run first and decide the status.</p>`,
687
+ );
688
+ parts.push(" </div>");
689
+ return parts.join("\n");
690
+ }
691
+
692
+ function renderSection(section, content) {
693
+ return [
694
+ ` <section class="recon-section" id="${section.id}">`,
695
+ ` <h3>${escapeHtml(section.title)}</h3>`,
696
+ content,
697
+ " </section>",
698
+ ]
699
+ .filter((part) => part !== "")
700
+ .join("\n");
701
+ }
702
+
703
+ // ── Product page ────────────────────────────────────────────────────────────
704
+
705
+ function detectFeatures(report, scenario, shots) {
706
+ const names = report.sections.map((section) => section.name);
707
+ const has = (pattern) => names.some((name) => pattern.test(name));
708
+ const shotSheets = shots.map((shot) => shot.sheet).join(" ");
709
+ return {
710
+ checks: has(/compliance checks/i),
711
+ sales: Boolean(scenario.sales),
712
+ purchases: Boolean(scenario.purchases),
713
+ bank: Boolean(scenario.bank),
714
+ payroll: Array.isArray(scenario.employees) && scenario.employees.length > 0,
715
+ openingBalances: Boolean(scenario.opening_balance || scenario.opening_debtors || scenario.opening_creditors),
716
+ fixedAssets: Array.isArray(scenario.opening_fixed_assets) && scenario.opening_fixed_assets.length > 0,
717
+ profitAndLoss: has(/profit & loss|profit and loss/i),
718
+ balanceSheet: has(/balance sheet/i),
719
+ stock: has(/^stock$/i) || Boolean(scenario.stock),
720
+ debtorsCreditors: has(/debtors/i) || Boolean(scenario.opening_debtors || scenario.opening_creditors),
721
+ vat: has(/vat/i) || /VATQtr/.test(shotSheets),
722
+ trialBalance: has(/trial balance/i),
723
+ corporationTax: has(/corporation tax/i),
724
+ incomeTax: has(/income tax|draft tax/i),
725
+ selfAssessment: has(/self assessment/i),
726
+ screenshots: shots.length > 0,
727
+ };
728
+ }
729
+
730
+ function buildProductPage(product, options) {
731
+ const config = PRODUCTS[product];
732
+ const runs = loadProductReports(options.reportsDir, product);
733
+ if (runs.length === 0) throw new Error(`No reports for ${product} in ${options.reportsDir}`);
734
+
735
+ const featured = runs.find((run) => run.scenario === config.featuredScenario) ?? runs[0];
736
+ const report = parseReport(featured.path);
737
+ const scenario = loadScenario(join(FIXTURES_DIR, `${featured.scenario}.toml`));
738
+
739
+ const checksSection = report.sections.find((section) => /compliance checks/i.test(section.name));
740
+ const appendixIndex = report.sections.findIndex((section) => /^appendix/i.test(section.name));
741
+ const bodySections = report.sections.filter((section, index) => {
742
+ if (section === checksSection) return false;
743
+ if (appendixIndex >= 0 && index >= appendixIndex) return false;
744
+ return section.level === 2;
745
+ });
746
+ const taxSections = bodySections.filter((section) => TAX_SECTION_PATTERNS.some((pattern) => pattern.test(section.name)));
747
+ const statementSections = bodySections.filter((section) => !taxSections.includes(section));
748
+ const appendixSections = appendixIndex >= 0 ? report.sections.slice(appendixIndex + 1).filter((section) => section.level === 3) : [];
749
+
750
+ const verdict = loadVerdict(options.reportsDir, product);
751
+
752
+ return { config, runs, featured, report, scenario, verdict, checksSection, statementSections, taxSections, appendixSections };
753
+ }
754
+
755
+ function renderProductPage(product, built, shots) {
756
+ const { config, runs, featured, report, scenario, verdict, checksSection, statementSections, taxSections, appendixSections } = built;
757
+ const checks = checksSection?.blocks.find((block) => block.type === "table");
758
+ const passes = checks ? checks.rows.filter((row) => /PASS/.test(row[row.length - 1])).length : 0;
759
+ const warnings = checks ? checks.rows.filter((row) => /WARNING/.test(row[row.length - 1])).length : 0;
760
+ const failures = checks ? checks.rows.filter((row) => /FAIL/.test(row[row.length - 1])).length : 0;
761
+
762
+ const summary = [
763
+ ` <ul class="recon-facts">`,
764
+ ` <li><strong>Status:</strong> ${escapeHtml(featured.status)}</li>`,
765
+ ` <li><strong>Featured scenario:</strong> ${escapeHtml(featured.scenario)} (${escapeHtml(scenario.metadata?.name ?? "")})</li>`,
766
+ ` <li><strong>Year end:</strong> ${escapeHtml(featured.yearEnd)}</li>`,
767
+ ` <li><strong>Checks:</strong> ${passes} passed, ${warnings} warnings, ${failures} failed</li>`,
768
+ ` <li><strong>Reconciliation runs published:</strong> ${runs.length}</li>`,
769
+ ` </ul>`,
770
+ renderVerdict(verdict),
771
+ ` <h4>Every run</h4>`,
772
+ renderTable({
773
+ head: ["Year end", "Period", "Scenario", "Status"],
774
+ align: ["left", "left", "left", "left"],
775
+ rows: runs.map((run) => [run.yearEnd, run.label, run.scenario, run.status]),
776
+ }),
777
+ ]
778
+ .filter(Boolean)
779
+ .join("\n");
780
+
781
+ const checksHtml = checksSection ? renderBlocks(checksSection.blocks) : "";
782
+
783
+ const screenshotsHtml = shots
784
+ .map(
785
+ (shot) =>
786
+ ` <figure class="recon-shot">\n <img src="${escapeHtml(shot.image)}" alt="${escapeHtml(shot.caption)} sheet of the populated workbook" loading="lazy" />\n <figcaption>${escapeHtml(shot.caption)} — ${escapeHtml(shot.file)}, sheet ${escapeHtml(shot.sheet)}</figcaption>\n </figure>`,
787
+ )
788
+ .join("\n");
789
+
790
+ const statementsHtml = [
791
+ renderReportSections(statementSections),
792
+ appendixSections.length
793
+ ? [
794
+ '<details class="recon-details">',
795
+ "<summary>Cell values behind these statements</summary>",
796
+ renderReportSections(appendixSections, 5),
797
+ "</details>",
798
+ ].join("\n")
799
+ : "",
800
+ ]
801
+ .filter(Boolean)
802
+ .join("\n");
803
+
804
+ const sectionContent = {
805
+ "summary": summary,
806
+ "reconciliation-checks": checksHtml,
807
+ "input-transactions": renderInputTransactions(scenario),
808
+ "screenshots": screenshotsHtml,
809
+ "accounting-statements": statementsHtml,
810
+ "tax-review": renderReportSections(taxSections),
811
+ };
812
+
813
+ const toc = [
814
+ ' <nav class="recon-toc" aria-label="On this page">',
815
+ " <ol>",
816
+ ...PAGE_SECTIONS.map((section) => ` <li><a href="#${section.id}">${escapeHtml(section.title)}</a></li>`),
817
+ " </ol>",
818
+ " </nav>",
819
+ ].join("\n");
820
+
821
+ const frontMatterHtml = renderFrontMatter(report);
822
+
823
+ const body = [
824
+ ' <nav class="nav-back" aria-label="Breadcrumb"><a href="index.html">&larr; Reconciliation reports</a></nav>',
825
+ ` <h2 class="kb-page-title">${escapeHtml(config.name)} reconciliation</h2>`,
826
+ ` <p class="kb-page-description">${escapeHtml(report.title)}. Every figure below comes from a scenario driven through the shipped workbooks and read back out of the recalculated sheets.</p>`,
827
+ frontMatterHtml,
828
+ toc,
829
+ ...PAGE_SECTIONS.map((section) => renderSection(section, sectionContent[section.id])),
830
+ ]
831
+ .filter(Boolean)
832
+ .join("\n");
833
+
834
+ return pageShell({
835
+ title: `${config.name} reconciliation - DIY Accounting Spreadsheets`,
836
+ description: `Reconciliation of the DIY Accounting ${config.name} spreadsheets: input transactions, sheet screenshots, accounting statements and the tax computation.`,
837
+ canonical: `https://spreadsheets.diyaccounting.co.uk/reconciliation/${product}.html`,
838
+ body,
839
+ });
840
+ }
841
+
842
+ // ── Index page ──────────────────────────────────────────────────────────────
843
+
844
+ function renderIndexPage(metadata) {
845
+ const products = PRODUCT_ORDER.filter((product) => metadata[product]);
846
+ const matrix = renderTable({
847
+ head: ["Feature", ...products.map((product) => metadata[product].name)],
848
+ align: ["left", ...products.map(() => "left")],
849
+ rows: FEATURES.map((feature) => [feature.label, ...products.map((product) => (metadata[product].features[feature.key] ? "yes" : "—"))]),
850
+ });
851
+
852
+ const runsTable = renderTable({
853
+ head: ["Product", "Featured scenario", "Year end", "Status", "Runs", "Updated"],
854
+ align: ["left", "left", "left", "left", "right", "left"],
855
+ rows: products.map((product) => {
856
+ const meta = metadata[product];
857
+ return [
858
+ { html: `<a href="${escapeHtml(meta.page)}">${escapeHtml(meta.name)}</a>` },
859
+ meta.featuredScenario,
860
+ meta.yearEnd,
861
+ meta.status,
862
+ String(meta.runs),
863
+ meta.updated,
864
+ ];
865
+ }),
866
+ });
867
+
868
+ const body = [
869
+ ' <nav class="nav-back" aria-label="Breadcrumb"><a href="../index.html">&larr; Products</a></nav>',
870
+ ' <h2 class="kb-page-title">Reconciliation reports</h2>',
871
+ ' <p class="kb-page-description">Each product is driven with a full year of transactions, recalculated in the shipped workbooks, and read back sheet by sheet. These pages publish what went in, what the sheets produced, and how the two tie up.</p>',
872
+ ' <p><a href="releases.html">Reconciled releases</a> lists every diya-gl release next to the scorecard it shipped with.</p>',
873
+ renderSection({ id: "products", title: "Products" }, runsTable),
874
+ renderSection(
875
+ { id: "matrix", title: "What each product reconciles" },
876
+ ` <p>The same features are checked from product to product. The larger packages fill more of them.</p>\n${matrix}`,
877
+ ),
878
+ ].join("\n");
879
+
880
+ return pageShell({
881
+ title: "Reconciliation reports - DIY Accounting Spreadsheets",
882
+ description:
883
+ "Published reconciliation of the DIY Accounting spreadsheets: input transactions, sheet screenshots, accounting statements and tax computations for every product.",
884
+ canonical: "https://spreadsheets.diyaccounting.co.uk/reconciliation/index.html",
885
+ body,
886
+ });
887
+ }
888
+
889
+ function buildIndex(outDir) {
890
+ const metadata = {};
891
+ for (const product of PRODUCT_ORDER) {
892
+ const path = join(outDir, `${product}.json`);
893
+ if (existsSync(path)) metadata[product] = JSON.parse(readFileSync(path, "utf8"));
894
+ }
895
+ const products = Object.keys(metadata);
896
+ if (products.length === 0) {
897
+ console.log("No product metadata, index not written");
898
+ return;
899
+ }
900
+ writeFileSync(join(outDir, "index.html"), renderIndexPage(metadata), "utf8");
901
+ console.log(`Index: ${products.length} products (${products.join(", ")})`);
902
+ }
903
+
904
+ // ── Reconciled releases page ────────────────────────────────────────────────
905
+
906
+ // app/data/releases.json carries one entry per diya-gl-v* tag, in the shape
907
+ // LP-3's publish workflow appends with --record: a tag, the date it was cut,
908
+ // and PROVENANCE_DATA's own fields as they stood at that tag. No tags exist
909
+ // yet, so this file starts absent -- loadReleases() returns an empty list
910
+ // rather than treating that as an error.
911
+ export function loadReleases(path) {
912
+ if (!existsSync(path)) return { releases: [] };
913
+ return JSON.parse(readFileSync(path, "utf8"));
914
+ }
915
+
916
+ // Appends one release under `tag`, carrying whatever PROVENANCE_DATA holds
917
+ // at the moment this runs. Called by --record, once a generate workflow has
918
+ // a passing reconciliation run to tag.
919
+ export function recordRelease(path, tag, provenanceData, date = new Date().toISOString().slice(0, 10)) {
920
+ const data = loadReleases(path);
921
+ data.releases.push({ tag, date, ...provenanceData });
922
+ mkdirSync(dirname(path), { recursive: true });
923
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
924
+ return data;
925
+ }
926
+
927
+ // The rows the releases page renders: every recorded release, or -- before
928
+ // any tag exists -- the current working state under one "unreleased" row,
929
+ // so the page is never empty and never invents a past release.
930
+ function releaseRows() {
931
+ const { releases } = loadReleases(RELEASES_FILE);
932
+ if (releases.length > 0) return releases;
933
+ return [{ tag: "unreleased", date: null, ...PROVENANCE_DATA }];
934
+ }
935
+
936
+ function renderReleasesTable(releases) {
937
+ const head = [
938
+ "Release",
939
+ "Date",
940
+ "Engine version",
941
+ "Tax data hash",
942
+ "Reconciled commit",
943
+ ...PRODUCT_ORDER.flatMap((product) => [`${PRODUCTS[product].name} template`, `${PRODUCTS[product].name} scorecard`]),
944
+ ];
945
+ const align = head.map(() => "left");
946
+ const rows = releases.map((release) => {
947
+ const cells = [
948
+ release.tag,
949
+ release.date ?? "unreleased",
950
+ release.engineVersion ?? "—",
951
+ release.taxDataHash ?? "—",
952
+ release.reconciledCommit || "—",
953
+ ];
954
+ for (const product of PRODUCT_ORDER) {
955
+ const template = release.templates?.[product];
956
+ cells.push(template?.hash ?? "—");
957
+ cells.push(template ? { html: `<a href="${product}.html">${escapeHtml(template.scorecard)}</a>` } : "—");
958
+ }
959
+ return cells;
960
+ });
961
+ return renderTable({ head, align, rows });
962
+ }
963
+
964
+ function renderReleasesPage(releases) {
965
+ const body = [
966
+ ' <nav class="nav-back" aria-label="Breadcrumb"><a href="index.html">&larr; Reconciliation reports</a></nav>',
967
+ ' <h2 class="kb-page-title">Reconciled releases</h2>',
968
+ ' <p class="kb-page-description">Every diya-gl release carries five stamps: the format it is written to, the engine build that produced it, the tax data it applied, each product\'s template, and the commit its scorecard was proved against. <a href="../diya-gl.html#versioning">The diya-gl format</a> explains what each one means.</p>',
969
+ renderSection({ id: "releases", title: "Releases" }, renderReleasesTable(releases)),
970
+ ].join("\n");
971
+
972
+ return pageShell({
973
+ title: "Reconciled releases - DIY Accounting Spreadsheets",
974
+ description:
975
+ "Every diya-gl release and the five provenance stamps it carries: format version, engine build, tax data, template hash and reconciled commit.",
976
+ canonical: "https://spreadsheets.diyaccounting.co.uk/reconciliation/releases.html",
977
+ body,
978
+ });
979
+ }
980
+
981
+ function buildReleasesPage(outDir) {
982
+ const releases = releaseRows();
983
+ writeFileSync(join(outDir, "releases.html"), renderReleasesPage(releases), "utf8");
984
+ writeFileSync(join(outDir, "releases.json"), `${JSON.stringify({ releases: releases.length }, null, 2)}\n`, "utf8");
985
+ console.log(`Releases: ${releases.length} release(s) (${releases.map((r) => r.tag).join(", ")})`);
986
+ }
987
+
988
+ // ── Entry point ─────────────────────────────────────────────────────────────
989
+
990
+ function parseArgs(argv) {
991
+ const args = { product: "all", reportsDir: REPORTS_DIR, outDir: OUT_DIR, screenshots: true, indexOnly: false, record: null };
992
+ for (let i = 0; i < argv.length; i++) {
993
+ const arg = argv[i];
994
+ if (arg === "--product") args.product = argv[++i];
995
+ else if (arg === "--reports") args.reportsDir = resolve(argv[++i]);
996
+ else if (arg === "--out") args.outDir = resolve(argv[++i]);
997
+ else if (arg === "--no-screenshots") args.screenshots = false;
998
+ else if (arg === "--index-only") args.indexOnly = true;
999
+ else if (arg === "--record") args.record = argv[++i];
1000
+ else throw new Error(`Unknown argument: ${arg}`);
1001
+ }
1002
+ if (args.product !== "all" && !PRODUCTS[args.product]) {
1003
+ throw new Error(`Unknown product: ${args.product}. Expected one of ${PRODUCT_ORDER.join(", ")} or all.`);
1004
+ }
1005
+ return args;
1006
+ }
1007
+
1008
+ async function main() {
1009
+ const args = parseArgs(process.argv.slice(2));
1010
+
1011
+ if (args.record) {
1012
+ recordRelease(RELEASES_FILE, args.record, PROVENANCE_DATA);
1013
+ console.log(`Recorded release ${args.record} in ${RELEASES_FILE.replace(ROOT + "/", "")}`);
1014
+ return;
1015
+ }
1016
+
1017
+ mkdirSync(args.outDir, { recursive: true });
1018
+
1019
+ if (!args.indexOnly) {
1020
+ const products = args.product === "all" ? PRODUCT_ORDER : [args.product];
1021
+ for (const product of products) {
1022
+ console.log(`${PRODUCTS[product].name}:`);
1023
+ const built = buildProductPage(product, args);
1024
+ const shots = args.screenshots ? await buildScreenshots(product, args.outDir) : [];
1025
+ writeFileSync(join(args.outDir, `${product}.html`), renderProductPage(product, built, shots), "utf8");
1026
+ const metadata = {
1027
+ product,
1028
+ name: built.config.name,
1029
+ page: `${product}.html`,
1030
+ catalogue: built.config.catalogue,
1031
+ featuredScenario: built.featured.scenario,
1032
+ yearEnd: built.featured.yearEnd,
1033
+ status: built.featured.status,
1034
+ runs: built.runs.length,
1035
+ updated: new Date().toISOString().slice(0, 10),
1036
+ features: detectFeatures(built.report, built.scenario, shots),
1037
+ };
1038
+ writeFileSync(join(args.outDir, `${product}.json`), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
1039
+ console.log(` page ${product}.html (${built.runs.length} runs, ${shots.length} screenshots)`);
1040
+ }
1041
+ }
1042
+
1043
+ buildReleasesPage(args.outDir);
1044
+ buildIndex(args.outDir);
1045
+ }
1046
+
1047
+ if (import.meta.url === `file://${process.argv[1]}`) {
1048
+ main().catch((error) => {
1049
+ console.error(error.message);
1050
+ process.exit(1);
1051
+ });
1052
+ }