diff --git a/AGENTS.md b/AGENTS.md
index b1ed860..0dbbbdb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1066,7 +1066,7 @@ petty-cash slip (`cash_float_entries.supplier_ref`) is booked against a supplier
- Due date = PO date + terms (`dueDate`). **Payments are not tracked**, so nothing is ever called "overdue".
- A supplier with anything booked against it cannot be deleted. Editing writes need a Full user.
-## The Expenses Overview — the cards are built FROM the sheet
+## The Expenses Overview — the cards are built FROM THE ROWS
`/expenses` is the hub for the Expenses section. The card list is built from the overhead ROWS,
not from a hand-written array of section names. This is the rule, and it exists because the old
@@ -1077,7 +1077,9 @@ Three of them — `Plant Overheads`, `Insurance`, `Tracking` — were not sectio
were GL-account labels living INSIDE `Vehicle Costs` and `Bastion Finance`. Their cards pointed at
routes that were never created, so each showed R 0 and 404'd. Three real sections (`Vehicle Costs`,
`Bastion Finance`, `Maintenance`) had no card at all, and the headline read R 437 159,01 against a
-stored R 680 485,37 — the R 243 326,36 the audit flagged as omitted.
+stored R 680 485,37 — the R 243 326,36 the audit flagged as omitted. R 100 000 of that was the
+`Maintenance` section, which the client then removed (see below), so the omission still outstanding
+is R 143 326,36 against a stored R 580 485,37.
**The rule.** `lib/expenses-sections.ts` holds the catalogue and the arithmetic:
@@ -1089,16 +1091,25 @@ stored R 680 485,37 — the R 243 326,36 the audit flagged as omitted.
- The headline is **Σ every overhead row**, so it equals the `/overheads` roll-up to the rand.
- Every card must point at a route that EXISTS. `lib/expenses-sections.test.ts` asserts this.
+**THE APP RUNS ON LIVE DATA, NOT ON THE WORKBOOK.** `overheads` is the source of truth. The workbook
+(`data/source/original.xlsx`) is only where the rows were first typed, and a row's presence in the
+app is a decision the client has made — never something to re-derive from the sheet. **Maintenance
+was removed on 2026-11-05 for exactly this reason:** it was an orphaned R 100 000 cell on the
+sheet's row 97 with no section header and no breakdown, given a section in round 3 by mistake. The
+live row was deleted, the seed row removed from `data/source/overheads.json` (so a re-seed cannot
+bring it back), the page and sidebar entry taken out, and the reconciliation note adjusted. The
+figures moved from R 680 485,37 to **R 580 485,37**.
+
**TWO groups, because the money is not the same money.**
- `vehicle` — a charge against a plant item: `Vehicle Costs`, `Bastion Finance`. These rows are the
SAME money the per-vehicle registers on `/running` already show (TLB06 R 29 036,08 = the Bastion
finance row; DT01 R 1 033,29 and WB02 R 3 318,14 = the Vehicle Costs rows). Their cards carry an
"Also shown on Running Costs — do not add the two" note. Total R 143 326,36.
-- `overhead` — the cost of running the company: `Personnel`, `Maintenance`, `Rent & Utilities`,
- `Cash Floats`, `Subscriptions`. Total R 537 159,01.
+- `overhead` — the cost of running the company: `Personnel`, `Rent & Utilities`,
+ `Cash Floats`, `Subscriptions`. Total R 437 159,01.
-Grand total **R 680 485,37**. The vehicle/overhead split is printed under the headline so a reader
+Grand total **R 580 485,37**. The vehicle/overhead split is printed under the headline so a reader
can see which part is plant money and does not add the `/running` YTD to this page by mistake.
**Purchase orders are deliberately OUTSIDE the overhead total.** A PO becomes running cost when it
@@ -1115,7 +1126,8 @@ R 1 110 486,87 workbook grand total (Cost sheet row 98)
− R 415 523,94 machine instalments — they live on each asset
− R 19 800,00 the workbook's own Personnel total is R 19 800 too high
+ R 5 322,44 Santam — the app keeps it, the workbook's subtotal skips it
-= R 680 485,37 the roll-up
+− R 100 000,00 Maintenance — an orphan cell, removed from the app 2026-11-05
+= R 580 485,37 the roll-up
```
**The cash float has TWO numbers.** The R 10 000 monthly `Cash Floats` allocation sits in the
diff --git a/app/(dashboard)/expenses/maintenance/page.tsx b/app/(dashboard)/expenses/maintenance/page.tsx
deleted file mode 100644
index d2382c5..0000000
--- a/app/(dashboard)/expenses/maintenance/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
-import { Wrench } from "lucide-react";
-
-// Reads PocketBase on every request. Without this Next tries to prerender the
-// page at build time, which makes `docker build` depend on the database being
-// reachable and then serves a frozen build-time snapshot to users.
-export const dynamic = "force-dynamic";
-
-export default function Maintenance() {
- return (
- }
- gradient="from-[#D1495B] to-[#8a6420]"
- iconBg="bg-gradient-to-br from-[#D1495B]/15 to-[#EDAE49]/15"
- iconColor="#D1495B"
- />
- );
-}
diff --git a/app/(dashboard)/expenses/page.tsx b/app/(dashboard)/expenses/page.tsx
index 2a88d15..5df8150 100644
--- a/app/(dashboard)/expenses/page.tsx
+++ b/app/(dashboard)/expenses/page.tsx
@@ -10,7 +10,7 @@ import {
} from "@/lib/expenses-sections";
import { ZAR } from "@/lib/utils";
import {
- Truck, CreditCard, Users, Wrench, Wallet, PackageOpen, Coins,
+ Truck, CreditCard, Users, Wallet, PackageOpen, Coins,
ArrowUpRight, Receipt, ShoppingCart, AlertTriangle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -41,7 +41,6 @@ const ICONS: Record = {
"Vehicle Costs": Truck,
"Bastion Finance": CreditCard,
"Personnel": Users,
- "Maintenance": Wrench,
"Rent & Utilities": Wallet,
"Cash Floats": Coins,
"Subscriptions": PackageOpen,
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index 201394e..00447cc 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -81,7 +81,6 @@ const navItems: NavItem[] = [
// this page replaced it.
{ href: "/expenses/suppliers-2", label: "Suppliers" },
{ href: "/expenses/personnel", label: "Personnel" },
- { href: "/expenses/maintenance", label: "Maintenance" },
{ href: "/expenses/subscriptions", label: "Subscriptions" },
{ href: "/expenses/rent", label: "Rent & Utilities" },
{ href: "/expenses/petty-cash", label: "Cash Floats" },
diff --git a/data/source/overheads-extra.json b/data/source/overheads-extra.json
deleted file mode 100644
index 7be8e81..0000000
--- a/data/source/overheads-extra.json
+++ /dev/null
@@ -1,156 +0,0 @@
-[
- {
- "section": "Insurance",
- "description": "Santam versekering",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 4524.0740000000005,
- "vat": 798.366,
- "amount_incl": 5322.4400000000005
- },
- {
- "section": "Tracking",
- "description": "Tracking",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 4524.0740000000005,
- "vat": 798.366,
- "amount_incl": 5322.4400000000005
- },
- {
- "section": "Vehicle Costs",
- "description": "Diesel Truck",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 1033.2916666666667,
- "vat": 0.0,
- "amount_incl": 1033.2916666666667
- },
- {
- "section": "Vehicle Costs",
- "description": "Water truck",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 3318.1416666666664,
- "vat": 0.0,
- "amount_incl": 3318.1416666666664
- },
- {
- "section": "Insurance",
- "description": "TLB 4 LL20TW GP - Insurance",
- "gl_account": "Insurance",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 2088.53,
- "vat": 313.27950000000004,
- "amount_incl": 2401.8095000000003
- },
- {
- "section": "Insurance",
- "description": "TLB 5 LN58CS GP - Insurance",
- "gl_account": "Insurance",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 2432.72,
- "vat": 364.90799999999996,
- "amount_incl": 2797.6279999999997
- },
- {
- "section": "Insurance",
- "description": "Desi Grader - Insurance",
- "gl_account": "Insurance",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 2099.21,
- "vat": 314.8815,
- "amount_incl": 2414.0915
- },
- {
- "section": "Insurance",
- "description": "Roller - Insurance",
- "gl_account": "Insurance",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 1268.91,
- "vat": 190.3365,
- "amount_incl": 1459.2465000000002
- },
- {
- "section": "Tracking",
- "description": "TLB 4 LL20TW GP - Tracker",
- "gl_account": "Tracking",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 603.92,
- "vat": 90.588,
- "amount_incl": 694.5079999999999
- },
- {
- "section": "Tracking",
- "description": "TLB 5 LN58CS GP - Tracker",
- "gl_account": "Tracking",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 526.43,
- "vat": 78.96449999999999,
- "amount_incl": 605.3944999999999
- },
- {
- "section": "Insurance",
- "description": "Toyota Bakkie - LD13HZ GP",
- "gl_account": "Insurance",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 1853.9,
- "vat": 278.085,
- "amount_incl": 2131.985
- },
- {
- "section": "Tracking",
- "description": "Toyota Bakkie - LD13HZ GP",
- "gl_account": "Tracking",
- "supplier": "Bastion",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 216.91,
- "vat": 32.536500000000004,
- "amount_incl": 249.44650000000001
- },
- {
- "section": "Cash Floats",
- "description": "Petty Cash",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 10000,
- "vat": 0,
- "amount_incl": 10000
- },
- {
- "section": "Cash Floats",
- "description": "Maintenance",
- "gl_account": "",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 100000,
- "vat": 0,
- "amount_incl": 100000
- }
-]
\ No newline at end of file
diff --git a/data/source/overheads.json b/data/source/overheads.json
index 66bf7b0..c17bd43 100644
--- a/data/source/overheads.json
+++ b/data/source/overheads.json
@@ -273,16 +273,5 @@
"amount_excl": 0,
"vat": 0,
"amount_incl": 10000.0
- },
- {
- "section": "Maintenance",
- "description": "Maintenance",
- "gl_account": "Maintenance",
- "supplier": "",
- "qty": 0,
- "unit_price": 0,
- "amount_excl": 0,
- "vat": 0,
- "amount_incl": 100000.0
}
]
\ No newline at end of file
diff --git a/lib/expenses-sections.test.ts b/lib/expenses-sections.test.ts
index 4e9c08e..f0a1353 100644
--- a/lib/expenses-sections.test.ts
+++ b/lib/expenses-sections.test.ts
@@ -33,7 +33,7 @@ test("every section in the sheet gets a card — none is silently dropped", () =
for (const s of sections) {
assert.ok(cards.some((c) => c.section === s), `no card for ${s}`);
}
- assert.equal(cards.length, 7, "the sheet has seven sections");
+ assert.equal(cards.length, 6, "the app has six sections");
});
test("the headline is the sum of EVERY row, matching the /overheads roll-up", () => {
@@ -41,9 +41,9 @@ test("the headline is the sum of EVERY row, matching the /overheads roll-up", ()
const totals = expenseTotals(LIVE, cards);
const sum = LIVE.reduce((s, o) => s + o.amount_incl, 0);
assert.equal(totals.grand, sum);
- assert.ok(Math.abs(totals.grand - 680485.37) < 0.01, `expected R 680 485,37, got ${totals.grand}`);
- assert.equal(totals.rowCount, 26);
- assert.equal(totals.cardCount, 7);
+ assert.ok(Math.abs(totals.grand - 580485.37) < 0.01, `expected R 580 485,37, got ${totals.grand}`);
+ assert.equal(totals.rowCount, 25);
+ assert.equal(totals.cardCount, 6);
});
test("the group subtotals add up to the headline", () => {
@@ -51,7 +51,7 @@ test("the group subtotals add up to the headline", () => {
const totals = expenseTotals(LIVE, cards);
assert.ok(Math.abs(totals.vehicle + totals.overhead - totals.grand) < 0.001, "groups do not sum to the total");
assert.ok(Math.abs(totals.vehicle - 143326.36) < 0.01, `vehicle group ${totals.vehicle}`);
- assert.ok(Math.abs(totals.overhead - 537159.01) < 0.01, `overhead group ${totals.overhead}`);
+ assert.ok(Math.abs(totals.overhead - 437159.01) < 0.01, `overhead group ${totals.overhead}`);
});
test("the vehicle group holds only the plant charges", () => {
@@ -59,12 +59,12 @@ test("the vehicle group holds only the plant charges", () => {
const vehicle = cards.filter((c) => c.group === "vehicle").map((c) => c.section).sort();
assert.deepEqual(vehicle, ["Bastion Finance", "Vehicle Costs"]);
const overhead = cards.filter((c) => c.group === "overhead").map((c) => c.section).sort();
- assert.deepEqual(overhead, ["Cash Floats", "Maintenance", "Personnel", "Rent & Utilities", "Subscriptions"]);
+ assert.deepEqual(overhead, ["Cash Floats", "Personnel", "Rent & Utilities", "Subscriptions"]);
});
test("a NEW section still gets a card and still counts toward the total", () => {
// The regression this catalogue exists to prevent: a section added to the
- // sheet must not vanish from the hub the way Vehicle Costs, Bastion Finance
+ // app must not vanish from the hub the way Vehicle Costs, Bastion Finance
// and Maintenance did.
const rows = [...LIVE, { ...LIVE[0], id: "new", section: "Diesel & Fuel", amount_incl: 5000 } as Overhead];
const cards = buildExpenseCards(rows);
@@ -74,7 +74,7 @@ test("a NEW section still gets a card and still counts toward the total", () =>
assert.equal(added!.unknown, true, "it must be flagged as not yet categorised");
assert.equal(added!.href, "", "an unknown section has no destination to link to");
const totals = expenseTotals(rows, cards);
- assert.ok(Math.abs(totals.grand - 685485.37) < 0.01, `total ${totals.grand}`);
+ assert.ok(Math.abs(totals.grand - 585485.37) < 0.01, `total ${totals.grand}`);
});
test("every card points at a route that exists", () => {
@@ -113,10 +113,11 @@ test("the plant charges are marked as also shown on Running Costs", () => {
test("the reconciliation note reconciles to the roll-up", () => {
const r = OVERHEAD_RECONCILIATION;
- const expected = r.workbookGrandTotal - r.plantInstalments - r.personnelOverstatement + r.santamDelta;
- assert.ok(Math.abs(expected - 680485.37) < 0.01, `reconciliation gives ${expected}`);
+ const expected = r.workbookGrandTotal - r.plantInstalments - r.personnelOverstatement
+ + r.santamDelta - r.maintenanceRemoved;
+ assert.ok(Math.abs(expected - 580485.37) < 0.01, `reconciliation gives ${expected}`);
assert.ok(Math.abs(OVERHEAD_EXPECTED - 694962.93) < 0.01, "the workbook target is wrong");
const note = squash(overheadReconciliationNote());
- assert.ok(note.includes("680 485,37"), `note does not carry the expected figure: ${note}`);
+ assert.ok(note.includes("580 485,37"), `note does not carry the expected figure: ${note}`);
assert.ok(!note.includes("689 640,49"), "the stale target is still quoted");
});
diff --git a/lib/expenses-sections.ts b/lib/expenses-sections.ts
index 7bd9a2c..3e155cf 100644
--- a/lib/expenses-sections.ts
+++ b/lib/expenses-sections.ts
@@ -17,6 +17,15 @@
* gets a card (it falls into the "other" bucket and is flagged), so the total
* can never quietly lose a category.
*
+ * ⚠️ **THE APP RUNS ON LIVE DATA, NOT ON THE WORKBOOK.** `overheads` is the
+ * source of truth; the workbook `data/source/original.xlsx` is only where the
+ * rows were first typed. A section that exists in the app but has no confirmed
+ * meaning belongs in the LIVE collection only until the client says what it is.
+ * Maintenance was exactly that: an orphaned R 100 000 cell on the sheet's row 97
+ * with no section header, given a section in round 3 and then removed again on
+ * the client's instruction (2026-11-05) — the live row was deleted and the seed
+ * row removed, so a re-seed cannot bring it back.
+ *
* Two GROUPS, because the money is not the same money:
*
* - `vehicle` — the section's cost is a charge against a plant item
@@ -110,14 +119,6 @@ export const EXPENSE_SECTIONS: ExpenseSectionDef[] = [
group: "overhead",
order: 10,
},
- {
- section: "Maintenance",
- href: "/expenses/maintenance",
- title: "Maintenance",
- description: "Monthly maintenance allocation",
- group: "overhead",
- order: 20,
- },
{
section: "Rent & Utilities",
href: "/expenses/rent",
@@ -261,19 +262,23 @@ export function expenseTotals(rows: Overhead[], cards: ExpenseCard[]): ExpenseTo
*
* The workbook's Cost sheet row 98 says R 1 110 486,87 for everything. Machine
* instalments (R 415 523,94) live on each asset now, so they are out of the
- * roll-up. That leaves R 694 962,93 expected. Two reconciling items are known,
- * and BOTH are workbook errors, not app errors:
+ * roll-up. That leaves R 694 962,93 expected. Three reconciling items are known:
*
* - The workbook's own Personnel total says R 412 000, but its line items are
* R 157 200 + R 235 000 = R 392 200. The workbook is R 19 800 too high, so
- * it comes OFF.
+ * it comes OFF. (A workbook error.)
* - The workbook's `Vehicle Costs` subtotal SKIPS Santam (R 5 322,44). The app
* keeps it, so the app is R 5 322,44 higher than the workbook's subtotal and
- * Santam goes back ON.
+ * Santam goes back ON. (The app is right here.)
+ * - Maintenance (R 100 000) was an orphaned cell on the sheet's row 97 with no
+ * section header and no breakdown. It was removed from the app on the
+ * client's instruction (2026-11-05), so it comes OFF too — even though the
+ * workbook's grand total includes it.
*
- * R 1 110 486,87 − R 415 523,94 − R 19 800,00 + R 5 322,44 = R 680 485,37,
- * which is exactly the roll-up. The old note on `/overheads` quoted
- * R 689 640,49 — a figure that reconciles with neither the workbook nor the app.
+ * R 1 110 486,87 − R 415 523,94 − R 19 800,00 + R 5 322,44 − R 100 000,00
+ * = R 580 485,37, which is exactly the roll-up. The old note on `/overheads`
+ * quoted R 689 640,49 — a figure that reconciles with neither the workbook nor
+ * the app.
*/
export const OVERHEAD_RECONCILIATION = {
/** Cost sheet row 98 — every cost in the workbook. */
@@ -284,6 +289,8 @@ export const OVERHEAD_RECONCILIATION = {
personnelOverstatement: 19800.0,
/** Santam: the app keeps it, the workbook's subtotal skips it. */
santamDelta: 5322.44,
+ /** Maintenance: an orphan cell with no section header. Removed 2026-11-05. */
+ maintenanceRemoved: 100000.0,
} as const;
/** `workbook − plant`. The workbook's own errors are reconciled separately. */
@@ -294,19 +301,22 @@ export const OVERHEAD_EXPECTED = 694962.93;
* quote a stale figure.
*
* The signs matter and are easy to get backwards:
- * the workbook's Personnel total is TOO HIGH (so it comes off), and the
- * workbook's `Vehicle Costs` subtotal SKIPS Santam (so Santam goes back on).
+ * the workbook's Personnel total is TOO HIGH (so it comes off), the workbook's
+ * `Vehicle Costs` subtotal SKIPS Santam (so Santam goes back on), and
+ * Maintenance is no longer in the app at all (so it comes off).
*/
export function overheadReconciliationNote(): string {
const r = OVERHEAD_RECONCILIATION;
const expected =
- r.workbookGrandTotal - r.plantInstalments - r.personnelOverstatement + r.santamDelta;
+ r.workbookGrandTotal - r.plantInstalments - r.personnelOverstatement
+ + r.santamDelta - r.maintenanceRemoved;
const m = (n: number) => n.toLocaleString("en-ZA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return (
`Workbook grand total (Cost sheet row 98) R ${m(r.workbookGrandTotal)}` +
` − plant instalments R ${m(r.plantInstalments)}` +
` − the workbook's own Personnel overstatement R ${m(r.personnelOverstatement)}` +
` + Santam, which the app keeps and the workbook's subtotal skips R ${m(r.santamDelta)}` +
+ ` − Maintenance, an orphan cell with no section header, removed from the app R ${m(r.maintenanceRemoved)}` +
` = R ${m(expected)} expected.`
);
}
diff --git a/scripts/verify-cost-reconciliation.mjs b/scripts/verify-cost-reconciliation.mjs
index 93964f8..42c7f54 100644
--- a/scripts/verify-cost-reconciliation.mjs
+++ b/scripts/verify-cost-reconciliation.mjs
@@ -98,9 +98,10 @@ const WORKBOOK_GRAND_TOTAL = 1110486.87; // Cost sheet row 98
const PLANT_INSTALMENTS = 415523.94; // "Plant Overheads" section (correctly excluded)
const TARGET_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS; // R 694,962.93
-// Two known reconciling items (Round 4 Part A.4):
+// Three known reconciling items (Round 4 Part A.4, plus Maintenance removed 2026-11-05):
const KNOWN_PERSONNEL_OVERSTATEMENT = 19800.00; // workbook's own Personnel total R 412,000 vs R 392,200 line items
const KNOWN_SANTAM_DELTA = -5322.44; // app is correct; workbook subtotal skips Santam
+const KNOWN_MAINTENANCE_REMOVED = 100000.00; // orphan cell, no section header; removed from the app on the client's instruction
console.log(`Workbook grand total (Cost row 98): R ${WORKBOOK_GRAND_TOTAL.toFixed(2)}`);
console.log(`Target (workbook grand total - Plant instalments): R ${TARGET_ROLLUP.toFixed(2)}`);
@@ -113,11 +114,13 @@ console.log(` ${"TOTAL".padEnd(22)} R ${rollupTotal.toFixed(2).padStart(12)}`);
const driftTarget = Math.abs(rollupTotal - TARGET_ROLLUP);
const explainedDrift =
- (TARGET_ROLLUP - rollupTotal) - (KNOWN_PERSONNEL_OVERSTATEMENT + KNOWN_SANTAM_DELTA);
+ (TARGET_ROLLUP - rollupTotal)
+ - (KNOWN_PERSONNEL_OVERSTATEMENT + KNOWN_SANTAM_DELTA + KNOWN_MAINTENANCE_REMOVED);
console.log("");
console.log(`Drift (vs target): R ${driftTarget.toFixed(2)}`);
console.log(` = Personnel overstatement (workbook error): R +${KNOWN_PERSONNEL_OVERSTATEMENT.toFixed(2)}`);
console.log(` + Santam delta (app is right; workbook wrong): R ${KNOWN_SANTAM_DELTA.toFixed(2)}`);
+console.log(` + Maintenance removed from the app: R +${KNOWN_MAINTENANCE_REMOVED.toFixed(2)}`);
console.log(` = Unexplained: R ${explainedDrift.toFixed(2)}`);
if (Math.abs(explainedDrift) > 1) {
@@ -140,6 +143,7 @@ console.log(" • Personnel: workbook's own total R 412,000, but R 157,200 + R
console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl