From ad4ac9173b92ce63c31a8c9462bac990381af94e Mon Sep 17 00:00:00 2001
From: David
Date: Tue, 6 Oct 2026 11:25:10 +0200
Subject: [PATCH] Invoices: one line per charge, and fold an abbreviated site
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PHI 179 was refused: "Machine lines total R5265 but the invoice subtotal is
R13730". Three separate faults, each of which only this invoice exposed.
1. The parser read ONE line per machine. A machine hired over more than one
period is printed as one heading followed by a repeating block per charge,
and PHI 179 is a single CT01 crane truck billed on 4, 16 and 18 Sep at
R5 265 + R3 200 + R5 265. The parser only ever looked at the first block,
so two thirds of the invoice went missing and the arithmetic check — which
did its job — rejected it. extractLines() now walks y-banded rows and emits
one line per money row, all carrying the machine's code. A machine billed
once still yields exactly one line, which is what PHI 168/169 do.
The banding matters: pdfjs splits one printed row across two y positions
(the TOTAL sits ~2pt above the rest), so the fixed +-12pt window the old
code used was never a row, it was a coincidence.
2. siteFromDescription required the description to START with "Hire Plant" or
"Plant Hire". PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" — it leads
with the machine type — so the regex returned "" and the invoice linked to
no site at all. The month/year pair is the part every description shares,
so that is the anchor now.
3. Even read correctly the site is "Cosmo", where every other invoice says
"Cosmo City". Sites are DERIVED from the invoice wording, so saving the
abbreviation would create a second "Cosmo" and split this customer across
two rows on /sites. resolveSiteName() folds a shorter whole-word prefix
onto a site already in the register, one direction only, so a genuinely new
site is still recorded as new.
Also: a problem invoice stored its PDF with no way to open it. The inbox
reported the failure but not the document behind it, and /api/invoices/:id/
document only serves imported invoices — the one case you most want to inspect.
New GET /api/invoices/intake/:id/document, plus a PDF link on each inbox row.
Verified: PHI 179 reconciles in full (R13 730 + R2 059.50 VAT = R15 789.50,
three lines, each balancing). PHI 168 and PHI 169 still reproduce their hand
extraction field for field, including line count. 40 tests, typecheck clean.
---
.../invoices/intake/[id]/document/route.ts | 62 +++++++
app/api/invoices/intake/route.ts | 11 +-
components/InvoiceIntake.tsx | 10 ++
lib/invoice-parser.test.ts | 127 ++++++++++++-
lib/invoice-parser.ts | 167 +++++++++++++-----
lib/sites.ts | 31 +++-
6 files changed, 362 insertions(+), 46 deletions(-)
create mode 100644 app/api/invoices/intake/[id]/document/route.ts
diff --git a/app/api/invoices/intake/[id]/document/route.ts b/app/api/invoices/intake/[id]/document/route.ts
new file mode 100644
index 0000000..82d9a95
--- /dev/null
+++ b/app/api/invoices/intake/[id]/document/route.ts
@@ -0,0 +1,62 @@
+import { serverAuth } from "@/lib/pb";
+
+export const dynamic = "force-dynamic";
+
+// GET /api/invoices/intake/:id/document
+// Streams the PDF a FAILED intake kept.
+//
+// A problem invoice is exactly the one you most need to look at — it was refused
+// because something about it did not reconcile — and before this route its PDF was
+// stored on `invoice_intake.source_pdf` with no way to open it. /api/invoices/:id/
+// document only serves the `invoices` collection, which a failed import never reaches,
+// so the inbox could report a problem and still not show the document behind it.
+//
+// Same reasoning as that route: do not link at PocketBase directly. The file is
+// protected, so a bare `/api/files/` 404s in the browser. Fetch it
+// server-side with the admin credentials and proxy.
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ try {
+ const pb = await serverAuth();
+ const intake = await pb.collection("invoice_intake").getOne(id);
+
+ const token = intake.source_pdf;
+ if (!token) {
+ return Response.json({ ok: false, error: "No document attached" }, { status: 404 });
+ }
+
+ // PocketBase resolves protected files by the full collectionId/recordId/filename
+ // path, so let the SDK build the URL from THIS record rather than hand-writing one.
+ const upstream = pb.files.getURL(intake, 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 },
+ );
+ }
+
+ const name = String(intake.file_name || "invoice.pdf").replace(/["\\]/g, "_");
+ return new Response(res.body, {
+ status: 200,
+ headers: {
+ "Content-Type": res.headers.get("Content-Type") ?? "application/pdf",
+ "Content-Disposition": `inline; filename="${name}"`,
+ "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 },
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts
index b7a0a3d..adcd068 100644
--- a/app/api/invoices/intake/route.ts
+++ b/app/api/invoices/intake/route.ts
@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
-import { syncSitesFromInvoices, findSiteContainer } from "@/lib/sites";
+import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites";
export const dynamic = "force-dynamic";
@@ -124,6 +124,15 @@ export async function POST(req: Request) {
);
}
+ // The supplier abbreviates the site on the invoice ("…Sep 2026 Cosmo" for the site the
+ // register calls "Cosmo City"). Resolve it BEFORE writing, because sites are derived
+ // from the invoice wording — saving the abbreviation would create a second "Cosmo" and
+ // split this customer across two sites on /sites.
+ parsed.site = resolveSiteName(
+ parsed.site,
+ [...new Set(allInvoices.map((i: any) => i.site).filter((s: unknown): s is string => typeof s === "string" && !!s.trim()))],
+ );
+
const periodEnd = new Date(parsed.period_to);
const bookedMonth = periodEnd.getUTCMonth() + 1;
const bookedYear = periodEnd.getUTCFullYear();
diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx
index 657a776..57c8b64 100644
--- a/components/InvoiceIntake.tsx
+++ b/components/InvoiceIntake.tsx
@@ -402,6 +402,16 @@ export function ProblemInbox() {