@diy-accounting-uk/diya-gl 1.2.65 → 1.2.66

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 (35) hide show
  1. package/dist/app/data/releases.json +26 -0
  2. package/dist/app/lib/product-sheets/format.js +15 -0
  3. package/dist/app/lib/product-sheets/payslips.js +115 -0
  4. package/dist/app/lib/product-sheets/salesinvoice.js +130 -0
  5. package/dist/app/lib/product-sheets/serial-dates.js +18 -0
  6. package/dist/app/lib/product-sheets/vat-chain.js +342 -0
  7. package/dist/app/lib/product-sheets/vat-rate.js +27 -0
  8. package/dist/app/lib/provenance-data.js +1 -1
  9. package/dist/app/products/ltd/cell-map.js +294 -0
  10. package/dist/app/products/ltd/checks/admin-filing.js +364 -0
  11. package/dist/app/products/ltd/checks/company-registers.js +301 -0
  12. package/dist/app/products/ltd/checks/fixed-assets.js +123 -0
  13. package/dist/app/products/ltd/checks/index.js +260 -0
  14. package/dist/app/products/ltd/checks/ledgers.js +420 -0
  15. package/dist/app/products/ltd/checks/payroll.js +462 -0
  16. package/dist/app/products/ltd/checks/vat.js +62 -0
  17. package/dist/app/products/ltd/layout.js +211 -0
  18. package/dist/app/products/ltd/reads.js +581 -0
  19. package/dist/app/products/ltd/report.js +358 -0
  20. package/dist/app/products/ltd/writes.js +728 -0
  21. package/dist/app/products/ltd.js +51 -4487
  22. package/dist/app/products/se/cell-map.js +314 -0
  23. package/dist/app/products/se/checks/admin.js +120 -0
  24. package/dist/app/products/se/checks/fixed-assets.js +219 -0
  25. package/dist/app/products/se/checks/index.js +444 -0
  26. package/dist/app/products/se/checks/ledgers.js +152 -0
  27. package/dist/app/products/se/checks/payroll.js +245 -0
  28. package/dist/app/products/se/checks/sa103f.js +574 -0
  29. package/dist/app/products/se/checks/vat.js +126 -0
  30. package/dist/app/products/se/layout.js +248 -0
  31. package/dist/app/products/se/reads.js +387 -0
  32. package/dist/app/products/se/report.js +634 -0
  33. package/dist/app/products/se/writes.js +640 -0
  34. package/dist/app/products/se.js +62 -4432
  35. package/package.json +1 -1
@@ -1923,6 +1923,32 @@
1923
1923
  "scorecard": "101608 passed, 0 warnings, 0 failed"
1924
1924
  }
1925
1925
  }
1926
+ },
1927
+ {
1928
+ "tag": "diya-gl-v1.2.65",
1929
+ "date": "2026-10-31",
1930
+ "formatVersion": "diya-gl/2",
1931
+ "engineVersion": "1.2.65+abe8b1846",
1932
+ "taxDataHash": "51bdff60e993",
1933
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1934
+ "templates": {
1935
+ "bst": {
1936
+ "hash": "0d003e10e00c",
1937
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1938
+ },
1939
+ "taxi": {
1940
+ "hash": "1ff69654d64d",
1941
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1942
+ },
1943
+ "se": {
1944
+ "hash": "064605ae76ed",
1945
+ "scorecard": "8015 passed, 0 warnings, 0 failed"
1946
+ },
1947
+ "ltd": {
1948
+ "hash": "b90d8b0f54a0",
1949
+ "scorecard": "101608 passed, 0 warnings, 0 failed"
1950
+ }
1951
+ }
1926
1952
  }
1927
1953
  ]
1928
1954
  }
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+
4
+ import { canonicalForUnit } from "../canonical-report-value.js";
5
+
6
+ export function fmt(v, unit = "money") {
7
+ if (v === null || v === undefined || v === "" || v === " ") return "—";
8
+ if (typeof v === "number") {
9
+ const canonical = Number(canonicalForUnit(v, unit));
10
+ // A nil that arrived by negation carries a sign bit and prints as "-0",
11
+ // which reads as a defect in a statement.
12
+ return (canonical === 0 ? 0 : canonical).toLocaleString("en-GB", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
13
+ }
14
+ return String(v);
15
+ }
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+
4
+ import { toExcelSerial } from "../spreadsheet-runner.js";
5
+ import { parseDate } from "../scenario-loader.js";
6
+ import {
7
+ PAYE_DUE_DAY,
8
+ PAYE_SCHEDULE_FIRST_ROW,
9
+ PAYE_SCHEDULE_MONTH_TABS,
10
+ PAYSLIPS_EMPLOYEE_BASE_ROWS,
11
+ PAYSLIPS_EMPLOYEE_START_DATE_OFFSET,
12
+ PAYSLIPS_ENTRY_COLUMNS,
13
+ payeTaxMonthDates,
14
+ payslipsMonthEntryRows,
15
+ payslipsStartDate,
16
+ payslipsWagesPaidCell,
17
+ } from "../payslips-layout.js";
18
+
19
+ // Payslips.xlsx Jul (sheet5.xml) and Aug (sheet6.xml) shipped with 35 dead
20
+ // #REF! cells: Jul!F11:F15 lost the weekly-block pay-date reference
21
+ // (Employee!F$24/F$26 compared against E$9), Jul!T41 carried a stray
22
+ // formula where every other month has a literal 0, and Aug!H11:M15 (K only
23
+ // on rows 12-15) lost the previous month's row-41 brought-forward
24
+ // reference. additionalReads never read either tab directly -- only the
25
+ // Payment/Admin aggregates -- so none of it ever surfaced. Every fixture's
26
+ // employees pay monthly, so the weekly-block gate (Employee!D$28 etc = "m")
27
+ // and the carry-forward gate (Aug!T$9 = "Y") are never true: both blocks
28
+ // always resolve to their blank/nil branch regardless of what the broken
29
+ // formula pointed at, which is exactly why the #REF!s shipped unnoticed.
30
+ export const PAYSLIPS_WEEKLY_ROWS = [11, 12, 13, 14, 15];
31
+ export const PAYSLIPS_JUL_DEAD_CELLS = [...PAYSLIPS_WEEKLY_ROWS.map((row) => `F${row}`), "T41"];
32
+ export const PAYSLIPS_AUG_BROUGHT_FORWARD_CELLS = PAYSLIPS_WEEKLY_ROWS.flatMap((row) => {
33
+ const cells = ["H", "I", "J", "L", "M"].map((col) => `${col}${row}`);
34
+ if (row >= 12) cells.push(`K${row}`);
35
+ return cells;
36
+ });
37
+
38
+ // The cells Payslips.xlsx cellWrites populates for a month's payroll, one
39
+ // employee a row down the month's own monthly block, plus the wages-paid date
40
+ // the block is dated from.
41
+ export const payslipsMonthEntryCells = (monthIndex) => [
42
+ payslipsWagesPaidCell(monthIndex),
43
+ ...payslipsMonthEntryRows(monthIndex).flatMap((row) => Object.values(PAYSLIPS_ENTRY_COLUMNS).map((col) => `${col}${row}`)),
44
+ ];
45
+
46
+ // The Employee sheet of Payslips.xlsx: the business details and up to five
47
+ // employee blocks. payrollStart is the payroll year the package's calendar runs
48
+ // on, payrollOpened the date the payroll record opened.
49
+ export function writePayslipsEmployee(scenario, { payrollStart, payrollOpened, shiftDate }) {
50
+ // Employee blocks start at rows 13, 39, 65, 91, 117 (26-row intervals)
51
+ const EMP_BASE_ROWS = PAYSLIPS_EMPLOYEE_BASE_ROWS;
52
+ const emp = {};
53
+
54
+ // Business details in Payslips Employee sheet
55
+ const biz = scenario.business || {};
56
+ if (biz.name) emp.D5 = biz.name;
57
+ if (biz.address) emp.D6 = biz.address;
58
+ if (biz.town) emp.D7 = biz.town;
59
+ if (biz.postcode) emp.D9 = biz.postcode;
60
+
61
+ for (let i = 0; i < Math.min(scenario.employees.length, 5); i++) {
62
+ const e = scenario.employees[i];
63
+ const base = EMP_BASE_ROWS[i];
64
+ if (e.name) {
65
+ const parts = e.name.split(" ");
66
+ emp[`D${base + 2}`] = parts.slice(-1)[0]; // surname
67
+ emp[`D${base + 3}`] = parts.slice(0, -1).join(" "); // forename(s)
68
+ }
69
+ if (e.niNumber) emp[`M${base + 2}`] = e.niNumber;
70
+ // The date the employee joined, read against the payroll year this
71
+ // package's calendar runs on. Without it the employee's line on every
72
+ // month tab stays blank and the printed payslip prints no figures.
73
+ if (e.startDate && payrollStart) {
74
+ const joined = parseDate(e.startDate);
75
+ const onSheet = payslipsStartDate(joined, payrollOpened, shiftDate(joined), payrollStart);
76
+ emp[`D${base + PAYSLIPS_EMPLOYEE_START_DATE_OFFSET}`] = toExcelSerial(
77
+ onSheet.getUTCFullYear(),
78
+ onSheet.getUTCMonth() + 1,
79
+ onSheet.getUTCDate(),
80
+ );
81
+ }
82
+ emp[`D${base + 15}`] = e.payFrequency === "weekly" ? "W" : "M";
83
+ // The payroll number, not the scenario's own employee id. The printed
84
+ // payslip adds this cell to its block's start row to find the
85
+ // employee's line on the month tab, so a name in it takes the whole
86
+ // page's arithmetic with it.
87
+ emp[`D${base + 16}`] = i + 1;
88
+ emp[`D${base + 17}`] = e.isDirector ? "D" : e.niCategory || "A";
89
+ }
90
+ return emp;
91
+ }
92
+
93
+ // The PAYE remittance schedule: one row per tax month, each with the month it
94
+ // ends and the day the payment falls due.
95
+ export function checkPayeSchedule({ check, num }, paymentSchedule, payrollYearOpens) {
96
+ if (paymentSchedule && payrollYearOpens) {
97
+ const asSerial = (day) => toExcelSerial(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate());
98
+ PAYE_SCHEDULE_MONTH_TABS.forEach((tab, taxMonth) => {
99
+ const row = PAYE_SCHEDULE_FIRST_ROW + taxMonth;
100
+ const { ends, due } = payeTaxMonthDates(payrollYearOpens, taxMonth);
101
+ check(
102
+ `Payslips!Payment B${row} tax month ${taxMonth + 1} ends on the last day of ${tab}`,
103
+ num(paymentSchedule[`B${row}`]),
104
+ asSerial(ends),
105
+ 0,
106
+ );
107
+ check(
108
+ `Payslips!Payment C${row} tax month ${taxMonth + 1} is due on the ${PAYE_DUE_DAY}th after it`,
109
+ num(paymentSchedule[`C${row}`]),
110
+ asSerial(due),
111
+ 0,
112
+ );
113
+ });
114
+ }
115
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+
4
+ // ── Sales invoice sample line (Salesinvoice.xlsx) ───────────────────────────
5
+ // The customer-facing invoice template has no external link into the rest of
6
+ // the book, so its own VAT rate is proved directly: the generator now writes
7
+ // the tax year's standard rate into Product Details!D2:D99 (generator.js),
8
+ // and this checks one sample invoice line, anchored to the fixture's own
9
+ // first sale, computes the right net, VAT and gross from it. Row 2 of
10
+ // Product Details already ships the template's own placeholder product code
11
+ // (A2 = 1001); this only sets its selling price. Business Details!B11 is the
12
+ // VAT registration number the invoice template itself gates its per-row VAT
13
+ // lookup on (verified against the XML: Invoice Template!N38 reads
14
+ // 'Product Details'!D:D only when O8, which reads 'Business Details'!B11, is
15
+ // not blank) -- an unregistered business is left untouched, the same as the
16
+ // template's own guidance for it. The invoice also carries a carriage charge
17
+ // (Invoice Database!E, the "Carriage Charge" column), which the template
18
+ // taxes separately from the product lines in Invoice Template!P62. That term
19
+ // used to read a literal 0.2; it now reads 'Product Details'!$D$2, the same
20
+ // cell every row of D2:D99 carries the tax year's rate into, so carriage is
21
+ // taxed at the written rate like every other line.
22
+ export const SALESINVOICE_VAT_REG_CELL = "B11";
23
+ // The same sheet's "Telephone" box, the entry cell beside its A8 label.
24
+ export const SALESINVOICE_TELEPHONE_CELL = "B8";
25
+ export const SALESINVOICE_SAMPLE_PRODUCT_CODE = 1001;
26
+ export const SALESINVOICE_SAMPLE_PRODUCT_ROW = 2;
27
+ export const SALESINVOICE_SAMPLE_CARRIAGE_CHARGE = 37.5;
28
+ export const SALESINVOICE_PRODUCT_DETAILS_COLUMNS = { code: "A", price: "C", vatRate: "D" };
29
+ export const SALESINVOICE_INVOICE_DATABASE_COLUMNS = {
30
+ activate: "A",
31
+ invoiceNumber: "B",
32
+ carriage: "E",
33
+ productCode1: "F",
34
+ quantity1: "G",
35
+ };
36
+ export const SALESINVOICE_INVOICE_TEMPLATE_CELLS = { netTotal: "P58", carriageNet: "P60", vatTotal: "P62", grossTotal: "P64" };
37
+ export const SALESINVOICE_LINE1_CELLS = { productCode: "C38", unitPrice: "J38", quantity: "L38", lineNet: "P38", lineVat: "V38" };
38
+
39
+ // The Salesinvoice.xlsx writes for a scenario: the telephone number, and one
40
+ // sample invoice line for a VAT-registered business.
41
+ export function writeSalesinvoice(scenario, rate) {
42
+ // Anchored to the fixture's own first sale so the customer-facing invoice
43
+ // total and VAT can be checked against a real figure (see checkSalesinvoice).
44
+ const salesinvoiceWrites = {};
45
+ // The invoice's own letterhead carries the business telephone number,
46
+ // which no other sheet in these workbooks has a box for.
47
+ if (scenario.business?.phone) salesinvoiceWrites["Business Details"] = { [SALESINVOICE_TELEPHONE_CELL]: scenario.business.phone };
48
+ const firstInvoiceSale = Object.values(scenario.sales || {}).flat()[0];
49
+ if (rate > 0 && scenario.business?.vat_number && firstInvoiceSale) {
50
+ salesinvoiceWrites["Business Details"] = {
51
+ ...salesinvoiceWrites["Business Details"],
52
+ [SALESINVOICE_VAT_REG_CELL]: scenario.business.vat_number,
53
+ };
54
+ salesinvoiceWrites["Invoice Database"] = {
55
+ [`${SALESINVOICE_INVOICE_DATABASE_COLUMNS.activate}2`]: 1,
56
+ [`${SALESINVOICE_INVOICE_DATABASE_COLUMNS.invoiceNumber}2`]: 1,
57
+ [`${SALESINVOICE_INVOICE_DATABASE_COLUMNS.carriage}2`]: SALESINVOICE_SAMPLE_CARRIAGE_CHARGE,
58
+ [`${SALESINVOICE_INVOICE_DATABASE_COLUMNS.productCode1}2`]: SALESINVOICE_SAMPLE_PRODUCT_CODE,
59
+ [`${SALESINVOICE_INVOICE_DATABASE_COLUMNS.quantity1}2`]: 1,
60
+ };
61
+ salesinvoiceWrites["Product Details"] = {
62
+ [`${SALESINVOICE_PRODUCT_DETAILS_COLUMNS.price}${SALESINVOICE_SAMPLE_PRODUCT_ROW}`]: firstInvoiceSale.amount,
63
+ };
64
+ }
65
+ return salesinvoiceWrites;
66
+ }
67
+
68
+ // The customer-facing invoice against the tax year's own VAT rate.
69
+ export function checkSalesinvoice({ check, num }, results, expected, taxData, rate) {
70
+ // Salesinvoice.xlsx carries no external link to the rest of the book, so a
71
+ // wrong figure here never reaches the accounts -- it reaches the
72
+ // customer's customer. The generator writes the tax year's standard rate
73
+ // into Product Details!D2:D99 (generator.js); this checks the rate landed
74
+ // and that the one sample invoice line, plus the invoice's carriage
75
+ // charge, both compute their VAT correctly from it, hand-computed from the
76
+ // fixture's own first sale and the sample carriage charge.
77
+ const invoiceProductDetails = results["Salesinvoice.xlsx!Product Details"];
78
+ const invoiceTemplate = results["Salesinvoice.xlsx!Invoice Template"];
79
+ if (invoiceProductDetails && invoiceTemplate && taxData) {
80
+ const standardRatePercent = Math.round(taxData.vat.standard_rate * 100);
81
+ check(
82
+ "Salesinvoice Product Details: VAT Rate = the tax year's standard rate",
83
+ num(invoiceProductDetails[`${SALESINVOICE_PRODUCT_DETAILS_COLUMNS.vatRate}${SALESINVOICE_SAMPLE_PRODUCT_ROW}`]),
84
+ standardRatePercent,
85
+ 0,
86
+ );
87
+
88
+ // cellWrites only puts the sample invoice line and carriage charge on the
89
+ // sheet for a VAT-registered business (rate > 0 and a VAT number) --
90
+ // otherwise Salesinvoice.xlsx keeps its blank template state, and these
91
+ // five figures would have nothing to compare against.
92
+ const firstInvoiceSale = Object.values(expected.sales || {}).flat()[0];
93
+ if (rate > 0 && expected.business?.vat_number && firstInvoiceSale) {
94
+ const expectedNet = firstInvoiceSale.amount;
95
+ const expectedLineVat = (expectedNet * standardRatePercent) / 100;
96
+ const expectedCarriageVat = (SALESINVOICE_SAMPLE_CARRIAGE_CHARGE * standardRatePercent) / 100;
97
+ const expectedVatTotal = expectedLineVat + expectedCarriageVat;
98
+ check(
99
+ "Salesinvoice: line VAT = price x quantity x the tax year's standard rate",
100
+ num(invoiceTemplate[SALESINVOICE_LINE1_CELLS.lineVat]),
101
+ expectedLineVat,
102
+ 0.01,
103
+ );
104
+ check(
105
+ "Salesinvoice: net total = the invoice's one line",
106
+ num(invoiceTemplate[SALESINVOICE_INVOICE_TEMPLATE_CELLS.netTotal]),
107
+ expectedNet,
108
+ 0.01,
109
+ );
110
+ check(
111
+ "Salesinvoice: carriage charge lands on the invoice",
112
+ num(invoiceTemplate[SALESINVOICE_INVOICE_TEMPLATE_CELLS.carriageNet]),
113
+ SALESINVOICE_SAMPLE_CARRIAGE_CHARGE,
114
+ 0.01,
115
+ );
116
+ check(
117
+ "Salesinvoice: VAT total = line VAT plus carriage VAT at the tax year's standard rate",
118
+ num(invoiceTemplate[SALESINVOICE_INVOICE_TEMPLATE_CELLS.vatTotal]),
119
+ expectedVatTotal,
120
+ 0.01,
121
+ );
122
+ check(
123
+ "Salesinvoice: amount payable = net plus carriage plus VAT",
124
+ num(invoiceTemplate[SALESINVOICE_INVOICE_TEMPLATE_CELLS.grossTotal]),
125
+ expectedNet + SALESINVOICE_SAMPLE_CARRIAGE_CHARGE + expectedVatTotal,
126
+ 0.01,
127
+ );
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+
4
+ // The date an Excel serial stands for.
5
+ export function excelSerialToUtcDate(serial) {
6
+ const epoch = Date.UTC(1899, 11, 30);
7
+ return new Date(epoch + Math.round(serial) * 24 * 60 * 60 * 1000);
8
+ }
9
+
10
+ export function formatSerialDate(serial) {
11
+ return excelSerialToUtcDate(serial).toLocaleDateString("en-GB", { timeZone: "UTC", day: "numeric", month: "long", year: "numeric" });
12
+ }
13
+
14
+ // The " (period ending d Month yyyy)" a VAT return line carries, or nothing
15
+ // at all when the form has no date on it.
16
+ export function periodEnding(serial) {
17
+ return serial ? ` (period ending ${formatSerialDate(serial)})` : "";
18
+ }
@@ -0,0 +1,342 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+
4
+ import { toExcelSerial } from "../spreadsheet-runner.js";
5
+ import { parseDate } from "../scenario-loader.js";
6
+ import { vatCycleRows, vatReturnCoverage } from "../report-generator.js";
7
+ import { fmt } from "./format.js";
8
+ import { formatSerialDate, periodEnding } from "./serial-dates.js";
9
+ import { netOfVat } from "./vat-rate.js";
10
+
11
+ // ── Vatinterface layout ────────────────────────────────────────────────────
12
+ // One row per VAT period, in date order. Rows 6-17 are the twelve accounting
13
+ // months in period order. Rows 4 and 5 are the two VAT periods before the
14
+ // accounting year, rows 18 to 20 the three after it; each is fed by its own
15
+ // S/P entry sheet rather than by a month tab. Column B is the period end date
16
+ // every VATQtr sheet looks up on, C the payment due date, D/F the period's
17
+ // sales net and output VAT, H/J its purchases net and input VAT, and E/G/I/K
18
+ // the rolling three-row sums the VAT boxes read. M carries the flat-rate flag
19
+ // box 6 switches on.
20
+ export const VATINTERFACE_ROWS = { first: 4, last: 20, firstMonth: 6 };
21
+
22
+ // Straddling VAT period name to the Vatinterface row it feeds, and to the
23
+ // pair of entry sheets it is entered on (S<period> and P<period>).
24
+ export const STRADDLING_PERIOD_ROWS = { "02Y1": 4, "03Y1": 5, "04Y2": 18, "05Y2": 19, "06Y2": 20 };
25
+
26
+ // A business that is not registered for VAT turns the rate off on the first
27
+ // month's Sales tab, and the rest of the book follows that cell.
28
+ export function writeVatRateCell(salesWrites, firstTab, rateCell, rate) {
29
+ if (!salesWrites[firstTab]) salesWrites[firstTab] = {};
30
+ salesWrites[firstTab][rateCell] = rate * 100;
31
+ }
32
+
33
+ // layout names what differs between the products' VAT workbooks:
34
+ // workbook the VAT workbook's file name
35
+ // monthTabs (results) => the month tab names, in accounting-year order
36
+ // salesNetCell, salesVatCell, purchasesNetCell, purchasesVatCell
37
+ // the cells of a month tab that hold its net and VAT totals
38
+
39
+ // Straddling VAT periods: entries on the S<period> and P<period> entry sheets.
40
+ export function writeStraddlingPeriod(vatReturnWrites, entries, sheetPrefix, nameField, columns, { workbook, shiftDate }) {
41
+ for (const entry of entries) {
42
+ if (!STRADDLING_PERIOD_ROWS[entry.period]) {
43
+ throw new Error(`Straddling VAT entry names period "${entry.period}", which ${workbook} has no sheet for`);
44
+ }
45
+ const sheetName = `${sheetPrefix}${entry.period}`;
46
+ if (!vatReturnWrites[sheetName]) vatReturnWrites[sheetName] = {};
47
+ const sheet = vatReturnWrites[sheetName];
48
+ // Matching the whole reference rather than its first letter keeps a
49
+ // write further right (AD, say) out of the count of rows already there.
50
+ const amountColumnKey = new RegExp(`^${columns.amount}\\d+$`);
51
+ const entryRow = Object.keys(sheet).filter((k) => amountColumnKey.test(k)).length + 5;
52
+ const d = shiftDate(parseDate(entry.date));
53
+ sheet[`${columns.date}${entryRow}`] = toExcelSerial(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
54
+ if (entry[nameField]) sheet[`${columns.name}${entryRow}`] = entry[nameField];
55
+ if (entry.invoice) sheet[`${columns.invoice}${entryRow}`] = entry.invoice;
56
+ if (entry.description && columns.description) sheet[`${columns.description}${entryRow}`] = entry.description;
57
+ sheet[`${columns.amount}${entryRow}`] = entry.amount;
58
+ }
59
+ }
60
+
61
+ // The VAT the books actually charged, taken from the month tabs and from the
62
+ // return itself. Every other statement in this report is stated net, so a
63
+ // registered trader and an unregistered one carrying the same trade read
64
+ // identically without it.
65
+ export function vatSection(results, layout) {
66
+ const { workbook, salesVatCell, salesNetCell, purchasesVatCell, purchasesNetCell } = layout;
67
+ const tabs = layout.monthTabs(results).filter((tab) => results[`Sales.xlsx!${tab}`] || results[`Purchases.xlsx!${tab}`]);
68
+ const months = tabs.map((tab) => [results[`Sales.xlsx!${tab}`], results[`Purchases.xlsx!${tab}`]]);
69
+ if (months.length === 0) return null;
70
+ const monthCells = (file, ...refs) => tabs.flatMap((tab) => refs.map((ref) => `${file}!${tab}!${ref}`));
71
+
72
+ const num = (v) => (typeof v === "number" ? v : 0);
73
+ const sum = (side, cell) => months.reduce((total, pair) => total + num(pair[side]?.[cell]), 0);
74
+ const salesVat = sum(0, salesVatCell);
75
+ const salesNet = sum(0, salesNetCell);
76
+ const purchasesVat = sum(1, purchasesVatCell);
77
+ const purchasesNet = sum(1, purchasesNetCell);
78
+
79
+ const rows = [
80
+ {
81
+ label: "Sales invoiced including VAT",
82
+ value: fmt(salesNet + salesVat),
83
+ indent: 1,
84
+ cells: monthCells("Sales.xlsx", salesVatCell, salesNetCell),
85
+ },
86
+ { label: "VAT charged on sales", value: fmt(salesVat), indent: 1, cells: monthCells("Sales.xlsx", salesVatCell) },
87
+ { label: "Sales net of VAT", value: fmt(salesNet), indent: 1, cells: monthCells("Sales.xlsx", salesNetCell) },
88
+ {
89
+ label: "Purchases invoiced including VAT",
90
+ value: fmt(purchasesNet + purchasesVat),
91
+ indent: 1,
92
+ cells: monthCells("Purchases.xlsx", purchasesVatCell, purchasesNetCell),
93
+ },
94
+ { label: "VAT reclaimed on purchases", value: fmt(purchasesVat), indent: 1, cells: monthCells("Purchases.xlsx", purchasesVatCell) },
95
+ { label: "Purchases net of VAT", value: fmt(purchasesNet), indent: 1, cells: monthCells("Purchases.xlsx", purchasesNetCell) },
96
+ {
97
+ label: "**VAT due for the year**",
98
+ value: fmt(salesVat - purchasesVat),
99
+ indent: 0,
100
+ cells: [...monthCells("Sales.xlsx", salesVatCell), ...monthCells("Purchases.xlsx", purchasesVatCell)],
101
+ },
102
+ ];
103
+ // The package ships five return forms: four quarters from the VAT start
104
+ // month and one more, for a business whose quarter stagger does not line up
105
+ // with those four. Printing four left the fifth out of the report
106
+ // altogether. Each form carries the period it was filled in for, and the
107
+ // cycle rows above the boxes say which months each one reaches.
108
+ const forms = [];
109
+ const quarterRows = [];
110
+ for (let q = 1; q <= 5; q++) {
111
+ const boxes = results[`${workbook}!VATQtr${q}`];
112
+ if (!boxes) continue;
113
+ const end = num(boxes.G5);
114
+ forms.push({ name: `Q${q}`, end: vatinterfaceRowEnding(results, end, layout) });
115
+ const period = periodEnding(end);
116
+ const form = `${workbook}!VATQtr${q}`;
117
+ quarterRows.push({ label: `Q${q}${period} box 1: VAT due on sales`, value: fmt(num(boxes.G9)), indent: 1, cells: [`${form}!G9`] });
118
+ quarterRows.push({
119
+ label: `Q${q}${period} box 4: VAT reclaimed on purchases`,
120
+ value: fmt(num(boxes.G15)),
121
+ indent: 1,
122
+ cells: [`${form}!G15`],
123
+ });
124
+ quarterRows.push({ label: `Q${q}${period} box 5: net VAT due`, value: fmt(num(boxes.G17)), indent: 1, cells: [`${form}!G17`] });
125
+ }
126
+ if (quarterRows.length > 0) {
127
+ rows.push(...vatCycleRows(vatinterfacePeriods(results, layout), forms));
128
+ rows.push({ label: "**The return forms as the package fills them in**", value: "" });
129
+ rows.push(...quarterRows);
130
+ }
131
+ return { title: "VAT Returns", rows };
132
+ }
133
+
134
+ // The VAT periods the interface carries, one per row, with the VAT on each
135
+ // side and whether the period is one of the twelve accounting months.
136
+ export function vatinterfacePeriods(results, layout) {
137
+ const vatinterface = results[`${layout.workbook}!Vatinterface`];
138
+ if (!vatinterface) return [];
139
+ const num = (v) => (typeof v === "number" ? v : 0);
140
+ const periods = [];
141
+ for (let row = VATINTERFACE_ROWS.first; row <= VATINTERFACE_ROWS.last; row++) {
142
+ const end = num(vatinterface[`B${row}`]);
143
+ if (!end) continue;
144
+ periods.push({
145
+ row,
146
+ endLabel: formatSerialDate(end),
147
+ outputVat: num(vatinterface[`F${row}`]),
148
+ inputVat: num(vatinterface[`J${row}`]),
149
+ inAccountingYear: row >= VATINTERFACE_ROWS.firstMonth && row < VATINTERFACE_ROWS.firstMonth + 12,
150
+ cells: {
151
+ end: `${layout.workbook}!Vatinterface!B${row}`,
152
+ output: `${layout.workbook}!Vatinterface!F${row}`,
153
+ input: `${layout.workbook}!Vatinterface!J${row}`,
154
+ },
155
+ });
156
+ }
157
+ return periods;
158
+ }
159
+
160
+ // The interface row a return form's own period end date lands on, or null
161
+ // when the form names a date the interface does not carry.
162
+ export function vatinterfaceRowEnding(results, end, layout) {
163
+ const vatinterface = results[`${layout.workbook}!Vatinterface`];
164
+ if (!vatinterface || !end) return null;
165
+ for (let row = VATINTERFACE_ROWS.first; row <= VATINTERFACE_ROWS.last; row++) {
166
+ if (Math.round(typeof vatinterface[`B${row}`] === "number" ? vatinterface[`B${row}`] : 0) === Math.round(end)) return row;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // The Vatinterface and the five return forms against the month tabs and
172
+ // straddling entry sheets that feed them.
173
+ export function checkVatChain({ check, num, rate }, results, expected, layout) {
174
+ // ── Vatinterface: where in the VAT chain a break happened ────────────────
175
+ //
176
+ // The box checks above catch a break; these say where it is. Each interface
177
+ // row is compared against the leaf workbook or the straddling entry sheet
178
+ // that feeds it, each quarter column against the three period rows it sums,
179
+ // and each VAT box against the interface row its LOOKUP lands on. A month
180
+ // link that stops carrying fails on that month and side alone.
181
+ const vatinterface = results[`${layout.workbook}!Vatinterface`];
182
+ if (vatinterface) {
183
+ layout.monthTabs(results).forEach((tab, i) => {
184
+ const row = VATINTERFACE_ROWS.firstMonth + i;
185
+ const salesMonth = results[`Sales.xlsx!${tab}`];
186
+ const purchasesMonth = results[`Purchases.xlsx!${tab}`];
187
+ if (salesMonth) {
188
+ check(
189
+ `Vatinterface D${row}: ${tab} sales net = Sales.xlsx ${tab}`,
190
+ num(vatinterface[`D${row}`]),
191
+ num(salesMonth[layout.salesNetCell]),
192
+ );
193
+ check(
194
+ `Vatinterface F${row}: ${tab} output VAT = Sales.xlsx ${tab}`,
195
+ num(vatinterface[`F${row}`]),
196
+ num(salesMonth[layout.salesVatCell]),
197
+ );
198
+ }
199
+ if (purchasesMonth) {
200
+ check(
201
+ `Vatinterface H${row}: ${tab} purchases net = Purchases.xlsx ${tab}`,
202
+ num(vatinterface[`H${row}`]),
203
+ num(purchasesMonth[layout.purchasesNetCell]),
204
+ );
205
+ check(
206
+ `Vatinterface J${row}: ${tab} input VAT = Purchases.xlsx ${tab}`,
207
+ num(vatinterface[`J${row}`]),
208
+ num(purchasesMonth[layout.purchasesVatCell]),
209
+ );
210
+ }
211
+ });
212
+
213
+ // The straddling periods, anchored in the entries the scenario put on
214
+ // their own sheets. The sheets compute VAT from the gross figure at the
215
+ // standard rate, so the expectation splits the same gross the same way.
216
+ const straddlingGross = (entries) => {
217
+ const byPeriod = {};
218
+ for (const entry of entries || []) byPeriod[entry.period] = (byPeriod[entry.period] || 0) + entry.amount;
219
+ return byPeriod;
220
+ };
221
+ const straddlingSales = straddlingGross(expected.vat_straddling_sales);
222
+ const straddlingPurchases = straddlingGross(expected.vat_straddling_purchases);
223
+ if (expected.vat_straddling_sales || expected.vat_straddling_purchases) {
224
+ for (const [period, row] of Object.entries(STRADDLING_PERIOD_ROWS)) {
225
+ const salesGross = straddlingSales[period] || 0;
226
+ const purchasesGross = straddlingPurchases[period] || 0;
227
+ check(
228
+ `Vatinterface D${row}: ${period} sales net = the straddling sales entered for that period`,
229
+ num(vatinterface[`D${row}`]),
230
+ netOfVat(salesGross, rate),
231
+ );
232
+ check(
233
+ `Vatinterface F${row}: ${period} output VAT = the straddling sales entered for that period`,
234
+ num(vatinterface[`F${row}`]),
235
+ salesGross - netOfVat(salesGross, rate),
236
+ );
237
+ check(
238
+ `Vatinterface H${row}: ${period} purchases net = the straddling purchases entered for that period`,
239
+ num(vatinterface[`H${row}`]),
240
+ netOfVat(purchasesGross, rate),
241
+ );
242
+ check(
243
+ `Vatinterface J${row}: ${period} input VAT = the straddling purchases entered for that period`,
244
+ num(vatinterface[`J${row}`]),
245
+ purchasesGross - netOfVat(purchasesGross, rate),
246
+ );
247
+ }
248
+ }
249
+
250
+ const quarterColumns = [
251
+ ["E", "D", "sales net"],
252
+ ["G", "F", "output VAT"],
253
+ ["I", "H", "purchases net"],
254
+ ["K", "J", "input VAT"],
255
+ ];
256
+ for (let q = 1; q <= 5; q++) {
257
+ const qtr = results[`${layout.workbook}!VATQtr${q}`];
258
+ if (!qtr || typeof qtr.G5 !== "number") continue;
259
+ let row = null;
260
+ for (let r = VATINTERFACE_ROWS.first; r <= VATINTERFACE_ROWS.last; r++) {
261
+ if (Math.round(num(vatinterface[`B${r}`])) === Math.round(qtr.G5)) row = r;
262
+ }
263
+ check(`VAT Q${q}: quarter end date is one of the Vatinterface periods`, row === null ? 0 : 1, 1, 0);
264
+ if (row === null) continue;
265
+
266
+ if (row - 2 >= VATINTERFACE_ROWS.first) {
267
+ for (const [total, period, label] of quarterColumns) {
268
+ check(
269
+ `Vatinterface ${total}${row}: quarter ${label} = its three period rows`,
270
+ num(vatinterface[`${total}${row}`]),
271
+ num(vatinterface[`${period}${row - 2}`]) + num(vatinterface[`${period}${row - 1}`]) + num(vatinterface[`${period}${row}`]),
272
+ );
273
+ }
274
+ }
275
+
276
+ check(`VAT Q${q}: box 1 (G9) = Vatinterface quarter VAT due (G${row})`, num(qtr.G9), num(vatinterface[`G${row}`]));
277
+ check(`VAT Q${q}: box 4 (G15) = Vatinterface quarter VAT reclaimed (K${row})`, num(qtr.G15), num(vatinterface[`K${row}`]));
278
+ check(`VAT Q${q}: box 7 (G23) = Vatinterface quarter purchases net (I${row})`, num(qtr.G23), num(vatinterface[`I${row}`]));
279
+ // Box 6 is sales net of VAT, or sales including VAT when the flat rate
280
+ // scheme flag in column M is set.
281
+ const flatRate = num(vatinterface[`M${row}`]) > 0;
282
+ check(
283
+ `VAT Q${q}: box 6 (G21) = Vatinterface quarter sales ${flatRate ? "including" : "net of"} VAT`,
284
+ num(qtr.G21),
285
+ num(vatinterface[`E${row}`]) + (flatRate ? num(vatinterface[`G${row}`]) : 0),
286
+ );
287
+ check(
288
+ `VAT Q${q}: payment due date (G7) = Vatinterface final date for payment (C${row})`,
289
+ num(qtr.G7),
290
+ num(vatinterface[`C${row}`]),
291
+ 0,
292
+ );
293
+ }
294
+
295
+ // ── The five return forms as one cycle ────────────────────────
296
+ //
297
+ // Each form's own date decides which three interface rows it declares, so
298
+ // the five together are checked as a cycle: distinct periods, each a
299
+ // quarter after the one before it, Q1 to Q4 covering the twelve accounting
300
+ // months once each, and the fifth on the last period the interface
301
+ // carries.
302
+ const periods = vatinterfacePeriods(results, layout);
303
+ const returnForms = [];
304
+ for (let q = 1; q <= 5; q++) {
305
+ const qtr = results[`${layout.workbook}!VATQtr${q}`];
306
+ if (qtr && typeof qtr.G5 === "number") returnForms.push({ name: `Q${q}`, end: vatinterfaceRowEnding(results, qtr.G5, layout) });
307
+ }
308
+ const coverage = vatReturnCoverage(periods, returnForms);
309
+ if (coverage.placed.length === 5) {
310
+ const [q1, q2, q3, q4, q5] = coverage.placed;
311
+ check("VAT: the five returns end on five different periods", new Set(coverage.placed.map((form) => form.row)).size, 5, 0);
312
+ for (const [earlier, later] of [
313
+ [q1, q2],
314
+ [q2, q3],
315
+ [q3, q4],
316
+ [q4, q5],
317
+ ]) {
318
+ check(`VAT: ${later.name} ends a quarter after ${earlier.name}`, later.row - earlier.row, 3, 0);
319
+ }
320
+ const quarterlyRows = new Set([q1, q2, q3, q4].flatMap((form) => form.covers));
321
+ check(
322
+ "VAT: Q1-Q4 cover every month of the accounting year",
323
+ periods.filter((period) => period.inAccountingYear && quarterlyRows.has(period.row)).length,
324
+ periods.filter((period) => period.inAccountingYear).length,
325
+ 0,
326
+ );
327
+ check("VAT: Q5 ends on the last period the Vatinterface carries", q5.row, VATINTERFACE_ROWS.last, 0);
328
+
329
+ // Five consecutive quarters need fifteen periods and the interface
330
+ // carries seventeen, so no two returns reach the same one. A period
331
+ // declared twice would be filed twice, so it fails rather than warns,
332
+ // and the output VAT on it says what the second filing would repeat.
333
+ check("VAT: periods more than one of the five returns declares", coverage.shared.length, 0, 0);
334
+ check(
335
+ "VAT: output VAT declared on more than one of the five returns",
336
+ coverage.shared.reduce((total, period) => total + period.outputVat, 0),
337
+ 0,
338
+ 0,
339
+ );
340
+ }
341
+ }
342
+ }