@diy-accounting-uk/diya-gl 1.2.45 → 1.2.46

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.
@@ -1304,7 +1304,7 @@
1304
1304
  "tag": "diya-gl-v1.2.41",
1305
1305
  "date": "2026-10-01",
1306
1306
  "formatVersion": "diya-gl/1",
1307
- "engineVersion": "1.2.40+57de8f55b",
1307
+ "engineVersion": "1.2.41+5b1fc93c5",
1308
1308
  "taxDataHash": "89424bd36c0d",
1309
1309
  "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1310
1310
  "templates": {
@@ -1330,7 +1330,7 @@
1330
1330
  "tag": "diya-gl-v1.2.42",
1331
1331
  "date": "2026-10-03",
1332
1332
  "formatVersion": "diya-gl/1",
1333
- "engineVersion": "1.2.40+57de8f55b",
1333
+ "engineVersion": "1.2.42+ed6c7809b",
1334
1334
  "taxDataHash": "89424bd36c0d",
1335
1335
  "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1336
1336
  "templates": {
@@ -1356,7 +1356,7 @@
1356
1356
  "tag": "diya-gl-v1.2.43",
1357
1357
  "date": "2026-10-03",
1358
1358
  "formatVersion": "diya-gl/1",
1359
- "engineVersion": "1.2.40+57de8f55b",
1359
+ "engineVersion": "1.2.43+971ca45ad",
1360
1360
  "taxDataHash": "89424bd36c0d",
1361
1361
  "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1362
1362
  "templates": {
@@ -1382,7 +1382,33 @@
1382
1382
  "tag": "diya-gl-v1.2.44",
1383
1383
  "date": "2026-10-03",
1384
1384
  "formatVersion": "diya-gl/1",
1385
- "engineVersion": "1.2.40+57de8f55b",
1385
+ "engineVersion": "1.2.44+4b8f90020",
1386
+ "taxDataHash": "89424bd36c0d",
1387
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1388
+ "templates": {
1389
+ "bst": {
1390
+ "hash": "0d003e10e00c",
1391
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1392
+ },
1393
+ "taxi": {
1394
+ "hash": "1ff69654d64d",
1395
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1396
+ },
1397
+ "se": {
1398
+ "hash": "064605ae76ed",
1399
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1400
+ },
1401
+ "ltd": {
1402
+ "hash": "5bb7be9b6d97",
1403
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1404
+ }
1405
+ }
1406
+ },
1407
+ {
1408
+ "tag": "diya-gl-v1.2.45",
1409
+ "date": "2026-10-04",
1410
+ "formatVersion": "diya-gl/1",
1411
+ "engineVersion": "1.2.45+3801b0c2",
1386
1412
  "taxDataHash": "89424bd36c0d",
1387
1413
  "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1388
1414
  "templates": {
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { changeLinePostingDate } from "../diya-gl-edits.js";
17
17
  import { LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP } from "../scenario-extractor.js";
18
- import { BANK_ACCOUNT_FILES, BANK_TRANSFER_CODES, bankLayout } from "../ltd-layout.js";
18
+ import { BANK_ACCOUNT_FILES, BANK_TRANSFER_CODES, ltdBankPlacement } from "../ltd-layout.js";
19
19
 
20
20
  // The bank account each transfer letter names: Currentaccount is BB, so a
21
21
  // BB-coded line on any other workbook is a transfer to or from the current
@@ -161,13 +161,11 @@ export const LTD_CHECK_SPECS = [
161
161
  const fileName = bankFileOf(line);
162
162
  if (!fileName) return true;
163
163
  if (line.debitCreditCode !== "D" && line.debitCreditCode !== "C") return false;
164
- const layout = bankLayout(fileName);
165
- const analysed = line.debitCreditCode === "D" ? layout.receiptCodes : layout.paymentCodes;
166
- return !analysed.includes(line["diya-gl:bankCode"]);
164
+ return !ltdBankPlacement(fileName, line.debitCreditCode === "D", line["diya-gl:bankCode"], line.amount).analysed;
167
165
  });
168
166
  },
169
167
  consequence: function () {
170
- return "Each bank workbook analyses its own list of codes, and the four workbooks do not share one list -- the cash book has no receipt column for RV, RC or X. An entry coded outside its workbook's list has no column to land in, and the package cannot be written at all.";
168
+ return "Each bank workbook analyses its own list of codes, and the four workbooks do not share one list -- the cash book has no receipt column for RV, RC or X. A refund coded DR on a payment or CR on a receipt lands in the opposite block as a negative entry under its own code. Any other entry coded outside its workbook's list has no column to land in, and the package cannot be written at all.";
171
169
  },
172
170
  buildHelper: null,
173
171
  apply: null,
@@ -684,9 +684,13 @@ function bankEntries(lines, bankCode, side) {
684
684
  // straddling invoice is returned on a VAT period outside that year and is
685
685
  // banked in a book this one does not hold, so it neither needs a settlement
686
686
  // of its own nor takes up a bank line another invoice is waiting for.
687
- function journalEntries(lines, journal) {
687
+ // A credit note runs the other way from an invoice: its money goes back out
688
+ // as a refund (DR on a payment, CR on a receipt), or it is offset or written
689
+ // off and never reaches the bank. It neither wants a receipt or a payment
690
+ // the way an invoice does nor takes up one an invoice is waiting for.
691
+ function invoiceEntries(lines, journal) {
688
692
  return lines.filter(function (line) {
689
- return line.sourceJournalID === journal && !isStraddlingLine(line);
693
+ return line.sourceJournalID === journal && line.documentType !== "credit-note" && !isStraddlingLine(line);
690
694
  });
691
695
  }
692
696
 
@@ -769,7 +773,7 @@ const SETTLEMENT_KINDS = [
769
773
  actionLabel: "Add the sale",
770
774
  what: "sale",
771
775
  sources: function (ctx) {
772
- return unpaired(bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"), journalEntries(ctx.lines, "sales"));
776
+ return unpaired(bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"), invoiceEntries(ctx.lines, "sales"));
773
777
  },
774
778
  account: function (ctx) {
775
779
  return ctx.chart.sales[0] || null;
@@ -785,7 +789,7 @@ const SETTLEMENT_KINDS = [
785
789
  actionLabel: "Add the purchase",
786
790
  what: "purchase",
787
791
  sources: function (ctx) {
788
- return unpaired(bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"), journalEntries(ctx.lines, "purchases"));
792
+ return unpaired(bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"), invoiceEntries(ctx.lines, "purchases"));
789
793
  },
790
794
  account: function (ctx) {
791
795
  return repostAccount(ctx, "purchases");
@@ -801,7 +805,7 @@ const SETTLEMENT_KINDS = [
801
805
  actionLabel: "Add the receipt",
802
806
  what: "receipt",
803
807
  sources: function (ctx) {
804
- return unpaired(journalEntries(ctx.lines, "sales"), bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"));
808
+ return unpaired(invoiceEntries(ctx.lines, "sales"), bankEntries(ctx.lines, RECEIPT_BANK_CODE, "D"));
805
809
  },
806
810
  account: settlementBankAccount,
807
811
  build: function (ctx, source, account, entryNumber) {
@@ -815,7 +819,7 @@ const SETTLEMENT_KINDS = [
815
819
  actionLabel: "Add the payment",
816
820
  what: "payment",
817
821
  sources: function (ctx) {
818
- return unpaired(journalEntries(ctx.lines, "purchases"), bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"));
822
+ return unpaired(invoiceEntries(ctx.lines, "purchases"), bankEntries(ctx.lines, PAYMENT_BANK_CODE, "C"));
819
823
  },
820
824
  account: settlementBankAccount,
821
825
  build: function (ctx, source, account, entryNumber) {
@@ -24,7 +24,7 @@
24
24
  // makes the two comparable.
25
25
 
26
26
  import { toExcelSerial } from "../spreadsheet-runner.js";
27
- import { BANK_ACCOUNT_FILES, BANK_LAYOUTS, OPENING_FIXED_ASSET_COLUMNS, isLtdOpeningBankLine } from "../ltd-layout.js";
27
+ import { BANK_ACCOUNT_FILES, BANK_LAYOUTS, OPENING_FIXED_ASSET_COLUMNS, isLtdOpeningBankLine, ltdBankPlacement } from "../ltd-layout.js";
28
28
  import { apportionCorporationTax, financialYearsInPeriod, financialYearNumber, financialYearRatesFor } from "../tax/corporation-tax.js";
29
29
  import { calculateCapitalAllowances } from "../tax/capital-allowances.js";
30
30
  import {
@@ -451,10 +451,11 @@ function bankMonthTotals(scenario, tabs, periodStart, entries) {
451
451
  const tab = SHORT_MONTHS[parseDate(transaction.date).getUTCMonth()];
452
452
  const month = file.months[tab];
453
453
  if (!month) continue;
454
- const receipt = transaction.direction === "in";
455
- month[receipt ? "receipts" : "payments"] += transaction.amount;
454
+ const placement = ltdBankPlacement(fileName, transaction.direction === "in", transaction.code, transaction.amount);
455
+ const receipt = placement.receipt;
456
+ month[receipt ? "receipts" : "payments"] += placement.amount;
456
457
  const codes = receipt ? month.receiptCodes : month.paymentCodes;
457
- codes[transaction.code] = (codes[transaction.code] || 0) + transaction.amount;
458
+ codes[transaction.code] = (codes[transaction.code] || 0) + placement.amount;
458
459
  if (entries) {
459
460
  const monthEntries = entries[fileName].months[tab];
460
461
  addEntry(monthEntries, receipt ? "receipts" : "payments", transaction);
@@ -1816,13 +1817,16 @@ function buildPublishedBalanceSheet(tb, admin) {
1816
1817
  F6: fixedAssets,
1817
1818
  E10: tb.EJ19,
1818
1819
  E11: tb.EJ20,
1819
- E12: statementAccounts > 0 ? statementAccounts + tb.EJ25 + tb.EJ26 : tb.EJ25,
1820
+ // Cash and the intra transfers row stand in cash at bank whatever the
1821
+ // statement accounts do; only an overdrawn set of statement accounts
1822
+ // leaves it, for E19.
1823
+ E12: (statementAccounts > 0 ? statementAccounts : 0) + tb.EJ25 + tb.EJ26,
1820
1824
  E16: -(tb.EJ28 + tb.EJ29 + tb.EJ30 + tb.EJ31),
1821
1825
  E17: -tb.EJ35,
1822
1826
  E18: -(tb.EJ32 + tb.EJ33 + tb.EJ34),
1823
- // An overdrawn set of statement accounts leaves current assets and
1824
- // stands as a creditor instead (E19 = IF(SUM(EJ22:EJ24)<0,
1825
- // -SUM(EJ22:EJ24), 0)), mirroring E12's own test.
1827
+ // An overdrawn set of statement accounts stands as a creditor instead
1828
+ // (E19 = IF(SUM(EJ22:EJ24)<0, -SUM(EJ22:EJ24), 0)), mirroring E12's own
1829
+ // test.
1826
1830
  E19: statementAccounts < 0 ? -statementAccounts : 0,
1827
1831
  E29: -tb.EJ39,
1828
1832
  E30: -tb.EJ40,
@@ -2523,7 +2527,7 @@ function attributeLtdResults(attribution, context) {
2523
2527
  a.set(BS, "E10", EJ(19));
2524
2528
  a.set(BS, "E11", EJ(20));
2525
2529
  const statementAccounts = trialBalance.EJ22 + trialBalance.EJ23 + trialBalance.EJ24;
2526
- a.set(BS, "E12", ...(statementAccounts > 0 ? [22, 23, 24, 25, 26] : [25]).map(EJ));
2530
+ a.set(BS, "E12", ...(statementAccounts > 0 ? [22, 23, 24, 25, 26] : [25, 26]).map(EJ));
2527
2531
  a.set(BS, "E16", EJ(28), EJ(29), EJ(30), EJ(31));
2528
2532
  a.set(BS, "E17", EJ(35));
2529
2533
  a.set(BS, "E18", EJ(32), EJ(33), EJ(34));
@@ -11,7 +11,7 @@
11
11
  import { calculateFromDiyaGl } from "../diya-gl-calculator.js";
12
12
  import { diyaGlToScenario } from "../diya-gl-loader.js";
13
13
  import { loadTaxDataForBook, productOf } from "../product-workbook.js";
14
- import { isStraddlingLine, LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP } from "../scenario-extractor.js";
14
+ import { isStraddlingLine, LTD_PURCHASE_CODE_MAP, LTD_SALES_CODE_MAP, signedAmount } from "../scenario-extractor.js";
15
15
  import { dateFromExcelSerial } from "../calculators/shared.js";
16
16
  import { VAT_RATE } from "../calculators/ltd.js";
17
17
  import { splitVat, VATINTERFACE_FIRST_MONTH_ROW, VATINTERFACE_FIRST_ROW, VATINTERFACE_LAST_ROW } from "../tax/vat.js";
@@ -92,7 +92,8 @@ export function interfaceRows(results) {
92
92
  }
93
93
 
94
94
  function lineContribution(line, box) {
95
- const { vat, net } = splitGross(line.amount);
95
+ const gross = signedAmount(line);
96
+ const { vat, net } = splitGross(gross);
96
97
  return {
97
98
  entryNumber: line.entryNumber,
98
99
  lineNumber: line.lineNumber ?? null,
@@ -100,7 +101,7 @@ function lineContribution(line, box) {
100
101
  account: line.accountMainID,
101
102
  reference: line.documentReference ?? null,
102
103
  counterparty: line.detailComment ?? null,
103
- gross: pence(line.amount),
104
+ gross: pence(gross),
104
105
  contributes: pence(box === "vat" ? vat : net),
105
106
  };
106
107
  }
@@ -122,7 +123,7 @@ function linesForQuarter(lines, quarterRows) {
122
123
  }
123
124
 
124
125
  function sumContributions(lines, part) {
125
- return lines.reduce((total, line) => total + splitGross(line.amount)[part], 0);
126
+ return lines.reduce((total, line) => total + splitGross(signedAmount(line))[part], 0);
126
127
  }
127
128
 
128
129
  function reconcile(label, attributed, engine) {
@@ -32,6 +32,7 @@ import {
32
32
  splitStraddlingLines,
33
33
  deriveStraddlingEntries,
34
34
  signedAmount,
35
+ writtenOffBadDebtsNet,
35
36
  } from "./scenario-extractor.js";
36
37
  import { totalBusinessMiles, calculateMileageAllowance, FIXTURE_CAR_MILEAGE_RATES } from "./tax/mileage.js";
37
38
  import { compareLines } from "./diya-gl-canonical.js";
@@ -421,6 +422,7 @@ export function diyaGlToScenario(book, lines, product) {
421
422
  const mileageClaim = product === "se" ? calculateMileageAllowance(businessMiles, FIXTURE_CAR_MILEAGE_RATES) : 0;
422
423
  expected.total_motor_net = Math.round(cashMotor / vatDivisor + mileageClaim);
423
424
  expected.total_legal_net = Math.round((byCode.l || 0) / vatDivisor);
425
+ expected.total_bad_debts_net = writtenOffBadDebtsNet(salesLines, vatDivisor);
424
426
  if (product === "ltd") {
425
427
  expected.total_premises_net = Math.round((byCode.r || 0) / vatDivisor);
426
428
  }
@@ -116,6 +116,7 @@ async function writeCoreProperties(zip) {
116
116
  /**
117
117
  * The company and the terms in an already-composed package: the workbooks the
118
118
  * generator has no cells to edit, and the Company package's dividend voucher.
119
+ * A workbook also has every formula's cached value recalculated.
119
120
  *
120
121
  * @param {Uint8Array|Buffer} packageBuffer - an xlsx or docx
121
122
  * @returns {Promise<Uint8Array>}
@@ -123,6 +124,7 @@ async function writeCoreProperties(zip) {
123
124
  export async function applyCoreProperties(packageBuffer) {
124
125
  const zip = await JSZip.loadAsync(packageBuffer);
125
126
  await writeCoreProperties(zip);
127
+ if (zip.file("xl/workbook.xml")) await refreshZipCachedValues(zip);
126
128
  stabilizeDirDates(zip);
127
129
  return zip.generateAsync({ type: "uint8array", compression: "DEFLATE", compressionOptions: { level: 6 } });
128
130
  }
@@ -1483,9 +1485,10 @@ export async function generateSpreadsheet(templateBuffer, taxData, sheetsConfig)
1483
1485
  }
1484
1486
 
1485
1487
  // An application that shows cached values instead of recalculating reads
1486
- // every formula the edits above reach as the template left it, so those
1487
- // results are recalculated here and written beside their formulas.
1488
- await refreshZipCachedValues(zip, await JSZip.loadAsync(templateBuffer));
1488
+ // every formula as the template left it, including those the template
1489
+ // itself caches stale, so every result is recalculated here and written
1490
+ // beside its formula.
1491
+ await refreshZipCachedValues(zip);
1489
1492
 
1490
1493
  // Force full recalculation on open so cached formula values (e.g. G2=B23) update
1491
1494
  const wbXml = await zip.file("xl/workbook.xml").async("string");
@@ -1983,7 +1986,7 @@ export async function applyYearEndSequence(xlsxBuffer, templateFile, sheetsConfi
1983
1986
  }
1984
1987
 
1985
1988
  if (buffer === xlsxBuffer || !templateFile.endsWith(".xlsx")) return buffer;
1986
- return refreshCachedValues(xlsxBuffer, buffer);
1989
+ return refreshCachedValues(null, buffer);
1987
1990
  }
1988
1991
 
1989
1992
  export function formatDateDDMMYY(date) {
@@ -126,6 +126,49 @@ function bankLayout(fileName) {
126
126
 
127
127
  const BANK_LAYOUTS = Object.fromEntries(Object.values(BANK_ACCOUNT_FILES).map((fileName) => [fileName, bankLayout(fileName)]));
128
128
 
129
+ // A refund runs the opposite way to the code's own trade: a payment coded DR
130
+ // refunds a customer, a receipt coded CR is a refund from a supplier. All
131
+ // four workbooks analyse DR on receipts only and CR on payments only, so a
132
+ // refund lands in the opposite block under its own code with its amount
133
+ // negated. A negative DR receipt debits trade debtors and credits the bank,
134
+ // the same double entry as the refund itself.
135
+ const REFUND_CODES = new Set(["DR", "CR"]);
136
+
137
+ /**
138
+ * Where a bank entry lands in its workbook's month tab.
139
+ * @param {string} fileName - one of BANK_ACCOUNT_FILES' workbooks
140
+ * @param {boolean} isReceipt - money in (debitCreditCode "D", direction "in")
141
+ * @param {string} code - the entry's bank code letter
142
+ * @param {number} amount - the entry's own amount, positive
143
+ * @returns {{receipt: boolean, amount: number, analysed: boolean}} the block
144
+ * it lands in, the signed amount it lands with, and whether that block has
145
+ * a column for its code
146
+ */
147
+ export function ltdBankPlacement(fileName, isReceipt, code, amount) {
148
+ const layout = BANK_LAYOUTS[fileName];
149
+ const own = isReceipt ? layout.receiptCodes : layout.paymentCodes;
150
+ const opposite = isReceipt ? layout.paymentCodes : layout.receiptCodes;
151
+ if (!own.includes(code) && REFUND_CODES.has(code) && opposite.includes(code)) {
152
+ return { receipt: !isReceipt, amount: -amount, analysed: true };
153
+ }
154
+ return { receipt: isReceipt, amount, analysed: own.includes(code) };
155
+ }
156
+
157
+ /**
158
+ * The entry a workbook row stands for: the inverse of ltdBankPlacement. A
159
+ * negative DR receipt or CR payment row reads back as the refund it records.
160
+ * @param {boolean} inReceiptBlock - the row sits in the receipts block
161
+ * @param {string} code - the row's bank code letter
162
+ * @param {number} amount - the row's amount as the sheet holds it
163
+ * @returns {{debitCreditCode: string, amount: number}}
164
+ */
165
+ export function ltdBankEntryOfRow(inReceiptBlock, code, amount) {
166
+ if (amount < 0 && ((inReceiptBlock && code === "DR") || (!inReceiptBlock && code === "CR"))) {
167
+ return { debitCreditCode: inReceiptBlock ? "C" : "D", amount: -amount };
168
+ }
169
+ return { debitCreditCode: inReceiptBlock ? "D" : "C", amount };
170
+ }
171
+
129
172
  // OpenAccounts row 13 splits opening fixed assets across cost (G:K) and
130
173
  // depreciation (M:Q), one column per asset class.
131
174
  const OPENING_FIXED_ASSET_COLUMNS = {
@@ -12,7 +12,7 @@
12
12
 
13
13
  export const PROVENANCE_DATA = {
14
14
  formatVersion: "diya-gl/1",
15
- engineVersion: "1.2.45+3801b0c2",
15
+ engineVersion: "1.2.46+f38ebcb4",
16
16
  taxDataHash: "89424bd36c0d",
17
17
  reconciledCommit: "54e8731a91279975e61b1a232fbb67fda89515f0",
18
18
  templates: {
@@ -26,11 +26,11 @@ export const PROVENANCE_DATA = {
26
26
  },
27
27
  se: {
28
28
  hash: "064605ae76ed",
29
- scorecard: "8014 passed, 0 warnings, 0 failed",
29
+ scorecard: "8015 passed, 0 warnings, 0 failed",
30
30
  },
31
31
  ltd: {
32
- hash: "5bb7be9b6d97",
33
- scorecard: "101605 passed, 0 warnings, 0 failed",
32
+ hash: "ab3dc062e15f",
33
+ scorecard: "101608 passed, 0 warnings, 0 failed",
34
34
  },
35
35
  },
36
36
  };
@@ -364,9 +364,12 @@ function byPostingDate(a, b) {
364
364
  * Receipts settle the oldest invoice first. One that names a customer with an
365
365
  * invoice open settles that customer's own oldest invoice; the aggregate
366
366
  * banking runs, which name no single customer, settle the oldest invoice on
367
- * the book. Whatever survives every receipt is the closing debtors listing,
368
- * so the listing cannot drift away from the ledger the balance sheet
369
- * publishes.
367
+ * the book. A credit note (a refund given or a bad debt written off) names
368
+ * its customer and never reaches the bank, so it clears that customer's
369
+ * oldest open invoice before any receipt is applied; an aggregate banking run
370
+ * cannot take the invoice a write-off was raised against. Whatever survives
371
+ * is the closing debtors listing, so the listing cannot drift away from the
372
+ * ledger the balance sheet publishes.
370
373
  *
371
374
  * @param {Array} lines - parsed lines.jsonl entries (any journal)
372
375
  * @param {Array} openingDebtors - invoices brought forward, oldest first
@@ -374,18 +377,21 @@ function byPostingDate(a, b) {
374
377
  */
375
378
  export function buildClosingDebtors(lines, openingDebtors) {
376
379
  const invoices = openingDebtors.map((d) => ({ customer: d.customer, invoice: d.invoice, outstanding: d.amount }));
377
- for (const line of lines.filter((l) => l.sourceJournalID === "sales").sort(byPostingDate)) {
380
+ const salesLines = lines.filter((l) => l.sourceJournalID === "sales").sort(byPostingDate);
381
+ for (const line of salesLines.filter((l) => l.documentType !== "credit-note")) {
378
382
  invoices.push({ customer: line.detailComment, invoice: line.documentReference, outstanding: line.amount });
379
383
  }
380
384
 
381
385
  const customersInvoiced = new Set(invoices.map((invoice) => invoice.customer));
386
+ const creditNotes = salesLines.filter((l) => l.documentType === "credit-note");
382
387
  const receipts = lines
383
388
  .filter((l) => l.sourceJournalID === "bank" && l["diya-gl:bankCode"] === DEBTOR_RECEIPT_CODE && l.debitCreditCode === "D")
384
389
  .sort(byPostingDate);
385
390
 
386
- for (const receipt of receipts) {
387
- const payer = customersInvoiced.has(receipt.detailComment) ? receipt.detailComment : null;
388
- let unapplied = receipt.amount;
391
+ for (const settlement of [...creditNotes, ...receipts]) {
392
+ const creditNote = settlement.sourceJournalID === "sales";
393
+ const payer = creditNote || customersInvoiced.has(settlement.detailComment) ? settlement.detailComment : null;
394
+ let unapplied = settlement.amount;
389
395
  for (const invoice of invoices) {
390
396
  if (unapplied <= 0) break;
391
397
  if (invoice.outstanding <= 0) continue;
@@ -395,8 +401,10 @@ export function buildClosingDebtors(lines, openingDebtors) {
395
401
  unapplied -= settled;
396
402
  }
397
403
  if (unapplied > 0.005) {
404
+ const what = creditNote ? "Credit note" : "Bank receipt";
405
+ const verb = creditNote ? "credits" : "banks";
398
406
  throw new Error(
399
- `Bank receipt ${receipt.entryNumber} banks ${receipt.amount} from ${receipt.detailComment}, ${unapplied} of it against no open invoice`,
407
+ `${what} ${settlement.entryNumber} ${verb} ${settlement.amount} from ${settlement.detailComment}, ${unapplied} of it against no open invoice`,
400
408
  );
401
409
  }
402
410
  }
@@ -526,23 +534,31 @@ export function tomlLocalDate(value) {
526
534
  return value instanceof Date ? value.toISOString().slice(0, 10) : value;
527
535
  }
528
536
 
529
- // Bad debts written off (4005, sales code "o") and fixed-asset disposals
530
- // (4006, code "fs") already carry their own sign in the sales code's own
531
- // P&L formula (see LTD_SALES_CODE_MAP and the "o"/"fs" rows CONTEXT_SELF_
532
- // EMPLOYED.md and CONTEXT_LIMITED_COMPANY.md describe), so negating a
533
- // credit note against either account here would flip an already-correct
534
- // figure a second time.
535
- const SALES_ACCOUNTS_WITH_OWN_SIGN = new Set(["4005", "4006"]);
536
-
537
537
  // A credit note reverses part of the sale or purchase it names. The schema
538
538
  // fixes a line's amount at zero or more -- only documentType says which way
539
539
  // it points -- so every total that sums a sales or purchases line's amount
540
540
  // reads this instead of the raw field, or a refund inflates turnover (and a
541
- // supplier's credit inflates spend) instead of reducing it.
541
+ // supplier's credit inflates spend) instead of reducing it. A bad debt
542
+ // written off (4005) is a credit note too, and every template books it as a
543
+ // negative sale: the Limited Company and Self Employed P&L rows read the
544
+ // sales tabs' "Bad Debts written off" column negated (Ltd TrialBalance row
545
+ // 81 = -Sales!T1, SE Profit & Loss row 29 = -Sales!U1), and the Basic Sole
546
+ // Trader's one gross sales column feeds turnover. Only a negative entry
547
+ // raises the expense, lowers trade debtors and lowers turnover.
542
548
  export function signedAmount(line) {
543
- if (line.documentType !== "credit-note") return line.amount;
544
- if (line.sourceJournalID === "sales" && SALES_ACCOUNTS_WITH_OWN_SIGN.has(String(line.accountMainID))) return line.amount;
545
- return -line.amount;
549
+ return line.documentType === "credit-note" ? -line.amount : line.amount;
550
+ }
551
+
552
+ // The bad debts written off expense a book's sales journal implies, net of
553
+ // the sheet's VAT divisor: every credit note on 4005 is a debt written off,
554
+ // so its stated amount is the expense. It reads the line's own amount, not
555
+ // signedAmount(), so a check holding the P&L's bad debts row to it is
556
+ // anchored to the book and fails whichever way the sign goes wrong.
557
+ export function writtenOffBadDebtsNet(salesLines, vatDivisor) {
558
+ const writtenOff = salesLines
559
+ .filter((line) => String(line.accountMainID) === "4005" && line.documentType === "credit-note")
560
+ .reduce((sum, line) => sum + line.amount, 0);
561
+ return Math.round(writtenOff / vatDivisor);
546
562
  }
547
563
 
548
564
  export function computeNetSales(salesLines) {
@@ -1407,6 +1423,7 @@ export function formatScenarioToml(metadata, grouped, expected) {
1407
1423
  if (expected.total_motor_net !== undefined) parts.push(`total_motor_net = ${expected.total_motor_net}`);
1408
1424
  if (expected.total_legal_net !== undefined) parts.push(`total_legal_net = ${expected.total_legal_net}`);
1409
1425
  if (expected.total_premises_net !== undefined) parts.push(`total_premises_net = ${expected.total_premises_net}`);
1426
+ if (expected.total_bad_debts_net !== undefined) parts.push(`total_bad_debts_net = ${expected.total_bad_debts_net}`);
1410
1427
  if (expected.vat_output_total !== undefined) parts.push(`vat_output_total = ${expected.vat_output_total}`);
1411
1428
  if (expected.vat_input_total !== undefined) parts.push(`vat_input_total = ${expected.vat_input_total}`);
1412
1429
  if (expected.fixed_asset_additions?.length) {
@@ -195,9 +195,10 @@ export async function applyCellWrites(xlsxBuffer, cellWrites, { refreshFormulaRe
195
195
  }
196
196
 
197
197
  // The formulas reading the written cells carry results computed from the
198
- // values the cells held before; a workbook LibreOffice recalculates anyway
199
- // can skip bringing them up to date.
200
- if (refreshFormulaResults) await refreshZipCachedValues(zip, await JSZip.loadAsync(xlsxBuffer));
198
+ // values the cells held before, and the template caches some results its
199
+ // formulas do not produce; a workbook LibreOffice recalculates anyway can
200
+ // skip bringing them up to date.
201
+ if (refreshFormulaResults) await refreshZipCachedValues(zip);
201
202
 
202
203
  return zip.generateAsync({
203
204
  type: "uint8array",
@@ -16,6 +16,7 @@ import {
16
16
  LTD_SALES_CODE_MAP,
17
17
  } from "./scenario-extractor.js";
18
18
  import { calculateMileageAllowance } from "./tax/mileage.js";
19
+ import { ltdBankEntryOfRow } from "./ltd-layout.js";
19
20
  import {
20
21
  PAYSLIPS_ENTRY_COLUMNS,
21
22
  PAYSLIPS_EMPLOYEE_START_DATE_OFFSET,
@@ -46,6 +47,18 @@ const VAT_RATE_CELLS = { se: "H2", ltd: "G2" };
46
47
  * Build reverse code map: { code → accountMainID }.
47
48
  * For codes that map from multiple accounts, uses the first (primary) account.
48
49
  */
50
+ // A sales or purchases row entered as a negative amount is a credit note: a
51
+ // refund, a supplier's credit, or a bad debt written off, which the writer
52
+ // puts on the sheet as a negative sale. The schema keeps a line's amount at
53
+ // zero or more and says which way it points through documentType.
54
+ function asCreditNoteWhenNegative(line) {
55
+ if (line.amount < 0) {
56
+ line.amount = -line.amount;
57
+ line.documentType = "credit-note";
58
+ }
59
+ return line;
60
+ }
61
+
49
62
  export function buildReverseCodeMap(forwardMap) {
50
63
  const reverse = {};
51
64
  for (const [acctId, code] of Object.entries(forwardMap)) {
@@ -282,7 +295,7 @@ export async function extractBstTransactions(xlsxBuffer, extractionMap) {
282
295
 
283
296
  const regionsFor = (journal) => BST_TRANSACTION_REGIONS.filter((region) => region.sourceJournalID === journal);
284
297
  const push = (line, region, row) => {
285
- lines.push(line);
298
+ lines.push(asCreditNoteWhenNegative(line));
286
299
  if (extractionMap) extractionMap.recordLine(line, region, row, lines.length - 1);
287
300
  };
288
301
 
@@ -756,7 +769,7 @@ export async function extractMultiFileTransactions(set, product, extractionMap)
756
769
  }
757
770
  const cisSuffered = numberAt(xml, `${salesCisColumn}${row}`, salesStrings);
758
771
  if (cisSuffered) line["diya-gl:cisDeduction"] = cisSuffered;
759
- lines.push(line);
772
+ lines.push(asCreditNoteWhenNegative(line));
760
773
  if (extractionMap) extractionMap.recordLine(line, region, row, lines.length - 1, "Sales.xlsx");
761
774
  }
762
775
  }
@@ -829,7 +842,7 @@ export async function extractMultiFileTransactions(set, product, extractionMap)
829
842
  if (description) line.lineItemComment = description;
830
843
  const cisWithheld = numberAt(xml, `${purchasesCisColumn}${row}`, purchasesStrings);
831
844
  if (cisWithheld) line["diya-gl:cisDeduction"] = cisWithheld;
832
- lines.push(line);
845
+ lines.push(asCreditNoteWhenNegative(line));
833
846
  if (extractionMap) extractionMap.recordLine(line, region, row, lines.length - 1, "Purchases.xlsx");
834
847
  }
835
848
  }
@@ -950,14 +963,15 @@ export async function extractBankTransactions(set, product, period, extractionMa
950
963
  const code = readCellValue(xml, `E${row}`, sharedStrings) || "";
951
964
  const codeStr = typeof code === "string" ? code : String(code);
952
965
 
966
+ const entry = product === "ltd" ? ltdBankEntryOfRow(true, codeStr, amount) : { debitCreditCode: "D", amount };
953
967
  const line = {
954
968
  "sourceJournalID": "bank",
955
969
  "postingDate": excelSerialToDate(dateVal),
956
970
  "accountMainID": accountID,
957
- amount,
971
+ "amount": entry.amount,
958
972
  "detailComment": typeof source === "string" ? source : "",
959
973
  "diya-gl:bankCode": codeStr,
960
- "debitCreditCode": "D",
974
+ "debitCreditCode": entry.debitCreditCode,
961
975
  "diya-gl:bankAccountID": accountID,
962
976
  "entryNumber": nextEntryNumber("bank"),
963
977
  };
@@ -980,14 +994,15 @@ export async function extractBankTransactions(set, product, period, extractionMa
980
994
  const code = readCellValue(xml, `${payment.code}${row}`, sharedStrings) || "";
981
995
  const codeStr = typeof code === "string" ? code : String(code);
982
996
 
997
+ const entry = product === "ltd" ? ltdBankEntryOfRow(false, codeStr, amount) : { debitCreditCode: "C", amount };
983
998
  const line = {
984
999
  "sourceJournalID": "bank",
985
1000
  "postingDate": excelSerialToDate(dateVal),
986
1001
  "accountMainID": accountID,
987
- amount,
1002
+ "amount": entry.amount,
988
1003
  "detailComment": typeof supplier === "string" ? supplier : "",
989
1004
  "diya-gl:bankCode": codeStr,
990
- "debitCreditCode": "C",
1005
+ "debitCreditCode": entry.debitCreditCode,
991
1006
  "diya-gl:bankAccountID": accountID,
992
1007
  "entryNumber": nextEntryNumber("bank"),
993
1008
  };
@@ -34,7 +34,13 @@ import {
34
34
  payslipsStartDate,
35
35
  payslipsWagesPaidCell,
36
36
  } from "../lib/payslips-layout.js";
37
- import { BANK_ACCOUNT_FILES, BANK_LAYOUTS, OPENING_FIXED_ASSET_COLUMNS, isLtdOpeningBankLine } from "../lib/ltd-layout.js";
37
+ import {
38
+ BANK_ACCOUNT_FILES,
39
+ BANK_LAYOUTS,
40
+ OPENING_FIXED_ASSET_COLUMNS,
41
+ isLtdOpeningBankLine,
42
+ ltdBankPlacement,
43
+ } from "../lib/ltd-layout.js";
38
44
  import { apportionCorporationTax, financialYearRatesFor } from "../lib/tax/corporation-tax.js";
39
45
  import {
40
46
  buildCategoryNetting,
@@ -1009,12 +1015,12 @@ export function cellWrites(scenario, targetStartYear, yearEndMonth) {
1009
1015
  throw new Error(`Bank entry dated ${tx.date} (${tx.code} ${tx.amount}) has no direction`);
1010
1016
  }
1011
1017
  const layout = BANK_LAYOUTS[fileName];
1012
- const isReceipt = tx.direction === "in";
1013
- const block = isReceipt ? layout.receipt : layout.payment;
1014
- const analysedCodes = isReceipt ? layout.receiptCodes : layout.paymentCodes;
1015
- if (!analysedCodes.includes(tx.code)) {
1016
- throw new Error(`${fileName} analyses no ${isReceipt ? "receipt" : "payment"} under code ${tx.code}`);
1018
+ const placement = ltdBankPlacement(fileName, tx.direction === "in", tx.code, tx.amount);
1019
+ if (!placement.analysed) {
1020
+ throw new Error(`${fileName} analyses no ${tx.direction === "in" ? "receipt" : "payment"} under code ${tx.code}`);
1017
1021
  }
1022
+ const isReceipt = placement.receipt;
1023
+ const block = isReceipt ? layout.receipt : layout.payment;
1018
1024
 
1019
1025
  const rowKey = `${fileName}:${tabName}`;
1020
1026
  const rows = isReceipt ? receiptRows : paymentRows;
@@ -1025,7 +1031,7 @@ export function cellWrites(scenario, targetStartYear, yearEndMonth) {
1025
1031
  if (tx.reference) sheet[`${block.reference}${row}`] = tx.reference;
1026
1032
  if (tx.description) sheet[`${block.comment}${row}`] = tx.description;
1027
1033
  sheet[`${block.code}${row}`] = tx.code;
1028
- sheet[`${block.amount}${row}`] = tx.amount;
1034
+ sheet[`${block.amount}${row}`] = placement.amount;
1029
1035
  }
1030
1036
  }
1031
1037
  }
@@ -2446,6 +2452,13 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2446
2452
  // else in this file reads that account.
2447
2453
  check("Trial Balance: audit accuracy (EJ91)", results.TrialBalance.EJ91, 0);
2448
2454
 
2455
+ // The published balance sheet balances on its own: net assets against
2456
+ // shareholders' funds. EJ91 stays at 0 when the published sheet leaves a
2457
+ // trial balance row out, so the two tests are separate.
2458
+ if (results.PubBalSht) {
2459
+ check("Published balance sheet: net assets (F33) = shareholders' funds (F39)", num(results.PubBalSht.F33), num(results.PubBalSht.F39));
2460
+ }
2461
+
2449
2462
  // Opening balances. A balance sheet that never posts is still balanced, so
2450
2463
  // EJ91 alone cannot tell an unposted opening from a posted one. The next
2451
2464
  // two checks tie the sheet's own opening balance sheet and trial balance
@@ -2537,6 +2550,7 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2537
2550
  // Expense line totals (6f) — Ltd P&L keeps purchases at gross (same as SE)
2538
2551
  if (expected.total_premises_net) check("Premises", pl.B21 || 0, expected.total_premises_net);
2539
2552
  if (expected.total_legal_net) check("Legal & Professional", pl.B33 || 0, expected.total_legal_net);
2553
+ if (expected.total_bad_debts_net) check("Bad Debts written off", pl.B34 || 0, expected.total_bad_debts_net);
2540
2554
 
2541
2555
  // Stock and debtors on the published balance sheet. Both are derived: stock
2542
2556
  // comes from the physical count against the calculated value, debtors from
@@ -3501,16 +3515,15 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
3501
3515
 
3502
3516
  // PubBalSht!E12 "Cash at bank and in hand" reproduces the sheet's own
3503
3517
  // aggregation formula exactly (verified against the template: E12 =
3504
- // IF(SUM(EJ22:EJ24)>0, SUM(EJ22:EJ24)+EJ25+EJ26, EJ25)). The three
3505
- // statement-book accounts (Current, Savings, Credit Card) are summed
3506
- // first, then Cash and the Intra Cash & Bank Transfers row are added
3507
- // on top -- the credit card balance is summed straight in alongside
3508
- // the other three, not netted off as a creditor. EJ26 nets out any
3509
- // receipt or payment analysed under another account's transfer code,
3510
- // so a transfer between the company's own bank accounts never moves
3511
- // the combined total, whether or not both legs were entered.
3518
+ // IF(SUM(EJ22:EJ24)>0, SUM(EJ22:EJ24), 0)+EJ25+EJ26). The three
3519
+ // statement-book accounts (Current, Savings, Credit Card) count only
3520
+ // when they are in credit together, and Cash and the Intra Cash & Bank
3521
+ // Transfers row are added on top either way. EJ26 nets out any receipt
3522
+ // or payment analysed under another account's transfer code, so a
3523
+ // transfer between the company's own bank accounts never moves the
3524
+ // combined total, whether or not both legs were entered.
3512
3525
  const sumFirstThree = num(tb.EJ22) + num(tb.EJ23) + num(tb.EJ24);
3513
- const expectedE12 = sumFirstThree > 0 ? sumFirstThree + num(tb.EJ25) + num(tb.EJ26) : num(tb.EJ25);
3526
+ const expectedE12 = Math.max(sumFirstThree, 0) + num(tb.EJ25) + num(tb.EJ26);
3514
3527
  check("Published balance sheet: cash at bank = Trial Balance bank account aggregate", num(pubBalSht?.E12), expectedE12);
3515
3528
  // E19 "Bank Overdraft" carries the same three accounts when they are
3516
3529
  // overdrawn (E19 = IF(SUM(EJ22:EJ24)<0, -SUM(EJ22:EJ24), 0)), so an
@@ -2522,6 +2522,7 @@ export function checkCompliance(results, expected, taxData, calculateExpectedTax
2522
2522
  );
2523
2523
  }
2524
2524
  if (expected.total_legal_net) check("Legal & Professional", pl.B28 || 0, expected.total_legal_net);
2525
+ if (expected.total_bad_debts_net) check("Bad Debts written off", pl.B29 || 0, expected.total_bad_debts_net);
2525
2526
 
2526
2527
  // The mileage route. A mileage-log entry buys nothing: it states the miles
2527
2528
  // and the sheet prices them. Each Purchases month tab pools its own column
@@ -682,6 +682,13 @@ The Company workbooks give PAYE, VAT, CIS and corporation tax a column each. Bot
682
682
  Employed workbooks carry one HMRC Payments column between them, so a payment coded `RV`,
683
683
  `RC` or `RT` lands in the `RP` column there.
684
684
 
685
+ The four Company bank workbooks analyse `DR` on receipts only and `CR` on payments only. A
686
+ refund to a customer (a payment, `debitCreditCode` `C`, coded `DR`) lands in the receipts
687
+ block under `DR` as a negative amount, which debits trade debtors and credits the bank. A
688
+ refund from a supplier (a receipt, `debitCreditCode` `D`, coded `CR`) lands in the payments
689
+ block under `CR` as a negative amount. Reading the workbook back gives the refund line again.
690
+ Both Self Employed workbooks have a column for each code on both sides.
691
+
685
692
  **Bank transaction example:**
686
693
 
687
694
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.45",
3
+ "version": "1.2.46",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",