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.
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
||
* The Expenses section catalogue — ONE source of truth for the categories, their
|
||
* grouping and where each card goes.
|
||
*
|
||
* ⚠️ **THE CARD LIST IS BUILT FROM THE OVERHEAD ROWS, NOT FROM A HAND-WRITTEN ARRAY.**
|
||
* The old `/expenses` hub hard-coded seven section names. Three of them
|
||
* (`Plant Overheads`, `Insurance`, `Tracking`) were not section names at all —
|
||
* they were GL-account labels that live INSIDE `Vehicle Costs` and
|
||
* `Bastion Finance` — so their cards pointed at routes that never existed and
|
||
* showed R 0, while three real sections (`Vehicle Costs`, `Bastion Finance`,
|
||
* `Maintenance`) had no card at all. The headline was R 437 159,01 against a
|
||
* stored R 680 485,37.
|
||
*
|
||
* A hard-coded list cannot fix that: the next section added to the sheet would
|
||
* be silently dropped again. So `buildExpenseCards` reads the overhead rows and
|
||
* groups them by `section`. A section with no entry in `EXPENSE_SECTIONS` still
|
||
* 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
|
||
* (`Vehicle Costs`, `Bastion Finance`). These rows are the SAME money the
|
||
* per-vehicle registers on `/running` already show: TLB06's R 29 036,08
|
||
* instalment, TLB07's R 29 819, MG02's R 19 088,95, RV04's R 28 641, DT01's
|
||
* R 1 033,29 insurance and WB02's R 3 318,14. Counting them twice — once as
|
||
* an overhead and once as running cost — would overstate the business.
|
||
* - `overhead` — the section is a cost of running the company, not of running
|
||
* a machine: `Personnel`, `Maintenance`, `Rent & Utilities`, `Cash Floats`,
|
||
* `Subscriptions`.
|
||
*
|
||
* The headline is the sum of EVERY overhead row, so `/expenses` and
|
||
* `/overheads` cannot disagree. The group subtotals are shown separately so a
|
||
* reader can see which part is plant money and which is not — and so nobody
|
||
* adds the `/running` YTD to this page by mistake.
|
||
*/
|
||
import type { Overhead } from "@/lib/data";
|
||
|
||
export type ExpenseGroupId = "vehicle" | "overhead";
|
||
|
||
export type ExpenseGroup = {
|
||
id: ExpenseGroupId;
|
||
/** The heading above the row of cards. */
|
||
label: string;
|
||
/** One line under the heading explaining what the group means. */
|
||
note: string;
|
||
/** How the cards are ordered inside the group. */
|
||
sort: number;
|
||
};
|
||
|
||
export const EXPENSE_GROUPS: ExpenseGroup[] = [
|
||
{
|
||
id: "vehicle",
|
||
label: "Running cost — tied to a vehicle",
|
||
note: "Charges against a plant item. Running Costs already shows these per vehicle — do not add the two.",
|
||
sort: 10,
|
||
},
|
||
{
|
||
id: "overhead",
|
||
label: "Overheads — not tied to a vehicle",
|
||
note: "The cost of running the company: people, premises, licences and cash.",
|
||
sort: 20,
|
||
},
|
||
];
|
||
|
||
export type ExpenseSectionDef = {
|
||
/** The `section` value on the overhead rows. The key to the whole catalogue. */
|
||
section: string;
|
||
/** Where the card goes. Must be a route that EXISTS. */
|
||
href: string;
|
||
/** The label on the card. Defaults to the section name. */
|
||
title?: string;
|
||
/** One line under the card title. */
|
||
description: string;
|
||
/** Which group the card belongs to. */
|
||
group: ExpenseGroupId;
|
||
/** Lower sorts first inside its group. */
|
||
order: number;
|
||
/**
|
||
* Set when the section's money is ALSO shown on another page, so the card can
|
||
* say so rather than let the reader double-count.
|
||
*/
|
||
alsoOn?: { page: string; label: string };
|
||
};
|
||
|
||
export const EXPENSE_SECTIONS: ExpenseSectionDef[] = [
|
||
{
|
||
section: "Vehicle Costs",
|
||
href: "/expenses/vehicles",
|
||
title: "Vehicle Costs",
|
||
description: "Santam, tracking, diesel and water truck",
|
||
group: "vehicle",
|
||
order: 10,
|
||
alsoOn: { page: "/running", label: "Running Costs" },
|
||
},
|
||
{
|
||
section: "Bastion Finance",
|
||
href: "/expenses/finance",
|
||
title: "Finance (Bastion)",
|
||
description: "Monthly instalments, insurance and tracking",
|
||
group: "vehicle",
|
||
order: 20,
|
||
alsoOn: { page: "/running", label: "Running Costs" },
|
||
},
|
||
{
|
||
section: "Personnel",
|
||
href: "/expenses/personnel",
|
||
title: "Personnel",
|
||
description: "Staff salaries, drivers and operators",
|
||
group: "overhead",
|
||
order: 10,
|
||
},
|
||
{
|
||
section: "Rent & Utilities",
|
||
href: "/expenses/rent",
|
||
title: "Rent & Utilities",
|
||
description: "Office rent, water and lights",
|
||
group: "overhead",
|
||
order: 30,
|
||
},
|
||
{
|
||
section: "Cash Floats",
|
||
href: "/expenses/petty-cash",
|
||
title: "Cash Floats",
|
||
description: "Petty cash and the float on hand",
|
||
group: "overhead",
|
||
order: 40,
|
||
},
|
||
{
|
||
section: "Subscriptions",
|
||
href: "/expenses/subscriptions",
|
||
title: "Subscriptions",
|
||
description: "CCS and Buildsmart licences",
|
||
group: "overhead",
|
||
order: 50,
|
||
},
|
||
];
|
||
|
||
const bySection = new Map(EXPENSE_SECTIONS.map((s) => [s.section, s]));
|
||
|
||
export type ExpenseCard = {
|
||
/** The raw `section` value from the rows. */
|
||
section: string;
|
||
title: string;
|
||
description: string;
|
||
href: string;
|
||
group: ExpenseGroupId;
|
||
/** Σ `amount_incl` of the rows in this section. */
|
||
total: number;
|
||
/** How many overhead rows are in this section. */
|
||
count: number;
|
||
/** Σ `amount_excl` of the rows in this section. */
|
||
excl: number;
|
||
/** Σ `vat` of the rows in this section. */
|
||
vat: number;
|
||
/** Set when the money is also shown on another page. */
|
||
alsoOn?: { page: string; label: string };
|
||
/**
|
||
* TRUE when the section has no entry in `EXPENSE_SECTIONS` — a category that
|
||
* appeared in the sheet after this catalogue was written. It still gets a
|
||
* card and still counts toward the total; the card just says where it came
|
||
* from so a reader is not left guessing.
|
||
*/
|
||
unknown: boolean;
|
||
};
|
||
|
||
/**
|
||
* Group the overhead rows into one card per section.
|
||
*
|
||
* A section that is not in `EXPENSE_SECTIONS` is NOT dropped: it becomes an
|
||
* `overhead` card with no destination link and `unknown: true`. The total is
|
||
* the sum of every row either way.
|
||
*/
|
||
export function buildExpenseCards(rows: Overhead[]): ExpenseCard[] {
|
||
const grouped = new Map<string, Overhead[]>();
|
||
for (const r of rows) {
|
||
const key = r.section || "Uncategorised";
|
||
const list = grouped.get(key);
|
||
if (list) list.push(r);
|
||
else grouped.set(key, [r]);
|
||
}
|
||
|
||
const cards: ExpenseCard[] = [];
|
||
for (const [section, items] of grouped) {
|
||
const def = bySection.get(section);
|
||
const total = items.reduce((s, o) => s + (o.amount_incl || 0), 0);
|
||
const excl = items.reduce((s, o) => s + (o.amount_excl || 0), 0);
|
||
const vat = items.reduce((s, o) => s + (o.vat || 0), 0);
|
||
cards.push({
|
||
section,
|
||
title: def?.title ?? section,
|
||
description: def?.description ?? "New section — not yet categorised",
|
||
href: def?.href ?? "",
|
||
group: def?.group ?? "overhead",
|
||
total,
|
||
count: items.length,
|
||
excl,
|
||
vat,
|
||
alsoOn: def?.alsoOn,
|
||
unknown: !def,
|
||
});
|
||
}
|
||
|
||
return cards.sort((a, b) => {
|
||
const g = EXPENSE_GROUPS.find((x) => x.id === a.group)!.sort - EXPENSE_GROUPS.find((x) => x.id === b.group)!.sort;
|
||
if (g !== 0) return g;
|
||
const defA = bySection.get(a.section);
|
||
const defB = bySection.get(b.section);
|
||
const oa = defA?.order ?? 999;
|
||
const ob = defB?.order ?? 999;
|
||
if (oa !== ob) return oa - ob;
|
||
return a.section.localeCompare(b.section);
|
||
});
|
||
}
|
||
|
||
export type ExpenseTotals = {
|
||
/** Every overhead row, `amount_incl`. Equals the `/overheads` roll-up. */
|
||
grand: number;
|
||
/** Σ `amount_incl` per group. */
|
||
byGroup: Record<ExpenseGroupId, number>;
|
||
/** The group that is NOT tied to a vehicle. */
|
||
overhead: number;
|
||
/** The group that IS tied to a vehicle — the money `/running` also shows. */
|
||
vehicle: number;
|
||
/** How many cards there are in total. */
|
||
cardCount: number;
|
||
/** How many overhead rows there are in total. */
|
||
rowCount: number;
|
||
};
|
||
|
||
/**
|
||
* The headline and the group subtotals.
|
||
*
|
||
* `grand` is the sum of EVERY row — not the sum of the cards' groups — so a
|
||
* category that falls into an unlisted bucket can never quietly shrink it.
|
||
*/
|
||
export function expenseTotals(rows: Overhead[], cards: ExpenseCard[]): ExpenseTotals {
|
||
const grand = rows.reduce((s, o) => s + (o.amount_incl || 0), 0);
|
||
const byGroup: Record<ExpenseGroupId, number> = { vehicle: 0, overhead: 0 };
|
||
for (const c of cards) byGroup[c.group] += c.total;
|
||
return {
|
||
grand,
|
||
byGroup,
|
||
overhead: byGroup.overhead,
|
||
vehicle: byGroup.vehicle,
|
||
cardCount: cards.length,
|
||
rowCount: rows.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The `/overheads` reconciliation, as ONE set of constants.
|
||
*
|
||
* 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. 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. (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. (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 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. */
|
||
workbookGrandTotal: 1110486.87,
|
||
/** Machine instalments, which live on each asset rather than in overheads. */
|
||
plantInstalments: 415523.94,
|
||
/** The workbook's own Personnel total is R 19 800 too high. */
|
||
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. */
|
||
export const OVERHEAD_EXPECTED = 694962.93;
|
||
|
||
/**
|
||
* The reconciliation note, built from the constants above so the page can never
|
||
* 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), 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.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.`
|
||
);
|
||
}
|