Invoices: line-item extraction, booking to plant items, VAT-inclusive income

Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.

Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
  scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
  validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
  one file

Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
  second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
  monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
  read by both the app and the CLI so they cannot disagree

Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
  seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
  VAT stays visible rather than hidden.

Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
  so a direct PocketBase /api/files link 404s in a browser

Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David 2026-10-01 15:59:30 +02:00
parent 348395275a
commit baf0bddde6
23 changed files with 2377 additions and 26 deletions

View file

@ -19,6 +19,89 @@ If a route fails any of the above, it is not done.
- `npm run build` → 15 routes, webpack mode (PWA is webpack-only)
- `npm run typecheck` → `npx tsc --noEmit`
- `npm run seed` → wipe + re-import `data/source/*.json` into PocketBase. Fails loudly on any unmatched row or broken VAT.
- `node scripts/setup-invoice-collections.mjs` → create the `invoices` / `invoice_lines` collections (idempotent).
- `node scripts/book-invoice.mjs` → post invoice lines into `monthly_income` as additional rows.
## Invoices — adding another one
`scripts/seed.mjs` imports **every** `data/source/invoice-*.json` automatically, so a new
invoice is one file in that folder — no script edit. Each needs `invoice_no`, the period,
`vat_pct`, the header totals and a `lines` array. The seed re-checks the arithmetic on every
invoice and refuses any one that does not balance, while still seeding the others.
⚠️ `npm run seed` **deletes and recreates the `invoices` rows**, so any PDF uploaded through
`/income/invoices` is lost on the next seed and must be re-attached. Copy the PDFs somewhere
durable first — they are not in the repo.
`invoices.site` is a plain text label transcribed from the invoice's own description
(`PHI 166` → "West Park", `PHI 167` → "Halfway Gardens"). There is **no `sites` collection
yet** — build one when the client has sites to list, and back-fill `site` from this field.
## Invoice booking — run order matters
`npm run seed` **wipes `monthly_income`** and re-imports only the workbook, so invoice-derived
income is destroyed on every seed. Always:
```bash
npm run seed && node scripts/book-invoice.mjs
```
Booking is idempotent: each posted row is stamped with `monthly_income.invoice_line`, and
re-running removes the prior invoice-derived rows before re-inserting. Rows with an empty
`invoice_line` are workbook data and are never touched.
An invoice line with no `booked_asset` is **left unbooked and reported loudly** — never guessed
at. Assign it from `/income/invoices` (the **Booked to** column), or set `asset_code` in the
relevant `data/source/invoice-*.json` and re-run the pair above.
## One machine, many sites — not a duplicate
A machine hired to more than one site in the same month is **normal use, not a duplicate
billing**. Client ruling, 2026-10-01. Such a line is booked to the **same machine** as income,
and every hire accumulates on that one asset.
- `MPLANT05` (SC01) → PHI 166 West Park **and** PHI 171 Cosmo City, same period.
- `SS-01-CASE` (SS01) → PHI 167 Halfway Gardens **and** PHI 171 Cosmo City, same period.
`findDuplicateMachineCodes` still finds these overlaps, but the result is **informational, not
a problem**: it must never tint a row red, badge it as unbooked, or block booking. The income
figure's hover/tap breakdown shows each machine's full hire history across sites, which is
where a multi-site machine should be read. Only an **unbooked** line is a real problem.
The register has no `MPLANT04` / `MPLANT05` codes, so those map to `OC01` / `SC01` as the
consistent target — chosen, not derived. Confirm with the client if either should differ.
## Money on invoices — VAT is NOT revenue
On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT
24,205.13 / INVOICE TOTAL 185,572.63`. The R24,205.13 sits under the column headed **PLUS
VAT** — it is 15% VAT, *not* an escalation, and escalation is R0.00 on every line.
- The **invoice total (`total_incl`) is VAT-inclusive** — that is the headline figure.
- `total_excl` (hire) + `vat` = `total_incl`, checked per line and on the header by the seed.
- **Booked amount follows `lib/invoice-booking-policy.json` → `bookInclusiveVat`.** It is
currently `true`: amounts are booked VAT-**inclusive**, per the client's ruling of
2026-10-01. That means SARS output VAT of R29,935.13 (Sept 2026 set) sits **inside** the
R229,502.63 shown as income on `/income`, so those figures are a cash/billing view, not a
VAT-excl. P&L. Flip the flag to `false` and re-run `npm run seed && node scripts/book-invoice.mjs`
to book ex-VAT instead. Both the app and the CLI script read that one file, so they
cannot disagree.
- The VAT stays visible rather than hidden: `/income` shows "includes R 29 935 VAT" and each
machine card shows "incl R X VAT" (`IncomeRow.invoiceVat`). Do not collapse the two.
Extract these by coordinate (`pdfplumber`, x-positions) or read the rendered page visually.
Plain text extraction reorders these columns and silently turns VAT into escalation.
## Manual invoice actions
- `POST /api/invoice-lines/:id/book` body `{ assetId }` (`""` clears) — sets `booked_asset`
and **moves** the `monthly_income` row to the new machine rather than adding a second one.
- `POST /api/invoices/upload` multipart `file` + `invoiceNo` — PDF only, 20 MB cap, stored on
`invoices.source_pdf`.
- `GET /api/invoices/:id/document` — proxies the PDF. Do **not** link to
`NEXT_PUBLIC_PB_URL/api/files/<filename>`: the `invoices` collection is rule-locked and
that path 404s in a browser. PocketBase also resolves protected files by the full
`/api/files/<collectionId>/<recordId>/<filename>` path — build it with `pb.files.getURL()`.
## Color palette — never change
@ -50,5 +133,7 @@ Chart marks (deeper steps of the same hues — see HANDOFF-PROMPT-3 §A.4):
- URL: `process.env.NEXT_PUBLIC_PB_URL` (env or `.env.local`)
- Auth: `pb.collection("_superusers").authWithPassword(env)` (Round 1 §5.3)
- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines`
- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines`, `invoices`, `invoice_lines`
- `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time
- `monthly_income.invoice_line` → `invoice_lines`, nullable. Empty = workbook row, set = invoice-derived row
- `invoice_lines.booked_asset` → `assets`, nullable. Empty = line not yet booked to a machine

View file

@ -0,0 +1,105 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { bookedAmount, BOOKED_LABEL } from "@/lib/invoice-booking";
export const dynamic = "force-dynamic";
// POST /api/invoice-lines/:id/book
// Body: { assetId: string } — "" clears the booking.
//
// Sets invoice_lines.booked_asset and keeps the matching monthly_income row in step:
// the previous invoice-derived row for this line is removed first, then a fresh one is
// posted against the new machine. That makes changing a booking safe and repeatable.
//
// The posted amount follows the shared policy in lib/invoice-booking-policy.json, so
// this route and scripts/book-invoice.mjs can never disagree about it.
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
let assetId = "";
try {
const body = await req.json();
if (typeof body?.assetId !== "string") {
return Response.json({ ok: false, error: "assetId must be a string" }, { status: 400 });
}
assetId = body.assetId.trim();
} catch {
return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const pb = await serverAuth();
try {
const line = await pb.collection("invoice_lines").getOne(id);
const invoice = await pb.collection("invoices").getOne(line.invoice);
// Verify the target asset actually exists before writing anything, so a stale
// dropdown value fails here rather than creating a dangling relation.
let asset = null;
if (assetId) {
try {
asset = await pb.collection("assets").getOne(assetId);
} catch {
return Response.json(
{ ok: false, error: "That plant item no longer exists — reload the page" },
{ status: 400 },
);
}
}
// Drop the row this line previously contributed, if any.
const existing = await pb.collection("monthly_income").getFullList({
filter: `invoice_line = "${id}"`,
});
for (const row of existing) await pb.collection("monthly_income").delete(row.id);
await pb.collection("invoice_lines").update(id, {
booked_asset: assetId,
booked_year: line.booked_year || invoice.booked_year,
booked_month: line.booked_month || invoice.booked_month,
});
const amount = bookedAmount(line.amount_excl, line.vat);
let posted = null;
if (asset) {
posted = await pb.collection("monthly_income").create({
asset: assetId,
year: line.booked_year || invoice.booked_year,
month: line.booked_month || invoice.booked_month,
income: amount,
fixed_expenses: 0,
is_forecast: false,
invoice_line: id,
});
}
// Re-evaluate the header: booked only when every one of its lines has a target.
const siblings = await pb.collection("invoice_lines").getFullList({
filter: `invoice = "${line.invoice}"`,
});
const complete = siblings.every((l) => (l.id === id ? assetId : l.booked_asset));
await pb.collection("invoices").update(line.invoice, { is_booked: complete });
revalidatePath("/income/invoices");
revalidatePath("/income");
return Response.json({
ok: true,
booked: Boolean(asset),
asset: asset ? { id: asset.id, code: asset.code, name: asset.name } : null,
income: posted?.income ?? 0,
label: BOOKED_LABEL,
invoiceFullyBooked: complete,
});
} catch (e: any) {
return Response.json(
{ ok: false, error: e?.message || "Booking failed" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,57 @@
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// GET /api/invoices/:id/document
// Streams the invoice's source PDF.
//
// This deliberately does NOT link straight at PocketBase. The `invoices` collection
// ships with locked-down rules, so `/api/files/<name>` on the PB host 404s for anyone
// without a superuser token — the "Attached document" link would be dead in the browser.
// Fetching server-side with the admin credentials and proxying keeps PocketBase closed
// while still letting an authenticated user open the document.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const pb = await serverAuth();
const invoice = await pb.collection("invoices").getOne(id);
const token = invoice.source_pdf;
if (!token) {
return Response.json({ ok: false, error: "No document attached" }, { status: 404 });
}
// A bare `/api/files/<filename>` 404s for protected files even with an admin JWT —
// PocketBase resolves those by the full /collectionId/recordId/filename path. Let the
// SDK build the URL so the lookup matches whatever the field actually holds.
const upstream = pb.files.getURL(invoice, token);
const res = await fetch(upstream, {
headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
});
if (!res.ok) {
return Response.json(
{ ok: false, error: "Document could not be read from storage" },
{ status: 502 },
);
}
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "application/pdf",
"Content-Disposition": `inline; filename="${invoice.invoice_no}.pdf"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
} catch (e: any) {
return Response.json(
{ ok: false, error: e?.message || "Could not load document" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,98 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// POST /api/invoices/upload
// Body: multipart/form-data with `file` and `invoiceNo`.
//
// Stores the original document against the invoice's `source_pdf` file field so the
// source of every extracted figure stays attached to the row it came from.
//
// Accepts PDF only — an invoice in any other format cannot have its line items verified
// against the PDF totals, and accepting it silently would break that guarantee.
const MAX_BYTES = 20 * 1024 * 1024; // matches the PocketBase field's maxSize
export async function POST(req: Request) {
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ ok: false, error: "Expected multipart/form-data" }, { status: 400 });
}
const file = form.get("file");
const invoiceNo = String(form.get("invoiceNo") ?? "").trim();
if (!(file instanceof File) || file.size === 0) {
return Response.json({ ok: false, error: "No file received" }, { status: 400 });
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
return Response.json(
{ ok: false, error: "Only PDF invoices are accepted" },
{ status: 400 },
);
}
if (file.size > MAX_BYTES) {
return Response.json(
{ ok: false, error: `File is ${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` },
{ status: 400 },
);
}
const pb = await serverAuth();
try {
// Resolve the invoice by number; fall back to the only one present.
let target = null;
if (invoiceNo) {
const found = await pb.collection("invoices").getFullList({
filter: `invoice_no = "${invoiceNo.replace(/"/g, "")}"`,
perPage: 1,
});
target = found[0] ?? null;
if (!target) {
return Response.json(
{ ok: false, error: `No invoice found with number "${invoiceNo}"` },
{ status: 404 },
);
}
} else {
const all = await pb.collection("invoices").getFullList({ perPage: 2 });
if (all.length === 0) {
return Response.json({ ok: false, error: "No invoices exist yet" }, { status: 404 });
}
if (all.length > 1) {
return Response.json(
{ ok: false, error: "More than one invoice — specify which by number" },
{ status: 400 },
);
}
target = all[0];
}
const safeName = file.name.replace(/[^\w.\- ]+/g, "_").slice(-120);
const form2 = new FormData();
form2.append("source_pdf", new File([await file.arrayBuffer()], safeName, { type: "application/pdf" }));
const updated = await pb.collection("invoices").update(target.id, form2);
revalidatePath("/income/invoices");
const stored = updated.source_pdf as string | undefined;
return Response.json({
ok: true,
invoice_no: target.invoice_no,
file: safeName,
size: file.size,
// PocketBase returns "filename" or "filename_abcd1234"; the base name is the file.
path: stored?.split("/").pop() ?? null,
});
} catch (e: any) {
return Response.json(
{ ok: false, error: e?.message || "Upload failed" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,63 @@
import {
getAssets, getInvoices, getInvoiceLines, joinBookedLines,
findDuplicateMachineCodes,
} from "@/lib/data";
import { FileText } from "lucide-react";
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
export const dynamic = "force-dynamic";
export default async function InvoicesPage() {
const [invoices, lines, assets] = await Promise.all([
getInvoices(),
getInvoiceLines(),
getAssets(),
]);
// Group the lines per invoice once, so the client table gets plain data rather than
// making it re-derive the grouping on every render.
const linesByInvoice: Record<string, ReturnType<typeof joinBookedLines>> = {};
const joined = joinBookedLines(lines, assets);
for (const l of joined) {
(linesByInvoice[l.invoice] ||= []).push(l);
}
const unbookedCount = joined.filter((l) => !l.isBooked).length;
const multiSite = findDuplicateMachineCodes(invoices, joined);
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#003D5B]/15 grid place-items-center text-[#00798C]">
<FileText className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Invoices</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
One row per invoice · click to open the line items and book each to a plant item
</p>
</div>
</div>
{invoices.length === 0 ? (
<EmptyInvoices />
) : (
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} multiSite={multiSite} />
)}
{unbookedCount > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
{unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
<span className="font-medium text-[#D1495B]">unbooked</span> above — open one and pick a plant
item in the <span className="font-medium text-[#003D5B] dark:text-white">Booked to</span> column
and it posts to that machine&apos;s income immediately. To make it the default, set the asset in{" "}
<code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> and re-run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
)}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { getAssets, getActualMonthlyIncome, getRunningCosts } from "@/lib/data";
import { getAssets, getActualMonthlyIncome, getRunningCosts, getInvoiceLines } from "@/lib/data";
import { ZAR } from "@/lib/utils";
import { TrendingUp } from "lucide-react";
import { IncomeView } from "@/components/IncomeView";
@ -10,14 +10,16 @@ import { buildIncomeRows } from "@/lib/rows";
export const dynamic = "force-dynamic";
export default async function IncomePage() {
const [assets, incomeActual, running] = await Promise.all([
const [assets, incomeActual, running, invoiceLines] = await Promise.all([
getAssets(),
getActualMonthlyIncome(),
getRunningCosts(),
getInvoiceLines(),
]);
// Part 1.2: YTD figures use actuals only.
const rows = buildIncomeRows(assets, incomeActual, running);
// Part 1.2: YTD figures use actuals only. The invoice lines let each machine's figure
// show how much of it is invoice income and how much of that is VAT.
const rows = buildIncomeRows(assets, incomeActual, running, {}, invoiceLines);
const totalIncome = rows.reduce((s, m) => s + m.income, 0);
const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0);

View file

@ -48,7 +48,15 @@ export const metadata: Metadata = {
const navItems: NavItem[] = [
{ kind: "link", href: "/", label: "Dashboard", icon: <LayoutDashboard className="w-4 h-4" /> },
{ kind: "link", href: "/plant", label: "Plant Register", icon: <Boxes className="w-4 h-4" /> },
{ kind: "link", href: "/income", label: "Income", icon: <TrendingUp className="w-4 h-4" /> },
{
kind: "group",
label: "Income",
icon: <TrendingUp className="w-4 h-4" />,
children: [
{ href: "/income", label: "Overview" },
{ href: "/income/invoices", label: "Invoices" },
],
},
{ kind: "link", href: "/running", label: "Running Costs", icon: <Wrench className="w-4 h-4" /> },
{
kind: "group",

View file

@ -0,0 +1,105 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Loader2, AlertCircle } from "lucide-react";
type AssetOption = { id: string; code: string; name: string; category: string };
// Books one invoice line to a plant item from the register.
//
// Saves immediately on change — there is no separate "save" button to miss on a phone.
// On success it refreshes the router so the server-rendered header, banner and totals
// re-read from PocketBase rather than showing stale booking state.
export function BookingSelect({
lineId,
value,
assets,
}: {
lineId: string;
value: string;
assets: AssetOption[];
}) {
const router = useRouter();
const [current, setCurrent] = useState(value);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 44 plant items flat in one dropdown is unusable — group them by category.
const groups = useMemo(() => {
const byCategory = new Map<string, AssetOption[]>();
for (const a of assets) {
const key = a.category || "Other";
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key)!.push(a);
}
return Array.from(byCategory.entries())
.map(([category, items]) => ({ category, items: items.sort((x, y) => x.code.localeCompare(y.code)) }))
.sort((a, b) => a.category.localeCompare(b.category));
}, [assets]);
async function book(assetId: string) {
const previous = current;
setSaving(true);
setError(null);
setCurrent(assetId); // optimistic
try {
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId }),
});
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (!data.ok) {
setCurrent(previous); // roll back so the UI never claims a booking that failed
setError(data.error || "Booking failed");
} else {
router.refresh(); // re-read server-rendered banner/totals
}
} catch {
setCurrent(previous);
setError("Could not reach the server");
} finally {
setSaving(false);
}
}
return (
<div>
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor={`book-${lineId}`}>
Book this line to a plant item
</label>
<select
id={`book-${lineId}`}
value={current}
disabled={saving}
onChange={(e) => book(e.target.value)}
className={`min-h-[44px] w-full rounded-lg border px-2 text-sm transition focus:outline-none focus:border-[#30638E] disabled:opacity-60 ${
current
? "border-[#00798C]/30 bg-[#00798C]/5 text-[#003D5B] dark:text-white"
: "border-[#D1495B]/40 bg-[#D1495B]/5 text-[#D1495B]"
}`}
>
<option value="">— Not booked —</option>
{groups.map((g) => (
<optgroup key={g.category} label={g.category}>
{g.items.map((a) => (
<option key={a.id} value={a.id}>
{a.code} · {a.name}
</option>
))}
</optgroup>
))}
</select>
{saving && <Loader2 className="w-4 h-4 animate-spin text-[#00798C] shrink-0" aria-label="Saving" />}
</div>
{error && (
<p className="flex items-start gap-1 text-sm text-[#D1495B] mt-1">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{error}
</p>
)}
</div>
);
}

View file

@ -22,7 +22,11 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
const totalIncome = rows.reduce((s, r) => s + r.income, 0);
const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0);
const profitable = rows.filter((r) => r.ytdProfit > 0).length;
return { totalIncome, totalProfit, profitable };
// VAT already inside the booked figures — booked amounts are VAT-inclusive, so this
// is part of the income total and is called out rather than hidden.
const invoiceVat = rows.reduce((s, r) => s + r.invoiceVat, 0);
const invoiceIncome = rows.reduce((s, r) => s + r.invoiceIncome, 0);
return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome };
}, [rows]);
return (
@ -36,6 +40,11 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-medium">
{rows.length - totals.profitable} loss-making
</span>
{totals.invoiceVat > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
includes {ZAR(totals.invoiceVat)} VAT
</span>
)}
<span className="hidden md:inline">· sorted by {sort}</span>
</div>
{/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */}
@ -153,7 +162,7 @@ function CardsView({
<Sparkline data={r.months} color={palette.income} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
<div className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(r.income)}
</div>
@ -175,7 +184,7 @@ function CardsView({
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
<Sparkline data={r.months} color={palette.income} />
<div className="text-right">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
<div className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.income)}</div>
</div>
<div
@ -196,8 +205,14 @@ function CardsView({
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-[11px]">
{r.months.map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-gray-500 text-[10px] mb-1">{MONTH_NAMES[i]}</div>
<div className={`py-1.5 rounded tabular-nums ${(v ?? 0) > 0 ? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15" : "text-gray-300 dark:text-gray-600"}`}>
<div className="text-gray-400 dark:text-gray-500 text-xs mb-1">{MONTH_NAMES[i]}</div>
<div
className={`py-1.5 rounded tabular-nums ${
(v ?? 0) > 0
? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15"
: "text-gray-300 dark:text-gray-600"
}`}
>
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
</div>
</div>
@ -220,7 +235,7 @@ function CardsView({
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
@ -275,6 +290,31 @@ function GridView({ rows }: { rows: IncomeRow[] }) {
);
})}
</tbody>
{/* Totals row: the sum of every machine for each month. */}
<tfoot>
<tr className="border-t-2 border-gray-200 dark:border-white/15 bg-gray-50/80 dark:bg-white/[0.03]">
<td className="py-3 px-6 sticky left-0 z-10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur text-sm font-semibold text-[#003D5B] dark:text-white">
Total
</td>
<td className="py-3 px-3 text-sm font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
All machines
</td>
{MONTH_NAMES.map((m, i) => {
const sum = rows.reduce((s, r) => s + (r.months[i] ?? 0), 0);
return (
<td
key={m}
className="py-3 px-2 text-right tabular-nums whitespace-nowrap text-gray-600 dark:text-gray-300"
>
{sum > 0 ? ZAR(sum) : "—"}
</td>
);
})}
<td className="py-3 px-6 text-right tabular-nums font-semibold whitespace-nowrap text-[#003D5B] dark:text-white">
{ZAR(rows.reduce((s, r) => s + r.income, 0))}
</td>
</tr>
</tfoot>
</table>
</div>
</div>

273
components/InvoiceList.tsx Normal file
View file

@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText, Repeat } from "lucide-react";
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { BOOK_INCLUSIVE_VAT, BOOKED_LONG_LABEL } from "@/lib/invoice-booking";
import { ZAR, ZAR2 } from "@/lib/utils";
import { bookedAmount } from "@/lib/invoice-booking";
import { InvoiceSummary } from "@/components/InvoiceTable";
// One row per invoice. Clicking a row expands the full line-item detail underneath it,
// so the common case (scan for problems) is a single table read and the detail is opt-in.
//
// Anything wrong with an invoice — an unbooked line, no document attached — is surfaced
// on its own row, because a problem buried inside a collapsed detail is a problem you miss.
export function InvoiceList({
invoices,
linesByInvoice,
assets,
multiSite,
}: {
invoices: Invoice[];
linesByInvoice: Record<string, BookedLine[]>;
assets: Asset[];
// line id -> other invoice numbers billing the same machine over the same period.
// Informational only: a machine hired to two sites in a month is normal, not an error.
multiSite: Map<string, string[]>;
}) {
const [open, setOpen] = useState<string | null>(null);
const allLines = Object.values(linesByInvoice).flat();
const bookedLines = allLines.filter((l) => l.isBooked);
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(bookedLines, "amount_excl");
const bookedVat = sum(bookedLines, "vat");
const totals = invoices.reduce(
(acc, i) => ({
exVat: acc.exVat + (i.total_excl || 0),
vat: acc.vat + (i.vat || 0),
incl: acc.incl + (i.total_incl || 0),
}),
{ exVat: 0, vat: 0, incl: 0 },
);
return (
<>
<div className="mb-5 flex flex-wrap items-stretch gap-3">
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
{/* What actually got booked against the machines, per the shared policy. */}
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{BOOKED_LONG_LABEL}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(bookedAmount(bookedExVat, bookedVat))}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
{BOOK_INCLUSIVE_VAT
? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT`
: `${ZAR2(bookedVat)} VAT not counted as income`}
</div>
</div>
{/* What the debtors are actually invoiced. */}
<Stat label="Billed (incl VAT)" value={ZAR2(totals.incl)} tone="brand" />
</div>
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
{/* Rule 1 + 4: scrolls inside its own container; page never scrolls sideways. */}
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm min-w-[820px]">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur">
Invoice
</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Site</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Period</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Lines</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Revenue ex VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl VAT</th>
<th className="text-left py-3 px-4 font-medium whitespace-nowrap">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => {
const lines = linesByInvoice[inv.id] ?? [];
const booked = lines.filter((l) => l.isBooked);
const unbooked = lines.filter((l) => !l.isBooked);
const shared = lines.filter((l) => multiSite.get(l.id)?.length);
// Only an unbooked line is a problem. A machine used at two sites in a
// month is expected, so it must not tint the row or badge it as an error.
const hasProblem = unbooked.length > 0;
const isOpen = open === inv.id;
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") =>
ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(booked, "amount_excl");
const bookedVat = sum(booked, "vat");
const bookedTotal = bookedAmount(bookedExVat, bookedVat);
return (
<InvoiceRow
key={inv.id}
invoice={inv}
lineCount={lines.length}
unbooked={unbooked.length}
bookedExVat={bookedExVat}
bookedVat={bookedVat}
bookedTotal={bookedTotal}
shared={shared.map((l) => ({ machineCode: l.bookedCode || l.machine_code, others: multiSite.get(l.id) ?? [] }))}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
expanded={
isOpen ? (
<div className="p-3 sm:p-4 bg-gray-50/60 dark:bg-white/[0.02]">
<InvoiceSummary invoice={inv} lines={lines} assets={assets} embedded />
</div>
) : null
}
/>
);
})}
</tbody>
</table>
</div>
</div>
</>
);
}
function InvoiceRow({
invoice, lineCount, unbooked, bookedExVat, bookedVat, bookedTotal, shared,
hasProblem, isOpen, onToggle, expanded,
}: {
invoice: Invoice;
lineCount: number;
unbooked: number;
bookedExVat: number;
bookedVat: number;
bookedTotal: number;
shared: { machineCode: string; others: string[] }[];
hasProblem: boolean;
isOpen: boolean;
onToggle: () => void;
expanded: React.ReactNode;
}) {
return (
<>
<tr
className={`border-b border-gray-50 dark:border-white/5 transition ${
hasProblem ? "bg-[#D1495B]/[0.04]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
}`}
>
{/* Sticky first column so the invoice identity survives horizontal scroll. */}
<td
className={`py-2 px-4 sticky left-0 z-10 ${
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]" : "bg-white dark:bg-[#131a21]"
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
className="flex items-center gap-2 min-h-[44px] w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[#30638E] rounded-lg"
>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
<span className="font-mono text-xs font-semibold text-[#00798C] whitespace-nowrap">
{invoice.invoice_no}
</span>
</button>
</td>
<td className="py-2 px-3 whitespace-nowrap">
{invoice.site ? (
<span className="inline-flex items-center gap-1.5 text-sm text-[#003D5B] dark:text-white">
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
{invoice.site}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
)}
</td>
<td className="py-2 px-3 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap tabular-nums">
{invoice.period_from.slice(0, 10)} → {invoice.period_to.slice(0, 10)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{lineCount}</td>
<td className="py-2 px-3 text-right tabular-nums font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_excl)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
{ZAR2(invoice.vat)}
</td>
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_incl)}
</td>
<td className="py-2 px-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{hasProblem ? (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{unbooked} unbooked
</span>
) : (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
All booked
</span>
)}
{shared.length > 0 && (
<span
title="The same machine hired to more than one site this month — normal, each hire books to that machine."
className="text-xs text-[#8a6420] dark:text-[#EDAE49] flex items-start gap-1"
>
<Repeat className="w-3 h-3 mt-0.5 shrink-0" />
<span>
{shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "}
{[...new Set(shared.flatMap((d) => d.others))].join(", ")}
</span>
</span>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
booked {ZAR2(bookedTotal)}
{BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT</>}
</span>
</div>
</td>
</tr>
{isOpen && (
<tr>
{/* Span the full table so the detail panel is not clipped by the sticky column. */}
<td colSpan={8} className="p-0 border-b border-gray-100 dark:border-white/5">
{expanded}
</td>
</tr>
)}
</>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone: "teal" | "brand" }) {
if (tone === "brand") {
return (
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{label}</div>
<div className="text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}
return (
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
</div>
);
}
// Kept so an empty folder still renders a usable page.
export function EmptyInvoices() {
return (
<div className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm p-10 text-center">
<FileText className="w-8 h-8 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400">No invoices yet.</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
</div>
);
}
export { ZAR };

300
components/InvoiceTable.tsx Normal file
View file

@ -0,0 +1,300 @@
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { ZAR2 } from "@/lib/utils";
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { BookingSelect } from "@/components/BookingSelect";
import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader";
// Right-edge fade so a horizontally scrollable table reads as scrollable on touch.
// pointer-events-none so it never swallows a tap on the row underneath.
function ScrollFade() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent dark:from-[#131a21]"
/>
);
}
export function InvoiceSummary({
invoice,
lines,
assets,
embedded,
}: {
invoice: Invoice;
lines: BookedLine[];
assets: Asset[];
// Inside the expandable invoice row the surrounding card already shows the number and
// site, so the header block is dropped to avoid saying the same thing twice.
embedded?: boolean;
}) {
const unbooked = lines.filter((l) => !l.isBooked);
// Two different numbers, both worth showing.
// ex-VAT = what actually lands in the P&L as income for the machine.
// incl-VAT= what the debtor actually pays (ex-VAT + SARS VAT).
// Booking is done ex-VAT: VAT is collected for SARS, never income.
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(lines.filter((l) => l.isBooked), "amount_excl");
const bookedVat = sum(lines.filter((l) => l.isBooked), "vat");
const unbookedExVat = sum(unbooked, "amount_excl");
const unbookedVat = sum(unbooked, "vat");
const period = `${invoice.period_from.slice(0, 10)} → ${invoice.period_to.slice(0, 10)}`;
const periodMonth = new Date(invoice.period_to).toLocaleDateString("en-ZA", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
return (
<div
className={
embedded
? "bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-xl shadow-sm overflow-hidden"
: "bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm overflow-hidden mb-6"
}
>
{!embedded && (
<div className="p-5 border-b border-gray-100 dark:border-white/5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C]">
{invoice.invoice_no}
</span>
{/* Site is transcribed from the invoice's own description. There is no
sites collection yet — this is a plain label until one exists. */}
{invoice.site && (
<span className="text-sm px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49]">
{invoice.site}
</span>
)}
<span className="text-sm font-medium text-[#003D5B] dark:text-white">{invoice.debtor}</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{invoice.description}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-gray-500 dark:text-gray-400">
<span>Order {invoice.order_no || "—"}</span>
<span>Debtor {invoice.debtor_no || "—"}</span>
<span>{period}</span>
<span>{invoice.currency || "ZAR"}</span>
</div>
</div>
{/* The headline is the VAT-inclusive invoice total — what the debtor pays. */}
<div className="text-right shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(invoice.total_incl)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 tabular-nums">
{ZAR2(invoice.total_excl)} + {ZAR2(invoice.vat)} VAT
{invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}
</div>
</div>
</div>
</div>
)}
{embedded && (
<div className="px-4 py-3 border-b border-gray-100 dark:border-white/5 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{invoice.debtor} · {invoice.description}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>Order {invoice.order_no || "—"}</span>
<span>Debtor {invoice.debtor_no || "—"}</span>
<span>{period}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
<div className="text-xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(invoice.total_incl)}
</div>
</div>
</div>
)}
{/* Booking status. An unassigned line is never guessed at — it stays visible
and the shortfall is stated, rather than being charged to a wrong machine. */}
{unbooked.length > 0 ? (
<div className="flex items-start gap-3 px-5 py-3 bg-[#D1495B]/5 border-b border-[#D1495B]/15">
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
<div className="text-sm text-[#D1495B] dark:text-[#D1495B]">
<p>
{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a
plant item below.
</p>
<BookingMoney bookedExVat={bookedExVat} bookedVat={bookedVat} unbookedExVat={unbookedExVat} unbookedVat={unbookedVat} />
</div>
</div>
) : (
<div className="flex items-start gap-3 px-5 py-3 bg-[#00798C]/5 border-b border-[#00798C]/15">
<CheckCircle2 className="w-4 h-4 text-[#00798C] shrink-0 mt-0.5" />
<div className="text-sm text-[#00798C]">
<p>All {lines.length} lines booked to {periodMonth} income.</p>
<BookingMoney bookedExVat={bookedExVat} bookedVat={bookedVat} />
</div>
</div>
)}
<InvoiceTable lines={lines} assets={assets} />
<div className="p-5 border-t border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6">
<div className="lg:max-w-sm w-full">
<div className="text-sm font-medium text-[#003D5B] dark:text-white mb-2">
Source document
</div>
{invoice.source_pdf ? (
<AttachedDocument invoiceNo={invoice.invoice_no} invoiceId={invoice.id} />
) : (
<InvoiceUploader invoiceNo={invoice.invoice_no} />
)}
</div>
<dl className="w-full lg:w-80 shrink-0 space-y-2">
<TotalRow label="Hire (excl VAT)" value={invoice.total_excl} />
{invoice.escalation > 0 && <TotalRow label="Escalation" value={invoice.escalation} />}
<TotalRow
label={`VAT${invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}`}
value={invoice.vat}
/>
<div className="border-t border-gray-200 dark:border-white/10 pt-2">
<TotalRow label="Invoice total (incl VAT)" value={invoice.total_incl} strong />
</div>
</dl>
</div>
</div>
</div>
);
}
// Shows the booked money three ways at once, so nobody has to guess which figure the
// dashboard is using:
//
// booked to income (ex VAT) → what lands in the P&L for the machine
// VAT → collected for SARS, NOT income
// billed to customer → ex-VAT + VAT, what the debtor actually pays
function BookingMoney({
bookedExVat,
bookedVat,
unbookedExVat = 0,
unbookedVat = 0,
}: {
bookedExVat: number;
bookedVat: number;
unbookedExVat?: number;
unbookedVat?: number;
}) {
return (
<div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1">
<Item label="Booked to income (ex VAT)" value={bookedExVat} strong />
<Item label="VAT (to SARS, not income)" value={bookedVat} />
<Item label="Billed to customer" value={bookedExVat + bookedVat} strong />
{unbookedExVat > 0 && (
<>
<Item label="Still unbooked (ex VAT)" value={unbookedExVat} strong />
<Item label="Still unbooked (billed)" value={unbookedExVat + unbookedVat} />
</>
)}
</div>
);
}
function Item({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span className="opacity-80">{label}</span>
<span className={`font-semibold tabular-nums ${strong ? "underline decoration-dotted underline-offset-2" : ""}`}>
{ZAR2(value)}
</span>
</span>
);
}
function TotalRow({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-gray-500 dark:text-gray-400">{label}</dt>
<dd
className={`text-sm tabular-nums ${
strong ? "font-semibold text-[#003D5B] dark:text-white" : "text-gray-700 dark:text-gray-300"
}`}
>
{ZAR2(value || 0)}
</dd>
</div>
);
}
export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: Asset[] }) {
const options = assets
.filter((a) => a.is_active !== false)
.map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category }));
return (
// Rule 1 + 4: the table scrolls inside its own container, so the page itself never
// scrolls sideways at 375px. `-webkit-overflow-scrolling` is not a Tailwind utility —
// written as a class it is inert — so it is set inline to actually take effect.
<div className="group relative">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<th className="text-left py-3 px-5 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur">
Machine
</th>
<th className="text-left py-3 px-3 font-medium">Description</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Qty</th>
<th className="text-left py-3 px-3 font-medium">Unit</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Rate</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Hire ex VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Booked to</th>
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr
key={l.id}
className={`border-b border-gray-50 dark:border-white/5 ${
l.isBooked ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#D1495B]/[0.04]"
}`}
>
{/* Sticky first column — keeps the machine identifiable while scrolling.
It needs its own opaque background, so it has to repeat the row's
unbooked tint; otherwise the flag disappears under the white. */}
<td
className={`py-3 px-5 sticky left-0 z-10 ${
l.isBooked
? "bg-white dark:bg-[#131a21]"
: "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]"
}`}
>
<div className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-gray-400 dark:text-gray-500 tabular-nums">{l.line_no}</span>
<span className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</span>
</div>
</td>
<td className="py-3 px-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">{l.description}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{l.qty}</td>
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{l.unit}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.rate)}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.amount_excl)}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.vat)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(l.total_incl)}
</td>
<td className="py-3 px-3 w-[220px]">
<BookingSelect lineId={l.id} value={l.booked_asset || ""} assets={options} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<ScrollFade />
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
const MAX_BYTES = 20 * 1024 * 1024;
// Drag-and-drop / tap-to-browse PDF upload against an invoice's `source_pdf` field.
// Accepts PDF only, matching the server route — the client check is here to fail fast,
// the server check is the one that actually guards the data.
export function InvoiceUploader({ invoiceNo }: { invoiceNo: string }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState<{ status: "idle" | "uploading" | "done" | "error"; message?: string }>({
status: "idle",
});
async function send(file: File) {
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
setState({ status: "error", message: "Only PDF invoices are accepted" });
return;
}
if (file.size > MAX_BYTES) {
setState({ status: "error", message: `${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` });
return;
}
setState({ status: "uploading" });
const body = new FormData();
body.append("file", file);
body.append("invoiceNo", invoiceNo);
try {
const res = await fetch("/api/invoices/upload", { method: "POST", body });
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (!data.ok) setState({ status: "error", message: data.error || "Upload failed" });
else {
setState({ status: "done", message: `${data.file} attached` });
router.refresh();
}
} catch {
setState({ status: "error", message: "Could not reach the server" });
}
}
return (
<div>
{/* Whole zone is the tap target — button semantics on a div, so it stays one
44px-tall control instead of a small inner button. */}
<div
role="button"
tabIndex={0}
aria-label={`Upload the invoice PDF for ${invoiceNo}`}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) send(file);
}}
className={`flex items-center gap-3 min-h-[44px] px-4 py-3 rounded-xl border-2 border-dashed cursor-pointer transition ${
dragging
? "border-[#00798C] bg-[#00798C]/10"
: "border-gray-200 dark:border-white/10 hover:border-[#00798C]/50 hover:bg-gray-50 dark:hover:bg-white/[0.02]"
}`}
>
{state.status === "uploading" ? (
<Loader2 className="w-5 h-5 animate-spin text-[#00798C] shrink-0" />
) : state.status === "done" ? (
<CheckCircle2 className="w-5 h-5 text-[#00798C] shrink-0" />
) : (
<UploadCloud className="w-5 h-5 text-[#00798C] shrink-0" />
)}
<div className="min-w-0 text-left">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{state.status === "uploading"
? "Uploading…"
: state.status === "done"
? state.message
: "Drop the invoice PDF here, or tap to browse"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
PDF only · up to 20 MB · attaches to {invoiceNo}
</div>
</div>
</div>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) send(file);
e.target.value = ""; // allow re-picking the same file
}}
/>
{state.status === "error" && (
<p className="flex items-start gap-1.5 text-sm text-[#D1495B] mt-2">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
{state.message}
</p>
)}
</div>
);
}
// Shown once a document is attached, with a link that opens it. The link points at our
// own authenticated proxy, not at PocketBase's /api/files — the invoices collection is
// rule-locked, so a direct PB link 404s in the browser.
export function AttachedDocument({
invoiceNo,
invoiceId,
}: {
invoiceNo: string;
invoiceId: string;
}) {
const [showUploader, setShowUploader] = useState(false);
return (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api/invoices/${invoiceId}/document`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#00798C] hover:bg-[#00798C]/10 transition"
>
<FileText className="w-4 h-4 shrink-0" />
Open invoice PDF
</a>
<button
type="button"
onClick={() => setShowUploader((s) => !s)}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
{showUploader ? "Cancel" : "Replace"}
</button>
{showUploader && (
<div className="w-full">
<InvoiceUploader invoiceNo={invoiceNo} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,91 @@
{
"_comment": "Extracted from PHI 166.pdf (Std_ReqHireInvoice_DN.rpt) by coordinate-aware extraction AND confirmed by reading the rendered pages visually. The totals block on page 2 reads: SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT 24,205.13 / INVOICE TOTAL 185,572.63. So 24,205.13 is 15% VAT, NOT an escalation - escalation is zero on every line. INVOICE TOTAL 185,572.63 is therefore VAT-INCLUSIVE. Do NOT hand-edit a figure to make it balance - the seed re-checks all of this and fails loudly.",
"invoice_no": "PHI 166",
"invoice_date": "2026-09-18",
"order_no": "PHR 170",
"debtor": "Mega Infrastructure",
"debtor_no": "09MIS001",
"period_from": "2026-08-21",
"period_to": "2026-09-18",
"description": "Plant Hire SEP 2026 West Park",
"site": "West Park",
"section": "Plant Hire",
"category": "External Plant Hire",
"currency": "ZAR",
"vat_reg_no": "4830324812",
"source_file": "PHI 166.pdf",
"vat_pct": 15,
"subtotal": 161367.5,
"escalation": 0,
"vat": 24205.13,
"total_excl": 161367.5,
"total_incl": 185572.63,
"lines": [
{
"line_no": 1,
"machine_code": "MG-01-DEZI",
"asset_code": "MG01",
"description": "MG80 Motor Grader",
"qty": 169.5,
"unit": "Hr",
"rate": 345,
"rate_type": "Dry Rate",
"amount_excl": 58477.5,
"vat": 8771.63,
"total_incl": 67249.13
},
{
"line_no": 2,
"machine_code": "MPLANT05",
"asset_code": "SC01",
"_note": "Client ruling 2026-10-01: MPLANT05 also appears on PHI 171 (Cosmo City) over the same period. NOT a duplicate - the same container was used on more than one site this month, so both lines book to the single machine SC01. The register has no MPLANT05 and seven Site Containers (SC01-SC07); SC01 was chosen as the consistent target - change it in the Booked to column if it should be another.",
"description": "Storage Container",
"qty": 2,
"unit": "Month",
"rate": 1200,
"rate_type": "Dry Rate",
"amount_excl": 2400,
"vat": 360,
"total_incl": 2760
},
{
"line_no": 3,
"machine_code": "RV-03-CAT",
"asset_code": "RV03",
"description": "CP-533C 12 TON Roller Vibration",
"qty": 171.5,
"unit": "Hr",
"rate": 240,
"rate_type": "Dry Rate",
"amount_excl": 41160,
"vat": 6174,
"total_incl": 47334
},
{
"line_no": 4,
"machine_code": "TLB05-CASE",
"asset_code": "TLB05",
"description": "570 T",
"qty": 171.5,
"unit": "Hr",
"rate": 280,
"rate_type": "Dry Rate",
"amount_excl": 48020,
"vat": 7203,
"total_incl": 55223
},
{
"line_no": 5,
"machine_code": "TP601-TATA",
"asset_code": "TP601",
"description": "1518 6 Cube Tipper Truck",
"qty": 43.5,
"unit": "Hr",
"rate": 260,
"rate_type": "Dry Rate",
"amount_excl": 11310,
"vat": 1696.5,
"total_incl": 13006.5
}
]
}

View file

@ -0,0 +1,39 @@
{
"_comment": "Extracted from PHI 167.pdf (Std_ReqHireInvoice_DN.rpt) by coordinate-aware extraction AND confirmed by reading the rendered page visually. Totals block: SUB TOTAL 12,700.00 / Escalation 0.00 / PLUS VAT 1,905.00 / INVOICE TOTAL 14,605.00. The 1,905.00 sits under the column headed PLUS VAT - it is 15% VAT, NOT an escalation; escalation is zero. INVOICE TOTAL is therefore VAT-INCLUSIVE. Only amount_excl is booked to income. Site is taken from the invoice's own description text.",
"invoice_no": "PHI 167",
"invoice_date": "2026-09-18",
"order_no": "PHR 171",
"debtor": "Mega Infrastructure",
"debtor_no": "09MIS001",
"period_from": "2026-08-21",
"period_to": "2026-09-18",
"description": "Hire Plant SEp 2026 Halfway Gardens",
"site": "Halfway Gardens",
"section": "Plant Hire",
"category": "External Plant Hire",
"currency": "ZAR",
"vat_reg_no": "4830324812",
"source_file": "PHI 167.pdf",
"vat_pct": 15,
"subtotal": 12700,
"escalation": 0,
"vat": 1905,
"total_excl": 12700,
"total_incl": 14605,
"lines": [
{
"line_no": 1,
"machine_code": "SS-01-CASE",
"asset_code": "SS01",
"_note": "Register holds SS01 and SS02, both 'CASE420 (430)'. The invoice prints SS-01, so it maps to SS01; confirm with the client if SS02 was the one on site.",
"description": "430 Skidsteer",
"qty": 63.5,
"unit": "Hr",
"rate": 200,
"rate_type": "Dry Rate",
"amount_excl": 12700,
"vat": 1905,
"total_incl": 14605
}
]
}

View file

@ -0,0 +1,80 @@
{
"_comment": "Extracted from PHI 171.pdf (Std_ReqHireInvoice_DN.rpt) by coordinate-aware extraction AND confirmed by reading both rendered pages visually. Totals block spans two pages: page 1 shows SUB TOTAL 25,500.00 / Escalation 0.00, page 2 shows PLUS VAT 3,825.00 / INVOICE TOTAL 29,325.00. So 3,825.00 is 15% VAT, NOT an escalation; escalation is zero. INVOICE TOTAL is VAT-INCLUSIVE. Only amount_excl is booked to income.",
"invoice_no": "PHI 171",
"invoice_date": "2026-09-18",
"order_no": "PHR 173",
"debtor": "Mega Infrastructure",
"debtor_no": "09MIS001",
"period_from": "2026-08-21",
"period_to": "2026-09-18",
"description": "Hire Plant Sep 2026 Cosmo City",
"site": "Cosmo City",
"section": "Plant Hire",
"category": "External Plant Hire",
"currency": "ZAR",
"vat_reg_no": "4830324812",
"source_file": "PHI 171.pdf",
"vat_pct": 15,
"subtotal": 25500,
"escalation": 0,
"vat": 3825,
"total_excl": 25500,
"total_incl": 29325,
"lines": [
{
"line_no": 1,
"machine_code": "MPLANT04",
"asset_code": "OC01",
"_note": "No MPLANT04 in the register; the only Office Containers are OC01 and OC02. OC01 chosen as the consistent target - change it in the Booked to column if it should be OC02.",
"description": "Office Container",
"qty": 2,
"unit": "Month",
"rate": 1500,
"rate_type": "Dry Rate",
"amount_excl": 3000,
"vat": 450,
"total_incl": 3450
},
{
"line_no": 2,
"machine_code": "MPLANT05",
"asset_code": "SC01",
"_note": "Client ruling 2026-10-01: same code, rate and period as line 2 of PHI 166 (West Park). NOT a duplicate - the same container was used on more than one site this month, so both book to the single machine SC01. Matches the target used on PHI 166.",
"description": "Storage Container",
"qty": 2,
"unit": "Month",
"rate": 1200,
"rate_type": "Dry Rate",
"amount_excl": 2400,
"vat": 360,
"total_incl": 2760
},
{
"line_no": 3,
"machine_code": "SS-01-CASE",
"asset_code": "SS01",
"_note": "Register holds SS01 and SS02, both 'CASE420 (430)'. The invoice prints SS-01, so it maps to SS01.",
"description": "430 Skidsteer",
"qty": 90,
"unit": "Hr",
"rate": 200,
"rate_type": "Dry Rate",
"amount_excl": 18000,
"vat": 2700,
"total_incl": 20700
},
{
"line_no": 4,
"machine_code": "TLB07-CASE",
"asset_code": "TLB07",
"description": "570 ST",
"qty": 7.5,
"unit": "Hr",
"rate": 280,
"rate_type": "Dry Rate",
"amount_excl": 2100,
"vat": 315,
"total_incl": 2415
}
]
}

View file

@ -13,6 +13,7 @@ export type Asset = {
monthly_incl: number;
purchase_value: number;
is_provisional: boolean;
is_active: boolean;
} & RecordModel;
export type CostLine = {
@ -55,6 +56,58 @@ export type Overhead = {
amount_incl: number;
} & RecordModel;
// Invoice header. `booked_month`/`booked_year` record the period the invoice was
// posted into monthly_income; `is_booked` flips once every line has a target asset.
export type Invoice = {
id: string;
invoice_no: string;
invoice_date: string;
order_no: string;
debtor: string;
debtor_no: string;
period_from: string;
period_to: string;
description: string;
site: string;
section: string;
category: string;
currency: string;
vat_reg_no: string;
source_file: string;
subtotal: number;
escalation: number;
vat: number;
vat_pct: number;
total_excl: number;
total_incl: number;
source_pdf: string;
booked_month: number;
booked_year: number;
is_booked: boolean;
} & RecordModel;
// Invoice line. `machine_code` is what the invoice prints; `asset_code` is the
// register match; `booked_asset` is the machine the income is actually booked to.
// These can differ — an invoice line may legitimately be charged to another machine.
export type InvoiceLine = {
id: string;
invoice: string;
line_no: number;
machine_code: string;
asset_code: string;
description: string;
qty: number;
unit: string;
rate: number;
rate_type: string;
amount_excl: number;
vat: number;
total_incl: number;
booked_asset: string;
booked_month: number;
booked_year: number;
} & RecordModel;
export async function getAssets(): Promise<Asset[]> {
const pb = await serverAuth();
return pb.collection("assets").getFullList<Asset>({ sort: "category,name" });
@ -105,6 +158,78 @@ export async function getOverheadsBySection(section: string): Promise<Overhead[]
return all.filter((o) => o.section === section);
}
export async function getInvoices(): Promise<Invoice[]> {
const pb = await serverAuth();
return pb.collection("invoices").getFullList<Invoice>({ sort: "-invoice_date,-invoice_no" });
}
export async function getInvoiceLines(invoiceId?: string): Promise<InvoiceLine[]> {
const pb = await serverAuth();
return pb.collection("invoice_lines").getFullList<InvoiceLine>({
filter: invoiceId ? `invoice = "${invoiceId}"` : "",
sort: "line_no",
});
}
// An invoice line joined to the asset it is booked against. Kept as a plain derived
// type so the page never has to deal with PocketBase expand payloads.
export type BookedLine = InvoiceLine & {
bookedCode: string;
bookedName: string;
isBooked: boolean;
};
export function joinBookedLines(lines: InvoiceLine[], assets: Asset[]): BookedLine[] {
const byId = new Map(assets.map((a) => [a.id, a]));
return lines.map((l) => {
const a = l.booked_asset ? byId.get(l.booked_asset) : undefined;
return {
...l,
bookedCode: a?.code || "",
bookedName: a?.name || "",
isBooked: Boolean(a),
};
});
}
// One machine can legitimately be hired to several sites in the same month — that is
// multi-site use, not a duplicate. These are flagged so the reader can see the pattern,
// NOT as a data error, so nothing here is styled as a problem.
//
// Returns a map of line id → the other (invoice, site) pairs this same machine code was
// billed on over an overlapping period.
export function findDuplicateMachineCodes(
invoices: Invoice[],
lines: BookedLine[],
): Map<string, string[]> {
const byId = new Map(invoices.map((i) => [i.id, i]));
const groups = new Map<string, BookedLine[]>();
for (const l of lines) {
if (!l.machine_code) continue;
if (!groups.has(l.machine_code)) groups.set(l.machine_code, []);
groups.get(l.machine_code)!.push(l);
}
const out = new Map<string, string[]>();
for (const [, group] of groups) {
if (group.length < 2) continue;
for (const line of group) {
const inv = byId.get(line.invoice);
if (!inv) continue;
const others = new Set<string>();
for (const other of group) {
if (other.id === line.id) continue;
const oInv = byId.get(other.invoice);
if (!oInv || oInv.id === inv.id) continue;
const overlaps = inv.period_from <= oInv.period_to && oInv.period_from <= inv.period_to;
if (overlaps) others.add(oInv.invoice_no);
}
if (others.size) out.set(line.id, [...others]);
}
}
return out;
}
export const MONTH_NAMES = [
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
];

View file

@ -0,0 +1,6 @@
{
"_comment": "SINGLE SOURCE OF TRUTH for how invoice line amounts are treated when booked. Read by BOTH the Next.js app (lib/invoice-booking.ts) and the CLI scripts (scripts/book-invoice.mjs) so the two can never drift apart. To flip, change bookInclusiveVat and re-run: npm run seed && node scripts/book-invoice.mjs",
"_ruling": "Client decision 2026-10-01: invoice amounts are booked to a machine VAT-INCLUSIVE. This is the amount the debtor actually pays and it is what the overview totals show as booked income.",
"_consequence": "Booked income now includes SARS output VAT. On the September 2026 set that is R29,935.13 of VAT inside R229,502.63. If a VAT-excl. P&L is ever needed, set bookInclusiveVat back to false and re-run the booking — the VAT is still stored per line in invoice_lines.vat, so nothing is lost.",
"bookInclusiveVat": true
}

24
lib/invoice-booking.ts Normal file
View file

@ -0,0 +1,24 @@
import policy from "./invoice-booking-policy.json";
// Single source of truth for the amount an invoice line contributes to a machine's
// income. The flag itself lives in ./invoice-booking-policy.json because the CLI
// booking script must obey exactly the same rule — two copies of a financial
// constant will eventually disagree.
//
// Client ruling, 2026-10-01: amounts are booked VAT-INCLUSIVE, i.e. the figure the
// debtor actually pays. Set `bookInclusiveVat: false` in the JSON to book ex-VAT
// instead, then re-run `npm run seed && node scripts/book-invoice.mjs`.
export const BOOK_INCLUSIVE_VAT = policy.bookInclusiveVat;
// The amount to post to monthly_income for one invoice line.
export function bookedAmount(amountExcl: number, vat: number): number {
const r = (n: number) => Math.round((n || 0) * 100) / 100;
return BOOK_INCLUSIVE_VAT ? r(amountExcl) + r(vat) : r(amountExcl);
}
// Label for whichever mode is active, so the UI never says "ex VAT" while booking
// the inclusive figure (or the reverse).
export const BOOKED_LABEL = BOOK_INCLUSIVE_VAT ? "incl VAT" : "ex VAT";
export const BOOKED_LONG_LABEL = BOOK_INCLUSIVE_VAT
? "Booked to income (incl VAT)"
: "Booked to income (ex VAT)";

108
lib/rows.test.ts Normal file
View file

@ -0,0 +1,108 @@
// Regression tests for buildIncomeRows month aggregation.
//
// Run: node --import tsx --test lib/rows.test.ts
//
// Background: a machine can legitimately have SEVERAL income rows in one month — the
// workbook row plus one row per invoice line booked to it (one machine hired to two sites
// in a month produces two invoice rows). buildIncomeRows used to assign into months[i],
// so the last row read won and the others vanished from the month view while still being
// counted in the YTD total. SS01 September showed R20,700 when it was really R35,305.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildIncomeRows } from "./rows";
const asset = (id: string, code = "SS01") => ({
id,
code,
name: code,
category: "Plant",
type: "",
gl_account: "",
supplier: "",
monthly_excl: 0,
monthly_incl: 0,
purchase_value: 0,
is_provisional: false,
is_active: true,
}) as any;
const inc = (assetId: string, month: number, income: number, forecast = false) =>
({
id: `${assetId}-${month}-${income}`,
asset: assetId,
year: 2026,
month,
income,
fixed_expenses: 0,
is_forecast: forecast,
}) as any;
test("two invoice rows in one month are summed, not overwritten", () => {
// The real SS01 September 2026 case: PHI 167 + PHI 171 booked to the same machine.
const rows = buildIncomeRows(
[asset("a1")],
[inc("a1", 9, 14605), inc("a1", 9, 20700)],
);
assert.equal(rows.length, 1);
const r = rows[0];
assert.equal(r.months[8], 35305, "September cell must be both invoices");
assert.equal(r.income, 35305, "YTD total must agree with the month cell");
// The invariant that was broken: the cells must add up to the headline.
const sumOfMonths = r.months.reduce((s: number, v: any) => s + (v ?? 0), 0);
assert.equal(sumOfMonths, r.income, "month cells must sum to YTD income");
});
test("workbook row plus invoice rows in the same month all count", () => {
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 0), inc("a1", 9, 14605), inc("a1", 9, 20700)]);
assert.equal(rows[0].months[8], 35305);
});
test("months with no actual data stay null", () => {
// getActualMonthlyIncome() filters forecast rows out before this point, so the guard
// here is simply "no row for that month -> render a dash".
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 14605)]);
assert.equal(rows[0].months[8], 14605);
assert.equal(rows[0].months[9], null, "a month with no actual row must render as null");
assert.equal(rows[0].months[11], null);
});
test("per-month profit uses the accumulated month, not the last row", () => {
// Default: profit = income - fixed. Both invoices must be in the month's profit.
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 14605), inc("a1", 9, 20700)]);
assert.equal(rows[0].profit[8], 35305);
});
test("multi-site machine attributes the VAT across both invoices", () => {
const line = (id: string, amount: number, vat: number) =>
({
id,
invoice: "i1",
line_no: 1,
machine_code: "SS-01-CASE",
asset_code: "SS01",
description: "430 Skidsteer",
qty: 1,
unit: "Hr",
rate: amount,
rate_type: "Dry Rate",
amount_excl: amount - vat,
vat,
total_incl: amount,
booked_asset: "a1",
booked_month: 9,
booked_year: 2026,
}) as any;
const rows = buildIncomeRows(
[asset("a1")],
[inc("a1", 9, 14605), inc("a1", 9, 20700)],
[],
{},
[line("l1", 14605, 1905), line("l2", 20700, 2700)],
);
const r = rows[0];
assert.equal(r.invoiceIncome, 35305, "combined invoice total for the vehicle");
assert.equal(r.invoiceVat, 4605, "VAT inside that total, across both invoices");
assert.equal(r.invoiceIncome, r.months[8], "invoice total matches the September cell");
});

View file

@ -1,4 +1,4 @@
import type { Asset, MonthlyIncome, MonthlyRunningCost } from "./data";
import type { Asset, InvoiceLine, MonthlyIncome, MonthlyRunningCost } from "./data";
import { computeProfit, type ProfitOptions } from "./profit";
export type IncomeRow = {
@ -14,6 +14,13 @@ export type IncomeRow = {
running: number;
ytdProfit: number;
lastActualMonth: number; // Part A.3: clip x-axis
// Portion of `income` that came from invoices, and how much of that is VAT.
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
// this the per-machine figure is not explainable.
invoiceIncome: number;
invoiceVat: number;
// Every invoice that contributed to this machine, so the card can show the combined
// total and break it down by invoice and site without another query.
};
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`/`runningMonths`.
@ -22,7 +29,10 @@ export function buildIncomeRows(
assets: Asset[],
income: MonthlyIncome[],
running?: MonthlyRunningCost[],
profitOpts: ProfitOptions = {}
profitOpts: ProfitOptions = {},
// Invoice line items, used only to attribute the VAT already inside each booked
// amount back to the machine that earned it.
invoiceLines?: InvoiceLine[],
): IncomeRow[] {
// Determine the last actual month globally (Part A.2/A.3)
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
@ -38,6 +48,7 @@ export function buildIncomeRows(
profit: Array(12).fill(0),
income: 0, fixed: 0, running: 0, ytdProfit: 0,
lastActualMonth,
invoiceIncome: 0, invoiceVat: 0,
});
}
for (const r of income) {
@ -46,10 +57,14 @@ export function buildIncomeRows(
const i = r.month - 1;
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
//
// ACCUMULATE, do not assign: a machine can have several income rows in one month —
// a workbook row plus one per invoice line booked to it. Assigning made the cell show
// only the last row, so SS01 (two September invoices) displayed R20,700 while its own
// YTD total included both (R35,305), and the September column total came out short.
if (r.month <= lastActualMonth) {
row.months[i] = r.income;
row.fixedMonths[i] = r.fixed_expenses;
row.profit[i] = computeProfit({ income: r.income, fixed: r.fixed_expenses, running: 0 }, profitOpts);
row.months[i] = (row.months[i] ?? 0) + r.income;
row.fixedMonths[i] = (row.fixedMonths[i] ?? 0) + r.fixed_expenses;
}
row.income += r.income;
row.fixed += r.fixed_expenses;
@ -62,12 +77,33 @@ export function buildIncomeRows(
if (c.month <= lastActualMonth) {
row.runningMonths[c.month - 1] = c.amount;
}
// Re-compute per-month profit so it includes running cost regardless of includeRunningCost flag.
row.profit[c.month - 1] = computeProfit(
{ income: row.months[c.month - 1] ?? 0, fixed: row.fixedMonths[c.month - 1] ?? 0, running: c.amount },
profitOpts
);
}
// Per-month profit is computed ONCE, after income and running have both been
// accumulated, so it reflects the whole month rather than whichever row happened to be
// read last. Previously this lived inside the two loops above and inherited the same
// last-write-wins bug.
for (const row of byAsset.values()) {
for (let i = 0; i < 12; i++) {
if (i + 1 > lastActualMonth) continue;
row.profit[i] = computeProfit(
{
income: row.months[i] ?? 0,
fixed: row.fixedMonths[i] ?? 0,
running: row.runningMonths[i] ?? 0,
},
profitOpts,
);
}
}
// Attribute invoice-derived income and its embedded VAT per machine.
for (const l of invoiceLines ?? []) {
const row = byAsset.get(l.booked_asset);
if (!row) continue;
row.invoiceIncome += l.total_incl || 0;
row.invoiceVat += l.vat || 0;
}
for (const row of byAsset.values()) {
row.ytdProfit = computeProfit({ income: row.income, fixed: row.fixed, running: row.running }, profitOpts);
}

166
scripts/book-invoice.mjs Normal file
View file

@ -0,0 +1,166 @@
// Post invoice lines into `monthly_income` as ADDITIONAL rows.
//
// Run order matters: `npm run seed` wipes monthly_income and re-imports the workbook,
// so invoice-derived income must be (re)posted afterwards.
//
// Idempotent: every row it creates is stamped with `invoice_line`, so a re-run removes
// the previous invoice-derived rows first instead of adding duplicates. Workbook rows
// (empty invoice_line) are never touched.
//
// Usage: node scripts/book-invoice.mjs [--invoice "PHI 166"] [--dry-run]
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const envPath = resolve(ROOT, ".env.local");
if (!existsSync(envPath)) {
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
process.exit(1);
}
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
if (!EMAIL) missing.push("PB_ADMIN");
if (!PASS) missing.push("PB_PASS");
if (missing.length) {
console.error(`✗ Missing required env: ${missing.join(", ")}`);
process.exit(1);
}
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const onlyIdx = args.indexOf("--invoice");
const onlyInvoice = onlyIdx >= 0 ? args[onlyIdx + 1] : null;
// Shared policy — the app reads the same file, so the booked amount can never differ
// between this script and the /income/invoices UI. See lib/invoice-booking-policy.json.
const POLICY = JSON.parse(readFileSync(resolve(ROOT, "lib", "invoice-booking-policy.json"), "utf8"));
const round2 = (n) => Math.round((n || 0) * 100) / 100;
const bookedAmount = (excl, vat) => (POLICY.bookInclusiveVat ? round2(excl) + round2(vat) : round2(excl));
const BOOKED_LABEL = POLICY.bookInclusiveVat ? "incl VAT" : "ex VAT";
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
console.log(" ✓ authenticated");
const invoices = await pb.collection("invoices").getFullList({ sort: "invoice_date" });
if (!invoices.length) {
console.error("✗ No invoices found. Run `npm run seed` first.");
process.exit(1);
}
const targets = onlyInvoice ? invoices.filter((i) => i.invoice_no === onlyInvoice) : invoices;
if (!targets.length) {
console.error(`✗ No invoice matched "${onlyInvoice}". Available: ${invoices.map((i) => i.invoice_no).join(", ")}`);
process.exit(1);
}
const allLines = await pb.collection("invoice_lines").getFullList({ sort: "line_no" });
const existingIncome = await pb.collection("monthly_income").getFullList();
// Remove previously invoice-derived rows for the target invoices only.
const targetIds = new Set(targets.map((i) => i.id));
const linesOfTargets = allLines.filter((l) => targetIds.has(l.invoice));
const lineIds = new Set(linesOfTargets.map((l) => l.id));
const staleRows = existingIncome.filter((r) => r.invoice_line && lineIds.has(r.invoice_line));
console.log(`\n→ Booking ${targets.length} invoice(s), ${linesOfTargets.length} line(s)`);
if (dryRun) console.log(" (dry run — nothing will be written)");
if (staleRows.length) {
console.log(` · removing ${staleRows.length} previously posted invoice row(s)`);
if (!dryRun) for (const r of staleRows) await pb.collection("monthly_income").delete(r.id);
}
const errors = [];
const unbooked = [];
let posted = 0;
for (const inv of targets) {
console.log(`\n ${inv.invoice_no} — ${inv.debtor} — ${inv.description}`);
console.log(` period ${inv.period_from} → ${inv.period_to} · booking to ${inv.booked_year}-${String(inv.booked_month).padStart(2, "0")}`);
const lines = allLines.filter((l) => l.invoice === inv.id);
let invoiceComplete = true;
for (const l of lines) {
if (!l.booked_asset) {
unbooked.push(` ${inv.invoice_no} line ${l.line_no} (${l.machine_code} ${l.description}) — no target asset`);
invoiceComplete = false;
console.log(` line ${l.line_no} ${l.machine_code.padEnd(14)} → UNBOOKED (no target asset)`);
continue;
}
const asset = await pb.collection("assets").getOne(l.booked_asset).catch(() => null);
if (!asset) {
errors.push(` ${inv.invoice_no} line ${l.line_no}: booked_asset ${l.booked_asset} no longer exists in assets`);
invoiceComplete = false;
continue;
}
// Amount posted follows the shared policy (VAT-inclusive per the client's 2026-10-01
// ruling). Flip lib/invoice-booking-policy.json and re-run to change it.
const amount = bookedAmount(l.amount_excl, l.vat);
const arrow = asset.code && l.asset_code && asset.code !== l.asset_code
? ` (invoice code ${l.asset_code} → booked to ${asset.code})`
: "";
console.log(` line ${l.line_no} ${String(l.machine_code).padEnd(14)} → ${String(asset.code || asset.name).padEnd(8)} R${amount.toFixed(2)} ${BOOKED_LABEL}${arrow}`);
if (!dryRun) {
try {
await pb.collection("monthly_income").create({
asset: l.booked_asset,
year: l.booked_year || inv.booked_year,
month: l.booked_month || inv.booked_month,
income: amount,
fixed_expenses: 0,
is_forecast: false,
invoice_line: l.id,
});
posted++;
} catch (e) {
errors.push(` ${inv.invoice_no} line ${l.line_no}: ${e.response?.data?.message || e.message}`);
}
} else {
posted++;
}
}
if (!dryRun) {
await pb.collection("invoices").update(inv.id, { is_booked: invoiceComplete });
}
}
console.log(`\n=== Summary ===`);
console.log(`Invoices processed: ${targets.length}`);
console.log(`Rows posted: ${posted}`);
console.log(`Unbooked lines: ${unbooked.length}`);
if (unbooked.length) {
console.log("\n ⚠ These lines are NOT in monthly_income:");
for (const u of unbooked) console.log(` -${u}`);
console.log("\n Assign the target asset in data/source/invoice-phi166.json (asset_code) and re-run");
console.log(" `npm run seed`, then `node scripts/book-invoice.mjs`.");
}
if (errors.length) {
console.error(`\n✗ ${errors.length} error(s):`);
for (const e of errors) console.error(e);
process.exit(2);
}
console.log(dryRun ? "\n✅ Dry run complete — no changes written" : "\n✅ Booking complete");

View file

@ -3,13 +3,26 @@
//
// Usage: PB_ADMIN=... PB_PASS=... node scripts/seed.mjs
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = resolve(__dirname, "..", "data", "source");
// Load .env.local so `npm run seed` works without exporting vars by hand.
// Explicit process.env values still win — CI/deploys can override.
const envPath = resolve(__dirname, "..", ".env.local");
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#") || !t.includes("=")) continue;
const i = t.indexOf("=");
const k = t.slice(0, i).trim();
if (!process.env[k]) process.env[k] = t.slice(i + 1).trim();
}
}
// Read required env — fail loudly if missing (per Part 5.2)
const PB = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
@ -29,6 +42,8 @@ const INCOME_COLL = "monthly_income";
const RUN_COLL = "monthly_running_costs";
const OVERHEADS_COLL = "overheads";
const COST_LINES_COLL = "cost_lines";
const INVOICES_COLL = "invoices";
const INVOICE_LINES_COLL = "invoice_lines";
const assets = JSON.parse(readFileSync(resolve(DATA_DIR, "assets.json"), "utf8"));
const machines = JSON.parse(readFileSync(resolve(DATA_DIR, "machines.json"), "utf8"));
@ -36,6 +51,14 @@ const mrc = JSON.parse(readFileSync(resolve(DATA_DIR, "mrc.json"), "utf8"));
const overheads = JSON.parse(readFileSync(resolve(DATA_DIR, "overheads.json"), "utf8"));
const costLines = JSON.parse(readFileSync(resolve(DATA_DIR, "cost_lines.json"), "utf8"));
// Invoices: one JSON per source PDF, all loaded automatically. Dropping a new
// invoice-*.json into data/source is the whole procedure for adding another invoice.
const invoiceFiles = readdirSync(DATA_DIR).filter((f) => /^invoice-.*\.json$/i.test(f)).sort();
const invoices = invoiceFiles.map((f) => ({
...JSON.parse(readFileSync(resolve(DATA_DIR, f), "utf8")),
_file: f,
}));
const pb = new PocketBase(PB);
pb.autoCancellation(false);
@ -44,7 +67,7 @@ await pb.collection("_superusers").authWithPassword(EMAIL, PASS); // Part 5.3: _
console.log(" ✓ authenticated");
// Wipe existing rows so we can re-seed cleanly
for (const c of [ASSETS_COLL, INCOME_COLL, RUN_COLL, OVERHEADS_COLL, COST_LINES_COLL]) {
for (const c of [ASSETS_COLL, INCOME_COLL, RUN_COLL, OVERHEADS_COLL, COST_LINES_COLL, INVOICE_LINES_COLL, INVOICES_COLL]) {
const items = await pb.collection(c).getFullList();
for (const r of items) await pb.collection(c).delete(r.id);
}
@ -91,6 +114,7 @@ for (const a of assets) {
monthly_incl: a.monthly_incl || 0,
purchase_value: a.purchase_value || 0,
is_provisional: a.is_provisional || false, // Part C.3
is_active: a.is_active !== false, // default true; only "AEB Plant Versekering" is false
});
if (a.code) codeToId[a.code] = rec.id;
nameToId[normalizeName(a.name)] = rec.id;
@ -244,17 +268,162 @@ if (VAT_WARN.length > 0) {
for (const w of VAT_WARN) console.log(` - ${w}`);
}
// === Insert invoices + invoice lines ===
// Every data/source/invoice-*.json is picked up automatically, so dropping a new
// invoice PDF's transcription in that folder is all it takes to add another one.
//
// The line items are transcribed from the source PDF, so the arithmetic is re-checked
// here rather than trusted. Per the project's standing rule: never silently correct a
// figure to make it balance — trip loudly and let the client confirm.
console.log(`\n→ Seeding ${invoices.length} invoice(s)`);
const invErrors = [];
const round2 = (n) => Math.round(n * 100) / 100;
let invoicesSeeded = 0;
let lineRows = 0;
for (const inv of invoices) {
const where = inv.source_file || inv.invoice_no;
// Scoped per invoice: a warning on one invoice must never block the next one.
// (Using the cumulative array here silently skipped every invoice after the first
// one that had an unbooked line.)
const errs = [];
// Header arithmetic. `subtotal` is the ex-VAT hire, `vat` is the "PLUS VAT" column,
// and `total_incl` is the VAT-inclusive figure the debtor actually pays.
const sumHire = round2(inv.lines.reduce((s, l) => s + l.amount_excl, 0));
const sumVat = round2(inv.lines.reduce((s, l) => s + l.vat, 0));
if (sumHire !== round2(inv.subtotal)) {
errs.push(`${where}: hire lines total ${sumHire} but invoice subtotal is ${inv.subtotal}`);
}
if (sumVat !== round2(inv.vat)) {
errs.push(`${where}: VAT lines total ${sumVat} but invoice PLUS VAT is ${inv.vat}`);
}
if (round2(inv.subtotal + inv.escalation) !== round2(inv.total_excl)) {
errs.push(`${where}: subtotal ${inv.subtotal} + escalation ${inv.escalation} != total_excl ${inv.total_excl}`);
}
if (round2(inv.total_excl + inv.vat) !== round2(inv.total_incl)) {
errs.push(`${where}: total_excl ${inv.total_excl} + vat ${inv.vat} != total_incl ${inv.total_incl}`);
}
if (inv.vat > 0 && inv.vat_pct && inv.vat / inv.total_excl > inv.vat_pct / 100 + 0.001) {
errs.push(`${where}: VAT ${inv.vat} on excl ${inv.total_excl} exceeds the stated ${inv.vat_pct}% rate`);
}
// Per-line arithmetic
for (const l of inv.lines) {
if (round2(l.amount_excl + l.vat) !== round2(l.total_incl)) {
errs.push(`${where} line ${l.line_no} (${l.machine_code}) excl ${l.amount_excl} + vat ${l.vat} != incl ${l.total_incl}`);
}
if (inv.vat_pct) {
const expected = round2(l.amount_excl * inv.vat_pct / 100);
if (Math.abs(expected - l.vat) > 0.02) {
errs.push(`${where} line ${l.line_no} (${l.machine_code}) VAT is ${l.vat}, but ${inv.vat_pct}% of ${l.amount_excl} is ${expected}`);
}
}
if (l.qty && l.rate && round2(l.qty * l.rate) !== round2(l.amount_excl)) {
errs.push(`${where} line ${l.line_no} (${l.machine_code}) qty ${l.qty} x rate ${l.rate} = ${round2(l.qty * l.rate)}, but amount is ${l.amount_excl}`);
}
}
if (errs.length) {
// One broken invoice must not block the others — report it and move on.
invErrors.push(...errs);
console.error(` ✗ ${where}: NOT seeded —`);
for (const e of errs) console.error(` - ${e}`);
continue;
}
const periodEnd = new Date(inv.period_to);
let invoiceRec = null;
try {
invoiceRec = await pb.collection(INVOICES_COLL).create({
invoice_no: inv.invoice_no,
invoice_date: inv.invoice_date,
order_no: inv.order_no,
debtor: inv.debtor,
debtor_no: inv.debtor_no,
period_from: inv.period_from,
period_to: inv.period_to,
description: inv.description,
site: inv.site || "",
section: inv.section,
category: inv.category,
currency: inv.currency,
vat_reg_no: inv.vat_reg_no,
source_file: inv.source_file,
subtotal: inv.subtotal,
escalation: inv.escalation,
vat: inv.vat,
vat_pct: inv.vat_pct,
total_excl: inv.total_excl,
total_incl: inv.total_incl,
booked_month: periodEnd.getUTCMonth() + 1,
booked_year: periodEnd.getUTCFullYear(),
is_booked: false,
});
} catch (e) {
invErrors.push(`${where}: header failed — ${e.response?.data?.message || e.message}`);
continue;
}
invoicesSeeded++;
let okLines = 0;
for (const l of inv.lines) {
// An unmatched line is seeded with booked_asset EMPTY rather than guessed, so it
// shows on the page as "unbooked" instead of silently charging the wrong machine.
let assetId = l.asset_code ? codeToId[l.asset_code] : null;
if (!assetId) {
const why = l.asset_code
? `no asset matched code "${l.asset_code}"`
: "no target asset assigned";
invErrors.push(`UNBOOKED: ${where} line ${l.line_no} "${l.machine_code}" (${l.description}) — ${why}. ${l._unmatched || ""}`);
}
try {
await pb.collection(INVOICE_LINES_COLL).create({
invoice: invoiceRec.id,
line_no: l.line_no,
machine_code: l.machine_code,
asset_code: l.asset_code || "",
description: l.description,
qty: l.qty,
unit: l.unit,
rate: l.rate,
rate_type: l.rate_type,
amount_excl: l.amount_excl,
vat: l.vat,
total_incl: l.total_incl,
booked_asset: assetId || "",
booked_month: invoiceRec.booked_month,
booked_year: invoiceRec.booked_year,
});
okLines++;
lineRows++;
} catch (e) {
invErrors.push(`${where} line ${l.line_no} ${l.machine_code}: ${e.response?.data?.message || e.message}`);
}
}
console.log(` ✓ ${inv.invoice_no}${inv.site ? ` (${inv.site})` : ""} — ${okLines}/${inv.lines.length} lines, total R${inv.total_incl.toFixed(2)} incl VAT`);
}
console.log("\n=== Summary ===");
console.log(`Assets: ${ok}/${assets.length}`);
console.log(`Cost lines: ${clRows}`);
console.log(`Income: ${incomeRows}`);
console.log(`Running: ${runRows}`);
console.log(`Overheads: ${ohRows}`);
console.log(`Invoices: ${invoicesSeeded}/${invoices.length} (+ ${lineRows} lines)`);
const totalErrors = seedErrors.length + clErrors.length + incomeErrors.length + runErrors.length + ohErrors.length;
const totalErrors = seedErrors.length + clErrors.length + incomeErrors.length + runErrors.length + ohErrors.length + invErrors.length;
if (totalErrors > 0) {
console.error(`\n✗ ${totalErrors} errors — seed FAILED loudly:`);
for (const e of [...seedErrors, ...clErrors, ...incomeErrors, ...runErrors, ...ohErrors]) console.error(` - ${e}`);
for (const e of [...seedErrors, ...clErrors, ...incomeErrors, ...runErrors, ...ohErrors, ...invErrors]) console.error(` - ${e}`);
if (invErrors.length) {
console.error("\n Fix the target asset in the matching data/source/invoice-*.json, or assign it");
console.error(" in /income/invoices (Booked to), then re-run: node scripts/book-invoice.mjs");
}
console.error("\n ⚠ monthly_income was wiped and re-imported from the workbook only.");
console.error(" Invoice-derived income is NOT in it — re-run `node scripts/book-invoice.mjs` after fixing the above.");
process.exit(2);
}
console.log("\n✅ Seed complete");
console.log(" Invoice lines are seeded but NOT yet in monthly_income.");
console.log(" Run `node scripts/book-invoice.mjs` to post them.");

View file

@ -0,0 +1,213 @@
// Create the `invoices` + `invoice_lines` PocketBase collections.
//
// Idempotent: safe to re-run. Creates what is missing and reports drift instead of
// silently leaving a half-shaped schema behind (the project fails loudly rather than
// inventing data — see AGENTS.md).
//
// Usage: node scripts/setup-invoice-collections.mjs
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
// Load .env.local the same way seed.mjs does — a script whose job is to shape the
// schema cannot fail on a missing URL and report success.
const envPath = resolve(ROOT, ".env.local");
if (!existsSync(envPath)) {
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
process.exit(1);
}
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
if (!EMAIL) missing.push("PB_ADMIN");
if (!PASS) missing.push("PB_PASS");
if (missing.length) {
console.error(`✗ Missing required env: ${missing.join(", ")}`);
process.exit(1);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
console.log(" ✓ authenticated");
// Mirror the rules the existing collections use so behaviour stays uniform.
const reference = await pb.collections.getOne("monthly_income");
const RULES = {
listRule: reference.listRule,
viewRule: reference.viewRule,
createRule: reference.createRule ?? null,
updateRule: reference.updateRule ?? null,
deleteRule: reference.deleteRule ?? null,
};
console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`);
const assetsCollection = await pb.collections.getOne("assets");
const assetsId = assetsCollection.id;
async function ensureCollection(name, fields) {
const existing = await pb.collections.getFullList({ filter: `name = "${name}"`, perPage: 1 });
if (existing.length) {
const have = new Set(existing[0].fields.map((f) => f.name));
const missing = fields.filter((f) => !have.has(f.name));
if (!missing.length) {
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
} else {
// Grow the existing schema rather than refusing: the collection is additive and
// re-seeding rewrites its rows anyway. Report exactly what was added.
await updateSchema(name, (cur) => [...cur, ...missing], `added to ${name}: ${missing.map((f) => f.name).join(", ")}`);
}
return existing[0].id;
}
// NOTE: pb.collections.create() serialises an array schema into repeated `fields=`
// params, which PocketBase rejects with "invalid formatting". The collections API
// wants `fields` as one real JSON array inside a JSON body — so post it directly
// with a pre-stringified body, which the SDK passes through untouched.
const created = await pb.send("/api/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
type: "base",
fields,
listRule: RULES.listRule ?? "",
viewRule: RULES.viewRule ?? "",
createRule: RULES.createRule ?? "",
updateRule: RULES.updateRule ?? "",
deleteRule: RULES.deleteRule ?? "",
}),
});
console.log(` ✓ created ${name} (${created.id}) with ${created.fields.length} fields`);
return created.id;
}
// Adds one field to an existing collection if it is not there yet. PocketBase has no
// partial field API — the schema is replaced wholesale — so the current field list is
// read back and re-sent with the new field appended.
async function updateSchema(collectionName, transform, label) {
const coll = await pb.collections.getOne(collectionName);
const next = transform([...coll.fields]);
if (JSON.stringify(next) === JSON.stringify(coll.fields)) {
console.log(` · ${collectionName} schema already current`);
return;
}
await pb.send(`/api/collections/${coll.name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: next }),
});
console.log(` ✓ ${label}`);
}
async function ensureField(collectionName, field) {
await updateSchema(
collectionName,
(fields) => (fields.some((f) => f.name === field.name) ? fields : [...fields, field]),
`added ${collectionName}.${field.name}`
);
}
// Drops fields that a previous version of the script created and that are no longer
// part of the model. Explicit list only — never prune anything else.
async function dropField(collectionName, ...names) {
await updateSchema(
collectionName,
(fields) => fields.filter((f) => !names.includes(f.name)),
`dropped ${collectionName}.${names.join(", ")}`
);
}
console.log("\n→ Ensuring collections");
const invoicesId = await ensureCollection("invoices", [
{ name: "invoice_no", type: "text", required: true },
{ name: "invoice_date", type: "date", required: true },
{ name: "order_no", type: "text" },
{ name: "debtor", type: "text" },
{ name: "debtor_no", type: "text" },
{ name: "period_from", type: "date" },
{ name: "period_to", type: "date" },
{ name: "description", type: "text" },
{ name: "site", type: "text" },
{ name: "section", type: "text" },
{ name: "category", type: "text" },
{ name: "currency", type: "text" },
{ name: "vat_reg_no", type: "text" },
{ name: "source_file", type: "text" },
{ name: "subtotal", type: "number" },
{ name: "escalation", type: "number" },
{ name: "vat", type: "number" },
{ name: "vat_pct", type: "number" },
{ name: "total_excl", type: "number" },
{ name: "total_incl", type: "number" },
// Original document, uploaded from the Invoices page (drag-and-drop).
{ name: "source_pdf", type: "file", maxSelect: 1, maxSize: 20971520 },
{ name: "booked_month", type: "number" },
{ name: "booked_year", type: "number" },
{ name: "is_booked", type: "bool" },
]);
const invoiceLinesId = await ensureCollection("invoice_lines", [
{ name: "invoice", type: "relation", required: true, collectionId: invoicesId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
{ name: "line_no", type: "number", required: true },
{ name: "machine_code", type: "text" },
{ name: "asset_code", type: "text" },
{ name: "description", type: "text" },
{ name: "qty", type: "number" },
{ name: "unit", type: "text" },
{ name: "rate", type: "number" },
{ name: "rate_type", type: "text" },
{ name: "amount_excl", type: "number" },
{ name: "vat", type: "number" },
{ name: "total_incl", type: "number" },
// The machine this line is actually booked against. Deliberately nullable: an
// invoice line whose target asset is unresolved must stay visibly unbooked
// rather than be silently charged to a guess.
{ name: "booked_asset", type: "relation", collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: false },
{ name: "booked_month", type: "number" },
{ name: "booked_year", type: "number" },
]);
// Provenance back-reference. `npm run seed` wipes monthly_income, so posting an
// invoice has to be re-runnable without double-counting. Marking each posted row with
// the invoice line it came from makes booking idempotent and auditable: a row with an
// empty `invoice_line` is workbook data, a row with one is invoice-derived.
await ensureField("monthly_income", {
name: "invoice_line",
type: "relation",
collectionId: invoiceLinesId,
minSelect: 1,
maxSelect: 1,
cascadeDelete: false,
});
// `escalation_pct` and `escalation_amount` came from a misreading of the source PDF:
// the R24,205.13 column is literally headed "PLUS VAT", and the Escalation column is
// R0.00 on every line. Superseded by vat_pct / vat.
await dropField("invoices", "escalation_pct");
await dropField("invoice_lines", "escalation_amount");
console.log("\n=== Summary ===");
console.log(`invoices ${invoicesId}`);
console.log(`invoice_lines -> invoices (cascade delete)`);
console.log(`invoice_lines -> assets (booked_asset)`);
if (process.exitCode) {
console.error("\n✗ Schema drift detected — see above.");
} else {
console.log("\n✅ Invoice collections ready");
}