The Expenses Overview had three cards pointing at routes that were never created
("Plant Overheads", "Insurance", "Tracking" were GL-account labels living inside
"Vehicle Costs" and "Bastion Finance", not section names). Each showed R 0 and
404'd, while three real sections 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 2026-11-04
audit flagged as omitted (EX-01, EX-02).
The card list is now built FROM the overhead rows (lib/expenses-sections.ts), not
from a hand-written array of section names. A hard-coded list is what caused the
bug: the next section added to the sheet would be dropped the same way. A
section with no catalogue entry still gets a card, flagged, and still counts
toward the total - so a category can never vanish again. lib/expenses-sections.test.ts
reads data/source/overheads.json directly rather than a hand-typed fixture, so
it cannot drift by a cent, and asserts the total equals the sum of every row.
The cards are in TWO groups, because the money is not the same money:
Running cost - tied to a vehicle R 143 326,36
Vehicle Costs, Bastion Finance
Overheads - not tied to a vehicle R 537 159,01
Personnel, Maintenance, Rent & Utilities, Cash Floats, Subscriptions
Total monthly overhead R 680 485,37
The vehicle sections are the SAME money the per-vehicle registers on /running
already show - TLB06 R 29 036,08 is the Bastion finance row, DT01 R 1 033,29 and
WB02 R 3 318,14 are the Vehicle Costs rows - so those two cards carry an "Also
shown on Running Costs - do not add the two" note, and the split is printed
under the headline. The grand total is the sum of every overhead row, so /expenses
and /overheads now agree to the rand.
Also:
- /expenses/maintenance is new (the R 100 000 section had no page anywhere),
built on the same ExpenseSectionPage as its siblings.
- Purchase orders sit OUTSIDE the overhead total on purpose: a PO becomes
running cost when it lands on a machine. The strip reads the same loader as
the Suppliers page (lib/supplier-spend-data.ts) over the same default view,
so the two screens quote identical rands.
- /expenses/petty-cash now shows the R 10 000 monthly float allocation, so the
hub card and the page cannot disagree.
- The /overheads reconciliation note is generated from OVERHEAD_RECONCILIATION
instead of being typed. It quoted R 689 640,49, which reconciles with neither
the workbook nor the app. The real arithmetic is R 1 110 486,87 - R 415 523,94
(plant instalments, they live on each asset) - R 19 800,00 (the workbook's own
Personnel total is too high) + R 5 322,44 (Santam: the app keeps it, the
workbook subtotal skips it) = R 680 485,37.
- Maintenance is in the sidebar - every other expense section page is.
Vehicle Costs and Bastion Finance stay OUT of the sidebar: Running Costs
already covers them. The hub cards still link to their pages.
The Subscriptions VAT rows are still broken and npm run seed still exits
non-zero on them. Flagged, not quietly fixed.
Verified: typecheck clean, 162 tests (156 pass, 0 fail, 6 skipped), production
build clean, mobile checklist at 375x812 and 768x1024 on /expenses,
/expenses/maintenance, /expenses/petty-cash, /expenses/suppliers-2 and
/overheads - no horizontal scroll, no text under 12px, no tap target under
44x44.
70 lines
3 KiB
TypeScript
70 lines
3 KiB
TypeScript
/**
|
|
* Loading the supplier spend inputs — ONE place, so the Expenses hub and the
|
|
* Suppliers page cannot quote different rands for the same money.
|
|
*
|
|
* The Suppliers page used to build these inputs inline. The hub now needs the
|
|
* same figures, and two copies of this query would drift — and would both
|
|
* silently show R 0 if a collection failed to load. So the query lives here
|
|
* once, and reports WHICH optional sources were unavailable so a caller can say
|
|
* so instead of presenting a wrong number as fact.
|
|
*
|
|
* The purchase_invoices and supplier_payments collections are created by setup
|
|
* scripts and are absent on a fresh database — they are optional. The
|
|
* suppliers, purchases and cash_float_entries collections are not.
|
|
*/
|
|
import { serverAuth } from "@/lib/pb";
|
|
import { buildSupplierSpend, type SpendInput, type SupplierSpendRow } from "@/lib/supplier-spend";
|
|
|
|
export type SupplierSpendData = {
|
|
rows: SupplierSpendRow[];
|
|
/** Optional sources that could not be read. Their figures are NOT in the totals. */
|
|
unavailable: string[];
|
|
};
|
|
|
|
export async function getSupplierSpend(year: number, month: number): Promise<SupplierSpendData> {
|
|
const pb = await serverAuth();
|
|
const unavailable: string[] = [];
|
|
|
|
const [suppliers, purchases, slips, invoicesResult, paymentsResult] = await Promise.all([
|
|
pb.collection("suppliers").getFullList({ sort: "name" }),
|
|
pb.collection("purchases").getFullList(),
|
|
pb.collection("cash_float_entries").getFullList({ filter: 'kind = "expense"' }),
|
|
pb.collection("purchase_invoices").getFullList().catch(() => {
|
|
unavailable.push("purchase invoices");
|
|
return [] as any[];
|
|
}),
|
|
pb.collection("supplier_payments").getFullList().catch(() => {
|
|
unavailable.push("supplier payments");
|
|
return [] as any[];
|
|
}),
|
|
]);
|
|
|
|
const invByPo = new Map<string, any[]>();
|
|
for (const v of invoicesResult) invByPo.set(v.purchase, [...(invByPo.get(v.purchase) ?? []), v]);
|
|
|
|
const input: SpendInput = {
|
|
year,
|
|
month,
|
|
suppliers: suppliers.map((s: any) => ({
|
|
id: s.id, name: String(s.name), code: String(s.code || ""), terms: String(s.terms || ""),
|
|
})),
|
|
purchases: purchases.map((p: any) => ({
|
|
id: p.id, po_no: String(p.po_no || ""), po_date: String(p.po_date || ""),
|
|
total: Number(p.total || 0), supplier_ref: String(p.supplier_ref || ""),
|
|
invoices: (invByPo.get(p.id) ?? []).map((v: any) => ({
|
|
invoice_no: String(v.invoice_no || ""), invoice_date: String(v.invoice_date || ""),
|
|
amount_incl: typeof v.amount_incl === "number" && v.amount_incl > 0 ? v.amount_incl : null,
|
|
})),
|
|
})),
|
|
payments: paymentsResult.map((x: any) => ({
|
|
id: x.id, supplier_ref: String(x.supplier || ""), paid_on: String(x.paid_on || ""),
|
|
description: String(x.description || ""), amount: Number(x.amount || 0),
|
|
})),
|
|
slips: slips.map((s: any) => ({
|
|
amount: Number(s.amount || 0), at: String(s.occurred_on || s.created || ""),
|
|
supplier_ref: String(s.supplier_ref || ""),
|
|
})),
|
|
};
|
|
|
|
return { rows: buildSupplierSpend(input), unavailable };
|
|
}
|