@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,1938 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 DIY Accounting Ltd
3
+ //
4
+ // ltd.js — JS calculation engine for the Limited Company product.
5
+ //
6
+ // This module answers the question the Excel package answers: given the same
7
+ // book, journal and tax year, what does every cell the reconciliation reads
8
+ // hold? So it follows the workbook's own arithmetic rather than a tidier
9
+ // route to the same statement. Two consequences run through the file.
10
+ //
11
+ // A journal row takes its own VAT off its own gross and rounds nothing
12
+ // (Sales and Purchases column G = F * rate / (100 + rate), column H = F - G),
13
+ // and every analysis total is a sum of those row figures. Netting a month's
14
+ // gross instead leaves pennies behind, so sheetNet() is used wherever a
15
+ // statement line comes off a journal. The writer, by contrast, rounds a
16
+ // figure it puts on the Fixed Assets schedule to the penny, so a capital
17
+ // purchase or a disposal goes through writerNet().
18
+ //
19
+ // The workbook only holds what the writer put in it. cellWrites() fills the
20
+ // Fixed Assets schedule, the share register, the board minute and the stock
21
+ // count from the scenario, so where the scenario carries none of those the
22
+ // sheet keeps its own empty layout and every figure downstream follows. This
23
+ // engine reads the same scenario and lands in the same place, which is what
24
+ // makes the two comparable.
25
+
26
+ import { toExcelSerial } from "../spreadsheet-runner.js";
27
+ import { BANK_ACCOUNT_FILES, BANK_LAYOUTS, OPENING_FIXED_ASSET_COLUMNS, isLtdOpeningBankLine } from "../ltd-layout.js";
28
+ import { apportionCorporationTax, financialYearsInPeriod } from "../tax/corporation-tax.js";
29
+ import { calculateCapitalAllowances } from "../tax/capital-allowances.js";
30
+ import {
31
+ monthlyPayrollBlockRow,
32
+ PAYE_SCHEDULE_FIRST_ROW,
33
+ PAYE_SCHEDULE_MONTH_TAB_CELLS,
34
+ PAYE_SCHEDULE_MONTH_TABS,
35
+ payeTaxMonthDates,
36
+ PAYROLL_WEEKS_PER_MONTH,
37
+ PAYSLIP_PRINT_CELLS,
38
+ PAYSLIP_PRINT_FIRST_PAYROLL_NUMBER,
39
+ PAYSLIP_PRINT_MONTHLY_HEADING,
40
+ PAYSLIP_PRINT_PERIOD_CELLS,
41
+ PAYSLIP_PRINT_SHEET,
42
+ PAYSLIP_PRINT_PERIOD,
43
+ PAYSLIP_PRINT_TO_DATE_CELLS,
44
+ PAYSLIPS_DIRECTLY_READ_MONTH_INDEXES,
45
+ PAYSLIPS_ENTRY_COLUMNS,
46
+ PAYSLIPS_ZERO_FILLED_COLUMNS,
47
+ payslipsMonthEntryRows,
48
+ payslipsMonthPeriod,
49
+ payslipsPeriodStartCell,
50
+ payslipsWagesPaidCell,
51
+ payrollYearStart,
52
+ } from "../payslips-layout.js";
53
+ import { addMonths, endOfMonth, SHEET_BLANK } from "./shared.js";
54
+ import { registerOfficers } from "../scenario-loader.js";
55
+
56
+ const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
57
+ const MONTH_COLS = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N"];
58
+ const VAT_RATE = 0.2;
59
+
60
+ // Sales and Purchases analysis columns, code letter by column letter, in the
61
+ // order row 5 of each month tab tests them. Column AK of a Purchases month
62
+ // tab is the CIS certificates column, which the writer fills from a
63
+ // sub-contractor purchase rather than from a code letter.
64
+ export const SALES_ANALYSIS_COLUMNS = { a: "O", b: "P", c: "Q", d: "R", g: "S", o: "T", fs: "U" };
65
+ export const PURCHASE_ANALYSIS_COLUMNS = {
66
+ s: "O",
67
+ c: "P",
68
+ o: "Q",
69
+ d: "R",
70
+ w: "S",
71
+ r: "T",
72
+ p: "U",
73
+ t: "V",
74
+ q: "W",
75
+ m: "X",
76
+ u: "Y",
77
+ a: "Z",
78
+ g: "AA",
79
+ h: "AB",
80
+ v: "AC",
81
+ n: "AD",
82
+ f: "AE",
83
+ l: "AF",
84
+ y: "AG",
85
+ z: "AH",
86
+ fa: "AI",
87
+ };
88
+ const SALES_CIS_COLUMN = "V";
89
+ const PURCHASES_CIS_COLUMN = "AK";
90
+
91
+ // The two ledgers each journal workbook keeps beside its twelve month tabs,
92
+ // for the amounts owed at the year start and the year end.
93
+ const SALES_LEDGER_SHEETS = ["OpeningDebtors", "ClosingDebtors"];
94
+ const PURCHASES_LEDGER_SHEETS = ["OpeningCreditors", "ClosingCreditors"];
95
+
96
+ // The Admin B column is a chain of dates anchored at B32, the year end. It
97
+ // runs from thirteen months before the year end to four months after it.
98
+ const ADMIN_DATE_CHAIN_FIRST_ROW = 6;
99
+ const ADMIN_DATE_CHAIN_LAST_ROW = 40;
100
+
101
+ // The management P&L's five turnover rows, and the gap between each and the
102
+ // trial balance income row it reads. The trial balance holds income as a
103
+ // credit, so the P&L negates it back to a positive turnover.
104
+ const SALES_PL_ROWS = [4, 5, 6, 7, 8];
105
+ const SALES_ROW_OFFSET = 49;
106
+ const SALES_BAD_DEBT_ROW = 34;
107
+
108
+ // The management P&L's expense rows and the trial balance row each reads.
109
+ const EXPENSE_PL_ROWS = { 21: 68, 22: 69, 23: 70, 24: 71, 25: 72, 26: 73, 27: 74, 28: 75, 29: 76, 30: 77, 31: 78, 32: 79, 33: 80 };
110
+
111
+ // WagesInterface holds a month a row in two blocks, employees above
112
+ // directors, twelve rows each.
113
+ const WAGES_INTERFACE_EMPLOYEE_FIRST_ROW = 4;
114
+ const WAGES_INTERFACE_DIRECTOR_FIRST_ROW = 17;
115
+
116
+ // Fixed Assets schedule blocks. Each class has a block of rows for assets
117
+ // already owned with a totals row, and a second block for assets bought in
118
+ // the year with a totals row of its own. The published note reads one column
119
+ // per class.
120
+ const SCHEDULE_CLASSES = {
121
+ land: {
122
+ existingRows: [8, 9, 10],
123
+ existingTotalRow: 11,
124
+ newRows: [60, 61, 62, 63],
125
+ newTotalRow: 64,
126
+ noteColumn: "B",
127
+ rateCell: "H7",
128
+ openingKey: "land_buildings",
129
+ label: "Land & Property",
130
+ },
131
+ plant: {
132
+ existingRows: [14, 15, 16, 17, 18, 19, 20, 21],
133
+ existingTotalRow: 22,
134
+ newRows: [67, 68, 69, 70, 71, 72, 73, 74],
135
+ newTotalRow: 75,
136
+ noteColumn: "C",
137
+ rateCell: "H13",
138
+ openingKey: "plant_machinery",
139
+ label: "Plant & Machinery",
140
+ },
141
+ fixtures: {
142
+ existingRows: [25, 26, 27, 28, 29],
143
+ existingTotalRow: 30,
144
+ newRows: [78, 79, 80, 81, 82],
145
+ newTotalRow: 83,
146
+ noteColumn: "D",
147
+ rateCell: "H24",
148
+ openingKey: "fixtures_fittings",
149
+ label: "Fixtures & Fittings",
150
+ },
151
+ computer: {
152
+ existingRows: [33, 34, 35, 36, 37, 38, 39, 40],
153
+ existingTotalRow: 41,
154
+ newRows: [86, 87, 88, 89, 90, 91, 92, 93],
155
+ newTotalRow: 94,
156
+ noteColumn: "E",
157
+ rateCell: "H32",
158
+ openingKey: "computer_technology",
159
+ label: "Computers",
160
+ },
161
+ motor: {
162
+ existingRows: [50, 51, 52, 53, 54],
163
+ existingTotalRow: 55,
164
+ newRows: [103, 104, 105, 106, 107],
165
+ newTotalRow: 108,
166
+ noteColumn: "F",
167
+ rateCell: "H43",
168
+ openingKey: "motor_vehicles",
169
+ label: "Motor Vehicles",
170
+ },
171
+ };
172
+
173
+ // The trial balance's own fixed asset rows, class by class.
174
+ const SCHEDULE_COST_ROWS = { land: 6, plant: 7, fixtures: 8, computer: 9, motor: 10 };
175
+ const SCHEDULE_DEPRECIATION_ROWS = { land: 11, plant: 12, fixtures: 13, computer: 14, motor: 15 };
176
+
177
+ // Assets bought in the year all land on the New Plant & Machinery rows,
178
+ // matching the writer.
179
+ const SCHEDULE_NEW_ASSET_CLASS = "plant";
180
+
181
+ // The classes whose new-asset rows carry a capital allowance formula, and
182
+ // their existing-block writing down allowance totals. Land claims none.
183
+ const SCHEDULE_ALLOWANCE_CLASSES = ["plant", "fixtures", "computer", "motor"];
184
+
185
+ // The cars sub-block of the motor class. Its rows carry a writing down
186
+ // allowance formula rather than an investment allowance one, and the
187
+ // calculator never places an asset on them, so each stays on the sheet's own
188
+ // blank branch.
189
+ const SCHEDULE_CAR_ROWS = [97, 98, 99, 100, 101];
190
+
191
+ // A class totals row states whether the schedule and the opening balance
192
+ // sheet agree about that class. The computer block's warning carries a
193
+ // spelling slip the template has always had, and the reconciliation reads the
194
+ // cell as it stands.
195
+ const SCHEDULE_DISAGREEMENT_TEXT = "Check Opening Balance Sheet figures agree";
196
+ const SCHEDULE_COMPUTER_DISAGREEMENT_TEXT = "Check Opening Balkance Sheet figures agree";
197
+
198
+ // Vatinterface rows: two VAT periods before the accounting year, its own
199
+ // twelve months, then three after. Each row carries one period's figures, and
200
+ // columns E, G, I and K carry the rolling three-row sums the return boxes
201
+ // read. Row 4 reads the Admin month end two rows above the period start, and
202
+ // every row after it is two Admin rows further on.
203
+ const VATINTERFACE_FIRST_ROW = 4;
204
+ const VATINTERFACE_LAST_ROW = 20;
205
+ const VATINTERFACE_FIRST_MONTH_ROW = 6;
206
+ const VATINTERFACE_FIRST_ADMIN_ROW = 6;
207
+
208
+ // A VAT period either side of the accounting year is entered on its own pair
209
+ // of sheets rather than reached through a month tab.
210
+ const STRADDLING_PERIOD_ROWS = { "02Y1": 4, "03Y1": 5, "04Y2": 18, "05Y2": 19, "06Y2": 20 };
211
+
212
+ // The payroll calendar: tax week 1 is the five days from 6 April, every week
213
+ // after it is seven days, and the payroll months take the weeks the shared
214
+ // layout names.
215
+ const PAYROLL_FIRST_WEEK_DAYS = 5;
216
+ const PAYSLIPS_CALENDAR_FIRST_ROW = 2;
217
+
218
+ const REGISTER_MEMBER_ROWS = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
219
+ // The register of directors and secretary runs one officer a row from row 2,
220
+ // and the register of directors' interests one a row from row 2 as well.
221
+ const DIRECTOR_SECRETARY_OFFICER_ROWS = [2, 3, 4, 5, 6, 7, 8];
222
+ const DIRECTORS_INTERESTS_ROWS = [2, 3, 4, 5, 6];
223
+ const SHARE_NOMINAL_VALUE = 1;
224
+ const CHARGE_REGISTER_ROWS = [2, 3, 4, 5, 6];
225
+ const HP_AGREEMENT_ROWS = [8, 10];
226
+ const EXPENSES_FORM_MONTHS = Array.from({ length: 12 }, (_, index) => `Month ${String(index + 1).padStart(2, "0")}`);
227
+
228
+ // The trial balance cells the reconciliation reads. The sheet carries a row
229
+ // for every account in the chart and the statements read it whole, so the
230
+ // engine builds the whole column and publishes this much of it.
231
+ const TRIAL_BALANCE_READS = [
232
+ "D6",
233
+ "D7",
234
+ "D8",
235
+ "D9",
236
+ "D10",
237
+ "D11",
238
+ "D12",
239
+ "D13",
240
+ "D14",
241
+ "D15",
242
+ "D19",
243
+ "D20",
244
+ "D22",
245
+ "D23",
246
+ "D24",
247
+ "D25",
248
+ "D28",
249
+ "D29",
250
+ "D30",
251
+ "D31",
252
+ "D33",
253
+ "D35",
254
+ "D39",
255
+ "D40",
256
+ "D42",
257
+ "D43",
258
+ "D91",
259
+ "EH35",
260
+ "EJ20",
261
+ "EJ22",
262
+ "EJ23",
263
+ "EJ24",
264
+ "EJ25",
265
+ "EJ26",
266
+ "EJ28",
267
+ "EJ29",
268
+ "EJ30",
269
+ "EJ31",
270
+ "EJ32",
271
+ "EJ33",
272
+ "EJ34",
273
+ "EJ35",
274
+ "EJ39",
275
+ "EJ40",
276
+ "EJ48",
277
+ "EJ66",
278
+ "EJ91",
279
+ "L34",
280
+ ];
281
+
282
+ // Every trial balance row EJ91 adds up: the balance sheet down to the profit
283
+ // distribution, then the income and expense rows.
284
+ const AUDIT_ROWS = [
285
+ 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 20, 22, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 37, 39, 40, 42, 43, 44, 47, 48,
286
+ 49, 53, 54, 55, 56, 57, 58, 60, 61, 62, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87,
287
+ 88, 89,
288
+ ];
289
+
290
+ // ── Small helpers ──────────────────────────────────────────────────────────
291
+
292
+ function sheetVat(gross, rate) {
293
+ return (gross * rate) / (1 + rate);
294
+ }
295
+
296
+ function sheetNet(gross, rate) {
297
+ return gross - sheetVat(gross, rate);
298
+ }
299
+
300
+ // The net figure the writer puts on a schedule row, rounded to the penny.
301
+ function writerNet(gross, rate) {
302
+ return Math.round((gross / (1 + rate)) * 100) / 100;
303
+ }
304
+
305
+ function sum(values) {
306
+ return values.reduce((total, value) => total + value, 0);
307
+ }
308
+
309
+ function sumValuesOf(object) {
310
+ return object ? sum(Object.values(object)) : 0;
311
+ }
312
+
313
+ function parseDate(value) {
314
+ if (value instanceof Date) return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
315
+ const [year, month, day] = String(value).slice(0, 10).split("-").map(Number);
316
+ return new Date(Date.UTC(year, month - 1, day));
317
+ }
318
+
319
+ function serialOf(date) {
320
+ return toExcelSerial(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
321
+ }
322
+
323
+ function fromSerial(serial) {
324
+ return new Date(Date.UTC(1899, 11, 30) + Math.round(serial) * 24 * 60 * 60 * 1000);
325
+ }
326
+
327
+ function zeroMonths() {
328
+ return Array.from({ length: 12 }, () => 0);
329
+ }
330
+
331
+ // The cells of a month's totals the report does not already carry. Those are
332
+ // the ones a sibling workbook's link reads and nothing else does.
333
+ function linkOnlyCells(all, reported) {
334
+ return Object.fromEntries(Object.entries(all).filter(([cell]) => !(cell in reported)));
335
+ }
336
+
337
+ // ── The accounting period ──────────────────────────────────────────────────
338
+
339
+ // The twelve month tabs in accounting-period order, and the Admin sheet's own
340
+ // B-column month ends. B32 is the year end and every other B entry is two
341
+ // rows per month away from it, so a row number alone names a month end either
342
+ // side of the year.
343
+ function periodFrom(book) {
344
+ const yearEnd = parseDate(book.documentInfo?.periodCoveredEnd);
345
+ const start = addMonths(new Date(Date.UTC(yearEnd.getUTCFullYear(), yearEnd.getUTCMonth(), 1)), -11);
346
+ const tabs = Array.from({ length: 12 }, (_, index) => SHORT_MONTHS[(start.getUTCMonth() + index) % 12]);
347
+ const adminMonthEnd = (row) => endOfMonth(yearEnd, (row - 32) / 2);
348
+ return { yearEnd, start, tabs, adminMonthEnd };
349
+ }
350
+
351
+ // Every journal entry bucketed onto the month tab it lands on. The scenario's
352
+ // month keys already sit in the book's own period, and the writer moves each
353
+ // entry by the gap between that period and the package's, which is nil when
354
+ // both are this book's.
355
+ function bucketByTab(journal, tabs) {
356
+ const buckets = Object.fromEntries(tabs.map((tab) => [tab, []]));
357
+ for (const transactions of Object.values(journal || {})) {
358
+ for (const transaction of transactions) {
359
+ const tab = SHORT_MONTHS[parseDate(transaction.date).getUTCMonth()];
360
+ if (buckets[tab]) buckets[tab].push(transaction);
361
+ }
362
+ }
363
+ return buckets;
364
+ }
365
+
366
+ // ── Sales and Purchases month tabs ─────────────────────────────────────────
367
+
368
+ // Row 1 of a month tab totals every analysis column, and an analysis column
369
+ // takes a row's net figure when the row's code letter matches.
370
+ function journalMonthTotals(transactions, rate, analysisColumns, defaultCode, cisColumn) {
371
+ const totals = { F1: 0, G1: 0, H1: 0, G2: rate * 100, [`${cisColumn}1`]: 0 };
372
+ for (const column of Object.values(analysisColumns)) totals[`${column}1`] = 0;
373
+ for (const transaction of transactions) {
374
+ const vat = sheetVat(transaction.amount, rate);
375
+ const net = transaction.amount - vat;
376
+ totals.F1 += transaction.amount;
377
+ totals.G1 += vat;
378
+ totals.H1 += net;
379
+ const column = analysisColumns[(transaction.code || defaultCode).toLowerCase()];
380
+ if (column) totals[`${column}1`] += net;
381
+ if (transaction.cis_deduction) totals[`${cisColumn}1`] += transaction.cis_deduction;
382
+ }
383
+ return totals;
384
+ }
385
+
386
+ // ── Bank workbooks ─────────────────────────────────────────────────────────
387
+
388
+ // Each workbook's month tabs: the receipts and payments totals and each code
389
+ // letter's own total. A "BC"-coded entry dated the period's own opening day
390
+ // is the account's opening balance, which the workbook takes in A1 rather
391
+ // than as a statement line; a "BC"-coded entry any other day is an ordinary
392
+ // transfer statement line, the same as any other code (BC also names the
393
+ // sibling a Cashaccount.xlsx transfer points at, on every other workbook's
394
+ // own tabs -- see BANK_TRANSFER_CODES).
395
+ function bankMonthTotals(scenario, tabs, periodStart) {
396
+ const files = {};
397
+ for (const fileName of Object.values(BANK_ACCOUNT_FILES)) {
398
+ files[fileName] = {
399
+ opening: 0,
400
+ months: Object.fromEntries(tabs.map((tab) => [tab, { receipts: 0, payments: 0, receiptCodes: {}, paymentCodes: {} }])),
401
+ };
402
+ }
403
+ for (const transactions of Object.values(scenario.bank || {})) {
404
+ for (const transaction of transactions) {
405
+ const fileName = BANK_ACCOUNT_FILES[transaction.account || "1200"];
406
+ if (!fileName) continue;
407
+ const file = files[fileName];
408
+ if (isLtdOpeningBankLine(transaction.code, parseDate(transaction.date), periodStart)) {
409
+ file.opening = transaction.amount;
410
+ continue;
411
+ }
412
+ const month = file.months[SHORT_MONTHS[parseDate(transaction.date).getUTCMonth()]];
413
+ if (!month) continue;
414
+ const receipt = transaction.direction === "in";
415
+ month[receipt ? "receipts" : "payments"] += transaction.amount;
416
+ const codes = receipt ? month.receiptCodes : month.paymentCodes;
417
+ codes[transaction.code] = (codes[transaction.code] || 0) + transaction.amount;
418
+ }
419
+ }
420
+ return files;
421
+ }
422
+
423
+ // One bank code's own total for each month of the year, across all four
424
+ // workbooks.
425
+ function bankCodeMonths(banks, code, side, tabs) {
426
+ return tabs.map((tab) =>
427
+ sum(
428
+ Object.values(banks).map((file) => {
429
+ const month = file.months[tab];
430
+ const codes = side === "receipt" ? month.receiptCodes : month.paymentCodes;
431
+ return codes[code] || 0;
432
+ }),
433
+ ),
434
+ );
435
+ }
436
+
437
+ // ── Fixed Assets schedule ──────────────────────────────────────────────────
438
+
439
+ // One schedule row per asset, computed the way the sheet's own formulas
440
+ // compute it. An asset's depreciation charge is its cost at the class rate,
441
+ // capped at the net book value it carries; a disposal drops its net book
442
+ // value to nil and pulls its cost and accumulated depreciation into the
443
+ // disposal columns.
444
+ function buildSchedule(scenario, rate, depreciationRates, investmentAllowancePercent, writingDownPercent) {
445
+ const rows = [];
446
+ const usedByClass = {};
447
+ for (const asset of scenario.opening_fixed_assets || []) {
448
+ const layout = SCHEDULE_CLASSES[asset.category];
449
+ if (!layout) continue;
450
+ const index = (usedByClass[asset.category] = (usedByClass[asset.category] || 0) + 1) - 1;
451
+ const row = layout.existingRows[index];
452
+ if (row === undefined) continue;
453
+ rows.push({
454
+ assetClass: asset.category,
455
+ row,
456
+ acquiredInYear: false,
457
+ cost: asset.cost,
458
+ depreciationBroughtForward: asset.acc_dep || 0,
459
+ taxWrittenDownValue: asset.tax_wdv || 0,
460
+ });
461
+ }
462
+
463
+ const newLayout = SCHEDULE_CLASSES[SCHEDULE_NEW_ASSET_CLASS];
464
+ let newIndex = 0;
465
+ for (const transactions of Object.values(scenario.purchases || {})) {
466
+ for (const transaction of transactions) {
467
+ if (transaction.code !== "fa") continue;
468
+ const row = newLayout.newRows[newIndex++];
469
+ if (row === undefined) continue;
470
+ rows.push({
471
+ assetClass: SCHEDULE_NEW_ASSET_CLASS,
472
+ row,
473
+ acquiredInYear: true,
474
+ purchasedOn: serialOf(parseDate(transaction.date)),
475
+ description: transaction.supplier,
476
+ cost: writerNet(transaction.amount, rate),
477
+ depreciationBroughtForward: 0,
478
+ taxWrittenDownValue: 0,
479
+ });
480
+ }
481
+ }
482
+
483
+ // A disposal attaches to an asset already on the schedule, assets brought
484
+ // forward first, so the sheet's own disposal formulas resolve that row's
485
+ // cost and accumulated depreciation.
486
+ const disposalOrder = rows.filter((row) => !row.acquiredInYear).concat(rows.filter((row) => row.acquiredInYear));
487
+ let disposalIndex = 0;
488
+ for (const transactions of Object.values(scenario.sales || {})) {
489
+ for (const transaction of transactions) {
490
+ if (transaction.code !== "fs") continue;
491
+ const target = disposalOrder[disposalIndex++];
492
+ if (!target) continue;
493
+ target.disposalProceeds = writerNet(transaction.amount, rate);
494
+ }
495
+ }
496
+
497
+ for (const row of rows) {
498
+ const ratePercent = depreciationRates[row.assetClass];
499
+ const netBookValue = row.cost - row.depreciationBroughtForward;
500
+ row.depreciationCharge = row.cost > 0 ? Math.min(row.cost * ratePercent, netBookValue) : 0;
501
+ row.depreciationCarriedForward = row.cost > 0 ? row.depreciationBroughtForward + row.depreciationCharge : 0;
502
+ row.disposed = (row.disposalProceeds || 0) > 0;
503
+ row.netBookValueCarriedForward = row.cost > 0 && !row.disposed ? row.cost - row.depreciationCarriedForward : 0;
504
+ row.disposalCost = row.disposed ? row.cost : 0;
505
+ row.disposalDepreciation = row.disposed ? row.depreciationCarriedForward : 0;
506
+
507
+ const claimRates = {
508
+ investmentAllowancePercent: row.acquiredInYear ? investmentAllowancePercent : 0,
509
+ writingDownPercent,
510
+ };
511
+ const claim = calculateCapitalAllowances(
512
+ [
513
+ {
514
+ acquiredInYear: row.acquiredInYear,
515
+ cost: row.cost,
516
+ taxWrittenDownValue: row.taxWrittenDownValue,
517
+ disposalProceeds: row.disposalProceeds || 0,
518
+ },
519
+ ],
520
+ claimRates,
521
+ );
522
+ row.investmentAllowance = claim.investmentAllowance;
523
+ row.writingDownAllowance = claim.writingDownAllowance;
524
+ row.poolCarriedForward = row.acquiredInYear
525
+ ? row.cost > 0
526
+ ? row.cost - claim.investmentAllowance
527
+ : 0
528
+ : row.taxWrittenDownValue > 0
529
+ ? row.taxWrittenDownValue - claim.writingDownAllowance
530
+ : 0;
531
+ row.balancingAllowance = claim.balancingAllowance;
532
+ row.balancingCharge = claim.balancingCharge;
533
+ }
534
+
535
+ return rows;
536
+ }
537
+
538
+ // A block's own totals row: E cost, F depreciation brought forward, G net
539
+ // book value brought forward, I the charge for the year, J depreciation
540
+ // carried forward, K net book value carried forward, O the tax written-down
541
+ // value brought forward, Q the investment allowance, R the writing down
542
+ // allowance, S the pool carried forward, V disposal proceeds, W disposal
543
+ // cost, X disposal depreciation, Y the balancing allowance, Z the balancing
544
+ // charge.
545
+ function scheduleTotals(rows) {
546
+ return {
547
+ E: sum(rows.map((row) => row.cost)),
548
+ F: sum(rows.map((row) => row.depreciationBroughtForward)),
549
+ G: sum(rows.map((row) => row.cost - row.depreciationBroughtForward)),
550
+ I: sum(rows.map((row) => row.depreciationCharge)),
551
+ J: sum(rows.map((row) => row.depreciationCarriedForward)),
552
+ K: sum(rows.map((row) => row.netBookValueCarriedForward)),
553
+ O: sum(rows.map((row) => row.taxWrittenDownValue)),
554
+ Q: sum(rows.map((row) => row.investmentAllowance)),
555
+ R: sum(rows.map((row) => row.writingDownAllowance)),
556
+ S: sum(rows.map((row) => row.poolCarriedForward)),
557
+ V: sum(rows.map((row) => row.disposalProceeds || 0)),
558
+ W: sum(rows.map((row) => row.disposalCost)),
559
+ X: sum(rows.map((row) => row.disposalDepreciation)),
560
+ Y: sum(rows.map((row) => row.balancingAllowance)),
561
+ Z: sum(rows.map((row) => row.balancingCharge)),
562
+ };
563
+ }
564
+
565
+ function scheduleBlocks(rows) {
566
+ const blocks = {};
567
+ for (const [className, layout] of Object.entries(SCHEDULE_CLASSES)) {
568
+ blocks[className] = {
569
+ layout,
570
+ existing: scheduleTotals(rows.filter((row) => row.assetClass === className && !row.acquiredInYear)),
571
+ newAssets: scheduleTotals(rows.filter((row) => row.assetClass === className && row.acquiredInYear)),
572
+ };
573
+ }
574
+ blocks.allExisting = scheduleTotals(rows.filter((row) => !row.acquiredInYear));
575
+ blocks.allNew = scheduleTotals(rows.filter((row) => row.acquiredInYear));
576
+ blocks.whole = scheduleTotals(rows);
577
+ return blocks;
578
+ }
579
+
580
+ // ── Payroll ────────────────────────────────────────────────────────────────
581
+
582
+ // One month tab's payslip entries, in the order the writer fills the block.
583
+ function payrollEntriesByTab(scenario, tabs) {
584
+ const buckets = Object.fromEntries(tabs.map((tab) => [tab, []]));
585
+ for (const [monthKey, entries] of Object.entries(scenario.payroll || {})) {
586
+ const tab = SHORT_MONTHS.find((month) => month.toLowerCase() === monthKey);
587
+ if (tab === undefined || buckets[tab] === undefined) continue;
588
+ buckets[tab].push(...entries);
589
+ }
590
+ return buckets;
591
+ }
592
+
593
+ // A month tab gives an employee their line by position, so the first entry in
594
+ // a month's block belongs to the first employee on the Employee sheet. `keep`
595
+ // picks the positions a bucket counts, which is how the employees' and
596
+ // directors' halves of the month are told apart.
597
+ function payrollByTab(entriesByTab, keep = () => true) {
598
+ const buckets = {};
599
+ for (const [tab, allEntries] of Object.entries(entriesByTab)) {
600
+ const entries = allEntries.filter((entry, index) => keep(index));
601
+ buckets[tab] = entries.reduce(
602
+ (sums, entry) => ({
603
+ grossPay: sums.grossPay + (entry.grossPay || 0),
604
+ incomeTax: sums.incomeTax + (entry.incomeTax || 0),
605
+ employeeNI: sums.employeeNI + (entry.employeeNI || 0),
606
+ employerNI: sums.employerNI + (entry.employerNI || 0),
607
+ }),
608
+ { grossPay: 0, incomeTax: 0, employeeNI: 0, employerNI: 0 },
609
+ );
610
+ }
611
+ return buckets;
612
+ }
613
+
614
+ // The row each payroll month opens on, with the tax week and the days from
615
+ // 6 April that put it there.
616
+ function payrollMonthStarts() {
617
+ const starts = [];
618
+ let weeksBefore = 0;
619
+ for (let month = 1; month <= 12; month++) {
620
+ const daysBefore = weeksBefore === 0 ? 0 : PAYROLL_FIRST_WEEK_DAYS + (weeksBefore - 1) * 7;
621
+ starts.push({ month, row: PAYSLIPS_CALENDAR_FIRST_ROW + daysBefore, daysBefore, week: weeksBefore + 1 });
622
+ weeksBefore += PAYROLL_WEEKS_PER_MONTH[month - 1];
623
+ }
624
+ return starts;
625
+ }
626
+
627
+ // ── The engine ─────────────────────────────────────────────────────────────
628
+
629
+ // Every figure the package holds, in two parts: `results`, the cells the
630
+ // reconciliation reads, and `linkCells`, the leaf cells a sibling workbook's
631
+ // external link addresses and nothing else does. Both come off the same run,
632
+ // so a cell in each carries the same value.
633
+ function computeLtd(book, lines, taxData, scenario) {
634
+ const rate = scenario?.metadata?.vat_registered === false ? 0 : VAT_RATE;
635
+ const linkCells = {};
636
+ const link = (key, cells) => Object.assign((linkCells[key] ||= {}), cells);
637
+ const period = periodFrom(book);
638
+ const tabs = period.tabs;
639
+ const results = {};
640
+
641
+ const admin = buildAdmin(taxData, period);
642
+ results.Admin = admin;
643
+ link("Admin", { ...linkOnlyCells(adminDateChain(period), admin), N11: admin.B32 });
644
+
645
+ const salesByTab = bucketByTab(scenario.sales, tabs);
646
+ const purchasesByTab = bucketByTab(scenario.purchases, tabs);
647
+ const salesMonths = {};
648
+ const purchaseMonths = {};
649
+ for (const tab of tabs) {
650
+ salesMonths[tab] = journalMonthTotals(salesByTab[tab], rate, SALES_ANALYSIS_COLUMNS, "a", SALES_CIS_COLUMN);
651
+ purchaseMonths[tab] = journalMonthTotals(purchasesByTab[tab], rate, PURCHASE_ANALYSIS_COLUMNS, "g", PURCHASES_CIS_COLUMN);
652
+ results[`Sales.xlsx!${tab}`] = {
653
+ G1: salesMonths[tab].G1,
654
+ G2: salesMonths[tab].G2,
655
+ H1: salesMonths[tab].H1,
656
+ T1: salesMonths[tab].T1,
657
+ U1: salesMonths[tab].U1,
658
+ };
659
+ results[`Purchases.xlsx!${tab}`] = {
660
+ G1: purchaseMonths[tab].G1,
661
+ G2: purchaseMonths[tab].G2,
662
+ H1: purchaseMonths[tab].H1,
663
+ O1: purchaseMonths[tab].O1,
664
+ R1: purchaseMonths[tab].R1,
665
+ S1: purchaseMonths[tab].S1,
666
+ AI1: purchaseMonths[tab].AI1,
667
+ };
668
+ link(`Sales.xlsx!${tab}`, linkOnlyCells(salesMonths[tab], results[`Sales.xlsx!${tab}`]));
669
+ link(`Purchases.xlsx!${tab}`, linkOnlyCells(purchaseMonths[tab], results[`Purchases.xlsx!${tab}`]));
670
+ }
671
+ // A Sales tab's flat-rate cell is empty on the first tab and reads the tab
672
+ // before it after that, so every tab but the first, and both ledgers, hold
673
+ // nil. The two ledgers echo the first tab's VAT rate.
674
+ for (const tab of tabs.slice(1)) link(`Sales.xlsx!${tab}`, { G4: 0 });
675
+ for (const ledger of SALES_LEDGER_SHEETS) link(`Sales.xlsx!${ledger}`, { G2: salesMonths[tabs[0]].G2, G4: 0 });
676
+ for (const ledger of PURCHASES_LEDGER_SHEETS) link(`Purchases.xlsx!${ledger}`, { G2: purchaseMonths[tabs[0]].G2 });
677
+ const salesMonthly = (column) => tabs.map((tab) => salesMonths[tab][`${column}1`] || 0);
678
+ const purchasesMonthly = (column) => tabs.map((tab) => purchaseMonths[tab][`${column}1`] || 0);
679
+
680
+ const banks = bankMonthTotals(scenario, tabs, period.start);
681
+ for (const [fileName, file] of Object.entries(banks)) {
682
+ let balance = file.opening;
683
+ let openingOfLastMonth = balance;
684
+ for (const tab of tabs) {
685
+ openingOfLastMonth = balance;
686
+ const month = file.months[tab];
687
+ balance = balance + month.receipts - month.payments;
688
+ const layout = BANK_LAYOUTS[fileName];
689
+ link(`${fileName}!${tab}`, {
690
+ [`${layout.receipt.amount}1`]: month.receipts,
691
+ [`${layout.payment.amount}1`]: month.payments,
692
+ ...Object.fromEntries(layout.receiptColumns.map(([code, column]) => [`${column}1`, month.receiptCodes[code] || 0])),
693
+ ...Object.fromEntries(layout.paymentColumns.map(([code, column]) => [`${column}1`, month.paymentCodes[code] || 0])),
694
+ });
695
+ }
696
+ results[`${fileName}!${tabs[11]}`] = { A1: openingOfLastMonth, A2: balance };
697
+ }
698
+
699
+ const depreciationRates = {
700
+ land: taxData.depreciation?.land_and_property ?? 0,
701
+ plant: taxData.depreciation?.plant_and_machinery ?? 0,
702
+ fixtures: taxData.depreciation?.fixtures_and_fittings ?? 0,
703
+ computer: taxData.depreciation?.computer_equipment ?? 0,
704
+ motor: taxData.depreciation?.motor_vehicles ?? 0,
705
+ };
706
+ const openingBalance = scenario.opening_balance || {};
707
+ const scheduleRows = buildSchedule(scenario, rate, depreciationRates, admin.G5, admin.G6);
708
+ const blocks = scheduleBlocks(scheduleRows);
709
+ const scheduleSheet = buildScheduleSheet(blocks, openingBalance, depreciationRates);
710
+ // The reconciliation checks pin a disposal's WDA and balancing allowance
711
+ // split against the first existing motor row directly, not just the class
712
+ // total, so that row's own O/R/S/V/Y cells need a value here too.
713
+ const firstMotorRow = SCHEDULE_CLASSES.motor.existingRows[0];
714
+ const motorRow = scheduleRows.find((row) => row.row === firstMotorRow && !row.acquiredInYear);
715
+ if (motorRow) {
716
+ scheduleSheet[`O${firstMotorRow}`] = motorRow.taxWrittenDownValue;
717
+ scheduleSheet[`R${firstMotorRow}`] = motorRow.writingDownAllowance;
718
+ scheduleSheet[`S${firstMotorRow}`] = motorRow.poolCarriedForward;
719
+ if (motorRow.disposalProceeds !== undefined) scheduleSheet[`V${firstMotorRow}`] = motorRow.disposalProceeds;
720
+ if (motorRow.disposed) scheduleSheet[`Y${firstMotorRow}`] = motorRow.balancingAllowance;
721
+ }
722
+ results["Fixedassets.xlsx!Schedule"] = scheduleSheet;
723
+ link("Fixedassets.xlsx!Schedule", scheduleLinkCells(blocks, scheduleRows));
724
+ // The workbook's own tie-out between the schedule and the two ledgers. E11
725
+ // re-sums the schedule's New-asset cost totals (E6:E10 = Schedule E64, E75,
726
+ // E83, E94, E108) and K11 the disposal proceeds on both halves of each
727
+ // class (K6:K10 = Schedule V11+V64 and so on down). E13 and K13 read the
728
+ // ledgers' own annual fixed asset totals across a leaf-to-leaf link
729
+ // ([2]Mar!AI2 and [3]Mar!U2), and E15/K15 are the differences the sheet
730
+ // prints. Each side has to come off its own source: deriving both from the
731
+ // ledger leaves the difference nil whatever the schedule holds.
732
+ const purchasesFixedAssetTotal = sum(purchasesMonthly("AI"));
733
+ const salesFixedAssetTotal = sum(salesMonthly("U"));
734
+ link(`Purchases.xlsx!${tabs[11]}`, { AI2: purchasesFixedAssetTotal });
735
+ link(`Sales.xlsx!${tabs[11]}`, { U2: salesFixedAssetTotal });
736
+ results["Fixedassets.xlsx!FAreconciliation"] = {
737
+ E11: blocks.allNew.E,
738
+ E13: purchasesFixedAssetTotal,
739
+ E15: purchasesFixedAssetTotal - blocks.allNew.E,
740
+ K11: blocks.whole.V,
741
+ K13: salesFixedAssetTotal,
742
+ K15: salesFixedAssetTotal - blocks.whole.V,
743
+ };
744
+ const hp = buildHirePurchase(scenario);
745
+ results["Fixedassets.xlsx!HPfinance"] = hp.sheet;
746
+
747
+ const payrollEntries = payrollEntriesByTab(scenario, tabs);
748
+ const isDirectorsLine = (index) => Boolean((scenario.employees || [])[index]?.isDirector);
749
+ const payroll = payrollByTab(payrollEntries);
750
+ const employeePayroll = payrollByTab(payrollEntries, (index) => !isDirectorsLine(index));
751
+ const directorPayroll = payrollByTab(payrollEntries, isDirectorsLine);
752
+ results.WagesInterface = buildWagesInterface(employeePayroll, directorPayroll, tabs);
753
+ const yearStart = payrollYearStart(payrollYearOf(taxData, period));
754
+ results["Payslips.xlsx!Payment"] = buildPayslipsPayment(payroll, yearStart);
755
+ results["Payslips.xlsx!Admin"] = buildPayslipsCalendar(taxData, period, tabs);
756
+ for (const monthIndex of PAYSLIPS_DIRECTLY_READ_MONTH_INDEXES) {
757
+ const monthTab = buildPayslipsMonthTab(monthIndex, payrollEntries[tabs[monthIndex]] || []);
758
+ addPayslipsWeeklyRemnants(monthTab, monthIndex);
759
+ results[`Payslips.xlsx!${tabs[monthIndex]}`] = monthTab;
760
+ }
761
+ // Every month tab opens its monthly payroll block with the day the month it
762
+ // is named for begins, and carries its own whole-month totals on row 1 for
763
+ // the PAYE schedule to read, so all twelve carry those cells whether the
764
+ // reconciliation reads the rest of the tab or not.
765
+ for (let monthIndex = 0; monthIndex < 12; monthIndex++) {
766
+ const tab = tabs[monthIndex];
767
+ const key = `Payslips.xlsx!${tab}`;
768
+ if (!results[key]) results[key] = {};
769
+ results[key][payslipsPeriodStartCell(monthIndex)] = serialOf(payslipsMonthPeriod(period.start, monthIndex, yearStart).first);
770
+ results[key][PAYE_SCHEDULE_MONTH_TAB_CELLS.employerNI] = payroll[tab].employerNI;
771
+ results[key][PAYE_SCHEDULE_MONTH_TAB_CELLS.employeeNI] = payroll[tab].employeeNI;
772
+ results[key][PAYE_SCHEDULE_MONTH_TAB_CELLS.incomeTax] = payroll[tab].incomeTax;
773
+ results[key][PAYE_SCHEDULE_MONTH_TAB_CELLS.studentLoan] = 0;
774
+ // Row 1 also totals the month's gross pay, its statutory pay and its
775
+ // other deductions, and row 2 is the directors' block below it.
776
+ link(key, {
777
+ G1: 0,
778
+ M1: payroll[tab].grossPay,
779
+ Q1: 0,
780
+ M2: directorPayroll[tab].grossPay,
781
+ N2: directorPayroll[tab].incomeTax,
782
+ O2: directorPayroll[tab].employeeNI,
783
+ P2: 0,
784
+ Q2: 0,
785
+ T2: directorPayroll[tab].employerNI,
786
+ });
787
+ }
788
+ results[`Payslips.xlsx!${PAYSLIP_PRINT_SHEET}`] = buildPayslipsPrintPage(PAYSLIP_PRINT_PERIOD, tabs, payrollEntries);
789
+
790
+ const companySecretary = buildCompanySecretary(scenario);
791
+ Object.assign(results, companySecretary);
792
+ const boardMeeting = companySecretary["Companysecretary.xlsx!Boardmeeting"] || {};
793
+ const dividendDeclared = boardMeeting.E4 || 0;
794
+ const shareIssue = boardMeeting.E6 || 0;
795
+
796
+ const stock = buildStock(scenario, openingBalance, rate, salesMonthly("O"), purchasesMonthly("O"));
797
+ results.Stock = stock.sheet;
798
+ results.OpenAccounts = buildOpenAccounts(book, scenario, openingBalance);
799
+ link("OpenAccounts", openingFixedAssetCells(openingBalance));
800
+
801
+ const trialBalance = buildTrialBalance({
802
+ openingBalance,
803
+ salesMonthly,
804
+ purchasesMonthly,
805
+ banks,
806
+ payroll,
807
+ employeePayroll,
808
+ directorPayroll,
809
+ blocks,
810
+ stock,
811
+ tabs,
812
+ hp,
813
+ dividendDeclared,
814
+ shareIssue,
815
+ smallProfitsRatePercent: admin.P7,
816
+ });
817
+
818
+ const monthlyPl = buildMonthlyProfitAndLoss(trialBalance, tabs);
819
+ const publishedPl = buildPublishedProfitAndLoss(trialBalance, monthlyPl, admin);
820
+ const corporationTax = buildCorporationTax({ admin, trialBalance, blocks, publishedPl });
821
+
822
+ // The tax charge closes the books: the trial balance's corporation tax rows
823
+ // read it, the published P&L reads those rows back, and the retained profit
824
+ // that leaves is the reserve movement. That is the order the workbook's own
825
+ // recalculation takes.
826
+ trialBalance.EJ35 = trialBalance.corporationTaxCreditorBeforeCharge - corporationTax.K35 + trialBalance.EH35;
827
+ trialBalance.EJ47 = corporationTax.K35;
828
+ publishedPl.F50 = trialBalance.EJ47;
829
+ publishedPl.F51 = publishedPl.F49 - publishedPl.F50;
830
+ publishedPl.F52 = trialBalance.EJ48;
831
+ publishedPl.F54 = publishedPl.F51 - publishedPl.F52;
832
+ trialBalance.EJ43 = trialBalance.retainedEarningsBroughtForward - publishedPl.F54;
833
+ trialBalance.EJ49 = publishedPl.F54;
834
+ trialBalance.EJ91 = sum(AUDIT_ROWS.map((row) => trialBalance[`EJ${row}`] || 0));
835
+
836
+ results["MnthP&L"] = monthlyPl;
837
+ results.TrialBalance = trialBalanceReads(trialBalance);
838
+ results.CorporationTax = corporationTaxReads(corporationTax);
839
+ results.CT600 = buildCt600(corporationTax, monthlyPl, admin);
840
+ results["PubP&L"] = publishedPl;
841
+ results.PubBalSht = buildPublishedBalanceSheet(trialBalance, admin);
842
+ results.PubNotes = buildPublishedNotes(blocks, trialBalance, corporationTax, admin, depreciationRates);
843
+ results.Report = buildDirectorsReport(publishedPl, results.PubBalSht, companySecretary);
844
+
845
+ Object.assign(results, buildVatReturns(salesMonths, purchaseMonths, period, scenario, rate));
846
+
847
+ const mileageRate = taxData.mileage?.higher_rate_pence ?? 0;
848
+ for (const sheet of EXPENSES_FORM_MONTHS) results[`expensesform.xlsx!${sheet}`] = { C30: mileageRate };
849
+
850
+ Object.assign(results, buildSalesInvoice(scenario, rate, taxData));
851
+
852
+ return { results, linkCells };
853
+ }
854
+
855
+ // The cells the reconciliation reads.
856
+ export function calculateLtdResults(book, lines, taxData, scenario) {
857
+ return computeLtd(book, lines, taxData, scenario).results;
858
+ }
859
+
860
+ // Every cell a sibling workbook's link addresses, on top of the report's
861
+ // cells. The link-cache refresh reads its figures from here.
862
+ export function calculateLtdCells(book, lines, taxData, scenario) {
863
+ const { results, linkCells } = computeLtd(book, lines, taxData, scenario);
864
+ const cells = Object.fromEntries(Object.entries(results).map(([key, sheet]) => [key, { ...sheet }]));
865
+ for (const [key, sheet] of Object.entries(linkCells)) Object.assign((cells[key] ||= {}), sheet);
866
+ return cells;
867
+ }
868
+
869
+ // ── Admin ──────────────────────────────────────────────────────────────────
870
+
871
+ // Everything the generator injects from the tax-year data, plus the period
872
+ // dates the whole book hangs off. The corporation tax rows split the period
873
+ // at 31 March: L6 is the period start, N6 the earlier of the first financial
874
+ // year's end and the period end, L7 the day after and N7 the period end.
875
+ function buildAdmin(taxData, period) {
876
+ const corporationTax = taxData.corporation_tax || {};
877
+ const capitalAllowances = taxData.capital_allowances || {};
878
+ const depreciation = taxData.depreciation || {};
879
+ const mileage = taxData.mileage || {};
880
+ const vat = taxData.vat || {};
881
+
882
+ const yearEndSerial = serialOf(period.yearEnd);
883
+ const periodStartSerial = serialOf(period.start);
884
+ const financialYears = financialYearsInPeriod(period.start, period.yearEnd);
885
+
886
+ return {
887
+ B9: periodStartSerial,
888
+ B32: yearEndSerial,
889
+ F21: yearEndSerial,
890
+ P6: Math.round(corporationTax.small_profits_rate * 100),
891
+ P7: Math.round(corporationTax.small_profits_rate * 100),
892
+ P8: Math.round(corporationTax.main_rate * 100),
893
+ P9: corporationTax.marginal_relief_fraction,
894
+ P12: corporationTax.small_profits_limit,
895
+ P13: corporationTax.main_rate_limit,
896
+ G5: Math.round(capitalAllowances.annual_investment_allowance * 100),
897
+ G6: Math.round(capitalAllowances.writing_down_allowance_main * 100),
898
+ G7: Math.round(capitalAllowances.annual_investment_allowance * 100),
899
+ G8: Math.round(capitalAllowances.writing_down_allowance_main * 100),
900
+ G15: depreciation.land_and_property,
901
+ G16: depreciation.plant_and_machinery,
902
+ G17: depreciation.fixtures_and_fittings,
903
+ G18: depreciation.computer_equipment,
904
+ G19: depreciation.motor_vehicles,
905
+ N16: mileage.higher_rate_limit,
906
+ O16: mileage.higher_rate_pence,
907
+ N17: mileage.lower_rate_start,
908
+ O17: mileage.lower_rate_pence,
909
+ M19: Math.round(vat.standard_rate * 100),
910
+ M21: Math.round(vat.standard_rate * 100),
911
+ K6: financialYears.years[0].year,
912
+ K7: financialYears.years[1].year,
913
+ L6: periodStartSerial,
914
+ L7: serialOf(financialYears.years[1].start),
915
+ N6: serialOf(financialYears.years[0].end),
916
+ N7: yearEndSerial,
917
+ };
918
+ }
919
+
920
+ // Every row of the chain: an even row is a month end and the odd row under
921
+ // it the day the next month opens.
922
+ function adminDateChain(period) {
923
+ const chain = {};
924
+ for (let row = ADMIN_DATE_CHAIN_FIRST_ROW; row <= ADMIN_DATE_CHAIN_LAST_ROW; row++) {
925
+ chain[`B${row}`] = row % 2 === 0 ? serialOf(period.adminMonthEnd(row)) : chain[`B${row - 1}`] + 1;
926
+ }
927
+ return chain;
928
+ }
929
+
930
+ // The opening cost and depreciation the opening balance sheet splits across
931
+ // row 13, one column a class, for the classes the book carries a figure for.
932
+ function openingFixedAssetCells(openingBalance) {
933
+ const cells = {};
934
+ const cost = openingBalance.fixed_asset_cost || {};
935
+ const depreciation = openingBalance.fixed_asset_depreciation || {};
936
+ for (const [assetClass, columns] of Object.entries(OPENING_FIXED_ASSET_COLUMNS)) {
937
+ if (cost[assetClass] !== undefined) cells[`${columns.cost}13`] = cost[assetClass];
938
+ if (depreciation[assetClass] !== undefined) cells[`${columns.depreciation}13`] = depreciation[assetClass];
939
+ }
940
+ return cells;
941
+ }
942
+
943
+ // ── Fixed Assets schedule sheet ────────────────────────────────────────────
944
+
945
+ // The schedule cells only the Financialaccounts corporation tax page reads:
946
+ // each class's writing down allowance on the existing block, the investment
947
+ // allowance an asset bought in the year claims, and the date, description and
948
+ // cost the writer put on that asset's row. A new-asset row with no asset on
949
+ // it stays on the sheet's own blank branch.
950
+ function scheduleLinkCells(blocks, rows) {
951
+ const cells = {};
952
+ for (const className of SCHEDULE_ALLOWANCE_CLASSES) {
953
+ const layout = SCHEDULE_CLASSES[className];
954
+ cells[`R${layout.existingTotalRow}`] = blocks[className].existing.R;
955
+ for (const row of layout.newRows) {
956
+ cells[`Q${row}`] = rows.find((asset) => asset.row === row)?.investmentAllowance ?? SHEET_BLANK;
957
+ }
958
+ }
959
+ for (const row of SCHEDULE_CAR_ROWS) cells[`R${row}`] = SHEET_BLANK;
960
+ for (const asset of rows) {
961
+ if (!asset.acquiredInYear) continue;
962
+ cells[`B${asset.row}`] = asset.purchasedOn;
963
+ cells[`E${asset.row}`] = asset.cost;
964
+ if (asset.description !== undefined) cells[`C${asset.row}`] = asset.description;
965
+ }
966
+ return cells;
967
+ }
968
+
969
+ function buildScheduleSheet(blocks, openingBalance, depreciationRates) {
970
+ const sheet = {};
971
+ for (const [className, layout] of Object.entries(SCHEDULE_CLASSES)) {
972
+ const block = blocks[className];
973
+ for (const column of ["E", "F", "I", "W", "X"]) {
974
+ sheet[`${column}${layout.existingTotalRow}`] = block.existing[column];
975
+ sheet[`${column}${layout.newTotalRow}`] = block.newAssets[column];
976
+ }
977
+ sheet[layout.rateCell] = depreciationRates[className];
978
+
979
+ const openingCost = openingBalance.fixed_asset_cost?.[layout.openingKey] || 0;
980
+ const openingDepreciation = openingBalance.fixed_asset_depreciation?.[layout.openingKey] || 0;
981
+ const agrees = block.existing.E - block.existing.F === openingCost - openingDepreciation;
982
+ sheet[`B${layout.existingTotalRow}`] = agrees
983
+ ? `Existing ${layout.label}`
984
+ : className === "computer"
985
+ ? SCHEDULE_COMPUTER_DISAGREEMENT_TEXT
986
+ : SCHEDULE_DISAGREEMENT_TEXT;
987
+ }
988
+ sheet.E57 = blocks.allExisting.E;
989
+ sheet.E110 = blocks.allNew.E;
990
+ for (const column of ["E", "F", "I", "J", "K", "Q", "R", "V", "W", "X", "Y", "Z"]) {
991
+ sheet[`${column}1`] = blocks.whole[column];
992
+ }
993
+ // Net book value brought forward is stated for an asset already owned and
994
+ // left blank for one bought in the year, so the whole-schedule total is the
995
+ // existing blocks alone.
996
+ sheet.G1 = blocks.allExisting.G;
997
+ return sheet;
998
+ }
999
+
1000
+ // ── Hire purchase ──────────────────────────────────────────────────────────
1001
+
1002
+ // The "New Hire Purchase Agreements" block totals what the agreements
1003
+ // financed in E2, and each agreement's row splits its monthly payment into
1004
+ // capital and interest.
1005
+ function buildHirePurchase(scenario) {
1006
+ const agreements = (scenario.hp_agreements || []).slice(0, HP_AGREEMENT_ROWS.length);
1007
+ const sheet = { E2: sum(agreements.map((agreement) => agreement.amount_financed || 0)) };
1008
+ agreements.forEach((agreement, index) => {
1009
+ const row = HP_AGREEMENT_ROWS[index];
1010
+ const months = agreement.months || 0;
1011
+ const financed = agreement.amount_financed || 0;
1012
+ if (months <= 0 || financed <= 0) return;
1013
+ const interest = agreement.total_interest || 0;
1014
+ const adminCharges = agreement.admin_charges || 0;
1015
+ // The whole monthly payment, the interest inside it, and the capital that
1016
+ // is left: I = (E + F + G) / H, K = G / H, J = I - K.
1017
+ sheet[`I${row}`] = (financed + adminCharges + interest) / months;
1018
+ sheet[`K${row}`] = interest / months;
1019
+ sheet[`J${row}`] = sheet[`I${row}`] - sheet[`K${row}`];
1020
+ });
1021
+ return { sheet, longTermCreditor: sheet.E2 };
1022
+ }
1023
+
1024
+ // ── Payroll sheets ─────────────────────────────────────────────────────────
1025
+
1026
+ // One month a row in each of two blocks, employees from row 4 and directors
1027
+ // from row 17. The sheet works the employees' side out as the month's whole
1028
+ // payroll less the month tab's own directors sub-total, so what reaches the
1029
+ // P&L's two wages lines depends on which lines belong to a director.
1030
+ function buildWagesInterface(employeePayroll, directorPayroll, tabs) {
1031
+ const sheet = {};
1032
+ const block = (firstRow, payroll) =>
1033
+ tabs.forEach((tab, index) => {
1034
+ const row = firstRow + index;
1035
+ sheet[`C${row}`] = payroll[tab].grossPay;
1036
+ sheet[`D${row}`] = payroll[tab].incomeTax;
1037
+ sheet[`E${row}`] = payroll[tab].employeeNI;
1038
+ sheet[`H${row}`] = payroll[tab].employerNI;
1039
+ });
1040
+ block(WAGES_INTERFACE_EMPLOYEE_FIRST_ROW, employeePayroll);
1041
+ block(WAGES_INTERFACE_DIRECTOR_FIRST_ROW, directorPayroll);
1042
+ return sheet;
1043
+ }
1044
+
1045
+ // The PAYE remittance schedule, one row per tax month from row 4. B is the
1046
+ // month end and C the day the payment falls due, both counted off the payroll
1047
+ // year's first day. D is the National Insurance due, employer and employee, E
1048
+ // the income tax and I the whole amount payable; the statutory pay and student
1049
+ // loan columns the total also carries stay nil. A row takes the month tab
1050
+ // named for the calendar month its tax month ends in, so row 4 is April
1051
+ // whatever the package's year end.
1052
+ function buildPayslipsPayment(payroll, payrollYearOpens) {
1053
+ const sheet = {};
1054
+ PAYE_SCHEDULE_MONTH_TABS.forEach((tab, taxMonth) => {
1055
+ const row = PAYE_SCHEDULE_FIRST_ROW + taxMonth;
1056
+ const { ends, due } = payeTaxMonthDates(payrollYearOpens, taxMonth);
1057
+ sheet[`B${row}`] = serialOf(ends);
1058
+ sheet[`C${row}`] = serialOf(due);
1059
+ const nationalInsurance = payroll[tab].employerNI + payroll[tab].employeeNI;
1060
+ sheet[`D${row}`] = nationalInsurance;
1061
+ sheet[`E${row}`] = payroll[tab].incomeTax;
1062
+ sheet[`I${row}`] = nationalInsurance + payroll[tab].incomeTax;
1063
+ });
1064
+ return sheet;
1065
+ }
1066
+
1067
+ // The rows a month tab's weekly blocks keep for an employee paid weekly, and
1068
+ // the row the last of them totals into. Every fixture pays monthly, so the
1069
+ // weekly gate never reads true and these resolve to the sheet's own
1070
+ // not-carried-forward branch: nil where the template holds a figure, blank
1071
+ // where it holds text.
1072
+ const PAYSLIPS_WEEKLY_ROWS = [11, 12, 13, 14, 15];
1073
+ const PAYSLIPS_WEEKLY_PERIOD_TOTAL_CELL = "T41";
1074
+ // The columns the following month's block brings a part-finished weekly cycle
1075
+ // forward in. K starts a row lower than the rest, and M -- the payslip total
1076
+ // -- brings its blank forward rather than a nil, so neither engine carries it.
1077
+ const PAYSLIPS_BROUGHT_FORWARD_COLUMNS = ["H", "I", "J", "L"];
1078
+ const PAYSLIPS_BROUGHT_FORWARD_LATE_COLUMN = { column: "K", firstRow: 12 };
1079
+
1080
+ // One month tab's monthly payroll block: an employee a row from block row +
1081
+ // 3, with the wages-paid date above them. A row the scenario has no employee
1082
+ // for keeps the three columns the template ships as a literal zero and stays
1083
+ // blank in the other five, which is what the workbook itself carries there.
1084
+ function buildPayslipsMonthTab(monthIndex, entries) {
1085
+ const sheet = {};
1086
+ const columns = PAYSLIPS_ENTRY_COLUMNS;
1087
+ payslipsMonthEntryRows(monthIndex).forEach((row, index) => {
1088
+ const entry = entries[index];
1089
+ if (!entry) {
1090
+ for (const column of PAYSLIPS_ZERO_FILLED_COLUMNS) sheet[`${column}${row}`] = 0;
1091
+ return;
1092
+ }
1093
+ if (entry.name) sheet[`${columns.name}${row}`] = entry.name;
1094
+ if (entry.taxCode) sheet[`${columns.taxCode}${row}`] = entry.taxCode;
1095
+ sheet[`${columns.grossPay}${row}`] = entry.grossPay || 0;
1096
+ sheet[`${columns.incomeTax}${row}`] = entry.incomeTax || 0;
1097
+ sheet[`${columns.employeeNI}${row}`] = entry.employeeNI || 0;
1098
+ sheet[`${columns.netPay}${row}`] = entry.netPay || 0;
1099
+ sheet[`${columns.employerNI}${row}`] = entry.employerNI || 0;
1100
+ if (entry.reference) sheet[`${columns.reference}${row}`] = entry.reference;
1101
+ });
1102
+ if (entries.length > 0) sheet[payslipsWagesPaidCell(monthIndex)] = serialOf(parseDate(entries[0].date));
1103
+ return sheet;
1104
+ }
1105
+
1106
+ // Template position 3 keeps its weekly employee lines and its period total;
1107
+ // position 4 keeps the cells that would bring an unfinished weekly cycle in
1108
+ // from the month before it.
1109
+ function addPayslipsWeeklyRemnants(sheet, monthIndex) {
1110
+ if (monthIndex === PAYSLIPS_DIRECTLY_READ_MONTH_INDEXES[0]) {
1111
+ sheet[PAYSLIPS_WEEKLY_PERIOD_TOTAL_CELL] = 0;
1112
+ return;
1113
+ }
1114
+ if (monthIndex !== PAYSLIPS_DIRECTLY_READ_MONTH_INDEXES[1]) return;
1115
+ for (const row of PAYSLIPS_WEEKLY_ROWS) {
1116
+ for (const column of PAYSLIPS_BROUGHT_FORWARD_COLUMNS) sheet[`${column}${row}`] = 0;
1117
+ if (row >= PAYSLIPS_BROUGHT_FORWARD_LATE_COLUMN.firstRow) sheet[`${PAYSLIPS_BROUGHT_FORWARD_LATE_COLUMN.column}${row}`] = 0;
1118
+ }
1119
+ }
1120
+
1121
+ // The page the employer prints. H3 and H4 are the join -- the tab the chosen
1122
+ // period lands on and the row its block starts at -- and I9, I10 and L7 the
1123
+ // heading it prints above the figures. Everything below the heading is the
1124
+ // first employee's own line on that month tab, gated on M8, the payroll
1125
+ // number their Employee-sheet block gives them once their starting date has
1126
+ // arrived. The year-to-date row runs from the payroll year's first month, so
1127
+ // it adds that employee's line over every month up to the one printed.
1128
+ function buildPayslipsPrintPage(period, tabs, entriesByTab) {
1129
+ const monthIndex = period - 1;
1130
+ const sheet = {
1131
+ [PAYSLIP_PRINT_CELLS.tab]: tabs[monthIndex],
1132
+ [PAYSLIP_PRINT_CELLS.blockRow]: monthlyPayrollBlockRow(monthIndex),
1133
+ [PAYSLIP_PRINT_CELLS.heading]: PAYSLIP_PRINT_MONTHLY_HEADING,
1134
+ [PAYSLIP_PRINT_CELLS.periodNumber]: period,
1135
+ };
1136
+ const entries = entriesByTab[tabs[monthIndex]] || [];
1137
+ if (entries.length === 0) return sheet;
1138
+
1139
+ const entry = entries[0];
1140
+ sheet[PAYSLIP_PRINT_CELLS.periodEnd] = serialOf(parseDate(entry.date));
1141
+ sheet.M8 = PAYSLIP_PRINT_FIRST_PAYROLL_NUMBER;
1142
+ for (const [cell, field] of Object.entries(PAYSLIP_PRINT_PERIOD_CELLS)) sheet[cell] = entry[field] || 0;
1143
+
1144
+ const toDate = tabs.slice(0, period).flatMap((tab) => (entriesByTab[tab] || []).slice(0, 1));
1145
+ for (const [cell, field] of Object.entries(PAYSLIP_PRINT_TO_DATE_CELLS)) {
1146
+ sheet[cell] = toDate.reduce((total, line) => total + (line[field] || 0), 0);
1147
+ }
1148
+ // M18's ADDRESS now points at the same cell I9 does -- the wages-paid date.
1149
+ sheet.M18 = sheet[PAYSLIP_PRINT_CELLS.periodEnd];
1150
+ return sheet;
1151
+ }
1152
+
1153
+ // The year the package's payroll runs in, which is the year its tax data
1154
+ // opens in. A book that carries no tax data derives it the way cellWrites
1155
+ // does: the year before the year end's own calendar year, unless the year
1156
+ // end falls in January to March, when the payroll year already opened the
1157
+ // April before that year.
1158
+ function payrollYearOf(taxData, period) {
1159
+ const financialYearStart = taxData.financial_year?.start;
1160
+ if (financialYearStart) return new Date(financialYearStart).getUTCFullYear();
1161
+ const targetStartYear = period.yearEnd.getUTCFullYear() - 1;
1162
+ const yearEndMonth = period.yearEnd.getUTCMonth() + 1;
1163
+ return yearEndMonth <= 3 ? targetStartYear : targetStartYear + 1;
1164
+ }
1165
+
1166
+ // The calendar every payslip dates from. B2 carries the payroll year's first
1167
+ // day and every date under it is the row above plus one, so naming the row a
1168
+ // month opens on names its date, its tax week and its week within the month.
1169
+ // The payroll year is the tax year the package was generated for, not the
1170
+ // accounting period, so a company with a June year end still runs its payroll
1171
+ // from the 6 April the rates start on. Column A is headed "Month Sheet" and
1172
+ // is the printed payslip's join, so it names the package's own month tabs in
1173
+ // order rather than the calendar months the dates beside it fall in.
1174
+ function buildPayslipsCalendar(taxData, period, tabs) {
1175
+ const anchor = serialOf(payrollYearStart(payrollYearOf(taxData, period)));
1176
+ const sheet = { B2: anchor };
1177
+ for (const { month, row, daysBefore, week } of payrollMonthStarts()) {
1178
+ sheet[`A${row}`] = tabs[month - 1];
1179
+ sheet[`B${row}`] = anchor + daysBefore;
1180
+ sheet[`C${row}`] = week;
1181
+ sheet[`D${row}`] = month;
1182
+ sheet[`F${row}`] = 1;
1183
+ }
1184
+ return sheet;
1185
+ }
1186
+
1187
+ // ── Company secretary ──────────────────────────────────────────────────────
1188
+
1189
+ // The share register, the board minute and the register of charges. Row 3 of
1190
+ // the members register carries the template's own fully-paid ordinary share
1191
+ // placeholder, which is where F1 takes the nominal value from whether or not
1192
+ // a scenario names any members.
1193
+ function buildCompanySecretary(scenario) {
1194
+ const register = { F1: SHARE_NOMINAL_VALUE };
1195
+ const members = scenario.members || [];
1196
+ members.forEach((member, index) => {
1197
+ const row = REGISTER_MEMBER_ROWS[index];
1198
+ if (row === undefined) return;
1199
+ register[`A${row}`] = member.name;
1200
+ register[`G${row}`] = member.shares;
1201
+ });
1202
+ // The directors' report prints the first two members a line each, so those
1203
+ // two share cells read back as nil rather than as nothing even when the
1204
+ // register is empty.
1205
+ for (const row of REGISTER_MEMBER_ROWS.slice(0, 2)) if (register[`G${row}`] === undefined) register[`G${row}`] = 0;
1206
+ register.G1 = sum(REGISTER_MEMBER_ROWS.map((row) => register[`G${row}`] || 0));
1207
+
1208
+ // The minute and the charges register are read whether or not a scenario
1209
+ // fills them: a company that minuted no dividend still has a sheet, and the
1210
+ // checks that read it are the ones that would catch a dividend appearing
1211
+ // from nowhere.
1212
+ const boardMeeting = {};
1213
+ if (scenario.dividend) {
1214
+ boardMeeting.E4 = scenario.dividend.declared || 0;
1215
+ if (scenario.dividend.board_meeting) boardMeeting.F2 = serialOf(parseDate(scenario.dividend.board_meeting));
1216
+ }
1217
+
1218
+ const charges = {};
1219
+ (scenario.charges || []).forEach((charge, index) => {
1220
+ const row = CHARGE_REGISTER_ROWS[index];
1221
+ if (row === undefined) return;
1222
+ charges[`C${row}`] = charge.valuation;
1223
+ });
1224
+
1225
+ // The register of directors and secretary, and the register of directors'
1226
+ // interests: every officer the book declares, in the order it declares
1227
+ // them, with the business's own address and the capacity each was
1228
+ // appointed in. A director's interest is dated from the day the register of
1229
+ // members says they acquired their shares.
1230
+ const officers = {};
1231
+ const interests = {};
1232
+ const directors = registerOfficers(scenario);
1233
+ const business = scenario.business || {};
1234
+ const officerAddress = [business.address, business.town, business.postcode].filter(Boolean).join(", ") || undefined;
1235
+ directors.forEach((director, index) => {
1236
+ const officerRow = DIRECTOR_SECRETARY_OFFICER_ROWS[index];
1237
+ if (officerRow !== undefined) {
1238
+ officers[`A${officerRow}`] = director.name;
1239
+ if (officerAddress) officers[`B${officerRow}`] = officerAddress;
1240
+ if (director.appointed) officers[`C${officerRow}`] = serialOf(parseDate(director.appointed));
1241
+ officers[`D${officerRow}`] = director.role || "Director";
1242
+ }
1243
+ const interestRow = DIRECTORS_INTERESTS_ROWS[index];
1244
+ if (interestRow === undefined) return;
1245
+ interests[`A${interestRow}`] = director.name;
1246
+ if (officerAddress) interests[`B${interestRow}`] = officerAddress;
1247
+ const holding = members.find((member) => member.name === director.name);
1248
+ if (holding?.acquired) interests[`C${interestRow}`] = serialOf(parseDate(holding.acquired));
1249
+ });
1250
+
1251
+ return {
1252
+ "Companysecretary.xlsx!RegisterofMembers": register,
1253
+ "Companysecretary.xlsx!Boardmeeting": boardMeeting,
1254
+ "Companysecretary.xlsx!Charges&Debentures": charges,
1255
+ "Companysecretary.xlsx!Directors&Secretary": officers,
1256
+ "Companysecretary.xlsx!DirectorsInterests": interests,
1257
+ };
1258
+ }
1259
+
1260
+ // ── The customer-facing invoice ────────────────────────────────────────────
1261
+
1262
+ // The sample carriage charge cellWrites puts on the invoice's Invoice
1263
+ // Database!E2 for a VAT-registered scenario (app/products/ltd.js).
1264
+ const SALESINVOICE_SAMPLE_CARRIAGE_CHARGE = 37.5;
1265
+
1266
+ // Salesinvoice.xlsx links to nothing else in the book. The generator writes
1267
+ // the tax year's standard rate down Product Details column D, and the invoice
1268
+ // page looks its one sample line up from there.
1269
+ //
1270
+ // cellWrites only raises the sample invoice line for a VAT-registered
1271
+ // business with a first sale to anchor it on (rate > 0, a VAT number, and a
1272
+ // sale) -- the same condition checkCompliance's carriage checks gate on
1273
+ // (app/products/ltd.js). A recalculated package proves the two states: with
1274
+ // the line raised, Invoice Template!N27 through P64 resolve to real figures
1275
+ // off the sample line and its carriage charge; without it, N27 stays blank
1276
+ // and every cell downstream of it follows -- P58, P62 and P64 land on their
1277
+ // formulas' own zero branch, while C38, J38, L38, P38 and the carriage cell
1278
+ // P60 land on their formulas' own blank-string branch. This mirrors both
1279
+ // states rather than assuming one.
1280
+ function buildSalesInvoice(scenario, rate, taxData) {
1281
+ const standardRatePercent = Math.round((taxData?.vat?.standard_rate ?? 0) * 100);
1282
+ const productDetails = { D2: standardRatePercent };
1283
+ const firstInvoiceSale = Object.values(scenario.sales || {}).flat()[0];
1284
+ if (!(rate > 0 && scenario.business?.vat_number && firstInvoiceSale)) {
1285
+ return {
1286
+ "Salesinvoice.xlsx!Product Details": productDetails,
1287
+ "Salesinvoice.xlsx!Invoice Template": {
1288
+ J38: SHEET_BLANK,
1289
+ L38: SHEET_BLANK,
1290
+ P38: SHEET_BLANK,
1291
+ V38: 0,
1292
+ P58: 0,
1293
+ P60: SHEET_BLANK,
1294
+ P62: 0,
1295
+ P64: 0,
1296
+ },
1297
+ };
1298
+ }
1299
+ const lineNet = firstInvoiceSale.amount;
1300
+ const lineVat = (lineNet * standardRatePercent) / 100;
1301
+ const carriageVat = (SALESINVOICE_SAMPLE_CARRIAGE_CHARGE * standardRatePercent) / 100;
1302
+ const vatTotal = lineVat + carriageVat;
1303
+ return {
1304
+ "Salesinvoice.xlsx!Product Details": productDetails,
1305
+ "Salesinvoice.xlsx!Invoice Template": {
1306
+ J38: lineNet,
1307
+ L38: 1,
1308
+ P38: lineNet,
1309
+ V38: lineVat,
1310
+ P58: lineNet,
1311
+ P60: SALESINVOICE_SAMPLE_CARRIAGE_CHARGE,
1312
+ P62: vatTotal,
1313
+ P64: lineNet + SALESINVOICE_SAMPLE_CARRIAGE_CHARGE + vatTotal,
1314
+ },
1315
+ };
1316
+ }
1317
+
1318
+ // ── Stock ──────────────────────────────────────────────────────────────────
1319
+
1320
+ // The sheet runs a row per month end, carrying the opening figure forward,
1321
+ // adding the materials bought and taking out the materials its own percentage
1322
+ // reckons went into the product sales. With no percentage the materials
1323
+ // column stays switched off and the calculated value is the opening figure
1324
+ // all the way down. Only the final row takes a physical count, and the
1325
+ // difference between count and calculation is the year's stock adjustment.
1326
+ function buildStock(scenario, openingBalance, rate, productASalesMonthly, materialsBoughtMonthly) {
1327
+ const opening = openingBalance.stock || 0;
1328
+ const materialsPercent = scenario.stock?.materials_percent;
1329
+ const active = materialsPercent !== undefined;
1330
+
1331
+ const movements = materialsBoughtMonthly.map((bought, index) => (active ? bought - materialsPercent * productASalesMonthly[index] : 0));
1332
+ const calculated = opening + sum(movements);
1333
+ const count = scenario.stock?.closing ?? calculated;
1334
+ const adjustment = count === calculated ? 0 : count - calculated;
1335
+ movements[movements.length - 1] += adjustment;
1336
+
1337
+ return {
1338
+ sheet: { D6: opening, D30: calculated, AB30: count, Z30: adjustment },
1339
+ opening,
1340
+ closing: opening + sum(movements),
1341
+ movements,
1342
+ };
1343
+ }
1344
+
1345
+ // ── Opening balance sheet ──────────────────────────────────────────────────
1346
+
1347
+ function buildOpenAccounts(book, scenario, openingBalance) {
1348
+ const entity = book.entityInformation || {};
1349
+ const business = scenario.business || {};
1350
+ const directors = (scenario.employees || []).filter((employee) => employee.isDirector);
1351
+
1352
+ const cost = sumValuesOf(openingBalance.fixed_asset_cost);
1353
+ const depreciation = sumValuesOf(openingBalance.fixed_asset_depreciation);
1354
+ const bank =
1355
+ (openingBalance.current_account || 0) +
1356
+ (openingBalance.savings_account || 0) +
1357
+ (openingBalance.credit_card || 0) +
1358
+ (openingBalance.cash || 0);
1359
+ const taxAndSocial = (openingBalance.paye_due || 0) + (openingBalance.vat_due || 0) + (openingBalance.cis_due || 0);
1360
+
1361
+ const assets =
1362
+ cost -
1363
+ depreciation +
1364
+ (openingBalance.stock || 0) +
1365
+ (openingBalance.trade_debtors || 0) +
1366
+ bank +
1367
+ (openingBalance.long_term_debtors || 0);
1368
+ const liabilities =
1369
+ (openingBalance.trade_creditors || 0) +
1370
+ (openingBalance.net_wages_due || 0) +
1371
+ (openingBalance.wage_deductions_due || 0) +
1372
+ (openingBalance.dividends_due || 0) +
1373
+ (openingBalance.corporation_tax || 0) +
1374
+ taxAndSocial +
1375
+ (openingBalance.directors_loan || 0) +
1376
+ (openingBalance.long_term_creditors || 0) +
1377
+ (openingBalance.share_capital || 0) +
1378
+ (openingBalance.retained_earnings || 0) +
1379
+ (openingBalance.capital_reserves || 0);
1380
+
1381
+ const sheet = {
1382
+ E2: business.name || entity.organizationIdentifier || "",
1383
+ E13: cost - depreciation,
1384
+ E15: openingBalance.stock || 0,
1385
+ E16: openingBalance.trade_debtors || 0,
1386
+ E18: bank,
1387
+ E20: openingBalance.trade_creditors || 0,
1388
+ E24: openingBalance.corporation_tax || 0,
1389
+ E26: taxAndSocial,
1390
+ E30: openingBalance.directors_loan || 0,
1391
+ E33: openingBalance.share_capital || 0,
1392
+ E34: openingBalance.retained_earnings || 0,
1393
+ E37: assets - liabilities,
1394
+ E48: 0,
1395
+ };
1396
+ if (business.company_number) sheet.E3 = business.company_number;
1397
+ if (business.phone) sheet.E4 = business.phone;
1398
+ const description = business.description || entity.organizationDescription;
1399
+ if (description) sheet.E8 = description;
1400
+ if (business.address) sheet.J3 = business.address;
1401
+ if (business.town) sheet.J4 = business.town;
1402
+ if (business.postcode) sheet.N6 = business.postcode;
1403
+ if (business.utr) sheet.O3 = business.utr;
1404
+ if (directors[0]?.name) sheet.E5 = directors[0].name;
1405
+ return sheet;
1406
+ }
1407
+
1408
+ // ── Trial balance ──────────────────────────────────────────────────────────
1409
+
1410
+ // Every closing balance the statements read. The workbook builds each one as
1411
+ // the opening figure plus twelve months of movement plus a year-end
1412
+ // adjustment, so that is the order here: the opening column first, then the
1413
+ // journals, the banks and the payroll, then the schedule and the board
1414
+ // minute. Each income and expense row keeps its twelve monthly figures beside
1415
+ // its total, because the management P&L reads the months and the published
1416
+ // statements read the year.
1417
+ function buildTrialBalance(input) {
1418
+ const {
1419
+ openingBalance,
1420
+ salesMonthly,
1421
+ purchasesMonthly,
1422
+ banks,
1423
+ payroll,
1424
+ employeePayroll,
1425
+ directorPayroll,
1426
+ blocks,
1427
+ stock,
1428
+ tabs,
1429
+ hp,
1430
+ dividendDeclared,
1431
+ shareIssue,
1432
+ } = input;
1433
+ const openingCost = openingBalance.fixed_asset_cost || {};
1434
+ const openingDepreciation = openingBalance.fixed_asset_depreciation || {};
1435
+ const receipts = (code) => bankCodeMonths(banks, code, "receipt", tabs);
1436
+ const payments = (code) => bankCodeMonths(banks, code, "payment", tabs);
1437
+ const negated = (values) => values.map((value) => -value);
1438
+ const added = (...series) => series[0].map((_, index) => sum(series.map((values) => values[index])));
1439
+
1440
+ const tb = { monthly: {} };
1441
+
1442
+ // The opening column, cell by cell from the opening balance sheet.
1443
+ tb.D6 = openingCost.land_buildings || 0;
1444
+ tb.D7 = openingCost.plant_machinery || 0;
1445
+ tb.D8 = openingCost.fixtures_fittings || 0;
1446
+ tb.D9 = openingCost.computer_technology || 0;
1447
+ tb.D10 = openingCost.motor_vehicles || 0;
1448
+ tb.D11 = -(openingDepreciation.land_buildings || 0);
1449
+ tb.D12 = -(openingDepreciation.plant_machinery || 0);
1450
+ tb.D13 = -(openingDepreciation.fixtures_fittings || 0);
1451
+ tb.D14 = -(openingDepreciation.computer_technology || 0);
1452
+ tb.D15 = -(openingDepreciation.motor_vehicles || 0);
1453
+ tb.D19 = openingBalance.stock || 0;
1454
+ tb.D20 = openingBalance.trade_debtors || 0;
1455
+ tb.D22 = openingBalance.current_account || 0;
1456
+ tb.D23 = openingBalance.savings_account || 0;
1457
+ tb.D24 = openingBalance.credit_card || 0;
1458
+ tb.D25 = openingBalance.cash || 0;
1459
+ tb.D28 = -(openingBalance.trade_creditors || 0);
1460
+ tb.D29 = -(openingBalance.net_wages_due || 0);
1461
+ tb.D30 = -(openingBalance.wage_deductions_due || 0);
1462
+ tb.D31 = -(openingBalance.dividends_due || 0);
1463
+ tb.D32 = -(openingBalance.cis_due || 0);
1464
+ tb.D33 = -(openingBalance.vat_due || 0);
1465
+ tb.D34 = -(openingBalance.paye_due || 0);
1466
+ tb.D35 = -(openingBalance.corporation_tax || 0);
1467
+ tb.D37 = openingBalance.long_term_debtors || 0;
1468
+ tb.D39 = -(openingBalance.directors_loan || 0);
1469
+ tb.D40 = -(openingBalance.long_term_creditors || 0);
1470
+ tb.D42 = -(openingBalance.share_capital || 0);
1471
+ tb.D43 = -(openingBalance.retained_earnings || 0);
1472
+ tb.D44 = -(openingBalance.capital_reserves || 0);
1473
+ tb.D91 = sum(
1474
+ Object.entries(tb)
1475
+ .filter(([key]) => /^D\d+$/.test(key))
1476
+ .map(([, value]) => value),
1477
+ );
1478
+
1479
+ // Income and expense rows, month by month. The sales analysis columns are
1480
+ // held as credits, which is why the management P&L negates them back.
1481
+ const monthly = tb.monthly;
1482
+ monthly[53] = negated(salesMonthly("O"));
1483
+ monthly[54] = negated(salesMonthly("P"));
1484
+ monthly[55] = negated(salesMonthly("Q"));
1485
+ monthly[56] = negated(salesMonthly("R"));
1486
+ monthly[57] = negated(salesMonthly("S"));
1487
+ monthly[58] = negated(receipts("K"));
1488
+ monthly[60] = added(purchasesMonthly("O"), negated(stock.movements));
1489
+ monthly[61] = purchasesMonthly("P");
1490
+ monthly[62] = purchasesMonthly("Q");
1491
+ monthly[64] = tabs.map((tab) => employeePayroll[tab].grossPay);
1492
+ monthly[65] = purchasesMonthly("S");
1493
+ monthly[66] = added(
1494
+ purchasesMonthly("R"),
1495
+ tabs.map((tab) => directorPayroll[tab].grossPay),
1496
+ );
1497
+ monthly[67] = tabs.map((tab) => payroll[tab].employerNI);
1498
+ for (const [row, column] of Object.entries({
1499
+ 68: "T",
1500
+ 69: "U",
1501
+ 70: "V",
1502
+ 71: "W",
1503
+ 72: "X",
1504
+ 73: "Y",
1505
+ 74: "Z",
1506
+ 75: "AA",
1507
+ 76: "AB",
1508
+ 77: "AC",
1509
+ 78: "AD",
1510
+ 79: "AE",
1511
+ 80: "AF",
1512
+ })) {
1513
+ monthly[row] = purchasesMonthly(column);
1514
+ }
1515
+ monthly[81] = negated(salesMonthly("T"));
1516
+ monthly[82] = payments("J");
1517
+ monthly[83] = payments("B");
1518
+ monthly[84] = purchasesMonthly("AG");
1519
+ monthly[85] = purchasesMonthly("AH");
1520
+ // Depreciation and the loss on disposal reach the books a twelfth a month.
1521
+ const lossOnDisposal = blocks.whole.W - blocks.whole.X - blocks.whole.V;
1522
+ monthly[86] = zeroMonths().map(() => lossOnDisposal / 12);
1523
+ monthly[87] = zeroMonths().map(() => blocks.whole.I / 12);
1524
+ monthly[88] = negated(receipts("X"));
1525
+ monthly[89] = payments("X");
1526
+ monthly[19] = stock.movements;
1527
+ for (const [row, values] of Object.entries(monthly)) tb[`EJ${row}`] = sum(values) + (tb[`D${row}`] || 0);
1528
+
1529
+ // Interest received arrives net of the tax deducted at source, and the
1530
+ // computation charges the gross figure. EH58 is the grossing-up and EH35
1531
+ // gives the same tax back as a credit against the charge.
1532
+ tb.EH58 = tb.EJ58 / ((100 - input.smallProfitsRatePercent) / 100) - tb.EJ58;
1533
+ tb.EJ58 += tb.EH58;
1534
+ tb.EH35 = -tb.EH58;
1535
+
1536
+ // The fixed asset rows. Cost carries the additions and drops the disposals,
1537
+ // accumulated depreciation carries the year's charge and drops the
1538
+ // disposals' own, and the two accrual rows take the additions and the
1539
+ // disposal proceeds back out so the year's movement lands once.
1540
+ for (const [className, row] of Object.entries(SCHEDULE_COST_ROWS)) {
1541
+ const block = blocks[className];
1542
+ tb[`EJ${row}`] = (tb[`D${row}`] || 0) + block.newAssets.E - (block.existing.W + block.newAssets.W);
1543
+ }
1544
+ for (const [className, row] of Object.entries(SCHEDULE_DEPRECIATION_ROWS)) {
1545
+ const block = blocks[className];
1546
+ tb[`EJ${row}`] = (tb[`D${row}`] || 0) - (block.existing.I + block.newAssets.I) + (block.existing.X + block.newAssets.X);
1547
+ }
1548
+ tb.EJ16 = sum(purchasesMonthly("AI")) - blocks.allNew.E;
1549
+ tb.EJ17 = -sum(salesMonthly("U")) - lossOnDisposal + (blocks.whole.W - blocks.whole.X);
1550
+
1551
+ // Debtors, creditors and the bank.
1552
+ tb.EJ20 = tb.D20 + sum(salesMonthly("F")) - sum(salesMonthly(SALES_CIS_COLUMN)) - sum(receipts("DR"));
1553
+ tb.EJ22 = tb.D22 + bankNet(banks["Currentaccount.xlsx"]);
1554
+ tb.EJ23 = tb.D23 + bankNet(banks["Savingaccount.xlsx"]);
1555
+ tb.EJ24 = tb.D24 + bankNet(banks["Creditcardaccount.xlsx"]);
1556
+ tb.EJ25 = tb.D25 + bankNet(banks["Cashaccount.xlsx"]) + shareIssue;
1557
+ tb.EJ26 = intraTransfers(banks);
1558
+ tb.EJ28 = tb.D28 - sum(purchasesMonthly("F")) + sum(purchasesMonthly(PURCHASES_CIS_COLUMN)) + sum(payments("CR")) + hp.longTermCreditor;
1559
+ tb.EJ29 = tb.D29;
1560
+ tb.EJ30 = tb.D30;
1561
+ tb.EJ31 = tb.D31 + sum(payments("DV")) - dividendDeclared;
1562
+ tb.EJ32 =
1563
+ tb.D32 + sum(salesMonthly(SALES_CIS_COLUMN)) - sum(purchasesMonthly(PURCHASES_CIS_COLUMN)) - sum(receipts("RC")) + sum(payments("RC"));
1564
+ tb.EJ33 = tb.D33 - sum(salesMonthly("G")) + sum(purchasesMonthly("G")) - sum(receipts("RV")) + sum(payments("RV"));
1565
+ tb.L34 = -sum(tabs.slice(0, 1).map((tab) => payroll[tab].incomeTax + payroll[tab].employeeNI + payroll[tab].employerNI));
1566
+ tb.EJ34 =
1567
+ tb.D34 + sum(payments("RP")) - sum(tabs.map((tab) => payroll[tab].incomeTax + payroll[tab].employeeNI + payroll[tab].employerNI));
1568
+ tb.EJ37 = tb.D37 - sum(receipts("LDR")) + sum(payments("LDR"));
1569
+ tb.EJ39 = tb.D39 - sum(receipts("DL")) + sum(payments("DL"));
1570
+ tb.EJ40 = tb.D40 - sum(receipts("LCR")) + sum(payments("LCR")) - hp.longTermCreditor;
1571
+ tb.EJ42 = tb.D42 - shareIssue;
1572
+ tb.EJ44 = tb.D44;
1573
+ tb.EJ48 = dividendDeclared;
1574
+
1575
+ tb.corporationTaxCreditorBeforeCharge = tb.D35 + sum(payments("RT"));
1576
+ tb.retainedEarningsBroughtForward = tb.D43;
1577
+ return tb;
1578
+ }
1579
+
1580
+ function bankNet(file) {
1581
+ if (!file) return 0;
1582
+ return sum(Object.values(file.months).map((month) => month.receipts - month.payments));
1583
+ }
1584
+
1585
+ // The transfer legs each workbook carries, so a movement between two accounts
1586
+ // nets to nil across the pair.
1587
+ function intraTransfers(banks) {
1588
+ let total = 0;
1589
+ for (const [fileName, file] of Object.entries(banks)) {
1590
+ for (const month of Object.values(file.months)) {
1591
+ for (const code of BANK_LAYOUTS[fileName].transfers) {
1592
+ total += -(month.receiptCodes[code] || 0) + (month.paymentCodes[code] || 0);
1593
+ }
1594
+ }
1595
+ }
1596
+ return total;
1597
+ }
1598
+
1599
+ // The cells the reconciliation reads off the trial balance.
1600
+ function trialBalanceReads(tb) {
1601
+ return Object.fromEntries(TRIAL_BALANCE_READS.map((cell) => [cell, tb[cell] || 0]));
1602
+ }
1603
+
1604
+ // ── Management profit and loss ─────────────────────────────────────────────
1605
+
1606
+ // Column B is the year and columns C to N the twelve months. A month column
1607
+ // is that month's own movement on the trial balance row the line reads.
1608
+ function buildMonthlyProfitAndLoss(tb, tabs) {
1609
+ const pl = {};
1610
+ const setRow = (row, values) => {
1611
+ tabs.forEach((tab, index) => {
1612
+ pl[`${MONTH_COLS[index]}${row}`] = values[index];
1613
+ });
1614
+ pl[`B${row}`] = sum(values);
1615
+ };
1616
+ const negated = (values) => values.map((value) => -value);
1617
+
1618
+ // The five turnover lines sit 49 rows above their trial balance rows, and
1619
+ // the trial balance holds income as a credit, which is why they come back
1620
+ // negated.
1621
+ for (const row of SALES_PL_ROWS) setRow(row, negated(tb.monthly[row + SALES_ROW_OFFSET]));
1622
+ setRow(
1623
+ 9,
1624
+ tabs.map((_, index) => sum(SALES_PL_ROWS.map((row) => pl[`${MONTH_COLS[index]}${row}`]))),
1625
+ );
1626
+
1627
+ pl.B11 = tb.EJ60;
1628
+ setRow(12, tb.monthly[61]);
1629
+ setRow(13, tb.monthly[62]);
1630
+ pl.B14 = pl.B11 + pl.B12 + pl.B13;
1631
+ pl.B16 = pl.B9 - pl.B14;
1632
+
1633
+ pl.B18 = tb.EJ64 + tb.EJ65;
1634
+ pl.B19 = tb.EJ66;
1635
+ pl.B20 = tb.EJ67;
1636
+ for (const [row, source] of Object.entries(EXPENSE_PL_ROWS)) setRow(row, tb.monthly[source]);
1637
+ setRow(SALES_BAD_DEBT_ROW, tb.monthly[81]);
1638
+ pl.B35 = tb.EJ82;
1639
+ pl.B36 = tb.EJ83 + tb.EJ88 + tb.EJ89;
1640
+ setRow(37, tb.monthly[84]);
1641
+ setRow(38, tb.monthly[85]);
1642
+ setRow(39, tb.monthly[86]);
1643
+ setRow(40, tb.monthly[87]);
1644
+
1645
+ pl.B41 = sum(Array.from({ length: 23 }, (_, index) => pl[`B${18 + index}`] || 0));
1646
+ pl.B43 = pl.B16 - pl.B41;
1647
+ // The month columns carry the interest as the accounts received it. The
1648
+ // grossing-up for tax deducted at source is a year-end adjustment the
1649
+ // working sheet reads, not a movement any month saw.
1650
+ pl.B44 = -sum(tb.monthly[58]);
1651
+ pl.B45 = pl.B43 + pl.B44;
1652
+ return pl;
1653
+ }
1654
+
1655
+ // ── Published statements ───────────────────────────────────────────────────
1656
+
1657
+ function buildPublishedProfitAndLoss(tb, pl, admin) {
1658
+ const turnover = -(tb.EJ53 + tb.EJ54 + tb.EJ55 + tb.EJ56);
1659
+ const grants = -tb.EJ57;
1660
+ const sheet = {
1661
+ D3: admin.B32,
1662
+ E5: admin.B32,
1663
+ B9: 0,
1664
+ B14: 0,
1665
+ B18: 0,
1666
+ B54: 0,
1667
+ F7: turnover,
1668
+ F8: grants,
1669
+ F9: turnover + grants,
1670
+ F16: pl.B14,
1671
+ F44: pl.B41,
1672
+ };
1673
+ sheet.F18 = sheet.F9 - sheet.F16;
1674
+ sheet.F46 = sheet.F18 - sheet.F44;
1675
+ sheet.F49 = sheet.F46 - tb.EJ58;
1676
+ return sheet;
1677
+ }
1678
+
1679
+ function buildPublishedBalanceSheet(tb, admin) {
1680
+ const fixedAssets = sum([6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].map((row) => tb[`EJ${row}`] || 0));
1681
+ const statementAccounts = tb.EJ22 + tb.EJ23 + tb.EJ24;
1682
+ const sheet = {
1683
+ D2: admin.B32,
1684
+ F6: fixedAssets,
1685
+ E10: tb.EJ19,
1686
+ E11: tb.EJ20,
1687
+ E12: statementAccounts > 0 ? statementAccounts + tb.EJ25 + tb.EJ26 : tb.EJ25,
1688
+ E16: -(tb.EJ28 + tb.EJ29 + tb.EJ30 + tb.EJ31),
1689
+ E17: -tb.EJ35,
1690
+ E18: -(tb.EJ32 + tb.EJ33 + tb.EJ34),
1691
+ E29: -tb.EJ39,
1692
+ E30: -tb.EJ40,
1693
+ F36: -tb.EJ42,
1694
+ };
1695
+ // The reserves rows sit under the share capital and roll into the
1696
+ // shareholders' funds total, which is the only one of the three the
1697
+ // statements quote.
1698
+ const revenueReserve = -tb.EJ43;
1699
+ const capitalReserve = -tb.EJ44;
1700
+ sheet.E13 = sheet.E10 + sheet.E11 + sheet.E12;
1701
+ sheet.E20 = sheet.E16 + sheet.E17 + sheet.E18;
1702
+ sheet.F22 = sheet.E13 - sheet.E20;
1703
+ sheet.F26 = sheet.F6 + sheet.F22;
1704
+ sheet.F31 = sheet.E29 + sheet.E30;
1705
+ sheet.F33 = sheet.F26 - sheet.F31;
1706
+ sheet.F39 = sheet.F36 + revenueReserve + capitalReserve;
1707
+ return sheet;
1708
+ }
1709
+
1710
+ // The fixed asset note reads the schedule class by class, and the emoluments
1711
+ // and tax notes read the trial balance and the working sheet.
1712
+ function buildPublishedNotes(blocks, tb, corporationTax, admin, depreciationRates) {
1713
+ const sheet = { A11: admin.B32 };
1714
+ const columns = [];
1715
+ for (const [className, layout] of Object.entries(SCHEDULE_CLASSES)) {
1716
+ const { existing, newAssets } = blocks[className];
1717
+ const column = layout.noteColumn;
1718
+ columns.push(column);
1719
+ sheet[`${column}8`] = Math.max(existing.E, 0);
1720
+ sheet[`${column}9`] = Math.max(newAssets.E, 0);
1721
+ sheet[`${column}10`] = Math.max(existing.W + newAssets.W, 0);
1722
+ sheet[`${column}11`] = sheet[`${column}8`] + sheet[`${column}9`] - sheet[`${column}10`];
1723
+ sheet[`${column}14`] = Math.max(existing.F, 0);
1724
+ sheet[`${column}15`] = Math.max(existing.I + newAssets.I, 0);
1725
+ sheet[`${column}16`] = Math.max(existing.X + newAssets.X, 0);
1726
+ sheet[`${column}17`] = sheet[`${column}14`] - sheet[`${column}16`] + sheet[`${column}15`];
1727
+ sheet[`${column}20`] = sheet[`${column}11`] - sheet[`${column}17`];
1728
+ }
1729
+ for (const row of [8, 9, 10, 11, 14, 15, 16, 17, 20]) {
1730
+ sheet[`G${row}`] = sum(columns.map((column) => sheet[`${column}${row}`]));
1731
+ }
1732
+ sheet.B27 = depreciationRates.land;
1733
+ sheet.B28 = depreciationRates.plant;
1734
+ sheet.B29 = depreciationRates.fixtures;
1735
+ sheet.B30 = depreciationRates.computer;
1736
+ sheet.B31 = depreciationRates.motor;
1737
+ sheet.D35 = tb.EJ66;
1738
+ sheet.D41 = corporationTax.K35;
1739
+ return sheet;
1740
+ }
1741
+
1742
+ function buildDirectorsReport(publishedPl, publishedBalanceSheet, companySecretary) {
1743
+ const register = companySecretary["Companysecretary.xlsx!RegisterofMembers"];
1744
+ const boardMeeting = companySecretary["Companysecretary.xlsx!Boardmeeting"] || {};
1745
+ const sheet = {
1746
+ F22: publishedBalanceSheet.D2,
1747
+ E87: publishedPl.F9,
1748
+ H87: publishedPl.B9,
1749
+ D94: boardMeeting.E4 || 0,
1750
+ I95: register.G1,
1751
+ F97: register.G3 ?? 0,
1752
+ F98: register.G4 ?? 0,
1753
+ };
1754
+ if (publishedPl.F9 > 0) sheet.D89 = publishedPl.F18 / publishedPl.F9;
1755
+ if (publishedPl.B9 > 0) sheet.I89 = publishedPl.B18 / publishedPl.B9;
1756
+ if (register.A3) sheet.A97 = register.A3;
1757
+ if (register.A4) sheet.A98 = register.A4;
1758
+ return sheet;
1759
+ }
1760
+
1761
+ // ── Corporation tax working sheet and CT600 ────────────────────────────────
1762
+
1763
+ function buildCorporationTax({ admin, trialBalance, blocks, publishedPl }) {
1764
+ const goodwill = trialBalance.EJ85 > 0 ? trialBalance.EJ85 : 0;
1765
+ const depreciation = trialBalance.EJ87 > 0 ? trialBalance.EJ87 : 0;
1766
+ const netBalancingCharge = blocks.whole.W > 0 ? blocks.whole.Z - blocks.whole.Y : 0;
1767
+
1768
+ const sheet = {
1769
+ E5: admin.L6,
1770
+ H5: admin.N7,
1771
+ K5: publishedPl.F46,
1772
+ I15: Math.max(blocks.allNew.Q, 0),
1773
+ I16: Math.max(blocks.allNew.R, 0),
1774
+ I17: Math.max(blocks.allExisting.R, 0),
1775
+ I18: netBalancingCharge !== 0 ? -netBalancingCharge : 0,
1776
+ };
1777
+ if (goodwill > 0) sheet.I7 = goodwill;
1778
+ if (depreciation > 0) sheet.I8 = depreciation;
1779
+ sheet.K10 = goodwill + depreciation;
1780
+ sheet.K12 = sheet.K5 + sheet.K10;
1781
+ sheet.K20 = sheet.I15 + sheet.I16 + sheet.I17 + sheet.I18;
1782
+ sheet.K22 = sheet.K12 - sheet.K20;
1783
+ sheet.K24 = -trialBalance.EJ58;
1784
+ sheet.K26 = 0;
1785
+ sheet.K28 = sheet.K22 + sheet.K24 - sheet.K26;
1786
+
1787
+ const financialYears = financialYearsInPeriod(fromSerial(admin.L6), fromSerial(admin.N7));
1788
+ const charge = apportionCorporationTax(sheet.K28, financialYears.years, financialYears.totalDays, {
1789
+ smallProfitsRatePercent: [admin.P6, admin.P7],
1790
+ mainRatePercent: admin.P8,
1791
+ marginalReliefFraction: admin.P9,
1792
+ lowerLimit: admin.P12,
1793
+ upperLimit: admin.P13,
1794
+ });
1795
+
1796
+ sheet.A33 = financialYears.years[0].days;
1797
+ sheet.A34 = financialYears.years[1].days;
1798
+ sheet.A35 = financialYears.totalDays;
1799
+ sheet.E33 = admin.K6;
1800
+ sheet.E34 = admin.K7;
1801
+ charge.rows.forEach((row, index) => {
1802
+ const sheetRow = 33 + index;
1803
+ sheet[`F${sheetRow}`] = row.profitShare;
1804
+ sheet[`G${sheetRow}`] = row.ratePercent;
1805
+ sheet[`J${sheetRow}`] = row.taxBeforeRelief;
1806
+ sheet[`L${sheetRow}`] = row.marginalRelief;
1807
+ sheet[`I${sheetRow}`] = row.tax;
1808
+ });
1809
+ sheet.K35 = charge.tax;
1810
+ sheet.K37 = trialBalance.EH35;
1811
+ sheet.K39 = sheet.K35 - sheet.K37;
1812
+ sheet.marginalRelief = charge.marginalRelief;
1813
+ return sheet;
1814
+ }
1815
+
1816
+ function corporationTaxReads(sheet) {
1817
+ const reads = {};
1818
+ for (const [key, value] of Object.entries(sheet)) if (/^[A-Z]+\d+$/.test(key)) reads[key] = value;
1819
+ return reads;
1820
+ }
1821
+
1822
+ // The filed form, box by box, from the working sheet beside it. A form states
1823
+ // a figure only when there is one to state: a box the sheet leaves blank
1824
+ // rather than nil is left out here too, and a year that made a loss files
1825
+ // nothing in the trading profit and chargeable profit boxes.
1826
+ function buildCt600(corporationTax, pl, admin) {
1827
+ const sheet = {
1828
+ B33: admin.L6,
1829
+ M33: admin.N7,
1830
+ AK66: pl.B9,
1831
+ C126: corporationTax.E33,
1832
+ N126: corporationTax.F33,
1833
+ AA126: corporationTax.G33,
1834
+ AJ126: corporationTax.J33,
1835
+ AJ128: corporationTax.J34,
1836
+ };
1837
+ // The second financial year's row is stated only when the period reaches
1838
+ // into it.
1839
+ if (corporationTax.A34 > 0) {
1840
+ sheet.C128 = corporationTax.E34;
1841
+ sheet.N128 = corporationTax.F34;
1842
+ sheet.AA128 = corporationTax.G34;
1843
+ }
1844
+ if (corporationTax.K22 > 0) sheet.Z70 = corporationTax.K22;
1845
+ if (corporationTax.K26 > 0) sheet.Z72 = corporationTax.K26;
1846
+ if (corporationTax.K24 > 0) sheet.AJ76 = corporationTax.K24;
1847
+ sheet.AJ74 = (sheet.Z70 || 0) - (sheet.Z72 || 0);
1848
+ sheet.AJ92 = sheet.AJ74 > 0 ? sheet.AJ74 + (sheet.AJ76 || 0) : 0;
1849
+ sheet.AJ110 = sheet.AJ92;
1850
+ sheet.AJ131 = sheet.AJ126 + sheet.AJ128;
1851
+ sheet.Y133 = corporationTax.marginalRelief;
1852
+ sheet.Y135 = sheet.AJ131 - sheet.Y133;
1853
+ sheet.AJ145 = sheet.Y135;
1854
+ sheet.AJ154 = corporationTax.K37 > 0 ? corporationTax.K37 : 0;
1855
+ sheet.AJ159 = sheet.AJ145 > 0 ? sheet.AJ145 - sheet.AJ154 : 0;
1856
+ sheet.AJ166 = sheet.AJ159 > 0 ? sheet.AJ159 : 0;
1857
+ if (sheet.Y135 > 0 && sheet.AJ110 !== 0) sheet.W137 = (sheet.Y135 * 100) / sheet.AJ110;
1858
+ return sheet;
1859
+ }
1860
+
1861
+ // ── VAT interface and returns ──────────────────────────────────────────────
1862
+
1863
+ // One interface row per VAT period, in date order: the two periods before the
1864
+ // accounting year, its own twelve months, then the three after. Columns E, G,
1865
+ // I and K carry the rolling three-row sums a quarterly return reads, and each
1866
+ // return looks its own quarter end up in column B.
1867
+ function buildVatReturns(salesMonths, purchaseMonths, period, scenario, rate) {
1868
+ const rows = [];
1869
+ for (let row = VATINTERFACE_FIRST_ROW; row <= VATINTERFACE_LAST_ROW; row++) {
1870
+ const adminRow = VATINTERFACE_FIRST_ADMIN_ROW + (row - VATINTERFACE_FIRST_ROW) * 2;
1871
+ const monthIndex = row - VATINTERFACE_FIRST_MONTH_ROW;
1872
+ const tab = monthIndex >= 0 && monthIndex < 12 ? period.tabs[monthIndex] : null;
1873
+ const straddling = straddlingTotals(scenario, row, rate);
1874
+ rows.push({
1875
+ row,
1876
+ end: serialOf(period.adminMonthEnd(adminRow)),
1877
+ due: serialOf(period.adminMonthEnd(adminRow + 2)),
1878
+ salesNet: tab ? salesMonths[tab].H1 : straddling.salesNet,
1879
+ salesVat: tab ? salesMonths[tab].G1 : straddling.salesVat,
1880
+ purchasesNet: tab ? purchaseMonths[tab].H1 : straddling.purchasesNet,
1881
+ purchasesVat: tab ? purchaseMonths[tab].G1 : straddling.purchasesVat,
1882
+ });
1883
+ }
1884
+
1885
+ const byRow = Object.fromEntries(rows.map((entry) => [entry.row, entry]));
1886
+ const rolling = (row, field) => sum([row - 2, row - 1, row].map((index) => (byRow[index] ? byRow[index][field] : 0)));
1887
+
1888
+ const sheet = {};
1889
+ for (const entry of rows) {
1890
+ sheet[`B${entry.row}`] = entry.end;
1891
+ sheet[`C${entry.row}`] = entry.due;
1892
+ sheet[`D${entry.row}`] = entry.salesNet;
1893
+ sheet[`F${entry.row}`] = entry.salesVat;
1894
+ sheet[`H${entry.row}`] = entry.purchasesNet;
1895
+ sheet[`J${entry.row}`] = entry.purchasesVat;
1896
+ sheet[`M${entry.row}`] = 0;
1897
+ if (entry.row < VATINTERFACE_FIRST_MONTH_ROW) continue;
1898
+ sheet[`E${entry.row}`] = rolling(entry.row, "salesNet");
1899
+ sheet[`G${entry.row}`] = rolling(entry.row, "salesVat");
1900
+ sheet[`I${entry.row}`] = rolling(entry.row, "purchasesNet");
1901
+ sheet[`K${entry.row}`] = rolling(entry.row, "purchasesVat");
1902
+ }
1903
+
1904
+ const results = { "Vatreturns.xlsx!Vatinterface": sheet };
1905
+ for (let quarter = 1; quarter <= 5; quarter++) {
1906
+ const row = VATINTERFACE_FIRST_MONTH_ROW - 1 + quarter * 3;
1907
+ const outputVat = sheet[`G${row}`] || 0;
1908
+ const inputVat = sheet[`K${row}`] || 0;
1909
+ results[`Vatreturns.xlsx!VATQtr${quarter}`] = {
1910
+ G5: byRow[row].end,
1911
+ G7: byRow[row].due,
1912
+ G9: outputVat,
1913
+ G13: outputVat,
1914
+ G15: inputVat,
1915
+ G17: outputVat - inputVat,
1916
+ G21: sheet[`E${row}`] || 0,
1917
+ G23: sheet[`I${row}`] || 0,
1918
+ };
1919
+ }
1920
+ return results;
1921
+ }
1922
+
1923
+ function straddlingTotals(scenario, row, rate) {
1924
+ const totals = { salesNet: 0, salesVat: 0, purchasesNet: 0, purchasesVat: 0 };
1925
+ const period = Object.entries(STRADDLING_PERIOD_ROWS).find(([, entryRow]) => entryRow === row)?.[0];
1926
+ if (!period) return totals;
1927
+ for (const entry of scenario.vat_straddling_sales || []) {
1928
+ if (entry.period !== period) continue;
1929
+ totals.salesVat += sheetVat(entry.amount, rate);
1930
+ totals.salesNet += sheetNet(entry.amount, rate);
1931
+ }
1932
+ for (const entry of scenario.vat_straddling_purchases || []) {
1933
+ if (entry.period !== period) continue;
1934
+ totals.purchasesVat += sheetVat(entry.amount, rate);
1935
+ totals.purchasesNet += sheetNet(entry.amount, rate);
1936
+ }
1937
+ return totals;
1938
+ }