The R 100 000 "Maintenance" section never had a home. On the workbook's Cost
sheet it is cell I97 — an orphaned figure with no section header above it and
no breakdown under it, sitting inside the sheet's own grand total of
R 1 110 486,87. Round 3 gave it a section, a card, a page and a sidebar entry
(7194e78). It was never a cost: nothing was ever attached to it.
The client ruled on 2026-11-05 that the app works from LIVE data and the
workbook is only history. So Maintenance comes out completely:
- the live PocketBase row (section "Maintenance", R 100 000) is deleted
- the seed row is removed from data/source/overheads.json, so a re-seed
cannot bring it back
- the card entry, the /expenses/maintenance page and the sidebar link
are removed
- data/source/overheads-extra.json — a DEAD file nothing imported (zero
references anywhere in the repo; the seed reads overheads.json
explicitly and only globs invoice-*.json) holding the round-2
Insurance/Tracking sections plus a mislabelled duplicate
"{ section: Cash Floats, description: Maintenance, incl: 100000 }" —
is trashed
Both pages now read the same money: /expenses and /overheads both show
R 580 485,37, over 6 categories and 25 lines. The vehicle/overhead split
under the headline is R 143 326,36 tied to a vehicle and R 437 159,01
company overheads.
The reconciliation against the workbook gains a third itemised delta, and it
is a SUBTRACTION this time, because the workbook's grand total still counts
the orphan cell:
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 100 000,00 Maintenance — an orphan cell, removed from the app
= R 580 485,37 the roll-up
scripts/verify-cost-reconciliation.mjs now carries KNOWN_MAINTENANCE_REMOVED
and still exits 0 with R 0.00 unexplained. The note on /overheads is
generated from the constants, never typed, so it cannot drift. The three
signs matter: Personnel comes OFF, Santam goes back ON, Maintenance comes OFF.
AGENTS.md records the live-data ruling and renames the section heading from
"the cards are built FROM the sheet" to "FROM THE ROWS", since the sheet is
history and the rows are the truth.
The Subscriptions VAT errors (Charl - CCS, Buildsmart Yearly update) are
untouched — still flagged, still failing the seed loudly, still the client's
to fix.
Verified: typecheck clean; 162 tests (156 pass, 0 fail, 6 skipped);
production build clean and /expenses/maintenance gone from the route list;
reconciliation exits 0 with R 0.00 unexplained; /expenses and /overheads
both read R 580 485,37 at 375x812 with no overflow and no dead links.
123 lines
5.7 KiB
TypeScript
123 lines
5.7 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
buildExpenseCards,
|
|
expenseTotals,
|
|
overheadReconciliationNote,
|
|
OVERHEAD_EXPECTED,
|
|
OVERHEAD_RECONCILIATION,
|
|
EXPENSE_SECTIONS,
|
|
} from "./expenses-sections";
|
|
import type { Overhead } from "./data";
|
|
|
|
/**
|
|
* The REAL sheet, read from data/source/overheads.json — not a hand-typed copy.
|
|
*
|
|
* A fixture written by hand drifts from the sheet by a cent within a week, and a
|
|
* test that asserts against a drifted fixture proves nothing. This reads the file the
|
|
* seed imports, so the assertions hold against the source of truth.
|
|
*/
|
|
const LIVE: Overhead[] = JSON.parse(
|
|
readFileSync(resolve(process.cwd(), "data/source/overheads.json"), "utf8"),
|
|
).map((r: any, i: number) => ({ ...r, id: `row-${i}` } as Overhead));
|
|
|
|
/** en-ZA groups thousands with a non-breaking space, so compare on digits only. */
|
|
const squash = (s: string) => s.replace(/\u00a0/g, " ");
|
|
|
|
test("every section in the sheet gets a card — none is silently dropped", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
const sections = new Set(LIVE.map((r) => r.section));
|
|
assert.equal(cards.length, sections.size, "one card per section");
|
|
for (const s of sections) {
|
|
assert.ok(cards.some((c) => c.section === s), `no card for ${s}`);
|
|
}
|
|
assert.equal(cards.length, 6, "the app has six sections");
|
|
});
|
|
|
|
test("the headline is the sum of EVERY row, matching the /overheads roll-up", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
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 - 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", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
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 - 437159.01) < 0.01, `overhead group ${totals.overhead}`);
|
|
});
|
|
|
|
test("the vehicle group holds only the plant charges", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
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", "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
|
|
// 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);
|
|
const added = cards.find((c) => c.section === "Diesel & Fuel");
|
|
assert.ok(added, "the new section has no card");
|
|
assert.equal(added!.total, 5000);
|
|
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 - 585485.37) < 0.01, `total ${totals.grand}`);
|
|
});
|
|
|
|
test("every card points at a route that exists", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
const known = new Set(EXPENSE_SECTIONS.map((s) => s.href));
|
|
for (const c of cards) {
|
|
assert.ok(c.href, `${c.section} has no destination`);
|
|
assert.ok(known.has(c.href), `${c.section} points at an unlisted route`);
|
|
}
|
|
// The three routes the old hub linked to were never created.
|
|
for (const dead of ["/expenses/plant-overheads", "/expenses/insurance", "/expenses/tracking"]) {
|
|
assert.ok(!known.has(dead), `${dead} is still linked`);
|
|
}
|
|
});
|
|
|
|
test("a card total is the sum of its own rows", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
const personnel = cards.find((c) => c.section === "Personnel")!;
|
|
assert.equal(personnel.total, 392200);
|
|
assert.equal(personnel.count, 2);
|
|
const vehicle = cards.find((c) => c.section === "Vehicle Costs")!;
|
|
assert.ok(Math.abs(vehicle.total - 14996.31) < 0.01, `Vehicle Costs ${vehicle.total}`);
|
|
assert.equal(vehicle.count, 4);
|
|
});
|
|
|
|
test("the plant charges are marked as also shown on Running Costs", () => {
|
|
const cards = buildExpenseCards(LIVE);
|
|
for (const s of ["Vehicle Costs", "Bastion Finance"]) {
|
|
const c = cards.find((x) => x.section === s)!;
|
|
assert.ok(c.alsoOn, `${s} is not marked as plant money`);
|
|
assert.equal(c.alsoOn!.page, "/running");
|
|
}
|
|
const personnel = cards.find((c) => c.section === "Personnel")!;
|
|
assert.equal(personnel.alsoOn, undefined, "Personnel is not plant money");
|
|
});
|
|
|
|
test("the reconciliation note reconciles to the roll-up", () => {
|
|
const r = OVERHEAD_RECONCILIATION;
|
|
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("580 485,37"), `note does not carry the expected figure: ${note}`);
|
|
assert.ok(!note.includes("689 640,49"), "the stale target is still quoted");
|
|
});
|