@diy-accounting-uk/diya-gl 1.2.1 → 1.2.3

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.
@@ -682,6 +682,7 @@ format = "integer"
682
682
  notes = "Use when the AP straddles two financial years with different limits. Not for quarterly instalment payers"
683
683
  element = "FirstYear"
684
684
  xmlPath = "/IRenvelope/CompanyTaxReturn/CompanyTaxCalculation/CorporationTaxChargeable/AssociatedCompanies/AssociatedCompaniesFinancialYears/FirstYear"
685
+ sheetCell = "Financialaccounts.xlsx!CT600!Y118"
685
686
 
686
687
  [[box]]
687
688
  number = 328
@@ -691,6 +692,7 @@ format = "integer"
691
692
  notes = "As box 327"
692
693
  element = "SecondYear"
693
694
  xmlPath = "/IRenvelope/CompanyTaxReturn/CompanyTaxCalculation/CorporationTaxChargeable/AssociatedCompanies/AssociatedCompaniesFinancialYears/SecondYear"
695
+ sheetCell = "Financialaccounts.xlsx!CT600!Y120"
694
696
 
695
697
  [[box]]
696
698
  number = 329
@@ -289,16 +289,14 @@
289
289
  "rows": [
290
290
  {
291
291
  "box": 326,
292
- "rule": { "op": "fixed", "value": 0, "why": "the working sheet's marginal relief assumes no associated companies" }
293
- },
294
- {
295
- "box": 327,
296
- "rule": { "op": "fixed", "value": 0, "why": "the working sheet's marginal relief assumes no associated companies" }
297
- },
298
- {
299
- "box": 328,
300
- "rule": { "op": "fixed", "value": 0, "why": "the working sheet's marginal relief assumes no associated companies" }
292
+ "rule": {
293
+ "op": "fixed",
294
+ "value": 0,
295
+ "why": "the sheet reports associated companies by financial year in boxes 327 and 328, not as a whole-period count"
296
+ }
301
297
  },
298
+ { "box": 327 },
299
+ { "box": 328 },
302
300
  {
303
301
  "box": 329,
304
302
  "rule": {
@@ -848,7 +848,7 @@
848
848
  "box": "102",
849
849
  "label": "Adjustment to profits chargeable to Class 4 NICs",
850
850
  "cell": null,
851
- "field": "adjustmentToProfitsForClass4",
851
+ "field": "adjustments.adjustmentToProfitsForClass4",
852
852
  "route": "annual"
853
853
  },
854
854
  {
@@ -259,6 +259,58 @@
259
259
  "scorecard": "101279 passed, 0 warnings, 0 failed"
260
260
  }
261
261
  }
262
+ },
263
+ {
264
+ "tag": "diya-gl-v1.2.1",
265
+ "date": "2026-09-10",
266
+ "formatVersion": "diya-gl/1",
267
+ "engineVersion": "1.2.1+411866032aec",
268
+ "taxDataHash": "6a9c036b5f9c",
269
+ "reconciledCommit": "320b4b8a1585e16fa170c2d312c14df38d0bcf5b",
270
+ "templates": {
271
+ "bst": {
272
+ "hash": "0d003e10e00c",
273
+ "scorecard": "810 passed, 0 warnings, 0 failed"
274
+ },
275
+ "taxi": {
276
+ "hash": "1ff69654d64d",
277
+ "scorecard": "686 passed, 0 warnings, 0 failed"
278
+ },
279
+ "se": {
280
+ "hash": "c6a50059cc90",
281
+ "scorecard": "7679 passed, 0 warnings, 0 failed"
282
+ },
283
+ "ltd": {
284
+ "hash": "0c18ceeaced4",
285
+ "scorecard": "101279 passed, 0 warnings, 0 failed"
286
+ }
287
+ }
288
+ },
289
+ {
290
+ "tag": "diya-gl-v1.2.2",
291
+ "date": "2026-09-10",
292
+ "formatVersion": "diya-gl/1",
293
+ "engineVersion": "1.2.2+f31df0f7451c",
294
+ "taxDataHash": "6a9c036b5f9c",
295
+ "reconciledCommit": "320b4b8a1585e16fa170c2d312c14df38d0bcf5b",
296
+ "templates": {
297
+ "bst": {
298
+ "hash": "0d003e10e00c",
299
+ "scorecard": "810 passed, 0 warnings, 0 failed"
300
+ },
301
+ "taxi": {
302
+ "hash": "1ff69654d64d",
303
+ "scorecard": "686 passed, 0 warnings, 0 failed"
304
+ },
305
+ "se": {
306
+ "hash": "c6a50059cc90",
307
+ "scorecard": "7679 passed, 0 warnings, 0 failed"
308
+ },
309
+ "ltd": {
310
+ "hash": "0c18ceeaced4",
311
+ "scorecard": "101279 passed, 0 warnings, 0 failed"
312
+ }
313
+ }
262
314
  }
263
315
  ]
264
316
  }
@@ -31,6 +31,7 @@ import {
31
31
  STRADDLING_COLUMNS,
32
32
  STOCK_MATERIALS_PERCENT_CELL,
33
33
  STOCK_FINAL_COUNT_CELL,
34
+ ADMIN_ASSOCIATED_COMPANIES_CELL,
34
35
  SCHEDULE_ASSET_CLASSES,
35
36
  SCHEDULE_NEW_ASSET_ROWS,
36
37
  CHARGE_REGISTER_ROWS,
@@ -619,6 +620,7 @@ export function isLtdInputCell(file, sheet, cellRef, monthTabs = monthTabOrder(3
619
620
  case "Financialaccounts.xlsx":
620
621
  if (sheet === "OpenAccounts") return isOpenAccountsInputCell(cellRef);
621
622
  if (sheet === "Stock") return cellRef === STOCK_MATERIALS_PERCENT_CELL || cellRef === STOCK_FINAL_COUNT_CELL;
623
+ if (sheet === "Admin") return cellRef === ADMIN_ASSOCIATED_COMPANIES_CELL;
622
624
  return false;
623
625
  default:
624
626
  return false;
@@ -33,11 +33,11 @@ const SCHEMA_ROOT_FROM_APP = ["..", "web", "spreadsheets.diyaccounting.co.uk", "
33
33
  // serves the templates the DIYA-GL pages use, and keeps it in a user cache so
34
34
  // every later run works with no network.
35
35
  //
36
- // This stays on /books/ while the site still serves the pages there. Every
37
- // published version fetches this URL, so it moves only once the live site
38
- // redirects it, and the redirect keeps the older versions working after that.
36
+ // Both the current /diya-gl/ and legacy /books/ paths resolve — the old one
37
+ // through a redirect — so published versions built before the path moved keep
38
+ // working.
39
39
  const TEMPLATE_PREFIX = "templates/";
40
- const DEFAULT_TEMPLATE_SOURCE = "https://spreadsheets.diyaccounting.co.uk/books/assets/";
40
+ const DEFAULT_TEMPLATE_SOURCE = "https://spreadsheets.diyaccounting.co.uk/diya-gl/assets/";
41
41
 
42
42
  let templateTermsAnnounced = false;
43
43
 
@@ -638,7 +638,7 @@ function computeLtd(book, lines, taxData, scenario) {
638
638
  const tabs = period.tabs;
639
639
  const results = {};
640
640
 
641
- const admin = buildAdmin(taxData, period);
641
+ const admin = buildAdmin(taxData, period, scenario.business?.associated_companies ?? 0);
642
642
  results.Admin = admin;
643
643
  link("Admin", { ...linkOnlyCells(adminDateChain(period), admin), N11: admin.B32 });
644
644
 
@@ -872,7 +872,7 @@ export function calculateLtdCells(book, lines, taxData, scenario) {
872
872
  // dates the whole book hangs off. The corporation tax rows split the period
873
873
  // at 31 March: L6 is the period start, N6 the earlier of the first financial
874
874
  // year's end and the period end, L7 the day after and N7 the period end.
875
- function buildAdmin(taxData, period) {
875
+ function buildAdmin(taxData, period, associatedCompanies) {
876
876
  const corporationTax = taxData.corporation_tax || {};
877
877
  const capitalAllowances = taxData.capital_allowances || {};
878
878
  const depreciation = taxData.depreciation || {};
@@ -893,6 +893,7 @@ function buildAdmin(taxData, period) {
893
893
  P9: corporationTax.marginal_relief_fraction,
894
894
  P12: corporationTax.small_profits_limit,
895
895
  P13: corporationTax.main_rate_limit,
896
+ P14: associatedCompanies,
896
897
  G5: Math.round(capitalAllowances.annual_investment_allowance * 100),
897
898
  G6: Math.round(capitalAllowances.writing_down_allowance_main * 100),
898
899
  G7: Math.round(capitalAllowances.annual_investment_allowance * 100),
@@ -1791,6 +1792,7 @@ function buildCorporationTax({ admin, trialBalance, blocks, publishedPl }) {
1791
1792
  marginalReliefFraction: admin.P9,
1792
1793
  lowerLimit: admin.P12,
1793
1794
  upperLimit: admin.P13,
1795
+ associatedCompanies: admin.P14,
1794
1796
  });
1795
1797
 
1796
1798
  sheet.A33 = financialYears.years[0].days;
@@ -1833,6 +1835,7 @@ function buildCt600(corporationTax, pl, admin) {
1833
1835
  AA126: corporationTax.G33,
1834
1836
  AJ126: corporationTax.J33,
1835
1837
  AJ128: corporationTax.J34,
1838
+ Y118: admin.P14,
1836
1839
  };
1837
1840
  // The second financial year's row is stated only when the period reaches
1838
1841
  // into it.
@@ -1840,6 +1843,7 @@ function buildCt600(corporationTax, pl, admin) {
1840
1843
  sheet.C128 = corporationTax.E34;
1841
1844
  sheet.N128 = corporationTax.F34;
1842
1845
  sheet.AA128 = corporationTax.G34;
1846
+ sheet.Y120 = admin.P14;
1843
1847
  }
1844
1848
  if (corporationTax.K22 > 0) sheet.Z70 = corporationTax.K22;
1845
1849
  if (corporationTax.K26 > 0) sheet.Z72 = corporationTax.K26;
@@ -58,8 +58,19 @@ function primaryField(boxes, boxNumber) {
58
58
  return fieldsOf(boxEntry(boxes, boxNumber))[0];
59
59
  }
60
60
 
61
- function setPath(target, path, value) {
61
+ // "__proto__", "constructor" and "prototype" are not field names, they are
62
+ // the prototype chain -- a path carrying one would write onto Object.prototype
63
+ // rather than the object this call meant to build. Every path here comes from
64
+ // sa103-mtd-mapping.json's own "field" column, so a segment like this is a
65
+ // defect in that mapping file and must fail loudly rather than land quietly
66
+ // on some other object.
67
+ const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
68
+
69
+ export function setPath(target, path, value) {
62
70
  const parts = path.split(".");
71
+ for (const part of parts) {
72
+ if (UNSAFE_PATH_SEGMENTS.has(part)) throw new Error(`setPath: "${part}" is not a field name, in path "${path}"`);
73
+ }
63
74
  let node = target;
64
75
  for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]] ||= {};
65
76
  node[parts[parts.length - 1]] = value;
@@ -405,7 +416,8 @@ export function buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, option
405
416
  const box24 = boxEntry(boxes, "24");
406
417
  warnings.push({
407
418
  field: fieldsOf(box24)[1],
408
- reason: "SA103F box 24 combines advertising and business entertainment in one Profit & Loss Account row; the template cannot separate them.",
419
+ reason:
420
+ "SA103F box 24 combines advertising and business entertainment in one Profit & Loss Account row; the template cannot separate them.",
409
421
  });
410
422
  warnings.push({
411
423
  field: "periodExpenses.consolidatedExpenses",
@@ -414,7 +426,7 @@ export function buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, option
414
426
  warnings.push({
415
427
  field: disallowableField,
416
428
  reason:
417
- 'SA103F box 44 is Profit & Loss Account row 34 alone; a loss on disposal (row 33) stays inside box 29\'s allowable total, ' +
429
+ "SA103F box 44 is Profit & Loss Account row 34 alone; a loss on disposal (row 33) stays inside box 29's allowable total, " +
418
430
  "which is not an allowable deduction, with nothing moved to a disallowable box.",
419
431
  handComputed: round2(pl.B33 || 0),
420
432
  });
@@ -441,22 +453,53 @@ export function buildSelfEmploymentQuarterlyUpdates(book, lines, taxData, option
441
453
  // Every annual field this book can source, and a warning naming the box for
442
454
  // every one it cannot -- section 8 of the design records why each is out of
443
455
  // reach today: no cell in the template, a cell the customer fills by hand,
444
- // or a box the schema has no field for.
456
+ // or a box the schema has no field for. Three of these boxes (52, 54, 69)
457
+ // carry a field that also moves in and out of HMRC's own schema by tax
458
+ // year -- annualFieldsUnavailableForYear() below is what keeps a year that
459
+ // no longer accepts a field from warning about it as if it still did.
445
460
  const NO_SOURCE_ANNUAL_BOXES = [
446
461
  { box: "51", pick: 0 }, // allowances.capitalAllowanceSpecialRatePool
447
462
  { box: "51", pick: 1 }, // allowances.capitalAllowanceSingleAssetPool (shared with box 50)
463
+ { box: "52", pick: 0 }, // allowances.zeroEmissionsGoodsVehicleAllowance -- gone from 2025-26
448
464
  { box: "52.1", pick: 0 }, // allowances.zeroEmissionsCarAllowance
449
465
  { box: "53", pick: 0 }, // allowances.structuredBuildingAllowance
450
466
  { box: "53.1", pick: 0 }, // allowances.enhancedStructuredBuildingAllowance
467
+ { box: "54", pick: 1 }, // allowances.electricChargePointAllowance -- gone from 2025-26 (shared with box 55's enhancedCapitalAllowance)
451
468
  { box: "55", pick: 1 }, // allowances.businessPremisesRenovationAllowance (shared with enhancedCapitalAllowance)
452
469
  { box: "59", pick: 1 }, // adjustments.balancingChargeBpra (shared with balancingChargeOther)
453
470
  { box: "62", pick: 0 }, // adjustments.includedNonTaxableProfits
454
471
  { box: "68", pick: 0 }, // adjustments.basisAdjustment
472
+ { box: "69", pick: 0 }, // adjustments.overlapReliefUsed -- gone from 2026-27
455
473
  { box: "71", pick: 0 }, // adjustments.accountingAdjustment
456
- { box: "73.3", pick: 0 }, // adjustments.transitionProfitAmount
457
- { box: "73.3", pick: 1 }, // adjustments.transitionProfitAccelerationAmount
474
+ { box: "73.3", pick: 0 }, // adjustments.transitionProfitAmount -- added in 2024-25
475
+ { box: "73.3", pick: 1 }, // adjustments.transitionProfitAccelerationAmount -- added in 2024-25
458
476
  ];
459
477
 
478
+ // api.years in sa103-mtd-mapping.json is the source of which annual fields
479
+ // HMRC's schema accepts in a given tax year: a field named in an earlier
480
+ // year's own fieldsAdded is not yet live before that year, and a field
481
+ // named in that year's or an earlier year's fieldsGone is no longer live
482
+ // from that year on. A fieldsAdded entry that carries a "status" (the two
483
+ // 2026-27 additions, gated behind HMRC test flags) is not treated as a
484
+ // live field here -- neither has a cell in this template regardless, so
485
+ // the payload does not change, only whether the gap is worth warning about.
486
+ function annualFieldsUnavailableForYear(taxYear) {
487
+ const years = loadMapping().api.years;
488
+ const order = Object.keys(years);
489
+ const index = order.indexOf(taxYear);
490
+ if (index === -1) throw new Error(`sa103-mtd-mapping.json's api.years carries no entry for tax year "${taxYear}"`);
491
+ const unavailable = new Set();
492
+ for (let i = index + 1; i < order.length; i++) {
493
+ for (const added of years[order[i]].fieldsAdded || []) {
494
+ if (typeof added === "string") unavailable.add(added);
495
+ }
496
+ }
497
+ for (let i = 0; i <= index; i++) {
498
+ for (const gone of years[order[i]].fieldsGone || []) unavailable.add(gone);
499
+ }
500
+ return unavailable;
501
+ }
502
+
460
503
  /**
461
504
  * The annual submission that closes the year, built off the same engine the
462
505
  * reconciliation runs.
@@ -474,6 +517,8 @@ export function buildSelfEmploymentAnnualSubmission(book, lines, taxData, option
474
517
  const rawResults = calculateSeCells(book, lines, taxData, scenario);
475
518
  const seFull = rawResults["SE Full"];
476
519
  const boxes = sa103fBoxes();
520
+ const taxYear = taxYearLabel(taxData, book);
521
+ const unavailableFields = annualFieldsUnavailableForYear(taxYear);
477
522
 
478
523
  // Each mapping field name already carries its own top-level branch
479
524
  // ("allowances.annualInvestmentAllowance", "adjustments.balancingChargeOther",
@@ -507,13 +552,15 @@ export function buildSelfEmploymentAnnualSubmission(book, lines, taxData, option
507
552
  ];
508
553
  for (const { box: boxNumber, pick } of NO_SOURCE_ANNUAL_BOXES) {
509
554
  const entry = boxEntry(boxes, boxNumber);
555
+ const field = fieldsOf(entry)[pick];
556
+ if (unavailableFields.has(field)) continue;
510
557
  warnings.push({
511
- field: fieldsOf(entry)[pick],
558
+ field,
512
559
  reason: `SA103F box ${boxNumber} has no cell the template computes; the customer fills it in by hand.`,
513
560
  });
514
561
  }
515
562
 
516
- const result = { taxYear: taxYearLabel(taxData, book) };
563
+ const result = { taxYear };
517
564
  if (Object.keys(allowances).length > 0) result.allowances = allowances;
518
565
  if (Object.keys(adjustments).length > 0) result.adjustments = adjustments;
519
566
  result.warnings = warnings;
@@ -12,7 +12,7 @@
12
12
 
13
13
  export const PROVENANCE_DATA = {
14
14
  "formatVersion": "diya-gl/1",
15
- "engineVersion": "1.2.1+411866032aec",
15
+ "engineVersion": "1.2.3+894e8ba15123",
16
16
  "taxDataHash": "6a9c036b5f9c",
17
17
  "reconciledCommit": "320b4b8a1585e16fa170c2d312c14df38d0bcf5b",
18
18
  "templates": {
@@ -29,7 +29,7 @@ export const PROVENANCE_DATA = {
29
29
  "scorecard": "7679 passed, 0 warnings, 0 failed"
30
30
  },
31
31
  "ltd": {
32
- "hash": "0c18ceeaced4",
32
+ "hash": "d95b3df1e439",
33
33
  "scorecard": "101279 passed, 0 warnings, 0 failed"
34
34
  }
35
35
  }
@@ -1002,7 +1002,7 @@ export function formatScenarioToml(metadata, grouped, expected) {
1002
1002
  if (metadata.business) {
1003
1003
  parts.push("[business]");
1004
1004
  for (const [k, v] of Object.entries(metadata.business)) {
1005
- parts.push(`${k} = "${escapeTomlString(String(v))}"`);
1005
+ parts.push(typeof v === "number" ? `${k} = ${v}` : `${k} = "${escapeTomlString(String(v))}"`);
1006
1006
  }
1007
1007
  parts.push("");
1008
1008
  }
@@ -13,15 +13,20 @@
13
13
  * limit because there was one rate and no relief, and defaulting either of
14
14
  * those away invents a relief those years never had.
15
15
  *
16
+ * Both limits are divided by one plus the number of associated companies
17
+ * (`ctRates.associated_companies`, default 0 -- a company with no associates
18
+ * keeps the full limits).
19
+ *
16
20
  * @param {number} profit - profit chargeable to CT
17
- * @param {Object} ctRates - { small_profits_rate, main_rate, small_profits_limit, main_rate_limit, marginal_relief_fraction }
21
+ * @param {Object} ctRates - { small_profits_rate, main_rate, small_profits_limit, main_rate_limit, marginal_relief_fraction, associated_companies }
18
22
  * @returns {{ profitChargeable, smallProfitsRate, mainRate, corporationTax, marginalRelief }}
19
23
  */
20
24
  export function calculateCorporationTax(profit, ctRates) {
21
25
  const spr = ctRates.small_profits_rate;
22
26
  const mr = ctRates.main_rate;
23
- const spl = ctRates.small_profits_limit;
24
- const splu = ctRates.main_rate_limit ?? ctRates.small_profits_limit_upper ?? 250000;
27
+ const associatedDivisor = 1 + (ctRates.associated_companies ?? 0);
28
+ const spl = ctRates.small_profits_limit / associatedDivisor;
29
+ const splu = (ctRates.main_rate_limit ?? ctRates.small_profits_limit_upper ?? 250000) / associatedDivisor;
25
30
  const mrf = ctRates.marginal_relief_fraction ?? 0.015;
26
31
 
27
32
  if (profit <= 0) {
@@ -82,7 +87,8 @@ export function financialYearsInPeriod(periodStart, periodEnd) {
82
87
  * working sheet charges it: the chargeable profit is split across the two
83
88
  * years by day count, each share meets its own year's rate, and marginal
84
89
  * relief applies to a share that sits between the two limits, with both
85
- * limits apportioned by the same day count.
90
+ * limits apportioned by the same day count and then divided by one plus the
91
+ * number of associated companies.
86
92
  *
87
93
  * @param {number} profitChargeable
88
94
  * @param {Array<{ year: number, days: number }>} financialYears - two entries, the second possibly nil
@@ -93,14 +99,16 @@ export function financialYearsInPeriod(periodStart, periodEnd) {
93
99
  * @param {number} rates.marginalReliefFraction
94
100
  * @param {number} rates.lowerLimit
95
101
  * @param {number} rates.upperLimit
102
+ * @param {number} [rates.associatedCompanies] - default 0
96
103
  * @returns {{ rows: Array<{ year, days, profitShare, ratePercent, taxBeforeRelief, marginalRelief, tax }>, tax: number, marginalRelief: number, taxBeforeRelief: number }}
97
104
  */
98
105
  export function apportionCorporationTax(profitChargeable, financialYears, totalDays, rates) {
106
+ const associatedDivisor = 1 + (rates.associatedCompanies ?? 0);
99
107
  const rows = financialYears.map((financialYear, index) => {
100
108
  const share = totalDays > 0 ? financialYear.days / totalDays : 0;
101
109
  const profitShare = profitChargeable > 0 ? profitChargeable * share : 0;
102
- const lowerLimit = rates.lowerLimit * share;
103
- const upperLimit = rates.upperLimit * share;
110
+ const lowerLimit = (rates.lowerLimit * share) / associatedDivisor;
111
+ const upperLimit = (rates.upperLimit * share) / associatedDivisor;
104
112
  const ratePercent = profitShare <= lowerLimit ? rates.smallProfitsRatePercent[index] : rates.mainRatePercent;
105
113
  const taxBeforeRelief = (profitShare * ratePercent) / 100;
106
114
  const marginalRelief =
@@ -250,6 +250,11 @@ const STOCK_FINAL_ADJUSTMENT_CELL = "Z30";
250
250
  // N4 and T4 are all zero.
251
251
  export const STOCK_MATERIALS_PERCENT_CELL = "H4";
252
252
 
253
+ // The number of companies associated with this one, entered beside the
254
+ // corporation tax rates it divides. The only Admin cell cellWrites() fills;
255
+ // the rates beside it come from the tax data, not the book.
256
+ export const ADMIN_ASSOCIATED_COMPANIES_CELL = "P14";
257
+
253
258
  // ── OpenAccounts layout ────────────────────────────────────────────────────
254
259
  // Row 13 takes fixed assets as original cost (G:K) and accumulated
255
260
  // depreciation (M:Q), one column per asset class, with net book value in E13.
@@ -561,6 +566,15 @@ export function cellWrites(scenario, targetStartYear, yearEndMonth) {
561
566
  const directors = (scenario.employees || []).filter((e) => e.isDirector);
562
567
  if (directors[0]?.name) bd.E5 = directors[0].name;
563
568
  if (directors[1]?.name) bd.E6 = directors[1].name;
569
+
570
+ // The number of companies associated with this one, which divides both
571
+ // marginal relief limits. Written even when it is none: a blank cell and a
572
+ // stated zero mean the same to the sheet's arithmetic, but only the stated
573
+ // zero comes back when the package is read, and the JS engine states it
574
+ // either way. Leaving it blank makes the two halves of the roundtrip
575
+ // disagree about a cell they agree on the value of.
576
+ if (!hubWrites.Admin) hubWrites.Admin = {};
577
+ hubWrites.Admin.P14 = biz.associated_companies ?? 0;
564
578
  }
565
579
 
566
580
  // Opening balance sheet (OpenAccounts)
@@ -1185,6 +1199,8 @@ export const CELL_MAP = [
1185
1199
  ["CT600", "AJ131", "**Box 430: corporation tax**", "gl-cor:taxAmount (ct600.box430)", "CT600 as filed", 0],
1186
1200
  ["CT600", "Y133", "Box 435: marginal rate relief","gl-cor:taxAmount (ct600.box435)", "CT600 as filed", 1],
1187
1201
  ["CT600", "Y135", "**Box 440: corporation tax net of marginal rate relief**", "gl-cor:taxAmount (ct600.box440)", "CT600 as filed", 0],
1202
+ ["CT600", "Y118", "Box 327: associated companies, first financial year", "gl-cor:count (ct600.box327)", "CT600 as filed", 1],
1203
+ ["CT600", "Y120", "Box 328: associated companies, second financial year", "gl-cor:count (ct600.box328)", "CT600 as filed", 1],
1188
1204
  // ── Published P&L (column B is last year, column F this year) ──
1189
1205
  ["PubP&L", "F7", "Sales Turnover", "gl-cor:amount (pubPL.salesTurnover)","Published P&L", 1],
1190
1206
  ["PubP&L", "F8", "Investment Grants", "gl-cor:amount (pubPL.grants)", "Published P&L", 1],
@@ -1606,6 +1622,7 @@ export function standardReads() {
1606
1622
  for (const cell of CT600_CELLS) add("CT600", cell);
1607
1623
 
1608
1624
  for (const [cell] of ADMIN_TAX_DATA_CELLS) add("Admin", cell);
1625
+ add("Admin", "P14");
1609
1626
  add("Admin", "F21");
1610
1627
  add("Admin", "B9");
1611
1628
  add("Admin", "B32");
@@ -2043,9 +2060,9 @@ const RATE_CELLS = {
2043
2060
  // Cells that hold a whole number of something: days, years, miles, shares, a
2044
2061
  // week number or a flag.
2045
2062
  const COUNT_CELLS = {
2046
- Admin: ["K6", "K7", "N16", "N17"],
2063
+ Admin: ["K6", "K7", "N16", "N17", "P14"],
2047
2064
  CorporationTax: ["A33", "A34", "A35", "E33", "E34"],
2048
- CT600: ["C126"],
2065
+ CT600: ["C126", "Y118", "Y120"],
2049
2066
  Report: ["I95", "F97", "F98"],
2050
2067
  };
2051
2068
 
@@ -3957,6 +3974,11 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
3957
3974
  check(`Admin ${cell}: ${label}`, num(admin[cell]), fromTaxData(taxData), 0.0001);
3958
3975
  }
3959
3976
 
3977
+ // The associated companies count is a business fact, not a tax-year
3978
+ // one, so it is not in ADMIN_TAX_DATA_CELLS -- it comes from the
3979
+ // scenario's own business block, defaulting to none.
3980
+ check("Admin P14: number of associated companies", num(admin.P14), expected.business?.associated_companies ?? 0, 0);
3981
+
3960
3982
  // F21 is the year-end seed every other date in the package cascades
3961
3983
  // from. Its own anchor row and the three published documents that quote
3962
3984
  // the year end all have to land on it. The seed itself is measured
@@ -4094,6 +4116,15 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
4094
4116
  check("CT600: second financial year profit = second tax row profit", num(ct600.N128), num(corporationTax.F34));
4095
4117
  check("CT600: second financial year rate = second tax row rate", num(ct600.AA128), num(corporationTax.G34), 0);
4096
4118
  }
4119
+ // Boxes 327 and 328 carry the associated companies count for each
4120
+ // financial year row. The product tracks one count for the whole
4121
+ // period, so both boxes carry the same figure; box 328 follows box 385
4122
+ // and the rest of the second row blank when the period does not
4123
+ // straddle two financial years.
4124
+ check("CT600: associated companies, first financial year = Admin P14", num(ct600.Y118), num(admin.P14));
4125
+ if (num(corporationTax.A34) > 0) {
4126
+ check("CT600: associated companies, second financial year = Admin P14", num(ct600.Y120), num(admin.P14));
4127
+ }
4097
4128
  check("CT600: tax payable = tax chargeable", num(ct600.AJ131), num(ct600.AJ126) + num(ct600.AJ128));
4098
4129
  check("CT600: marginal rate relief = the working sheet's relief", num(ct600.Y133), num(corporationTax.L33) + num(corporationTax.L34));
4099
4130
  check("CT600: tax net of marginal relief = the working sheet's charge", num(ct600.Y135), num(corporationTax.K35));
@@ -4142,11 +4173,13 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
4142
4173
  // Each row's share of the profit is charged at the small profits rate
4143
4174
  // up to its share of the lower limit and at the main rate above it,
4144
4175
  // with marginal relief tapering the gap up to the upper limit. Both
4145
- // limits are shared out over the period the same way the profit is.
4176
+ // limits are shared out over the period the same way the profit is,
4177
+ // then divided by one plus the number of associated companies.
4146
4178
  const admin = results.Admin;
4147
4179
  if (admin && days > 0) {
4148
- const lowerLimit = (rowDays) => (num(admin.P12) * rowDays) / days;
4149
- const upperLimit = (rowDays) => (num(admin.P13) * rowDays) / days;
4180
+ const associatedDivisor = 1 + num(admin.P14);
4181
+ const lowerLimit = (rowDays) => (num(admin.P12) * rowDays) / days / associatedDivisor;
4182
+ const upperLimit = (rowDays) => (num(admin.P13) * rowDays) / days / associatedDivisor;
4150
4183
  const reliefFor = (rowProfit, rowDays) =>
4151
4184
  rowProfit > lowerLimit(rowDays) && rowProfit < upperLimit(rowDays) ? (upperLimit(rowDays) - rowProfit) * num(admin.P9) : 0;
4152
4185
  const rateFor = (rowProfit, rowDays, smallProfitsRate) => (rowProfit <= lowerLimit(rowDays) ? smallProfitsRate : num(admin.P8));
@@ -4186,7 +4219,10 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
4186
4219
 
4187
4220
  // The charge the period's profit actually bears, against the statutory
4188
4221
  // computation worked independently of the sheet.
4189
- const statutory = calculateCorporationTax(profit, taxData.corporation_tax).corporationTax;
4222
+ const statutory = calculateCorporationTax(profit, {
4223
+ ...taxData.corporation_tax,
4224
+ associated_companies: expected.business?.associated_companies ?? 0,
4225
+ }).corporationTax;
4190
4226
  check("CT: charge for the year = the statutory computation with marginal relief", num(ct.K35), statutory, 1);
4191
4227
  }
4192
4228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",