@diy-accounting-uk/diya-gl 1.2.17 → 1.2.19

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.
@@ -301,15 +301,28 @@
301
301
  "boxes": [
302
302
  { "box": "66", "label": "Not in use", "cell": null, "notInUse": true },
303
303
  { "box": "67", "label": "Not in use", "cell": null, "notInUse": true },
304
- { "box": "68", "label": "Adjustment where your accounting period was not 12 months long", "cell": null },
305
- { "box": "69", "label": "Not in use", "cell": null, "notInUse": true },
304
+ {
305
+ "box": "68",
306
+ "label": "Adjustment where your accounting period was not 12 months long",
307
+ "cell": "Financialaccounts.xlsx!SE Full!D197"
308
+ },
309
+ {
310
+ "box": "69",
311
+ "label": "Not in use",
312
+ "cell": "Financialaccounts.xlsx!Business Details!D64",
313
+ "notInUse": true
314
+ },
306
315
  { "box": "70", "label": "Not in use", "cell": null, "notInUse": true },
307
316
  { "box": "71", "label": "Adjustment for change of accounting practice", "cell": "Financialaccounts.xlsx!SE Full!D210" },
308
317
  { "box": "72", "label": "Averaging adjustment", "cell": null },
309
318
  { "box": "73", "label": "Adjusted profit for 2025-26", "cell": "Financialaccounts.xlsx!SE Full!O194" },
310
319
  { "box": "73.1", "label": "Not in use", "cell": null, "notInUse": true },
311
320
  { "box": "73.2", "label": "Not in use", "cell": null, "notInUse": true },
312
- { "box": "73.3", "label": "Spread of the transition profit treated as arising in this tax year", "cell": null },
321
+ {
322
+ "box": "73.3",
323
+ "label": "Spread of the transition profit treated as arising in this tax year",
324
+ "cell": "Financialaccounts.xlsx!SE Full!D201"
325
+ },
313
326
  { "box": "73.4", "label": "Loss brought forward set against this year's spread of the transition profit", "cell": null },
314
327
  {
315
328
  "box": "74",
@@ -574,7 +574,7 @@
574
574
  "form": "SA103F",
575
575
  "box": "68",
576
576
  "label": "Adjustment where your accounting period ended before 31 March 2026 or where your accounting period was not 12 months long – if the adjustment needs to be taken off the profit figure, put a minus sign in the box",
577
- "cell": null,
577
+ "cell": "SE Full!D197",
578
578
  "field": "adjustments.basisAdjustment",
579
579
  "route": "annual"
580
580
  },
@@ -582,7 +582,7 @@
582
582
  "form": "SA103F",
583
583
  "box": "69",
584
584
  "label": "Overlap relief used this year",
585
- "cell": null,
585
+ "cell": "Business Details!D64",
586
586
  "field": "adjustments.overlapReliefUsed",
587
587
  "route": "annual",
588
588
  "caveat": "the CSV lists box 69 (overlapReliefUsed) as live even though the 2026 SA103F prints boxes 69 and 70 as not in use; the field is dropped from the API schema from 2026-27 (see api.years['2026-27'].fieldsGone)"
@@ -621,7 +621,7 @@
621
621
  "form": "SA103F",
622
622
  "box": "73.3",
623
623
  "label": "Spread of the transition profit treated as arising in this tax year",
624
- "cell": null,
624
+ "cell": "SE Full!D201",
625
625
  "field": "adjustments.transitionProfitAmount | adjustments.transitionProfitAccelerationAmount",
626
626
  "route": "annual"
627
627
  },
@@ -675,6 +675,58 @@
675
675
  "scorecard": "101605 passed, 0 warnings, 0 failed"
676
676
  }
677
677
  }
678
+ },
679
+ {
680
+ "tag": "diya-gl-v1.2.17",
681
+ "date": "2026-09-15",
682
+ "formatVersion": "diya-gl/1",
683
+ "engineVersion": "1.2.17+67e3da11",
684
+ "taxDataHash": "3221a7c2f39c",
685
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
686
+ "templates": {
687
+ "bst": {
688
+ "hash": "0d003e10e00c",
689
+ "scorecard": "812 passed, 0 warnings, 0 failed"
690
+ },
691
+ "taxi": {
692
+ "hash": "1ff69654d64d",
693
+ "scorecard": "686 passed, 0 warnings, 0 failed"
694
+ },
695
+ "se": {
696
+ "hash": "107ff1b5c94a",
697
+ "scorecard": "7969 passed, 0 warnings, 0 failed"
698
+ },
699
+ "ltd": {
700
+ "hash": "caa14632083b",
701
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
702
+ }
703
+ }
704
+ },
705
+ {
706
+ "tag": "diya-gl-v1.2.18",
707
+ "date": "2026-09-15",
708
+ "formatVersion": "diya-gl/1",
709
+ "engineVersion": "1.2.18+80457d57",
710
+ "taxDataHash": "3221a7c2f39c",
711
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
712
+ "templates": {
713
+ "bst": {
714
+ "hash": "0d003e10e00c",
715
+ "scorecard": "812 passed, 0 warnings, 0 failed"
716
+ },
717
+ "taxi": {
718
+ "hash": "1ff69654d64d",
719
+ "scorecard": "686 passed, 0 warnings, 0 failed"
720
+ },
721
+ "se": {
722
+ "hash": "064605ae76ed",
723
+ "scorecard": "7982 passed, 0 warnings, 0 failed"
724
+ },
725
+ "ltd": {
726
+ "hash": "caa14632083b",
727
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
728
+ }
729
+ }
678
730
  }
679
731
  ]
680
732
  }
@@ -482,5 +482,23 @@
482
482
  "cell/Fixedassets.xlsx!Schedule!G116": "The fixture's own Structures and Buildings Allowance claim row (the Freeport claim): its date, building, expenditure, postcode and tax site marker. The Fixed assets view has no per-claim table, and the K121 total those rows sum into is on the same view under its own cell key.",
483
483
  "cell/Fixedassets.xlsx!Schedule!H116": "The fixture's own Structures and Buildings Allowance claim row (the Freeport claim): its rate, its days claimed and its allowance. The Fixed assets view has no per-claim table, and the K121 total those rows sum into is on the same view under its own cell key.",
484
484
  "cell/Fixedassets.xlsx!Schedule!J116": "The fixture's own Structures and Buildings Allowance claim row (the Freeport claim): its rate, its days claimed and its allowance. The Fixed assets view has no per-claim table, and the K121 total those rows sum into is on the same view under its own cell key.",
485
- "cell/Fixedassets.xlsx!Schedule!K116": "The fixture's own Structures and Buildings Allowance claim row (the Freeport claim): its rate, its days claimed and its allowance. The Fixed assets view has no per-claim table, and the K121 total those rows sum into is on the same view under its own cell key."
485
+ "cell/Fixedassets.xlsx!Schedule!K116": "The fixture's own Structures and Buildings Allowance claim row (the Freeport claim): its rate, its days claimed and its allowance. The Fixed assets view has no per-claim table, and the K121 total those rows sum into is on the same view under its own cell key.",
486
+ "cell/Financialaccounts.xlsx!Business Details!D59": "The basis period record's overlap profit brought forward (feeds box 69), entered by hand on Business Details' own ENTER block. No view renders that block; the book page has no basis period section.",
487
+ "cell/Financialaccounts.xlsx!Business Details!O59": "The basis period record's 2023-24 transition profit not yet treated as arising, entered by hand on Business Details' own ENTER block. No view renders that block.",
488
+ "cell/Financialaccounts.xlsx!Business Details!O69": "The basis period record's additional transition profit elected this year (feeds box 73.3), entered by hand on Business Details' own ENTER block. No view renders that block.",
489
+ "cell/Financialaccounts.xlsx!Business Details!D69": "The overlap balance carried forward, the sheet's own formula on Business Details' ENTER block. No view renders that block.",
490
+ "cell/Financialaccounts.xlsx!Business Details!O64": "The transition profit treated as arising this year before election (feeds box 73.3), the sheet's own formula on Business Details' ENTER block. No view renders that block.",
491
+ "cell/Financialaccounts.xlsx!Business Details!O74": "The transition profit carried forward to next year, the sheet's own formula on Business Details' ENTER block. No view renders that block.",
492
+ "cell/Financialaccounts.xlsx!Business Details!N27": "The book's own accounting period start date (box 8), printed on Business Details. No view renders the front page's boxes 8 and 9.",
493
+ "cell/Financialaccounts.xlsx!Business Details!N32": "The book's own accounting period end date (box 9), printed on Business Details. No view renders the front page's boxes 8 and 9.",
494
+ "cell/Financialaccounts.xlsx!Income Tax!E14": "The top-slice income tax on box 73.3's transition profit spread. The Income Tax view lists E5 to E12, E15 and E16, added before box 73.3 had a cell to read; E14 is not yet in that list.",
495
+ "section/business-details/overlap-profit-brought-forward-feeds-box-69": "The basis period record's own report row. No view renders Business Details' ENTER block.",
496
+ "section/business-details/transition-profit-not-yet-treated-as-arising": "The basis period record's own report row. No view renders Business Details' ENTER block.",
497
+ "section/business-details/additional-transition-profit-elected-this-year-feeds-box-73-3": "The basis period record's own report row. No view renders Business Details' ENTER block.",
498
+ "section/business-details/overlap-profit-carried-forward": "The basis period record's own report row. No view renders Business Details' ENTER block.",
499
+ "section/business-details/transition-profit-treated-as-arising-this-year-before-election-feeds-box-73-3": "The basis period record's own report row. No view renders Business Details' ENTER block.",
500
+ "section/business-details/transition-profit-carried-forward-to-next-year": "The basis period record's own report row. No view renders Business Details' ENTER block.",
501
+ "section/business-details/accounting-period-start-date-box-8": "The book's own accounting period start date. No view renders the front page's boxes 8 and 9.",
502
+ "section/business-details/accounting-period-end-date-box-9": "The book's own accounting period end date. No view renders the front page's boxes 8 and 9.",
503
+ "section/income-tax-calculation/income-tax-on-transition-profit-box-73-3-top-sliced": "Box 73.3's own top-slice income tax. The Income Tax view lists E5 to E12, E15 and E16, added before box 73.3 had a cell to read."
486
504
  }
@@ -30,6 +30,9 @@ import {
30
30
  ANNUAL_ALLOWANCE_CELLS,
31
31
  ANNUAL_ADJUSTMENT_CELLS,
32
32
  GOODS_FOR_OWN_USE_CELL,
33
+ BASIS_PERIOD_CELLS,
34
+ ACCOUNTING_PERIOD_START_CELL,
35
+ ACCOUNTING_PERIOD_END_CELL,
33
36
  SPECIAL_RATE_POOL_MARKER_COLUMN,
34
37
  SINGLE_ASSET_POOL_MARKER_COLUMN,
35
38
  PRIVATE_USE_COLUMN,
@@ -418,8 +421,19 @@ function isVatInputCell(sheet, cellRef) {
418
421
  // stock counts, the disallowable percentages and the SA103F boxes the book
419
422
  // states by hand.
420
423
  const SE_FULL_STATED_CELLS = new Set([...Object.values(ANNUAL_ALLOWANCE_CELLS), ...Object.values(ANNUAL_ADJUSTMENT_CELLS)]);
424
+ const BUSINESS_DETAILS_BASIS_PERIOD_CELLS = new Set([
425
+ ...Object.values(BASIS_PERIOD_CELLS),
426
+ ACCOUNTING_PERIOD_START_CELL,
427
+ ACCOUNTING_PERIOD_END_CELL,
428
+ ]);
421
429
  function isHubInputCell(sheet, cellRef) {
422
- if (sheet === "Business Details") return cellRef === "C5" || cellRef === BUSINESS_DESCRIPTION_CELL || cellRef === GOODS_FOR_OWN_USE_CELL;
430
+ if (sheet === "Business Details")
431
+ return (
432
+ cellRef === "C5" ||
433
+ cellRef === BUSINESS_DESCRIPTION_CELL ||
434
+ cellRef === GOODS_FOR_OWN_USE_CELL ||
435
+ BUSINESS_DETAILS_BASIS_PERIOD_CELLS.has(cellRef)
436
+ );
423
437
  if (sheet === "StockControl") return cellRef === STOCK_OPENING_COUNT_CELL || cellRef === STOCK_CLOSING_COUNT_CELL;
424
438
  if (sheet === "VitalTax") return Object.values(DISALLOWABLE_PERCENT_CELLS).includes(cellRef);
425
439
  if (sheet === "SE Full") return SE_FULL_STATED_CELLS.has(cellRef);
@@ -474,18 +474,13 @@ const BOOK_STATED_ANNUAL_BOXES = [
474
474
  // Every annual field no cell and no book field can source, and a warning
475
475
  // naming the box for each -- section 8 of the design records why each is
476
476
  // out of reach today: no cell in the template, or a figure the book has no
477
- // record for. Box 69's field also moves out of HMRC's own schema by tax
478
- // year -- annualFieldsUnavailableForYear() below is what keeps a year that
479
- // no longer accepts a field from warning about it as if it still did. Boxes
480
- // 53 and 53.1 are not here: the Schedule's SBA block computes them, and
481
- // they are filed as arrays below, one item per claim.
477
+ // record for. Boxes 53 and 53.1 are not here: the Schedule's SBA block
478
+ // computes them, and they are filed as arrays below, one item per claim.
479
+ // Boxes 68, 69 and 73.3 are not here either: the basis period record below
480
+ // computes and files them.
482
481
  const NO_SOURCE_ANNUAL_BOXES = [
483
482
  { box: "55", pick: 1 }, // allowances.businessPremisesRenovationAllowance (shared with enhancedCapitalAllowance)
484
483
  { box: "59", pick: 1 }, // adjustments.balancingChargeBpra (shared with balancingChargeOther)
485
- { box: "68", pick: 0 }, // adjustments.basisAdjustment
486
- { box: "69", pick: 0 }, // adjustments.overlapReliefUsed -- gone from 2026-27
487
- { box: "73.3", pick: 0 }, // adjustments.transitionProfitAmount -- added in 2024-25
488
- { box: "73.3", pick: 1 }, // adjustments.transitionProfitAccelerationAmount -- added in 2024-25
489
484
  ];
490
485
 
491
486
  // api.years in sa103-mtd-mapping.json is the source of which annual fields
@@ -624,6 +619,43 @@ export function buildSelfEmploymentAnnualSubmission(book, lines, taxData, option
624
619
  });
625
620
  }
626
621
 
622
+ // The basis period record (boxes 68, 69 and 73.3): the sheet computes
623
+ // every one of these from the book's stated basis period figures, so each
624
+ // files only when it carries a genuine adjustment -- box 68 is nil for
625
+ // almost every trader (s.7C, an accounting date of 31 March to 5 April),
626
+ // and a trader who has already used all of their overlap or transition
627
+ // profit states nothing further for it.
628
+ const businessDetails = rawResults["Business Details"] || {};
629
+ const businessDetailsNumber = (cell) => (typeof businessDetails[cell] === "number" ? businessDetails[cell] : 0);
630
+ const basisAdjustmentField = primaryField(boxes, "68");
631
+ if (!unavailableFields.has(basisAdjustmentField) && cellNumber("D197") !== 0) {
632
+ setPath(root, basisAdjustmentField, round2(cellNumber("D197")));
633
+ }
634
+ const overlapReliefField = primaryField(boxes, "69");
635
+ const overlapReliefUsed = businessDetailsNumber("D64");
636
+ if (!unavailableFields.has(overlapReliefField) && overlapReliefUsed > 0) {
637
+ setPath(root, overlapReliefField, round2(overlapReliefUsed));
638
+ }
639
+ const transitionProfitField = fieldsOf(boxEntry(boxes, "73.3"))[0];
640
+ const transitionAccelerationField = fieldsOf(boxEntry(boxes, "73.3"))[1];
641
+ const transitionProfitArising = businessDetailsNumber("O64");
642
+ const transitionAcceleration = businessDetailsNumber("O69");
643
+ if (!unavailableFields.has(transitionProfitField) && transitionProfitArising > 0) {
644
+ setPath(root, transitionProfitField, round2(transitionProfitArising));
645
+ }
646
+ if (!unavailableFields.has(transitionAccelerationField) && transitionAcceleration > 0) {
647
+ setPath(root, transitionAccelerationField, round2(transitionAcceleration));
648
+ }
649
+ // FA 2022 Sch 1 para 73(3): an election cannot exceed the untaxed balance
650
+ // left once this year's automatic slice (O64) is taken out of it.
651
+ const transitionUntaxedBalance = businessDetailsNumber("O59") - transitionProfitArising;
652
+ if (transitionAcceleration > transitionUntaxedBalance) {
653
+ warnings.push({
654
+ field: transitionAccelerationField,
655
+ reason: `the elected transition profit acceleration (${round2(transitionAcceleration)}) exceeds the untaxed balance (${round2(transitionUntaxedBalance)}); an election cannot exceed it (FA 2022 Sch 1 para 73(3)).`,
656
+ });
657
+ }
658
+
627
659
  // Structures and Buildings Allowance (boxes 53 and 53.1): the Schedule's
628
660
  // SBA block computes each claim's year allowance from the book's own
629
661
  // inputs, so the claim files as one item per row, not a stated scalar. A
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { MONTH_SHEETS, extractTaxYearStart } from "../scenario-loader.js";
18
18
  import { shiftMonths, periodShiftMonths } from "../period-shift.js";
19
+ import { taxYearFileName } from "../tax-year.js";
19
20
  import { standardReads, multiFileOptions, SE_YEAR_END_MONTH, SBA_CLAIM_ROWS } from "../../products/se.js";
20
21
  import { generateAdminDates, seVatPaymentDueDate, generatePayslipsCalendar } from "../generator.js";
21
22
  import { calculateIncomeTax } from "../tax/income-tax.js";
@@ -867,6 +868,35 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
867
868
  const paidOn = (entry) => excelSerial(shiftMonths(new Date(entry.date), monthOffset));
868
869
  const admin = buildAdmin(taxData, dateSerials);
869
870
 
871
+ // Business Details boxes 8, 9, and the basis period record (boxes 68, 69,
872
+ // 73.3): the writer's own whole-year shift, never the month-level one
873
+ // postings move by, and the sheet's own D64/O64/D69/O74 formulas.
874
+ const basisPeriodInput = (value) => (value === undefined ? SHEET_BLANK : value);
875
+ const basisPeriod = scenario.basis_period || {};
876
+ const businessDetails = {
877
+ D59: basisPeriodInput(basisPeriod.overlapProfitBroughtForward),
878
+ O59: basisPeriodInput(basisPeriod.transitionProfitBroughtForward),
879
+ O69: basisPeriodInput(basisPeriod.transitionProfitAccelerationAmount),
880
+ D74: basisPeriodInput(basisPeriod.followingPeriodProfit),
881
+ };
882
+ if (scenario.period_covered_start && scenario.period_covered_end) {
883
+ const naturalStartYear = parseInt(taxYearFileName(new Date(scenario.period_covered_end), "se").split("-")[1], 10);
884
+ const yearShift = startYear - naturalStartYear;
885
+ const shiftYears = (d) => shiftMonths(new Date(d), yearShift * 12);
886
+ businessDetails.N27 = excelSerial(shiftYears(scenario.period_covered_start));
887
+ businessDetails.N32 = excelSerial(shiftYears(scenario.period_covered_end));
888
+ } else {
889
+ // A flat scenario TOML (not a diya-gl book) carries no documentInfo, so
890
+ // the printed boxes stay the blank the template itself would show.
891
+ businessDetails.N27 = SHEET_BLANK;
892
+ businessDetails.N32 = SHEET_BLANK;
893
+ }
894
+ const b17Year = dateFromExcelSerial(admin.B17).getUTCFullYear();
895
+ businessDetails.D64 = b17Year === 2024 ? sheetNumber(businessDetails.D59) : 0;
896
+ businessDetails.O64 = b17Year < 2024 ? 0 : sheetNumber(businessDetails.O59) / Math.max(1, 2029 - b17Year);
897
+ businessDetails.D69 = sheetNumber(businessDetails.D59) - businessDetails.D64;
898
+ businessDetails.O74 = sheetNumber(businessDetails.O59) - businessDetails.O64 - sheetNumber(businessDetails.O69);
899
+
870
900
  const salesMonths = journalMonths(scenario.sales, rate, SALES_ANALYSIS_COLUMNS, "a");
871
901
  const purchasesMonths = journalMonths(scenario.purchases, rate, PURCHASES_ANALYSIS_COLUMNS);
872
902
  const mileage = mileageMonths(scenario, taxData?.mileage);
@@ -1329,19 +1359,43 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
1329
1359
  const fromNetLoss = -seFull.O129 + seFull.D174 - seFull.O169;
1330
1360
  return fromNetLoss < 0 ? -fromNetLoss : 0;
1331
1361
  });
1362
+ // Box 68 (D197), s.7A(2)-(3): nil under s.7C(1)(c), an accounting date of
1363
+ // 31 March to 5 April; otherwise this period's profit (box 64 less box
1364
+ // 65) times the share of its days that fall in the tax year, plus the
1365
+ // following period's profit (Business Details!D74) times the share of
1366
+ // the tax year its own twelve months (EDATE) cover, less this period's
1367
+ // whole profit.
1368
+ const periodProfit = seFull.O174 - seFull.O179;
1369
+ seFull.D197 = carry([seFull.O174, seFull.O179], () => {
1370
+ const periodEnd = businessDetails.N32;
1371
+ if (typeof periodEnd !== "number") return 0;
1372
+ const marchThirtyFirst = excelSerial(new Date(Date.UTC(b17Year, 2, 31)));
1373
+ if (periodEnd >= marchThirtyFirst) return 0;
1374
+ const periodStart = businessDetails.N27;
1375
+ const followingPeriodEnd = excelSerial(shiftMonths(dateFromExcelSerial(periodEnd), 12));
1376
+ const thisPeriodShare = (periodEnd - Math.max(periodStart, admin.B4) + 1) / (periodEnd - periodStart + 1);
1377
+ const nextPeriodShare = (admin.B17 - periodEnd) / (followingPeriodEnd - periodEnd);
1378
+ return thisPeriodShare * periodProfit + nextPeriodShare * sheetNumber(businessDetails.D74) - periodProfit;
1379
+ });
1332
1380
  // HMRC's SA103F working sheet for boxes 73 and 77 takes one figure, box
1333
1381
  // 64 less box 65 plus boxes 68, 71 and 72: box 73 is that figure when it
1334
1382
  // is positive and box 77 is its negation when it is not. The sheet reads
1335
- // O174-O179+N(D197)+D210+N(O190); boxes 68 and 72 have no input cell on
1336
- // this template, so their printed dash cells (D197, O190) contribute nil
1337
- // and box 71 (D210) is the only adjustment that moves either figure.
1338
- const adjustedProfitBeforeFloor = carry([seFull.O174, seFull.O179], () => seFull.O174 - seFull.O179 + sheetNumber(seFull.D210));
1383
+ // O174-O179+N(D197)+D210+N(O190); box 72 has no input cell on this
1384
+ // template, so its printed dash cell (O190) contributes nil.
1385
+ const adjustedProfitBeforeFloor = carry(
1386
+ [seFull.O174, seFull.O179, seFull.D197],
1387
+ () => seFull.O174 - seFull.O179 + sheetNumber(seFull.D197) + sheetNumber(seFull.D210),
1388
+ );
1339
1389
  seFull.O194 = carry([adjustedProfitBeforeFloor], () => Math.max(0, adjustedProfitBeforeFloor));
1340
1390
  seFull.O204 = pl.B11;
1341
1391
  seFull.O199 = carry([seFull.O194], () => (sheetNumber(seFull.D179) > 0 ? 0 : Math.min(seFull.O194 + seFull.O204, lossesBroughtForward)));
1342
1392
  seFull.O210 = carry([seFull.O194, seFull.O199], () => seFull.O194 - seFull.O199 + seFull.O204);
1343
1393
  seFull.D219 = carry([adjustedProfitBeforeFloor], () => Math.max(0, -adjustedProfitBeforeFloor));
1344
1394
  seFull.O224 = seFull.D219;
1395
+ // Box 73.3 (D201), para 73(1)-(4): the transition profit treated as
1396
+ // arising this year, before election (O64) plus the elected addition
1397
+ // (O69).
1398
+ seFull.D201 = sheetNumber(businessDetails.O64) + sheetNumber(businessDetails.O69);
1345
1399
  seFull.D231 = contractorDeductions;
1346
1400
  seFull.J280 = admin.N20;
1347
1401
 
@@ -1354,10 +1408,32 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
1354
1408
  // The deduction line already holds the contractor deductions negated, and the
1355
1409
  // total below it adds every line up.
1356
1410
  const contractorDeductionLine = -contractorDeductions;
1357
- const niLower = carry([taxableProfit], () =>
1358
- taxableProfit > admin.N20 ? (Math.min(taxableProfit, admin.N23) - admin.N20) * admin.L20 : 0,
1411
+ // Class 4 NIC follows the income tax charge (para 72(3) makes the spread
1412
+ // chargeable under ITTOIA Ch 2 Pt 2; SSCBA 1992 s.15(1)), so both bands
1413
+ // run over the taxable profit plus box 73.3's own spread (D201), even
1414
+ // though box 73.3 sits outside net income and box 76 for income tax.
1415
+ const transitionProfitSpread = sheetNumber(seFull.D201);
1416
+ const profitWithSpread = taxableProfit + transitionProfitSpread;
1417
+ const niLower = carry([taxableProfit, seFull.D201], () =>
1418
+ profitWithSpread > admin.N20 ? (Math.min(profitWithSpread, admin.N23) - admin.N20) * admin.L20 : 0,
1419
+ );
1420
+ const niUpper = carry([taxableProfit, seFull.D201], () =>
1421
+ profitWithSpread > admin.N23 ? (profitWithSpread - admin.N23) * admin.L23 : 0,
1359
1422
  );
1360
- const niUpper = carry([taxableProfit], () => (taxableProfit > admin.N23 ? (taxableProfit - admin.N23) * admin.L23 : 0));
1423
+ const taxableIncome = chargedOn("taxableIncome");
1424
+ const totalIncomeTax = chargedOn("totalIncomeTax");
1425
+ // Box 73.3's own top-slice charge (para 75(2)-(3)): the personal
1426
+ // allowance taper is left alone (the spread joins after E7, not before
1427
+ // E6), and the three bands run again over taxable income plus the
1428
+ // spread, less the tax E11 already charges on taxable income alone.
1429
+ const transitionProfitTax = carry([charged, seFull.D201], () => {
1430
+ if (transitionProfitSpread <= 0) return 0;
1431
+ const spread = taxableIncome + transitionProfitSpread;
1432
+ const basic = spread < admin.M11 ? spread * admin.N6 : admin.M11 * admin.N6;
1433
+ const higher = spread > admin.M11 ? (Math.min(spread, admin.N13) - admin.M11) * admin.N7 : 0;
1434
+ const additional = spread > admin.N13 ? (spread - admin.N13) * admin.N8 : 0;
1435
+ return basic + higher + additional - totalIncomeTax;
1436
+ });
1361
1437
  const incomeTax = {
1362
1438
  E5: taxableProfit,
1363
1439
  // E6 = IF(E5<=0,0,MAX(0,Admin!N$4-MAX(0,E5-Admin!N$5)/2)): a loss year
@@ -1365,7 +1441,7 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
1365
1441
  // this at nil rather than showing the allowance unused -- the same floor
1366
1442
  // Profit Forecast!C40 already applies below.
1367
1443
  E6: carry([taxableProfit, charged], () => (taxableProfit <= 0 ? 0 : charged.personalAllowance)),
1368
- E7: chargedOn("taxableIncome"),
1444
+ E7: taxableIncome,
1369
1445
  C8: admin.N11,
1370
1446
  D8: admin.N6,
1371
1447
  E8: chargedOn("basicRateTax"),
@@ -1375,14 +1451,18 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
1375
1451
  C10: admin.N13,
1376
1452
  D10: admin.N8,
1377
1453
  E10: chargedOn("additionalRateTax"),
1378
- E11: chargedOn("totalIncomeTax"),
1454
+ E11: totalIncomeTax,
1379
1455
  E12: contractorDeductionLine,
1380
1456
  C13: dateSerials[21],
1457
+ E14: transitionProfitTax,
1381
1458
  D15: admin.L20,
1382
1459
  E15: niLower,
1383
1460
  D16: admin.L23,
1384
1461
  E16: niUpper,
1385
- E18: carry([charged, niLower, niUpper], () => charged.totalIncomeTax + contractorDeductionLine + niLower + niUpper),
1462
+ E18: carry(
1463
+ [charged, niLower, niUpper, transitionProfitTax],
1464
+ () => totalIncomeTax + contractorDeductionLine + transitionProfitTax + niLower + niUpper,
1465
+ ),
1386
1466
  };
1387
1467
 
1388
1468
  // ── Profit forecast ──
@@ -1424,7 +1504,11 @@ export function calculateSeCells(book, lines, taxData, scenario = {}) {
1424
1504
 
1425
1505
  // ── The results, keyed the way the reconciliation reads them ──
1426
1506
  const results = {
1427
- "Business Details": { C5: businessName(scenario), O50: stated(annualAdjustments.goodsAndServicesOwnUse) },
1507
+ "Business Details": {
1508
+ C5: businessName(scenario),
1509
+ O50: stated(annualAdjustments.goodsAndServicesOwnUse),
1510
+ ...businessDetails,
1511
+ },
1428
1512
  "Profit & Loss Account": pl,
1429
1513
  "Income Tax": incomeTax,
1430
1514
  "Profit Forecast": forecast,
@@ -435,6 +435,12 @@ export function diyaGlToScenario(book, lines, product) {
435
435
  business,
436
436
  period_start_month: new Date(periodStart).getUTCMonth() + 1,
437
437
  period_start_year: new Date(periodStart).getUTCFullYear(),
438
+ // The book's own accounting period, carried through unshifted: cellWrites
439
+ // moves it onto the target package's own year the same way it moves
440
+ // every other date, when it prints boxes 8 and 9 and the box 68 formula
441
+ // reads it back off the printed cells (Business Details!N27, N32).
442
+ period_covered_start: periodStart,
443
+ period_covered_end: book.documentInfo?.periodCoveredEnd,
438
444
  sales: grouped.sales,
439
445
  purchases: grouped.purchases,
440
446
  expected,
@@ -452,8 +458,12 @@ export function diyaGlToScenario(book, lines, product) {
452
458
  // them the calculator adds back only the wholly disallowable pair and
453
459
  // charges tax on a larger profit than the package's own sheet does.
454
460
  if (book.tax?.selfEmployment) {
455
- const { allowances, adjustments, ...disallowable } = book.tax.selfEmployment;
461
+ const { allowances, adjustments, basisPeriod, ...disallowable } = book.tax.selfEmployment;
456
462
  if (Object.keys(disallowable).length > 0) scenario.disallowable = disallowable;
463
+ // The basis period record (SA103F boxes 68, 69 and 73.3): copied
464
+ // verbatim, since every figure in it is a stated amount the writer puts
465
+ // straight onto its Business Details cell.
466
+ if (basisPeriod) scenario.basis_period = { ...basisPeriod };
457
467
  // The annual SA103F figures the trader states by hand, keyed by HMRC's
458
468
  // own field names: the writer puts each on its SE Full box and the
459
469
  // derivation files it from there.
@@ -12,7 +12,7 @@
12
12
 
13
13
  export const PROVENANCE_DATA = {
14
14
  formatVersion: "diya-gl/1",
15
- engineVersion: "1.2.17+67e3da11",
15
+ engineVersion: "1.2.19+80457d57",
16
16
  taxDataHash: "3221a7c2f39c",
17
17
  reconciledCommit: "54e8731a91279975e61b1a232fbb67fda89515f0",
18
18
  templates: {
@@ -25,8 +25,8 @@ export const PROVENANCE_DATA = {
25
25
  scorecard: "686 passed, 0 warnings, 0 failed",
26
26
  },
27
27
  se: {
28
- hash: "107ff1b5c94a",
29
- scorecard: "7969 passed, 0 warnings, 0 failed",
28
+ hash: "064605ae76ed",
29
+ scorecard: "7982 passed, 0 warnings, 0 failed",
30
30
  },
31
31
  ltd: {
32
32
  hash: "caa14632083b",
@@ -1152,6 +1152,7 @@ export function formatScenarioToml(metadata, grouped, expected) {
1152
1152
  for (const [table, figures] of [
1153
1153
  ["annual_allowances", expected.annual_allowances],
1154
1154
  ["annual_adjustments", expected.annual_adjustments],
1155
+ ["basis_period", expected.basis_period],
1155
1156
  ]) {
1156
1157
  if (!figures) continue;
1157
1158
  parts.push(`[${table}]`);
@@ -1711,6 +1711,15 @@ const SE_ANNUAL_ADJUSTMENT_CELLS = {
1711
1711
  includedNonTaxableProfits: "D179",
1712
1712
  accountingAdjustment: "D210",
1713
1713
  };
1714
+ // The basis period record (SA103F boxes 68, 69 and 73.3), the four stated
1715
+ // cells app/products/se.js writes from BASIS_PERIOD_CELLS, all on
1716
+ // Business Details.
1717
+ const SE_BASIS_PERIOD_CELLS = {
1718
+ overlapProfitBroughtForward: "D59",
1719
+ transitionProfitBroughtForward: "O59",
1720
+ transitionProfitAccelerationAmount: "O69",
1721
+ followingPeriodProfit: "D74",
1722
+ };
1714
1723
 
1715
1724
  // Fixedassets.xlsx!Schedule rows 115 to 119, the Structures and Buildings
1716
1725
  // Allowance claims below the register (SA103F boxes 53 and 53.1); the same
@@ -2206,6 +2215,23 @@ function dateAt(xml, cellRef, sharedStrings) {
2206
2215
  return typeof value === "number" && value > 1 ? excelSerialToDate(value) : undefined;
2207
2216
  }
2208
2217
 
2218
+ // The SE package's own accounting period, read straight off the printed
2219
+ // boxes 8 and 9 (Business Details!N27, N32) rather than guessed from the
2220
+ // journal's posting dates: periodCovered()'s guess is always the twelve
2221
+ // whole months the package's own tab grid runs, which is right for every
2222
+ // other product but wrong for a book whose true accounting date sits
2223
+ // outside 31 March to 5 April.
2224
+ async function seAccountingPeriodFromSheet(set) {
2225
+ const hubZip = await openWorkbook(set, "Financialaccounts.xlsx");
2226
+ if (!hubZip) return null;
2227
+ const sheet = await openSheet(hubZip, "Business Details");
2228
+ if (!sheet) return null;
2229
+ const start = dateAt(sheet.xml, "N27", sheet.sharedStrings);
2230
+ const end = dateAt(sheet.xml, "N32", sheet.sharedStrings);
2231
+ if (!start || !end) return null;
2232
+ return { start, end };
2233
+ }
2234
+
2209
2235
  function assign(target, key, value) {
2210
2236
  if (value !== undefined && value !== "" && value !== 0) target[key] = value;
2211
2237
  }
@@ -2602,6 +2628,10 @@ export async function extractBook(set, product, lines, cellMap, options = {}) {
2602
2628
  }
2603
2629
  if (Object.keys(allowances).length > 0) selfEmployment.allowances = allowances;
2604
2630
  if (Object.keys(adjustments).length > 0) selfEmployment.adjustments = adjustments;
2631
+ if (entitySheet) {
2632
+ const basisPeriod = readStated(entitySheet, SE_BASIS_PERIOD_CELLS);
2633
+ if (Object.keys(basisPeriod).length > 0) selfEmployment.basisPeriod = basisPeriod;
2634
+ }
2605
2635
  }
2606
2636
  if (Object.keys(selfEmployment).length > 0) tax.selfEmployment = selfEmployment;
2607
2637
  }
@@ -2629,7 +2659,11 @@ export async function extractBook(set, product, lines, cellMap, options = {}) {
2629
2659
  }
2630
2660
  }
2631
2661
 
2632
- const period = periodCovered(await extractPeriodStartMonth(set, product), lines);
2662
+ // SE reads its true accounting period off the printed boxes, since a book
2663
+ // whose dates do not run April to March still has every posting written
2664
+ // onto the package's own April-March tab grid.
2665
+ const period =
2666
+ (product === "se" && (await seAccountingPeriodFromSheet(set))) || periodCovered(await extractPeriodStartMonth(set, product), lines);
2633
2667
  const book = {
2634
2668
  documentInfo: {
2635
2669
  entriesType: "journal",
@@ -8,8 +8,9 @@
8
8
 
9
9
  import { toExcelSerial } from "../lib/spreadsheet-runner.js";
10
10
  import { ACCOUNT_ID_COLUMN } from "../lib/xlsx-exporter.js";
11
- import { parseDate, MONTH_SHEETS } from "../lib/scenario-loader.js";
11
+ import { parseDate, MONTH_SHEETS, extractTaxYearStart } from "../lib/scenario-loader.js";
12
12
  import { shiftMonths, periodShiftMonths } from "../lib/period-shift.js";
13
+ import { taxYearFileName } from "../lib/tax-year.js";
13
14
  import {
14
15
  monthlyPayrollBlockRow,
15
16
  PAYE_DUE_DAY,
@@ -251,6 +252,27 @@ export const ANNUAL_ADJUSTMENT_CELLS = {
251
252
  // use" cell (label at N48), read by SE Full!D169 and SE Short!D94.
252
253
  export const GOODS_FOR_OWN_USE_CELL = "O50";
253
254
 
255
+ // The basis period record behind SA103F boxes 68, 69 and 73.3, on
256
+ // Business Details rows 57 to 75 (verified against the template): D59 box 69
257
+ // (overlap profit brought forward), O59 the 2023-24 transition profit not
258
+ // yet treated as arising, O69 the additional amount elected this year
259
+ // (para 73(1)), D74 the following period's profit (only read when box 9 is
260
+ // outside 31 March to 5 April). The sheet computes D64, O64, D69 and O74
261
+ // itself; no book field carries them.
262
+ export const BASIS_PERIOD_CELLS = {
263
+ overlapProfitBroughtForward: "D59",
264
+ transitionProfitBroughtForward: "O59",
265
+ transitionProfitAccelerationAmount: "O69",
266
+ followingPeriodProfit: "D74",
267
+ };
268
+ // Business Details boxes 8 and 9 (verified against the template: N27 merged
269
+ // N27:Q27, N32 merged N32:Q32, N32 today the formula =Admin!B17), the
270
+ // accounting period the trader's own books cover -- the sheet has nowhere
271
+ // else to read it from, since every posting date is written onto the
272
+ // package's own April-March tab grid regardless of the book's true dates.
273
+ export const ACCOUNTING_PERIOD_START_CELL = "N27";
274
+ export const ACCOUNTING_PERIOD_END_CELL = "N32";
275
+
254
276
  // Fixedassets.xlsx Schedule sheet -- verified against the template:
255
277
  // Existing assets (bought before the year start): rows 8-10 land,
256
278
  // 14-18 plant, 22-26 fixtures, 30-34 computers, 38-54 motor. Each row:
@@ -593,6 +615,38 @@ function composeWrites(scenario, targetStartYear) {
593
615
  hubWrites["SE Full"][cell] = figures[field];
594
616
  }
595
617
  }
618
+ // The basis period record (boxes 68, 69 and 73.3): stated figures, each
619
+ // on its own Business Details cell.
620
+ if (scenario.basis_period) {
621
+ hubWrites["Business Details"] = hubWrites["Business Details"] || {};
622
+ for (const [field, cell] of Object.entries(BASIS_PERIOD_CELLS)) {
623
+ if (scenario.basis_period[field] === undefined) continue;
624
+ hubWrites["Business Details"][cell] = scenario.basis_period[field];
625
+ }
626
+ }
627
+ // Boxes 8 and 9: the book's own accounting period, moved forward by whole
628
+ // years only -- the gap between the package's own start year and the
629
+ // year the book's period would open in on its own, never the month-level
630
+ // shift that moves postings onto the package's April-March tab grid.
631
+ if (targetStartYear && scenario.period_covered_start && scenario.period_covered_end) {
632
+ const periodEnd = parseDate(scenario.period_covered_end);
633
+ const naturalStartYear = parseInt(taxYearFileName(periodEnd, "se").split("-")[1], 10);
634
+ const yearShift = targetStartYear - naturalStartYear;
635
+ const shiftYears = (d) => shiftMonths(d, yearShift * 12);
636
+ const periodStart = shiftYears(parseDate(scenario.period_covered_start));
637
+ const periodEndShifted = shiftYears(periodEnd);
638
+ hubWrites["Business Details"] = hubWrites["Business Details"] || {};
639
+ hubWrites["Business Details"][ACCOUNTING_PERIOD_START_CELL] = toExcelSerial(
640
+ periodStart.getUTCFullYear(),
641
+ periodStart.getUTCMonth() + 1,
642
+ periodStart.getUTCDate(),
643
+ );
644
+ hubWrites["Business Details"][ACCOUNTING_PERIOD_END_CELL] = toExcelSerial(
645
+ periodEndShifted.getUTCFullYear(),
646
+ periodEndShifted.getUTCMonth() + 1,
647
+ periodEndShifted.getUTCDate(),
648
+ );
649
+ }
596
650
 
597
651
  // Payslips.xlsx employee details
598
652
  const payslipsWrites = {};
@@ -971,6 +1025,16 @@ export const CELL_MAP = [
971
1025
  // ── Business Details ──
972
1026
  ["Business Details", "C5", "Business Name", "entityInformation.organizationIdentifier", "Business Details", 0],
973
1027
  ["Business Details", "O50", "Value of goods and services for own use (box 24)", "tax.selfEmployment.adjustments.goodsAndServicesOwnUse", "Business Details", 1],
1028
+ ["Business Details", "N27", "Accounting period start date (box 8)", "documentInfo.periodCoveredStart", "Business Details", 1],
1029
+ ["Business Details", "N32", "Accounting period end date (box 9)", "documentInfo.periodCoveredEnd", "Business Details", 1],
1030
+ ["Business Details", "D59", "Overlap profit brought forward (feeds box 69)", "tax.selfEmployment.basisPeriod.overlapProfitBroughtForward", "Business Details", 1],
1031
+ ["Business Details", "D64", "Overlap relief used this year (box 69)", "gl-cor:amount (basisPeriod.overlapReliefUsed)", "Business Details", 1],
1032
+ ["Business Details", "D69", "Overlap profit carried forward", "gl-cor:amount (basisPeriod.overlapCarriedForward)", "Business Details", 1],
1033
+ ["Business Details", "D74", "Following period's profit (feeds box 68)", "tax.selfEmployment.basisPeriod.followingPeriodProfit", "Business Details", 1],
1034
+ ["Business Details", "O59", "Transition profit not yet treated as arising", "tax.selfEmployment.basisPeriod.transitionProfitBroughtForward", "Business Details", 1],
1035
+ ["Business Details", "O64", "Transition profit treated as arising this year, before election (feeds box 73.3)", "gl-cor:amount (basisPeriod.transitionArisingBeforeElection)", "Business Details", 1],
1036
+ ["Business Details", "O69", "Additional transition profit elected this year (feeds box 73.3)", "tax.selfEmployment.basisPeriod.transitionProfitAccelerationAmount", "Business Details", 1],
1037
+ ["Business Details", "O74", "Transition profit carried forward to next year", "gl-cor:amount (basisPeriod.transitionCarriedForward)", "Business Details", 1],
974
1038
  // ── Profit & Loss Account ──
975
1039
  ["Profit & Loss Account", "B5", "Product A sales (code a)", "accounts.sales.4000", "Profit & Loss Account", 1],
976
1040
  ["Profit & Loss Account", "B6", "Product B sales (code b)", "accounts.sales.4001", "Profit & Loss Account", 1],
@@ -1012,6 +1076,7 @@ export const CELL_MAP = [
1012
1076
  [TAX_SHEET, "E10", "Tax at Additional Rate (45%)", "tax.incomeTax.additionalRate", "Income Tax Calculation", 1],
1013
1077
  [TAX_SHEET, "E11", "**Total Income Tax**", "tax.incomeTax (total)", "Income Tax Calculation", 0],
1014
1078
  [TAX_SHEET, "E12", "Less: CIS Deducted", "diya-gl:cisDeduction (total)", "Income Tax Calculation", 1],
1079
+ [TAX_SHEET, "E14", "Income Tax on transition profit (box 73.3), top-sliced", "gl-cor:amount (transitionProfitTax)", "Income Tax Calculation", 1],
1015
1080
  [TAX_SHEET, "E15", "NI Class 4 (lower band)", "tax.nationalInsurance.class4MainRate", "Income Tax Calculation", 1],
1016
1081
  [TAX_SHEET, "E16", "NI Class 4 (upper band)", "tax.nationalInsurance.class4UpperRate","Income Tax Calculation", 1],
1017
1082
  [TAX_SHEET, "E18", "**Total Tax + NI**", "gl-cor:taxAmount (totalTaxNI)", "Income Tax Calculation", 0],
@@ -1119,8 +1184,10 @@ export const CELL_MAP = [
1119
1184
  ["SE Full", "O169", "**Total deductions from net profit (box 63)**", "gl-cor:amount (sa103f.totalDeductions)", "Self Assessment (SA103F)", 0],
1120
1185
  ["SE Full", "O174", "**Net business profit for tax purposes (box 64)**", "gl-cor:amount (sa103f.taxableProfit)", "Self Assessment (SA103F)", 0],
1121
1186
  ["SE Full", "O179", "Net business loss for tax purposes (box 65)", "gl-cor:amount (sa103f.taxableLoss)", "Self Assessment (SA103F)", 1],
1187
+ ["SE Full", "D197", "Adjustment where accounting period was not 12 months long (box 68)", "gl-cor:amount (sa103f.basisAdjustment)", "Self Assessment (SA103F)", 1],
1122
1188
  ["SE Full", "D210", "Adjustment for change of accounting practice (box 71)", "tax.selfEmployment.adjustments.accountingAdjustment", "Self Assessment (SA103F)", 1],
1123
1189
  ["SE Full", "O194", "**Adjusted profit (box 73)**", "gl-cor:amount (sa103f.adjustedProfit)", "Self Assessment (SA103F)", 0],
1190
+ ["SE Full", "D201", "Spread of the transition profit treated as arising this year (box 73.3)", "gl-cor:amount (sa103f.transitionProfitSpread)", "Self Assessment (SA103F)", 1],
1124
1191
  ["SE Full", "O199", "Loss brought forward set against this year (box 74)", "gl-cor:amount (sa103f.lossBroughtForward)", "Self Assessment (SA103F)", 1],
1125
1192
  ["SE Full", "O204", "Other business income not in boxes 15, 16 or 60 (box 75)", "gl-cor:amount (sa103f.otherBusinessIncome)", "Self Assessment (SA103F)", 1],
1126
1193
  ["SE Full", "O210", "**Total taxable profits from this business (box 76)**", "gl-cor:amount (sa103f.profitForTax)", "Self Assessment (SA103F)", 0],
@@ -1892,6 +1959,8 @@ export function unitFor(sheet, cell) {
1892
1959
  }
1893
1960
  switch (sheet) {
1894
1961
  case "Business Details":
1962
+ // Boxes 8 and 9, the book's own accounting period dates.
1963
+ if (cell === ACCOUNTING_PERIOD_START_CELL || cell === ACCOUNTING_PERIOD_END_CELL) return "date";
1895
1964
  return "text";
1896
1965
  case "Admin":
1897
1966
  if (ADMIN_TAX_YEAR_LABEL_CELLS.has(cell)) return "text";
@@ -2461,16 +2530,45 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2461
2530
  const tax = results[TAX_SHEET];
2462
2531
  const profit = tax.E5 || 0;
2463
2532
  const expectedTax = calculateExpectedTax(profit, taxData);
2533
+ // Class 4 NIC follows the income tax charge onto box 73.3's own spread
2534
+ // (FA 2022 Sch 1 para 72(3), SSCBA 1992 s.15(1)), even though the spread
2535
+ // sits outside box 76 and net income for income tax purposes.
2536
+ const transitionProfitSpread = num(results["SE Full"]?.D201);
2537
+ const expectedTaxWithSpread = calculateExpectedTax(profit + transitionProfitSpread, taxData);
2538
+ // Box 73.3's own top-slice charge (para 75(2)-(3)): the three bands run
2539
+ // again over taxable income (E7) plus the spread, less the tax already
2540
+ // charged on taxable income alone -- the personal allowance taper is
2541
+ // left untouched, since the spread never reaches E5/E6.
2542
+ const expectedTransitionProfitTax = (() => {
2543
+ if (transitionProfitSpread <= 0) return 0;
2544
+ const it = taxData.income_tax;
2545
+ const spreadTop = (tax.E7 || 0) + transitionProfitSpread;
2546
+ const basic = spreadTop < it.basic_band_end ? spreadTop * it.basic_rate : it.basic_band_end * it.basic_rate;
2547
+ const higher = spreadTop > it.basic_band_end ? (Math.min(spreadTop, it.higher_band_end) - it.basic_band_end) * it.higher_rate : 0;
2548
+ const additional = spreadTop > it.higher_band_end ? (spreadTop - it.higher_band_end) * it.additional_rate : 0;
2549
+ return basic + higher + additional - expectedTax.income_tax;
2550
+ })();
2464
2551
 
2465
2552
  check("Income Tax", tax.E11 || 0, expectedTax.income_tax, 0.01);
2466
- check("NI Class 4 (lower)", tax.E15 || 0, expectedTax.ni_class4_lower, 0.01);
2553
+ check("Income Tax on transition profit (box 73.3)", tax.E14 || 0, expectedTransitionProfitTax, 0.01);
2554
+ check("NI Class 4 (lower)", tax.E15 || 0, expectedTaxWithSpread.ni_class4_lower, 0.01);
2555
+ check("NI Class 4 (upper)", tax.E16 || 0, expectedTaxWithSpread.ni_class4_upper, 0.01);
2467
2556
  // E18 is the sheet's own SUM(E11:E17), and E12 (the CIS already deducted,
2468
2557
  // carried negative) sits inside that range, so the sheet's total is the
2469
2558
  // computed tax and NI less what the contractors have already paid over.
2470
2559
  const cisSuffered = Object.values(expected.sales || {})
2471
2560
  .flat()
2472
2561
  .reduce((total, tx) => total + (tx.cis_deduction || 0), 0);
2473
- check("Total Tax + NI, less the CIS already deducted", tax.E18 || 0, expectedTax.total_tax_and_ni - cisSuffered, 0.01);
2562
+ check(
2563
+ "Total Tax + NI, less the CIS already deducted",
2564
+ tax.E18 || 0,
2565
+ expectedTax.income_tax +
2566
+ expectedTransitionProfitTax +
2567
+ expectedTaxWithSpread.ni_class4_lower +
2568
+ expectedTaxWithSpread.ni_class4_upper -
2569
+ cisSuffered,
2570
+ 0.01,
2571
+ );
2474
2572
 
2475
2573
  // The allowance the sheet hands out, not the headline one. Above 100,000
2476
2574
  // of profit it falls by a pound for every two, and reaches nil at 125,140.
@@ -2499,7 +2597,11 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2499
2597
  // the sheet's own total is SUM(E11:E17), so the deduction line is added,
2500
2598
  // not subtracted. Every fixture so far carries nil CIS, which is why
2501
2599
  // subtracting it here passed.
2502
- check("Tax: Total = IT + CIS deduction line + NI", tax.E18, (tax.E11 || 0) + (tax.E12 || 0) + (tax.E15 || 0) + (tax.E16 || 0));
2600
+ check(
2601
+ "Tax: Total = IT + transition profit tax + CIS deduction line + NI",
2602
+ tax.E18,
2603
+ (tax.E11 || 0) + (tax.E14 || 0) + (tax.E12 || 0) + (tax.E15 || 0) + (tax.E16 || 0),
2604
+ );
2503
2605
 
2504
2606
  // SA103S cross-check (6g)
2505
2607
  const seShort = results["SE Short"];
@@ -2782,16 +2884,16 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2782
2884
  );
2783
2885
  // HMRC's working sheet for boxes 73 and 77 takes one figure, box 64 less
2784
2886
  // box 65 plus boxes 68, 71 and 72; box 73 is it when positive, box 77 its
2785
- // negation when not. Boxes 68 (D197) and 72 (O190) print a dash on this
2786
- // template and contribute nil, so box 71 is the only adjustment here.
2787
- const adjustedProfitBeforeFloor = num(seFull.O174) - num(seFull.O179) + num(seFull.D210);
2887
+ // negation when not. Box 72 (O190) prints a dash on this template and
2888
+ // contributes nil.
2889
+ const adjustedProfitBeforeFloor = num(seFull.O174) - num(seFull.O179) + num(seFull.D197) + num(seFull.D210);
2788
2890
  check(
2789
- "SA103F box 73 adjusted profit (O194) = box 64 less box 65 plus box 71, floored at nil",
2891
+ "SA103F box 73 adjusted profit (O194) = box 64 less box 65 plus boxes 68 and 71, floored at nil",
2790
2892
  num(seFull.O194),
2791
2893
  Math.max(0, adjustedProfitBeforeFloor),
2792
2894
  );
2793
2895
  check(
2794
- "SA103F box 77 adjusted loss (D219) = box 65 less box 64 and box 71, floored at nil",
2896
+ "SA103F box 77 adjusted loss (D219) = box 65 less box 64, 68 and 71, floored at nil",
2795
2897
  num(seFull.D219),
2796
2898
  Math.max(0, -adjustedProfitBeforeFloor),
2797
2899
  );
@@ -2801,6 +2903,114 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2801
2903
  num(seFull.O194) - num(seFull.O199) + num(seFull.O204),
2802
2904
  );
2803
2905
 
2906
+ // The basis period record (boxes 68, 69 and 73.3), anchored on the
2907
+ // fixture's own basisPeriod figures and the package's tax year --
2908
+ // independent of the Business Details cells the s.7A formula itself
2909
+ // reads, so a self-consistent-but-wrong sheet fails here.
2910
+ const businessDetails = results["Business Details"] || {};
2911
+ const basisPeriod = expected.basis_period || {};
2912
+ const bpOverlapBroughtForward = basisPeriod.overlapProfitBroughtForward || 0;
2913
+ const bpTransitionBroughtForward = basisPeriod.transitionProfitBroughtForward || 0;
2914
+ const bpTransitionAcceleration = basisPeriod.transitionProfitAccelerationAmount || 0;
2915
+ const bpFollowingPeriodProfit = basisPeriod.followingPeriodProfit || 0;
2916
+ check(
2917
+ "Business Details!D59 overlap profit brought forward = the fixture's own figure",
2918
+ num(businessDetails.D59),
2919
+ bpOverlapBroughtForward,
2920
+ );
2921
+ check(
2922
+ "Business Details!O59 transition profit not yet treated as arising = the fixture's own figure",
2923
+ num(businessDetails.O59),
2924
+ bpTransitionBroughtForward,
2925
+ );
2926
+ check(
2927
+ "Business Details!O69 additional transition profit elected this year = the fixture's own figure",
2928
+ num(businessDetails.O69),
2929
+ bpTransitionAcceleration,
2930
+ );
2931
+ check("Business Details!D74 following period's profit = the fixture's own figure", num(businessDetails.D74), bpFollowingPeriodProfit);
2932
+
2933
+ // The tax year this figure is computed for -- calculateSeCells' own
2934
+ // source (taxData.tax_year.start, falling back to the scenario's own
2935
+ // dates), not packageYearEnd: a caller checking a single computed
2936
+ // result rather than a generated package directory passes no year end.
2937
+ const basisPeriodStartYear = taxData?.tax_year?.start
2938
+ ? new Date(taxData.tax_year.start).getUTCFullYear()
2939
+ : extractTaxYearStart(expected);
2940
+ const packageTaxYearEnd = basisPeriodStartYear ? basisPeriodStartYear + 1 : null;
2941
+ // s.7A(2)(a): overlap relief is used in full on the 2023-24 return only.
2942
+ const expectedOverlapReliefUsed = packageTaxYearEnd === 2024 ? bpOverlapBroughtForward : 0;
2943
+ check(
2944
+ "Business Details!D64 overlap relief used this year = the 2023-24 return uses it in full, else nil",
2945
+ num(businessDetails.D64),
2946
+ expectedOverlapReliefUsed,
2947
+ );
2948
+ check(
2949
+ "Business Details!D69 overlap profit carried forward = box 69's input less box 69's relief used",
2950
+ num(businessDetails.D69),
2951
+ bpOverlapBroughtForward - expectedOverlapReliefUsed,
2952
+ );
2953
+ // para 72(3)-(4): 1/(years left, this one included) of the untaxed
2954
+ // transition balance, nil before 2023-24.
2955
+ const expectedTransitionArising =
2956
+ packageTaxYearEnd && packageTaxYearEnd >= 2024 ? bpTransitionBroughtForward / Math.max(1, 2029 - packageTaxYearEnd) : 0;
2957
+ check(
2958
+ "Business Details!O64 transition profit treated as arising this year, before election = the fixture's balance divided by the years left",
2959
+ num(businessDetails.O64),
2960
+ expectedTransitionArising,
2961
+ );
2962
+ check(
2963
+ "Business Details!O74 transition profit carried forward = the fixture's balance less what this year and the election take",
2964
+ num(businessDetails.O74),
2965
+ bpTransitionBroughtForward - expectedTransitionArising - bpTransitionAcceleration,
2966
+ );
2967
+ // para 73(3): an election cannot exceed the untaxed balance left once
2968
+ // this year's automatic slice is taken out of it -- checked against the
2969
+ // sheet's own O69, not the fixture's, so a sheet that carries an
2970
+ // over-large election fails here even when the fixture itself is fine.
2971
+ check(
2972
+ "Business Details!O69 election does not exceed the untaxed transition balance (para 73(3))",
2973
+ Math.max(0, num(businessDetails.O69) - (bpTransitionBroughtForward - expectedTransitionArising)),
2974
+ 0,
2975
+ );
2976
+ // Box 73.3 (D201): this year's automatic slice plus the election.
2977
+ check(
2978
+ "SA103F box 73.3 spread of the transition profit treated as arising (D201) = box O64 plus box O69",
2979
+ num(seFull.D201),
2980
+ expectedTransitionArising + bpTransitionAcceleration,
2981
+ );
2982
+
2983
+ // Box 68 (D197), s.7A: nil under s.7C for an accounting date of 31
2984
+ // March to 5 April. Both fixtures ship one, so the independent figure
2985
+ // is nil on every package year -- a book whose accounting date sits
2986
+ // outside that window is covered by the calc-tier basis period test.
2987
+ const periodProfit = num(seFull.O174) - num(seFull.O179);
2988
+ const periodCoveredStart = expected.period_covered_start ? parseDate(expected.period_covered_start) : null;
2989
+ const periodCoveredEnd = expected.period_covered_end ? parseDate(expected.period_covered_end) : null;
2990
+ let expectedBasisAdjustment = 0;
2991
+ if (periodCoveredStart && periodCoveredEnd && basisPeriodStartYear) {
2992
+ const naturalStartYear = parseInt(taxYearFileName(periodCoveredEnd, "se").split("-")[1], 10);
2993
+ const shiftYears = (d) => shiftMonths(d, (basisPeriodStartYear - naturalStartYear) * 12);
2994
+ const shiftedStart = shiftYears(periodCoveredStart);
2995
+ const shiftedEnd = shiftYears(periodCoveredEnd);
2996
+ const marchThirtyFirst = new Date(Date.UTC(basisPeriodStartYear + 1, 2, 31));
2997
+ if (shiftedEnd < marchThirtyFirst) {
2998
+ const dayMs = 24 * 60 * 60 * 1000;
2999
+ const taxYearStart = new Date(Date.UTC(basisPeriodStartYear, 3, 6));
3000
+ const taxYearEnd = new Date(Date.UTC(basisPeriodStartYear + 1, 3, 5));
3001
+ const followingPeriodEnd = shiftMonths(shiftedEnd, 12);
3002
+ const thisPeriodShare = (shiftedEnd - Math.max(shiftedStart, taxYearStart) + dayMs) / (shiftedEnd - shiftedStart + dayMs);
3003
+ const nextPeriodShare = (taxYearEnd - shiftedEnd) / (followingPeriodEnd - shiftedEnd);
3004
+ expectedBasisAdjustment = thisPeriodShare * periodProfit + nextPeriodShare * bpFollowingPeriodProfit - periodProfit;
3005
+ }
3006
+ }
3007
+ check(
3008
+ "SA103F box 68 adjustment where the accounting period was not 12 months long (D197) = the s.7A apportionment",
3009
+ num(seFull.D197),
3010
+ expectedBasisAdjustment,
3011
+ 0.01,
3012
+ );
3013
+
2804
3014
  // The capital allowance boxes have no profit and loss source: they read
2805
3015
  // the fixed asset schedule across the cross-file external link.
2806
3016
  const returnSchedule = results["Fixedassets.xlsx!Schedule"];
@@ -2931,19 +3141,19 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2931
3141
  num(seFull.D174) - num(seFull.O122) - num(seFull.O160),
2932
3142
  ownUseStated,
2933
3143
  );
2934
- // The same two boxes anchored on the figure the book states, so a sheet
2935
- // whose box 71 cell and adjusted figures agree with each other but not
2936
- // with the book fails here.
3144
+ // The same two boxes anchored on the figures the book states, so a
3145
+ // sheet whose box 71 cell and adjusted figures agree with each other
3146
+ // but not with the book fails here.
2937
3147
  const statedAccountingAdjustment = statedAdjustments.accountingAdjustment || 0;
2938
3148
  check(
2939
- "SA103F box 73 adjusted profit (O194) = box 64 less box 65 plus the box 71 figure the book states, floored at nil",
3149
+ "SA103F box 73 adjusted profit (O194) = box 64 less box 65 plus boxes 68 and 71 the book states, floored at nil",
2940
3150
  num(seFull.O194),
2941
- Math.max(0, num(seFull.O174) - num(seFull.O179) + statedAccountingAdjustment),
3151
+ Math.max(0, num(seFull.O174) - num(seFull.O179) + expectedBasisAdjustment + statedAccountingAdjustment),
2942
3152
  );
2943
3153
  check(
2944
- "SA103F box 77 adjusted loss (D219) = box 65 less box 64 and the box 71 figure the book states, floored at nil",
3154
+ "SA103F box 77 adjusted loss (D219) = box 65 less box 64, 68 and 71 the book states, floored at nil",
2945
3155
  num(seFull.D219),
2946
- Math.max(0, num(seFull.O179) - num(seFull.O174) - statedAccountingAdjustment),
3156
+ Math.max(0, num(seFull.O179) - num(seFull.O174) - expectedBasisAdjustment - statedAccountingAdjustment),
2947
3157
  );
2948
3158
 
2949
3159
  if (sa103s) {
@@ -897,6 +897,28 @@
897
897
  }
898
898
  }
899
899
  },
900
+ "basisPeriod": {
901
+ "type": "object",
902
+ "description": "The basis period record behind SA103F boxes 68, 69 and 73.3, for a book whose accounting date is not 31 March to 5 April. overlapProfitBroughtForward is deducted in full on the 2023-24 return (box 69). transitionProfitBroughtForward is the 2023-24 transition profit left after every amount already treated as arising; each year 1/(years left, this one included) of it is treated as arising (box 73.3), unless transitionProfitAccelerationAmount elects an additional amount that year. followingPeriodProfit is the profit (a loss negative) of the accounting period after this one, read only when box 9 is outside 31 March to 5 April, and feeds the box 68 apportionment.",
903
+ "additionalProperties": false,
904
+ "properties": {
905
+ "overlapProfitBroughtForward": {
906
+ "type": "number",
907
+ "minimum": 0
908
+ },
909
+ "transitionProfitBroughtForward": {
910
+ "type": "number",
911
+ "minimum": 0
912
+ },
913
+ "transitionProfitAccelerationAmount": {
914
+ "type": "number",
915
+ "minimum": 0
916
+ },
917
+ "followingPeriodProfit": {
918
+ "type": "number"
919
+ }
920
+ }
921
+ },
900
922
  "costOfGoods": {
901
923
  "type": "number",
902
924
  "minimum": 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.17",
3
+ "version": "1.2.19",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",