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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,78 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
71
71
  `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
72
72
  `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
73
73
 
74
+ ## API
75
+
76
+ The package root exports three functions that turn a book into the figures HMRC's MTD APIs
77
+ take. Each takes the parsed book, its lines and a parameters object, and returns a promise.
78
+ Nothing is sent anywhere: the answer is the request body to send, plus what fed it.
79
+
80
+ Load a book first:
81
+
82
+ ```js
83
+ import { readFileSync } from "node:fs";
84
+ import { parse } from "smol-toml";
85
+
86
+ function loadBook(dir) {
87
+ const book = parse(readFileSync(`${dir}/book.toml`, "utf8"));
88
+ const lines = readFileSync(`${dir}/lines.jsonl`, "utf8")
89
+ .split("\n")
90
+ .filter(Boolean)
91
+ .map((line) => JSON.parse(line));
92
+ return { book, lines };
93
+ }
94
+ ```
95
+
96
+ ### `deriveVatReturn(book, lines, { periodEnd, periodStart?, periodKey? })`
97
+
98
+ The nine VAT return boxes for the three-month period ending `periodEnd` (YYYY-MM-DD), from a
99
+ Limited Company book that declares `diya-gl:vatRegistered = true`. `periodStart`, when given,
100
+ must open that quarter; `periodKey` is echoed back. The answer carries `boxes` (box1 to box9),
101
+ `hmrc` (the same figures under HMRC's field names, boxes 1 to 5 to the penny and 6 to 9 in
102
+ whole pounds), `months`, `dueDate`, `scheme`, and `lines`: the journal lines behind boxes 1, 4,
103
+ 6 and 7, each with what it contributes. A book whose lines do not reconcile with its VAT
104
+ interface is refused.
105
+
106
+ ```js
107
+ import { deriveVatReturn } from "@diy-accounting-uk/diya-gl";
108
+
109
+ const { book, lines } = loadBook("my-company-book");
110
+ const answer = await deriveVatReturn(book, lines, { periodStart: "2025-07-01", periodEnd: "2025-09-30" });
111
+ console.log(answer.hmrc); // { vatDueSales, vatDueAcquisitions, totalVatDue, ..., totalAcquisitionsExVAT }
112
+ ```
113
+
114
+ ### `deriveItsaQuarterlyUpdate(book, lines, { periodEndDate?, quarterlyPeriodType?, taxYear? })`
115
+
116
+ One period of HMRC's Self Employment Business API from a Self Employed book. In 2023-24 and
117
+ 2024-25 each period carries its own figures; from 2025-26 each carries the running total from
118
+ 6 April. `periodEndDate` picks the period among the year's four; without it every period is
119
+ answered. `quarterlyPeriodType` is `standard` (6 April quarters, the default) or `calendar`.
120
+ `taxYear` (as `2025-26`) overrides the year the book's dates imply. Each period lists the fields
121
+ the book cannot source under `omitted`; those are left out of the body, never sent as zero.
122
+
123
+ ```js
124
+ import { deriveItsaQuarterlyUpdate } from "@diy-accounting-uk/diya-gl";
125
+
126
+ const { book, lines } = loadBook("my-self-employed-book");
127
+ const answer = await deriveItsaQuarterlyUpdate(book, lines, { periodEndDate: "2025-10-05" });
128
+ const [period] = answer.periods; // { periodDates, periodIncome, periodExpenses, ..., omitted, covers }
129
+ ```
130
+
131
+ ### `deriveItsaAnnualSubmission(book, lines, { taxYear? })`
132
+
133
+ The year's allowances and adjustments for the same API's annual submission, from a Self
134
+ Employed book, restricted to the fields HMRC accepts for that tax year. `taxYear` (as
135
+ `2025-26`) overrides the year the book's dates imply. The answer carries `allowances`,
136
+ `adjustments`, `omitted` and `warnings`.
137
+
138
+ ```js
139
+ import { deriveItsaAnnualSubmission } from "@diy-accounting-uk/diya-gl";
140
+
141
+ const { book, lines } = loadBook("my-self-employed-book");
142
+ const answer = await deriveItsaAnnualSubmission(book, lines, { taxYear: "2025-26" });
143
+ console.log(answer.allowances.annualInvestmentAllowance);
144
+ ```
145
+
74
146
  ## The format
75
147
 
76
148
  A diya-gl book's fields are drawn from the XBRL Global Ledger Taxonomy Framework 2015 and
@@ -1351,6 +1351,32 @@
1351
1351
  "scorecard": "101605 passed, 0 warnings, 0 failed"
1352
1352
  }
1353
1353
  }
1354
+ },
1355
+ {
1356
+ "tag": "diya-gl-v1.2.43",
1357
+ "date": "2026-10-03",
1358
+ "formatVersion": "diya-gl/1",
1359
+ "engineVersion": "1.2.40+57de8f55b",
1360
+ "taxDataHash": "89424bd36c0d",
1361
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1362
+ "templates": {
1363
+ "bst": {
1364
+ "hash": "0d003e10e00c",
1365
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1366
+ },
1367
+ "taxi": {
1368
+ "hash": "1ff69654d64d",
1369
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1370
+ },
1371
+ "se": {
1372
+ "hash": "064605ae76ed",
1373
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1374
+ },
1375
+ "ltd": {
1376
+ "hash": "5bb7be9b6d97",
1377
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1378
+ }
1379
+ }
1354
1380
  }
1355
1381
  ]
1356
1382
  }
@@ -60,7 +60,7 @@ import { attributionWriter, entriesOf, entryOf, fieldEntriesOf, unionOf } from "
60
60
 
61
61
  const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
62
62
  const MONTH_COLS = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N"];
63
- const VAT_RATE = 0.2;
63
+ export const VAT_RATE = 0.2;
64
64
 
65
65
  // Sales and Purchases analysis columns, code letter by column letter, in the
66
66
  // order row 5 of each month tab tests them. Column AK of a Purchases month
@@ -26,7 +26,7 @@ let cachedMapping = null;
26
26
 
27
27
  // Resolved on first use, not at import: the engine bundle loads in the browser,
28
28
  // where import.meta.url is an http URL and fileURLToPath throws.
29
- function loadMapping() {
29
+ export function loadMapping() {
30
30
  if (!cachedMapping) {
31
31
  const here = dirname(fileURLToPath(import.meta.url));
32
32
  const mappingPath = resolve(here, "..", "..", "data", "hmrc", "sa103-mtd-mapping.json");
@@ -50,7 +50,7 @@ function boxEntry(boxes, boxNumber) {
50
50
  // first is the one this template's cell actually answers -- section 8 of the
51
51
  // design records which, box by box -- so a caller asking for "the field this
52
52
  // box answers" gets that one unless told to reach past it.
53
- function fieldsOf(entry) {
53
+ export function fieldsOf(entry) {
54
54
  return entry.field.split("|").map((field) => field.trim());
55
55
  }
56
56
 
@@ -63,11 +63,15 @@ function primaryField(boxes, boxNumber) {
63
63
  // rather than the object this call meant to build. Every path here comes from
64
64
  // sa103-mtd-mapping.json's own "field" column, so a segment like this is a
65
65
  // defect in that mapping file and must fail loudly rather than land quietly
66
- // on some other object. The check sits inside the walk, as literal
67
- // comparisons, and each intermediate node is only reused when it is the
68
- // object's own property: the shape static analysis recognises as guarded.
66
+ // on some other object. The whole path is refused before anything is
67
+ // written, so a refused path leaves the target untouched. The check is
68
+ // repeated inside the walk, as literal comparisons, and each intermediate
69
+ // node is only reused when it is the object's own property: the shape static
70
+ // analysis recognises as guarded.
69
71
  export function setPath(target, path, value) {
70
72
  const parts = path.split(".");
73
+ const refused = parts.find((part) => part === "__proto__" || part === "constructor" || part === "prototype");
74
+ if (refused !== undefined) throw new Error(`setPath: "${refused}" is not a field name, in path "${path}"`);
71
75
  let node = target;
72
76
  for (let i = 0; i < parts.length; i++) {
73
77
  const part = parts[i];
@@ -491,7 +495,7 @@ const NO_SOURCE_ANNUAL_BOXES = [
491
495
  // 2026-27 additions, gated behind HMRC test flags) is not treated as a
492
496
  // live field here -- neither has a cell in this template regardless, so
493
497
  // the payload does not change, only whether the gap is worth warning about.
494
- function annualFieldsUnavailableForYear(taxYear) {
498
+ export function annualFieldsUnavailableForYear(taxYear) {
495
499
  const years = loadMapping().api.years;
496
500
  const order = Object.keys(years);
497
501
  const index = order.indexOf(taxYear);
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // index.js — the diya-gl package's public module: the filing figures a book
5
+ // answers, imported as `@diy-accounting-uk/diya-gl`.
6
+
7
+ export { deriveVatReturn } from "./vat-return.js";
8
+ export { deriveItsaQuarterlyUpdate, deriveItsaAnnualSubmission } from "./itsa.js";
@@ -0,0 +1,236 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // itsa.js — the two ITSA answers HMRC's Self Employment Business API takes
5
+ // from a self-employed (se) book. deriveItsaQuarterlyUpdate answers one
6
+ // period: the period's own figures in a tax year filed as dated period
7
+ // summaries, a running total from 6 April in a year filed as cumulative
8
+ // period summaries. deriveItsaAnnualSubmission answers the year's allowances
9
+ // and adjustments. Both call calculators/se-derivations.js and add no
10
+ // arithmetic beyond summing quarters.
11
+ //
12
+ // The field set HMRC accepts changes by tax year (sa103-mtd-mapping.json's
13
+ // api.years: allowances gone from 2025-26, an adjustment gone from 2026-27,
14
+ // two test-only additions), and every answer here is filtered to the year's
15
+ // live fields. A field the template cannot source is omitted, never sent as
16
+ // a zero.
17
+
18
+ import {
19
+ annualFieldsUnavailableForYear,
20
+ buildSelfEmploymentAnnualSubmission,
21
+ buildSelfEmploymentQuarterlyUpdates,
22
+ fieldsOf,
23
+ loadMapping,
24
+ setPath,
25
+ } from "../calculators/se-derivations.js";
26
+ import { loadTaxDataForBook, productOf } from "../product-workbook.js";
27
+
28
+ export const QUARTERLY_PERIOD_TYPES = ["standard", "calendar"];
29
+
30
+ const TAX_YEAR_LABEL = /^(\d{4})-(\d{2})$/;
31
+
32
+ function requireSelfEmployedBook(book, lines, caller) {
33
+ if (!book || !lines) throw new Error(`${caller} requires a book and its lines`);
34
+ const product = productOf(book);
35
+ if (product !== "se") {
36
+ throw new Error(`The book is a "${product}" book; the ITSA derivations read a self-employed (se) book`);
37
+ }
38
+ }
39
+
40
+ // "2025-26" names the package's se-2025-2026.toml; a label the mapping's
41
+ // api.years does not carry is refused before any figure is derived, because
42
+ // the field set for that year is unknown.
43
+ function taxYearFileNameFor(label) {
44
+ const match = TAX_YEAR_LABEL.exec(label ?? "");
45
+ if (!match) throw new Error(`taxYear must look like 2025-26, not "${label}"`);
46
+ const start = Number(match[1]);
47
+ const end = start + 1;
48
+ if (String(end).slice(-2) !== match[2]) throw new Error(`taxYear "${label}" does not name two consecutive years`);
49
+ return `se-${start}-${end}`;
50
+ }
51
+
52
+ async function taxDataFor(book, taxYear) {
53
+ const options = taxYear ? { taxYearName: taxYearFileNameFor(taxYear) } : {};
54
+ const taxData = await loadTaxDataForBook(book, options);
55
+ const label = taxData?.tax_year?.label;
56
+ if (!label) throw new Error("the tax year file declares no tax_year.label");
57
+ if (!loadMapping().api.years[label]) {
58
+ throw new Error(`sa103-mtd-mapping.json's api.years carries no entry for tax year "${label}"`);
59
+ }
60
+ return taxData;
61
+ }
62
+
63
+ function boxesOnRoute(route) {
64
+ return loadMapping().boxes.filter((entry) => entry.form === "SA103F" && entry.route === route && entry.field);
65
+ }
66
+
67
+ /**
68
+ * The quarterly field slots HMRC's period summary carries, from the mapping:
69
+ * every quarterly-route field (a box shared by two fields counts both) plus
70
+ * the consolidated-expenses election the box 31 caveat names. The mapping
71
+ * varies none of these by tax year; the year decides the shape, not the set.
72
+ * @returns {string[]}
73
+ */
74
+ export function quarterlyFieldSlots() {
75
+ const slots = new Set();
76
+ for (const entry of boxesOnRoute("quarterly")) for (const field of fieldsOf(entry)) slots.add(field);
77
+ slots.add("periodExpenses.consolidatedExpenses");
78
+ return [...slots].sort();
79
+ }
80
+
81
+ /**
82
+ * The annual field slots HMRC's schema accepts for one tax year: every
83
+ * annual-route field in the mapping, less those api.years says are gone by
84
+ * that year, less those added only in a later year, less the test-only
85
+ * additions (a fieldsAdded entry carrying a status).
86
+ * @param {string} taxYear - a label such as "2025-26"
87
+ * @returns {string[]}
88
+ */
89
+ export function annualFieldSlotsForTaxYear(taxYear) {
90
+ const unavailable = annualFieldsUnavailableForYear(taxYear);
91
+ for (const year of Object.values(loadMapping().api.years)) {
92
+ for (const added of year.fieldsAdded || []) {
93
+ if (typeof added !== "string") unavailable.add(added.field);
94
+ }
95
+ }
96
+ const slots = new Set();
97
+ for (const entry of boxesOnRoute("annual")) for (const field of fieldsOf(entry)) slots.add(field);
98
+ return [...slots].filter((field) => !unavailable.has(field)).sort();
99
+ }
100
+
101
+ function readPath(target, path) {
102
+ let node = target;
103
+ for (const part of path.split(".")) {
104
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, part)) return undefined;
105
+ node = node[part];
106
+ }
107
+ return node;
108
+ }
109
+
110
+ function round2(value) {
111
+ return Math.round(value * 100) / 100;
112
+ }
113
+
114
+ function sumPeriods(periods, slots) {
115
+ const total = {};
116
+ for (const slot of slots) {
117
+ let sum = null;
118
+ for (const period of periods) {
119
+ const value = readPath(period, slot);
120
+ if (typeof value === "number") sum = (sum ?? 0) + value;
121
+ }
122
+ if (sum !== null) setPath(total, slot, round2(sum));
123
+ }
124
+ return total;
125
+ }
126
+
127
+ // Rebuilds a payload from the slots the year accepts, in the mapping's own
128
+ // order, so a field the derivation never set stays absent and a field the
129
+ // year no longer accepts is dropped. Answers the payload and the slots left
130
+ // out of it.
131
+ function restrictToSlots(payload, slots) {
132
+ const kept = {};
133
+ const omitted = [];
134
+ for (const slot of slots) {
135
+ const value = readPath(payload, slot);
136
+ if (value === undefined) omitted.push(slot);
137
+ else setPath(kept, slot, value);
138
+ }
139
+ return { kept, omitted };
140
+ }
141
+
142
+ function periodLabel(period) {
143
+ return `${period.periodDates.periodStartDate} to ${period.periodDates.periodEndDate}`;
144
+ }
145
+
146
+ /**
147
+ * One period of the Self Employment Business API from a self-employed book.
148
+ * In a tax year HMRC files as dated period summaries (2023-24, 2024-25) the
149
+ * answer is that period's own figures; in a year filed as cumulative period
150
+ * summaries (2025-26 on) it is the running total from the year's first
151
+ * period through the one named. The period is picked by periodEndDate among
152
+ * the year's four; with no periodEndDate every period is answered.
153
+ * @param {Object} book - parsed book.toml
154
+ * @param {Array} lines - parsed lines.jsonl entries
155
+ * @param {{periodEndDate?: string, quarterlyPeriodType?: "standard"|"calendar", taxYear?: string}} [params] -
156
+ * quarterlyPeriodType defaults to standard (6 April quarters); taxYear (as 2025-26) overrides the year the
157
+ * book's dates imply
158
+ * @returns {Promise<Object>} { taxYear, shape, quarterlyPeriodType, fieldSlots, periods, warnings }
159
+ */
160
+ export async function deriveItsaQuarterlyUpdate(book, lines, { periodEndDate, quarterlyPeriodType, taxYear } = {}) {
161
+ requireSelfEmployedBook(book, lines, "deriveItsaQuarterlyUpdate");
162
+ const taxData = await taxDataFor(book, taxYear);
163
+ const label = taxData.tax_year.label;
164
+ const shape = loadMapping().api.years[label].quarterly;
165
+ const periodType = quarterlyPeriodType || "standard";
166
+ if (!QUARTERLY_PERIOD_TYPES.includes(periodType)) {
167
+ throw new Error(`quarterlyPeriodType must be one of ${QUARTERLY_PERIOD_TYPES.join(", ")}, not "${periodType}"`);
168
+ }
169
+
170
+ const derived = buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, { quarterlyPeriodType: periodType });
171
+ const slots = quarterlyFieldSlots();
172
+ const periods = derived.periods;
173
+
174
+ let wanted = periods;
175
+ if (periodEndDate) {
176
+ const index = periods.findIndex((period) => period.periodDates.periodEndDate === periodEndDate);
177
+ if (index === -1) {
178
+ throw new Error(
179
+ `No ${periodType} period of ${label} ends on ${periodEndDate}; the periods end on ${periods.map((p) => p.periodDates.periodEndDate).join(", ")}`,
180
+ );
181
+ }
182
+ wanted = [periods[index]];
183
+ }
184
+
185
+ const answers = wanted.map((period) => {
186
+ const index = periods.indexOf(period);
187
+ const covered = shape === "cumulative-period-summary" ? periods.slice(0, index + 1) : [period];
188
+ const figures = shape === "cumulative-period-summary" ? sumPeriods(covered, slots) : period;
189
+ const { kept, omitted } = restrictToSlots(figures, slots);
190
+ return {
191
+ periodDates: {
192
+ periodStartDate: covered[0].periodDates.periodStartDate,
193
+ periodEndDate: period.periodDates.periodEndDate,
194
+ },
195
+ ...kept,
196
+ omitted,
197
+ covers: covered.map(periodLabel),
198
+ };
199
+ });
200
+
201
+ return {
202
+ taxYear: label,
203
+ shape,
204
+ quarterlyPeriodType: periodType,
205
+ fieldSlots: slots,
206
+ periods: answers,
207
+ warnings: derived.warnings,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * The year's allowances and adjustments for the Self Employment Business
213
+ * API's annual submission, from a self-employed book, restricted to the
214
+ * fields HMRC's schema accepts for that tax year.
215
+ * @param {Object} book - parsed book.toml
216
+ * @param {Array} lines - parsed lines.jsonl entries
217
+ * @param {{taxYear?: string}} [params] - taxYear (as 2025-26) overrides the year the book's dates imply
218
+ * @returns {Promise<Object>} { taxYear, fieldSlots, allowances, adjustments, omitted, warnings }
219
+ */
220
+ export async function deriveItsaAnnualSubmission(book, lines, { taxYear } = {}) {
221
+ requireSelfEmployedBook(book, lines, "deriveItsaAnnualSubmission");
222
+ const taxData = await taxDataFor(book, taxYear);
223
+ const label = taxData.tax_year.label;
224
+ const slots = annualFieldSlotsForTaxYear(label);
225
+
226
+ const derived = buildSelfEmploymentAnnualSubmission(book, lines, taxData);
227
+ const { kept, omitted } = restrictToSlots(derived, slots);
228
+
229
+ return {
230
+ taxYear: label,
231
+ fieldSlots: slots,
232
+ ...kept,
233
+ omitted,
234
+ warnings: derived.warnings,
235
+ };
236
+ }
@@ -0,0 +1,238 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // vat-return.js — the nine VAT boxes for one obligation period, read from the
5
+ // Ltd engine's own Vatreturns.xlsx!Vatinterface results, with HMRC's field
6
+ // names, HMRC's rounding, and the journal lines that fed each box. Nothing
7
+ // here computes VAT the engine has not already computed: the boxes are the
8
+ // interface's quarter columns, and the lines are attributed with the same
9
+ // split the journal rows use, then reconciled against those columns.
10
+
11
+ import { calculateFromDiyaGl } from "../diya-gl-calculator.js";
12
+ import { diyaGlToScenario } from "../diya-gl-loader.js";
13
+ import { loadTaxDataForBook, productOf } from "../product-workbook.js";
14
+ import { isStraddlingLine, LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP } from "../scenario-extractor.js";
15
+ import { dateFromExcelSerial } from "../calculators/shared.js";
16
+ import { VAT_RATE } from "../calculators/ltd.js";
17
+ import { splitVat, VATINTERFACE_FIRST_MONTH_ROW, VATINTERFACE_FIRST_ROW, VATINTERFACE_LAST_ROW } from "../tax/vat.js";
18
+
19
+ const INTERFACE_SHEET = "Vatreturns.xlsx!Vatinterface";
20
+ const MONTHS_IN_QUARTER = 3;
21
+
22
+ /**
23
+ * The ISO day an Excel serial names.
24
+ * @param {number} serial
25
+ * @returns {string}
26
+ */
27
+ export function isoFromSerial(serial) {
28
+ return dateFromExcelSerial(serial).toISOString().slice(0, 10);
29
+ }
30
+
31
+ function isoDate(value) {
32
+ if (value === undefined || value === null) return null;
33
+ return new Date(value).toISOString().slice(0, 10);
34
+ }
35
+
36
+ function firstOfMonthsBefore(isoMonthEnd, monthsBefore) {
37
+ const [year, month] = isoMonthEnd.split("-").map(Number);
38
+ return new Date(Date.UTC(year, month - 1 - monthsBefore, 1)).toISOString().slice(0, 10);
39
+ }
40
+
41
+ function monthKey(isoDay) {
42
+ return isoDay.slice(0, 7);
43
+ }
44
+
45
+ function pence(value) {
46
+ return Math.round(value * 100) / 100;
47
+ }
48
+
49
+ function pounds(value) {
50
+ return Math.round(value);
51
+ }
52
+
53
+ function splitGross(gross) {
54
+ return splitVat(gross, VAT_RATE);
55
+ }
56
+
57
+ /**
58
+ * The interface rows the engine built for a book: one per period end, with
59
+ * the month figures and, from the first accounting month on, the quarter sums
60
+ * the return boxes read.
61
+ * @param {Object} results - calculateFromDiyaGl() output for a Company (ltd) book
62
+ * @returns {Array<{row: number, periodEnd: string, dueDate: string, month: Object, quarter: Object|null, flatRate: number}>}
63
+ */
64
+ export function interfaceRows(results) {
65
+ const sheet = results[INTERFACE_SHEET];
66
+ if (!sheet) throw new Error("The book's product carries no VAT interface; only a Company (ltd) book answers a VAT return");
67
+ const rows = [];
68
+ for (let row = VATINTERFACE_FIRST_ROW; row <= VATINTERFACE_LAST_ROW; row++) {
69
+ const hasQuarter = row >= VATINTERFACE_FIRST_MONTH_ROW;
70
+ rows.push({
71
+ row,
72
+ periodEnd: isoFromSerial(sheet[`B${row}`]),
73
+ dueDate: isoFromSerial(sheet[`C${row}`]),
74
+ month: {
75
+ salesNet: sheet[`D${row}`] || 0,
76
+ salesVat: sheet[`F${row}`] || 0,
77
+ purchasesNet: sheet[`H${row}`] || 0,
78
+ purchasesVat: sheet[`J${row}`] || 0,
79
+ },
80
+ quarter: hasQuarter
81
+ ? {
82
+ salesNet: sheet[`E${row}`] || 0,
83
+ salesVat: sheet[`G${row}`] || 0,
84
+ purchasesNet: sheet[`I${row}`] || 0,
85
+ purchasesVat: sheet[`K${row}`] || 0,
86
+ }
87
+ : null,
88
+ flatRate: sheet[`M${row}`] || 0,
89
+ });
90
+ }
91
+ return rows;
92
+ }
93
+
94
+ function lineContribution(line, box) {
95
+ const { vat, net } = splitGross(line.amount);
96
+ return {
97
+ entryNumber: line.entryNumber,
98
+ lineNumber: line.lineNumber ?? null,
99
+ date: line.postingDate,
100
+ account: line.accountMainID,
101
+ reference: line.documentReference ?? null,
102
+ counterparty: line.detailComment ?? null,
103
+ gross: pence(line.amount),
104
+ contributes: pence(box === "vat" ? vat : net),
105
+ };
106
+ }
107
+
108
+ // The journal lines behind one quarter, bucketed the way the engine's month
109
+ // tabs and straddling sheets take them: a line without diya-gl:vatPeriodEnd
110
+ // lands on the month its postingDate names; a line with it lands on the
111
+ // interface row carrying that period end.
112
+ function linesForQuarter(lines, quarterRows) {
113
+ const monthEnds = new Set(quarterRows.map((r) => r.periodEnd));
114
+ const months = new Set(quarterRows.map((r) => monthKey(r.periodEnd)));
115
+ const inQuarter = (line) => {
116
+ if (isStraddlingLine(line)) return monthEnds.has(isoDate(line["diya-gl:vatPeriodEnd"]));
117
+ return months.has(monthKey(line.postingDate));
118
+ };
119
+ const sales = lines.filter((l) => l.sourceJournalID === "sales" && LTD_SALES_CODE_MAP[l.accountMainID] && inQuarter(l));
120
+ const purchases = lines.filter((l) => l.sourceJournalID === "purchases" && LTD_PURCHASE_CODE_MAP[l.accountMainID] && inQuarter(l));
121
+ return { sales, purchases };
122
+ }
123
+
124
+ function sumContributions(lines, part) {
125
+ return lines.reduce((total, line) => total + splitGross(line.amount)[part], 0);
126
+ }
127
+
128
+ function reconcile(label, attributed, engine) {
129
+ if (Math.abs(attributed - engine) > 0.005) {
130
+ throw new Error(
131
+ `The lines attributed to ${label} sum to ${pence(attributed)} but the book's VAT interface carries ${pence(engine)}; ` +
132
+ "a line is dated outside the accounting year or posts to a month the interface does not carry, so this return cannot be derived",
133
+ );
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The nine VAT return boxes for the quarter ending on periodEnd, from a
139
+ * Company (ltd) book that declares itself VAT registered.
140
+ * @param {Object} book - parsed book.toml
141
+ * @param {Array} lines - parsed lines.jsonl entries
142
+ * @param {{periodEnd: string, periodStart?: string, periodKey?: string}} params - periodEnd is the
143
+ * obligation's period end (YYYY-MM-DD); periodStart, when given, must open that quarter; periodKey is
144
+ * echoed back untouched
145
+ * @returns {Promise<Object>} { vatRegistrationNumber, periodKey, periodStart, periodEnd, dueDate, scheme,
146
+ * months, boxes, hmrc, lines, notes }
147
+ */
148
+ export async function deriveVatReturn(book, lines, { periodEnd, periodStart, periodKey } = {}) {
149
+ if (!book || !lines) throw new Error("deriveVatReturn requires a book and its lines");
150
+ if (!periodEnd) throw new Error("deriveVatReturn requires periodEnd (the obligation's period end, YYYY-MM-DD)");
151
+ const entity = book.entityInformation ?? {};
152
+ if (entity["diya-gl:vatRegistered"] !== true) {
153
+ throw new Error(
154
+ "The book does not declare the company VAT registered (entityInformation diya-gl:vatRegistered), so it carries no VAT return",
155
+ );
156
+ }
157
+
158
+ const product = productOf(book);
159
+ const taxData = await loadTaxDataForBook(book);
160
+ const results = calculateFromDiyaGl(book, lines, product, taxData, diyaGlToScenario(book, lines, product));
161
+ const rows = interfaceRows(results);
162
+ const wantedEnd = isoDate(periodEnd);
163
+ const index = rows.findIndex((r) => r.periodEnd === wantedEnd);
164
+ if (index < 0) {
165
+ throw new Error(
166
+ `The book carries no VAT period ending ${wantedEnd}; it answers periods ending ${rows.map((r) => r.periodEnd).join(", ")}`,
167
+ );
168
+ }
169
+ const row = rows[index];
170
+ if (!row.quarter) {
171
+ throw new Error(
172
+ `The period ending ${wantedEnd} is before the book's accounting year; the earliest quarter this book answers ends ${rows[VATINTERFACE_FIRST_MONTH_ROW - VATINTERFACE_FIRST_ROW].periodEnd}`,
173
+ );
174
+ }
175
+ const expectedStart = firstOfMonthsBefore(wantedEnd, MONTHS_IN_QUARTER - 1);
176
+ if (periodStart && isoDate(periodStart) !== expectedStart) {
177
+ throw new Error(
178
+ `The book answers three-month periods ending on a month end; ${isoDate(periodStart)} to ${wantedEnd} is not one, the quarter ending ${wantedEnd} starts ${expectedStart}`,
179
+ );
180
+ }
181
+
182
+ const quarterRows = rows.slice(index - (MONTHS_IN_QUARTER - 1), index + 1);
183
+ const { sales, purchases } = linesForQuarter(lines, quarterRows);
184
+ reconcile("box 1", sumContributions(sales, "vat"), row.quarter.salesVat);
185
+ reconcile("box 4", sumContributions(purchases, "vat"), row.quarter.purchasesVat);
186
+ reconcile("box 6", sumContributions(sales, "net"), row.quarter.salesNet);
187
+ reconcile("box 7", sumContributions(purchases, "net"), row.quarter.purchasesNet);
188
+
189
+ const flatRate = row.flatRate > 0;
190
+ const box1 = pence(row.quarter.salesVat);
191
+ const box2 = 0;
192
+ const box3 = pence(box1 + box2);
193
+ const box4 = pence(row.quarter.purchasesVat);
194
+ const box5 = pence(box3 - box4);
195
+ const box6 = pounds(row.quarter.salesNet + (flatRate ? row.quarter.salesVat : 0));
196
+ const box7 = pounds(row.quarter.purchasesNet);
197
+ const box8 = 0;
198
+ const box9 = 0;
199
+
200
+ return {
201
+ vatRegistrationNumber: entity["diya-gl:vatNumber"] ?? null,
202
+ periodKey: periodKey ?? null,
203
+ periodStart: expectedStart,
204
+ periodEnd: wantedEnd,
205
+ dueDate: row.dueDate,
206
+ scheme: flatRate ? "flat-rate" : "standard",
207
+ months: quarterRows.map((r) => ({
208
+ periodEnd: r.periodEnd,
209
+ salesNet: pence(r.month.salesNet),
210
+ salesVat: pence(r.month.salesVat),
211
+ purchasesNet: pence(r.month.purchasesNet),
212
+ purchasesVat: pence(r.month.purchasesVat),
213
+ })),
214
+ boxes: { box1, box2, box3, box4, box5, box6, box7, box8, box9 },
215
+ hmrc: {
216
+ vatDueSales: box1,
217
+ vatDueAcquisitions: box2,
218
+ totalVatDue: box3,
219
+ vatReclaimedCurrPeriod: box4,
220
+ netVatDue: box5,
221
+ totalValueSalesExVAT: box6,
222
+ totalValuePurchasesExVAT: box7,
223
+ totalValueGoodsSuppliedExVAT: box8,
224
+ totalAcquisitionsExVAT: box9,
225
+ },
226
+ lines: {
227
+ box1: sales.map((l) => lineContribution(l, "vat")),
228
+ box4: purchases.map((l) => lineContribution(l, "vat")),
229
+ box6: sales.map((l) => lineContribution(l, "net")),
230
+ box7: purchases.map((l) => lineContribution(l, "net")),
231
+ },
232
+ notes: [
233
+ `The engine applies ${VAT_RATE * 100}% to every sales and purchases journal line; the lines' own taxCode and taxRate are not read.`,
234
+ "Boxes 2, 8 and 9 are nil: the book carries no EU acquisitions or supplies.",
235
+ "Boxes 1 to 5 are to the penny and boxes 6 to 9 whole pounds; box 5 is the rounded box 3 less the rounded box 4.",
236
+ ],
237
+ };
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.43",
3
+ "version": "1.2.44",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",
@@ -20,6 +20,11 @@
20
20
  "diya-gl-view": "bin/diya-gl-view.js",
21
21
  "diya-gl-mcp": "bin/diya-gl-mcp.js"
22
22
  },
23
+ "exports": {
24
+ ".": "./dist/app/lib/derivations/index.js",
25
+ "./dist/*": "./dist/*",
26
+ "./package.json": "./package.json"
27
+ },
23
28
  "files": ["bin/", "dist/", "README.md", "NOTICE"],
24
29
  "scripts": {
25
30
  "prepack": "node scripts/prepack.mjs"