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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,11 +37,12 @@ docker run --rm -v "$PWD":/data ghcr.io/diy-accounting-uk/diya-gl:latest \
37
37
  diya-gl recalc --package bst --data my-book --years se-2025-2026 --output-dir out
38
38
  diya-gl read-workbook --file my-workbook.xlsx --output-dir out
39
39
  diya-gl write-workbook --data my-book --output-dir out
40
+ diya-gl link my-book
40
41
  diya-gl mcp
41
42
  ```
42
43
 
43
44
  Each subcommand is also its own command, if you only want one on your `PATH`:
44
- `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-mcp`.
45
+ `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-link`, `diya-gl-mcp`.
45
46
 
46
47
  - **recalc** takes a diya-gl book (`--data`) or a populated workbook (`--source-dir`,
47
48
  `--mode saved|recalculate`) and writes `report.json`: every figure, report section and
@@ -51,6 +52,11 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
51
52
  - **write-workbook** takes a diya-gl book (`--data` or `--file`) and writes the Excel
52
53
  package its product composes onto its template — the same client-side path the DIYA-GL
53
54
  pages use, so it never needs LibreOffice. Add `--zip` for the package as one zip.
55
+ - **link** takes a book (a directory with `book.toml` and `lines.jsonl`, a diya-gl zip or a
56
+ diya-gl JSON file) and prints the `https://diya-gl.co.uk/<product>.html#book=<data>` link that
57
+ opens it, the whole book carried in the fragment so nothing is uploaded. `--base <url>`
58
+ replaces the origin. The fragment length goes to stderr, with a warning past 64 KB (about
59
+ 2,000 lines), where chat, email and QR codes start to cut links off.
54
60
  - **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with four tools:
55
61
  `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
56
62
  `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // diya-gl-link.js — print the diya-gl.co.uk link that opens a whole book,
6
+ // the book carried in the URL fragment.
7
+
8
+ import { runDistBin } from "./run-dist-bin.js";
9
+ runDistBin("app/bin/link.js", process.argv.slice(2));
package/bin/diya-gl.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Copyright (C) 2006-2026 DIY Accounting Limited
4
4
  //
5
- // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|mcp> [args].
5
+ // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|link|mcp> [args].
6
6
  // Each subcommand is also its own bin (diya-gl-recalc, and so on), for a
7
7
  // caller that wants one command on its PATH without the others.
8
8
 
@@ -16,6 +16,7 @@ const SUBCOMMANDS = {
16
16
  "recalc": "app/bin/report.js",
17
17
  "read-workbook": "app/bin/export.js",
18
18
  "write-workbook": "app/bin/write-workbook.js",
19
+ "link": "app/bin/link.js",
19
20
  "mcp": "app/bin/diya-gl-mcp.js",
20
21
  };
21
22
 
@@ -132,10 +132,16 @@ function parseArgs(argv) {
132
132
  // buildReportDocument -- the same loop report.js's --data mode runs, so the
133
133
  // CLI's --file mode, report.js and the MCP server's tools never diverge on
134
134
  // how R is built from the same D. Pure: no disk access, no console output.
135
- export function buildFileReportDocument(book, lines, packageName, productMod) {
135
+ //
136
+ // attribute asks for each attributed figure's entryNumbers. A year's book
137
+ // lists every line under each total it reaches, which multiplies R's size
138
+ // many times over, so the written report.json leaves them out and a caller
139
+ // that drills from a figure to its lines asks for them.
140
+ export function buildFileReportDocument(book, lines, packageName, productMod, { attribute = false } = {}) {
136
141
  const taxData = extractTaxDataFromBook(book, packageName);
137
142
  const scenario = diyaGlToScenario(book, lines, packageName);
138
- const results = calculateFromDiyaGl(book, lines, packageName, taxData, scenario);
143
+ const attribution = attribute ? {} : undefined;
144
+ const results = calculateFromDiyaGl(book, lines, packageName, taxData, scenario, { attribution });
139
145
  const mergedScenario = { ...scenario, ...scenario.expected };
140
146
  const periodEnd = book.documentInfo?.periodCoveredEnd;
141
147
  const yearEnd = periodEnd ? new Date(periodEnd).toISOString().slice(0, 10) : null;
@@ -147,6 +153,7 @@ export function buildFileReportDocument(book, lines, packageName, productMod) {
147
153
  packageName,
148
154
  engine: "js",
149
155
  results,
156
+ attribution,
150
157
  productMod,
151
158
  scenario: mergedScenario,
152
159
  taxData,
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // link.js — print the diya-gl.co.uk link that opens a whole book, the book
6
+ // carried in the URL fragment.
7
+ //
8
+ // Usage:
9
+ // node app/bin/link.js examples/basic-taxi-driver
10
+ // node app/bin/link.js my-book-diya-gl.zip
11
+ // node app/bin/link.js my-book.json --base http://localhost:8080
12
+ //
13
+ // The book is a directory holding book.toml and lines.jsonl, or any diya-gl
14
+ // zip or JSON file diya-gl-interchange.js reads. The link goes to stdout;
15
+ // its length and any warning go to stderr.
16
+
17
+ import { existsSync, readFileSync, statSync } from "fs";
18
+ import { join, resolve } from "path";
19
+ import { fileURLToPath } from "url";
20
+ import { parseDiyaGlData } from "../lib/diya-gl-loader.js";
21
+ import { readBookSource } from "../lib/diya-gl-interchange.js";
22
+ import { canonicalBookToml, canonicalLinesJsonl } from "../lib/diya-gl-canonical.js";
23
+ import { productIdOf } from "../lib/xlsx-exporter.js";
24
+ import { PRODUCTS } from "../lib/products.js";
25
+ import { encodeBookFragment, FRAGMENT_KEY } from "../lib/diya-gl-link.js";
26
+
27
+ const DEFAULT_BASE = "https://diya-gl.co.uk";
28
+ const USAGE = "Usage: node app/bin/link.js <book directory | diya-gl zip | diya-gl JSON file> [--base <url>]";
29
+
30
+ function parseArgs(argv) {
31
+ const args = argv.slice(2);
32
+ let base = DEFAULT_BASE;
33
+ const positional = [];
34
+ for (let i = 0; i < args.length; i++) {
35
+ if (args[i] === "--base") {
36
+ base = args[++i];
37
+ if (!base) throw new Error(`--base needs a URL. ${USAGE}`);
38
+ } else {
39
+ positional.push(args[i]);
40
+ }
41
+ }
42
+ if (positional.length !== 1) throw new Error(USAGE);
43
+ return { source: positional[0], base: base.replace(/\/+$/, "") };
44
+ }
45
+
46
+ async function readBookText(source) {
47
+ const path = resolve(source);
48
+ if (!existsSync(path)) throw new Error(`${source} does not exist.`);
49
+ if (statSync(path).isDirectory()) {
50
+ const toml = readFileSync(join(path, "book.toml"), "utf8");
51
+ const lines = readFileSync(join(path, "lines.jsonl"), "utf8");
52
+ return { toml, lines, book: parseDiyaGlData(toml, lines).book };
53
+ }
54
+ const { book, lines } = await readBookSource(readFileSync(path), source, { products: PRODUCTS });
55
+ return { toml: canonicalBookToml(book), lines: canonicalLinesJsonl(lines), book };
56
+ }
57
+
58
+ export async function bookLink(source, base = DEFAULT_BASE) {
59
+ const { toml, lines, book } = await readBookText(source);
60
+ const declared = book.entityInformation && book.entityInformation["diya-gl:product"];
61
+ const product = productIdOf(declared);
62
+ if (!product)
63
+ throw new Error(`The book declares no product with a page: entityInformation."diya-gl:product" is ${JSON.stringify(declared)}.`);
64
+ const { fragment, length, warning } = encodeBookFragment({ toml, lines });
65
+ return { url: `${base}/${product}.html#${FRAGMENT_KEY}=${fragment}`, length, warning };
66
+ }
67
+
68
+ async function main() {
69
+ const { source, base } = parseArgs(process.argv);
70
+ const { url, length, warning } = await bookLink(source, base);
71
+ console.log(url);
72
+ console.error(`Fragment length: ${length} characters`);
73
+ if (warning) console.error(`Warning: ${warning}`);
74
+ }
75
+
76
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
77
+ main().catch((error) => {
78
+ console.error(error.message);
79
+ process.exit(1);
80
+ });
81
+ }
@@ -13,6 +13,8 @@
13
13
  // --mode saved (default) Read xlsx cell values as-is from XML. No LibreOffice needed.
14
14
  // --mode recalculate Run xls roundtrip first, then read. Requires LibreOffice.
15
15
  // --data <dir> Compute reports from diya-gl data via JS engine. No Excel needed.
16
+ // --entry-numbers With --data, give each attributed figure in report.json the
17
+ // entryNumbers of the ledger lines behind it.
16
18
  //
17
19
  // Either mode writes report.json beside the markdown: R, the canonical
18
20
  // document verify-roundtrip.js scores. --data also writes its own input to
@@ -65,6 +67,7 @@ function parseArgs(argv) {
65
67
  const offset = getArg("--offset");
66
68
  const years = getArg("--years");
67
69
  const yearEndArg = getArg("--year-end");
70
+ const entryNumbers = args.includes("--entry-numbers");
68
71
 
69
72
  if (!packageName) {
70
73
  console.error("Error: --package is required (bst, taxi, se, ltd)");
@@ -79,7 +82,7 @@ function parseArgs(argv) {
79
82
  process.exit(1);
80
83
  }
81
84
 
82
- return { packageName, sourceDir, outputDir, mode, dataDir, offset, years, yearEndArg };
85
+ return { packageName, sourceDir, outputDir, mode, dataDir, offset, years, yearEndArg, entryNumbers };
83
86
  }
84
87
 
85
88
  // R, the canonical report document, beside the markdown. Both engines write
@@ -92,7 +95,7 @@ function writeReportJson(outputDir, options) {
92
95
  }
93
96
 
94
97
  async function main() {
95
- const { packageName, sourceDir, outputDir, mode, dataDir, offset, years, yearEndArg } = parseArgs(process.argv);
98
+ const { packageName, sourceDir, outputDir, mode, dataDir, offset, years, yearEndArg, entryNumbers } = parseArgs(process.argv);
96
99
 
97
100
  const productMod = PRODUCTS[packageName];
98
101
  if (!productMod) {
@@ -129,7 +132,8 @@ async function main() {
129
132
  // on the Excel side: opening balances, stock, debtors, creditors and
130
133
  // business details. Passing it through lights those up on the JS side too.
131
134
  const scenario = diyaGlToScenario(book, lines, packageName);
132
- const results = calculateFromDiyaGl(book, lines, packageName, taxData, scenario);
135
+ const attribution = entryNumbers ? {} : undefined;
136
+ const results = calculateFromDiyaGl(book, lines, packageName, taxData, scenario, { attribution });
133
137
 
134
138
  // Fixture anchors (opening_debtors, closing_creditors, ...) are top-level
135
139
  // scenario tables, not [expected] keys, so checks that anchor against
@@ -156,6 +160,7 @@ async function main() {
156
160
  packageName,
157
161
  engine: "js",
158
162
  results,
163
+ attribution,
159
164
  productMod,
160
165
  scenario: mergedScenario,
161
166
  taxData,
@@ -1273,6 +1273,58 @@
1273
1273
  "scorecard": "101605 passed, 0 warnings, 0 failed"
1274
1274
  }
1275
1275
  }
1276
+ },
1277
+ {
1278
+ "tag": "diya-gl-v1.2.40",
1279
+ "date": "2026-09-30",
1280
+ "formatVersion": "diya-gl/1",
1281
+ "engineVersion": "1.2.40+57de8f55b",
1282
+ "taxDataHash": "89424bd36c0d",
1283
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1284
+ "templates": {
1285
+ "bst": {
1286
+ "hash": "0d003e10e00c",
1287
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1288
+ },
1289
+ "taxi": {
1290
+ "hash": "1ff69654d64d",
1291
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1292
+ },
1293
+ "se": {
1294
+ "hash": "064605ae76ed",
1295
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1296
+ },
1297
+ "ltd": {
1298
+ "hash": "5bb7be9b6d97",
1299
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1300
+ }
1301
+ }
1302
+ },
1303
+ {
1304
+ "tag": "diya-gl-v1.2.41",
1305
+ "date": "2026-10-01",
1306
+ "formatVersion": "diya-gl/1",
1307
+ "engineVersion": "1.2.40+57de8f55b",
1308
+ "taxDataHash": "89424bd36c0d",
1309
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1310
+ "templates": {
1311
+ "bst": {
1312
+ "hash": "0d003e10e00c",
1313
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1314
+ },
1315
+ "taxi": {
1316
+ "hash": "1ff69654d64d",
1317
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1318
+ },
1319
+ "se": {
1320
+ "hash": "064605ae76ed",
1321
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1322
+ },
1323
+ "ltd": {
1324
+ "hash": "5bb7be9b6d97",
1325
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1326
+ }
1327
+ }
1276
1328
  }
1277
1329
  ]
1278
1330
  }
@@ -212,4 +212,10 @@ export const TAXI_BOOK_HELPERS = {
212
212
  },
213
213
  };
214
214
 
215
- export const TAXI_PRODUCT_RULES = { checks: [], warnings: TAXI_WARNINGS, sharedOffenders: {}, bookHelpers: TAXI_BOOK_HELPERS };
215
+ export const TAXI_PRODUCT_RULES = {
216
+ checks: [],
217
+ warnings: TAXI_WARNINGS,
218
+ sharedOffenders: {},
219
+ omittedSharedWarnings: ["book-missing-document-reference"],
220
+ bookHelpers: TAXI_BOOK_HELPERS,
221
+ };
@@ -407,6 +407,26 @@ function emptyDetailWarning(ctx) {
407
407
  };
408
408
  }
409
409
 
410
+ function missingDocumentReferenceWarning(ctx) {
411
+ const offenders = ctx.lines
412
+ .filter(function (line) {
413
+ return line.sourceJournalID === "sales" && (!line.documentReference || !String(line.documentReference).trim());
414
+ })
415
+ .sort(byEntryNumber);
416
+ const warn = offenders.length > 0;
417
+ return {
418
+ id: "book-missing-document-reference",
419
+ tier: "warning",
420
+ label: "Every sale names its invoice or receipt",
421
+ result: warn ? "warn" : "pass",
422
+ actual: offenders.length,
423
+ consequence: warn
424
+ ? "A sale with no invoice or receipt reference cannot be matched to its document when the books are reviewed later."
425
+ : null,
426
+ offenders: offenders.map(offenderOf),
427
+ };
428
+ }
429
+
410
430
  function negativeAmountWarning(ctx) {
411
431
  const offenders = ctx.lines
412
432
  .filter(function (line) {
@@ -469,14 +489,20 @@ function runWarnings(ctx, taxData, results) {
469
489
  vatWarning(ctx, taxData),
470
490
  duplicateEntriesWarning(ctx),
471
491
  emptyDetailWarning(ctx),
492
+ missingDocumentReferenceWarning(ctx),
472
493
  negativeAmountWarning(ctx),
473
494
  emptyMonthWarning(ctx),
474
- ].map(function (warning) {
475
- // A product whose sheets measure a shared warning differently replaces
476
- // the whole result, keeping its id; every other product keeps this one.
477
- const readDifferently = (ctx.productRules.sharedWarnings || {})[warning.id];
478
- return readDifferently ? readDifferently(ctx, taxData, warning) : warning;
479
- });
495
+ ]
496
+ .filter(function (warning) {
497
+ // A product whose sales carry no document leaves that shared warning out.
498
+ return !(ctx.productRules.omittedSharedWarnings || []).includes(warning.id);
499
+ })
500
+ .map(function (warning) {
501
+ // A product whose sheets measure a shared warning differently replaces
502
+ // the whole result, keeping its id; every other product keeps this one.
503
+ const readDifferently = (ctx.productRules.sharedWarnings || {})[warning.id];
504
+ return readDifferently ? readDifferently(ctx, taxData, warning) : warning;
505
+ });
480
506
  return shared.concat(
481
507
  ctx.productRules.warnings.map(function (warning) {
482
508
  return warning(ctx, taxData, results);
@@ -13,6 +13,7 @@ import { calculateIncomeTax } from "../tax/income-tax.js";
13
13
  import { calculateNIClass4 } from "../tax/national-insurance.js";
14
14
  import { calculateMileageAllowance, scenarioBusinessMiles } from "../tax/mileage.js";
15
15
  import { aggregateByCode } from "./shared.js";
16
+ import { attributionWriter, entriesOf } from "../entry-attribution.js";
16
17
 
17
18
  const BST_MONTH_COLS = {
18
19
  apr: "D",
@@ -78,7 +79,15 @@ function outstandingTotal(transactions) {
78
79
  return (transactions || []).reduce((sum, tx) => (tx.payment || tx.mileage ? sum : sum + (tx.amount || 0)), 0);
79
80
  }
80
81
 
81
- export function calculateBstResults(book, lines, taxData, scenario) {
82
+ /**
83
+ * @param {Object} book - parsed book.toml
84
+ * @param {Array} lines - parsed lines.jsonl entries
85
+ * @param {Object} taxData
86
+ * @param {Object} scenario
87
+ * @param {Object} [attribution] - filled in place with the entryNumbers behind each cell (entry-attribution.js)
88
+ * @returns {Object} { "SheetName": { "CellRef": value } }
89
+ */
90
+ export function calculateBstResults(book, lines, taxData, scenario, attribution) {
82
91
  // A book's own declared chart can number its purchase accounts under a
83
92
  // scheme other than the Basic Sole Trader master's own (see
84
93
  // resolveBstPurchaseCodeMap in diya-gl-loader.js); this must resolve to
@@ -105,7 +114,8 @@ export function calculateBstResults(book, lines, taxData, scenario) {
105
114
  // nothing, and cellWrites gives the sheet its miles rather than its amount
106
115
  // so the sheet can price the claim at its own Admin rates.
107
116
  const cashPurchaseLines = purchaseLines.filter((l) => !carriesBusinessMiles(l));
108
- const byCode = aggregateByCode(cashPurchaseLines, purchaseCodeMap);
117
+ const purchaseEntries = {};
118
+ const byCode = aggregateByCode(cashPurchaseLines, purchaseCodeMap, purchaseEntries);
109
119
 
110
120
  // The mileage claim the sheet makes of those miles. It reaches Motor
111
121
  // Expenses (verified against the template: PurchasesApr!G4 bands the running
@@ -377,5 +387,107 @@ export function calculateBstResults(book, lines, taxData, scenario) {
377
387
  results.PurchasesMar.C1 = businessMiles;
378
388
  results.PurchasesMar.A1 = Math.round(mileageAllowance * 100) / 100;
379
389
 
390
+ if (attribution) attributeBstResults(attribution, { results, scenario, salesLines, purchaseEntries, assetAdditions });
380
391
  return results;
381
392
  }
393
+
394
+ // The scenario transactions a mileage figure is read from: every one that
395
+ // records business miles, the same set scenarioBusinessMiles() sums.
396
+ function mileageTransactions(scenario) {
397
+ const transactions = [];
398
+ for (const table of [scenario.sales, scenario.purchases]) {
399
+ for (const monthTransactions of Object.values(table || {})) {
400
+ for (const tx of monthTransactions) if (typeof tx.mileage === "number") transactions.push(tx);
401
+ }
402
+ }
403
+ return transactions;
404
+ }
405
+
406
+ // The entries behind each cell calculateBstResults() writes, cell for cell
407
+ // beside the arithmetic above: a line-fed cell takes its own lines, and a
408
+ // derived cell the union of the cells it is computed from.
409
+ function attributeBstResults(attribution, { results, scenario, salesLines, purchaseEntries, assetAdditions }) {
410
+ const a = attributionWriter(attribution);
411
+ const PL = "Profit & Loss Acc";
412
+ const code = (letter) => purchaseEntries[letter];
413
+ const mileage = entriesOf(mileageTransactions(scenario));
414
+ const assets = entriesOf(assetAdditions);
415
+
416
+ for (const sheet of ["Admin", "Business Details", "PurchasesStock"]) {
417
+ for (const cell of Object.keys(results[sheet])) a.set(sheet, cell);
418
+ }
419
+
420
+ a.set(PL, "C4", salesLines);
421
+ for (const month of MONTH_ORDER) {
422
+ a.set(
423
+ PL,
424
+ `${BST_MONTH_COLS[month]}4`,
425
+ salesLines.filter((line) => getMonthKey(line.postingDate) === month),
426
+ );
427
+ }
428
+ a.set(PL, "C6", code("s"));
429
+ a.set(PL, "C7", code("d"));
430
+ a.set(PL, "C9", a.cells(PL, "C4", "C6", "C7"));
431
+ const expenseCells = { C11: "e", C12: "p", C13: "r", C14: "g", C16: "t", C17: "a", C18: "l", C19: "b", C20: "i", C21: "o" };
432
+ for (const [cell, letter] of Object.entries(expenseCells)) a.set(PL, cell, code(letter));
433
+ a.set(PL, "C15", code("m"), mileage);
434
+ a.set(PL, "C22", a.cells(PL, "C11", "C12", "C13", "C14", "C15", "C16", "C17", "C18", "C19", "C20", "C21"));
435
+ a.set(PL, "C24", a.cells(PL, "C9", "C22"));
436
+
437
+ for (const cell of Object.keys(results["Fixed Assets"])) a.set("Fixed Assets", cell, assets);
438
+ a.set("PurchasesMar", "X1", assets);
439
+ a.set("PurchasesMar", "C1", mileage);
440
+ a.set("PurchasesMar", "A1", mileage);
441
+
442
+ const SE = "SE Short";
443
+ for (const cell of ["D80", "O80", "D85", "O85"]) a.set(SE, cell, assets);
444
+ a.set(PL, "C26", assets);
445
+ a.set(PL, "C28", a.cells(PL, "C24", "C26"));
446
+ a.set(PL, "C30");
447
+
448
+ a.set(SE, "D38", a.get(PL, "C4"));
449
+ if (results[SE].D46 !== undefined) {
450
+ a.set(SE, "D46", a.cells(PL, "C6", "C7"));
451
+ a.set(SE, "D51", a.cells(PL, "C15", "C16"));
452
+ a.set(SE, "D55", a.get(PL, "C11"));
453
+ a.set(SE, "D60", a.get(PL, "C12"));
454
+ a.set(SE, "D64", a.get(PL, "C13"));
455
+ }
456
+ a.set(SE, "D71", a.cells(PL, "C4", "C6", "C7", "C22"));
457
+ a.set(SE, "O71", a.get(SE, "D71"));
458
+ a.set(SE, "D94");
459
+ a.set(SE, "O94");
460
+ a.set(SE, "D99", a.cells(SE, "D71", "O71", "D80", "D85", "O80", "O85"));
461
+ a.set(SE, "O99", a.get(PL, "C30"));
462
+ a.set(SE, "D106", a.cells(SE, "D99", "O99"));
463
+
464
+ const IT = "Income Tax";
465
+ const profit = a.get(SE, "D106");
466
+ for (const cell of ["E5", "E6", "E7", "E8", "E9", "E10", "E11", "E15", "E16"]) a.set(IT, cell, profit);
467
+ for (const cell of ["D8", "C9", "D9", "C10", "D10"]) a.set(IT, cell);
468
+ a.set(
469
+ IT,
470
+ "E12",
471
+ salesLines.filter((line) => line[CIS_DEDUCTION_FIELD]),
472
+ );
473
+ a.set(IT, "E18", a.cells(IT, "E11", "E12", "E15", "E16"));
474
+ a.set(PL, "C32", a.cells(IT, "E11", "E12"));
475
+ a.set(PL, "C33", a.cells(IT, "E15", "E16"));
476
+ a.set(PL, "C35", a.cells(PL, "C28", "C30", "C32", "C33"));
477
+
478
+ // Debtors & Creditors: each month row is the month's transactions with
479
+ // nothing recorded in the payment column, the same rows outstandingTotal()
480
+ // sums; the column totals add the opening figure the book states.
481
+ const outstanding = (transactions) => (transactions || []).filter((tx) => !(tx.payment || tx.mileage));
482
+ const DC = "Debtors & Creditors";
483
+ a.set(DC, "C3");
484
+ a.set(DC, "F3");
485
+ MONTH_ORDER.forEach((month, index) => {
486
+ const row = 5 + index * 2;
487
+ a.set(DC, `C${row}`, entriesOf(outstanding(scenario.sales?.[month])));
488
+ a.set(DC, `F${row}`, entriesOf(outstanding(scenario.purchases?.[month])));
489
+ });
490
+ const rows = MONTH_ORDER.map((_, index) => 5 + index * 2);
491
+ a.set(DC, "C29", a.cells(DC, "C3", ...rows.map((row) => `C${row}`)));
492
+ a.set(DC, "F29", a.cells(DC, "F3", ...rows.map((row) => `F${row}`)));
493
+ }