From d368f709f76949eea30ca71520c0d4ef3a61cae3 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 07:50:15 +0200 Subject: [PATCH] =?UTF-8?q?fix(expenses):=20remove=20Maintenance=20?= =?UTF-8?q?=E2=80=94=20the=20app=20runs=20on=20live=20data,=20not=20the=20?= =?UTF-8?q?sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 24 ++- app/(dashboard)/expenses/maintenance/page.tsx | 21 --- app/(dashboard)/expenses/page.tsx | 3 +- app/(dashboard)/layout.tsx | 1 - data/source/overheads-extra.json | 156 ------------------ data/source/overheads.json | 11 -- lib/expenses-sections.test.ts | 23 +-- lib/expenses-sections.ts | 46 ++++-- scripts/verify-cost-reconciliation.mjs | 8 +- 9 files changed, 65 insertions(+), 228 deletions(-) delete mode 100644 app/(dashboard)/expenses/maintenance/page.tsx delete mode 100644 data/source/overheads-extra.json 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