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