From 5caa923375e1df45aa5c30d05b3734edd82d96d8 Mon Sep 17 00:00:00 2001
From: David
Date: Fri, 2 Oct 2026 14:30:17 +0200
Subject: [PATCH] Invoices: the app ingests PDFs itself, with a problem-invoice
inbox
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extraction was previously manual: an agent read each PDF, mapped the columns
by hand and wrote data/source/invoice-*.json. That was the only way anything
got in, and the upload button just filed the document without reading it.
That supplier's report is one fixed template — the columns sit at identical
x-positions on every invoice, and only the line count and page count vary —
so the figures are now read by coordinate instead. Deliberately not an AI
extractor: this input does not need one, and a deterministic parse that fails
loudly is worth more here than a flexible one that can hallucinate a total.
lib/invoice-parser.ts reads the header, the machine lines across all pages,
the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT /
INVOICE TOTAL block, then applies the same arithmetic gates the seed does:
lines must sum to the subtotal, each line must satisfy qty x rate = amount
and amount + VAT = total, and VAT must match the stated rate. Anything that
does not reconcile is refused with the reason — never partially imported.
Validated by parsing all five existing invoices and comparing every field
against the hand-extracted JSON: identical, including the R24,205.13-VAT
column that a plain text read gets wrong. PHI 169 was then deleted and
re-ingested through the HTTP endpoint alone and came back field-for-field
identical, with no agent involved.
- "Add Invoice" drops a PDF in and imports it; lines are matched to plant
items by the code the invoice prints, and an unmatched line (the
container codes MPLANT04/MPLANT05) is left unassigned rather than guessed.
- A "Problem invoices" inbox lists every PDF that failed, with the reason,
and Retry re-reads the stored document and re-runs the parse. Retry
re-reports rather than claiming success, and only files imported data.
- invoice_intake collection records each attempt.
pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy
worker import to a .next path absent from the standalone image and every
parse dies with "Setting up fake worker failed". The parser also copies the
buffer before handing it over, since pdfjs transfers and detaches it.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
app/api/invoices/intake/[id]/retry/route.ts | 71 ++++
app/api/invoices/intake/[id]/route.ts | 30 ++
app/api/invoices/intake/route.ts | 229 +++++++++++
app/income/invoices/page.tsx | 4 +
components/InvoiceIntake.tsx | 308 ++++++++++++++
lib/invoice-parser.test.ts | 129 ++++++
lib/invoice-parser.ts | 423 ++++++++++++++++++++
next.config.ts | 7 +
package-lock.json | 278 +++++++++++++
package.json | 1 +
scripts/setup-invoice-collections.mjs | 20 +
11 files changed, 1500 insertions(+)
create mode 100644 app/api/invoices/intake/[id]/retry/route.ts
create mode 100644 app/api/invoices/intake/[id]/route.ts
create mode 100644 app/api/invoices/intake/route.ts
create mode 100644 components/InvoiceIntake.tsx
create mode 100644 lib/invoice-parser.test.ts
create mode 100644 lib/invoice-parser.ts
diff --git a/app/api/invoices/intake/[id]/retry/route.ts b/app/api/invoices/intake/[id]/retry/route.ts
new file mode 100644
index 0000000..f2800d6
--- /dev/null
+++ b/app/api/invoices/intake/[id]/retry/route.ts
@@ -0,0 +1,71 @@
+import { revalidatePath } from "next/cache";
+import { serverAuth } from "@/lib/pb";
+
+export const dynamic = "force-dynamic";
+
+// POST /api/invoices/intake/:id/retry
+// Re-attempts the import for a problem invoice.
+//
+// Retry is only meaningful once the REASON is addressed, so this never silently
+// re-parses a broken PDF and calls it fixed: it re-reads the stored document and, if
+// the parse still fails, updates the recorded reason instead of pretending it worked.
+// Once it succeeds the inbox row is marked imported and linked to the new invoice.
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const pb = await serverAuth();
+
+ try {
+ const record = await pb.collection("invoice_intake").getOne(id);
+ if (!record.source_pdf) {
+ return Response.json({ ok: false, error: "The original document is no longer stored" }, { status: 400 });
+ }
+
+ // Pull the stored bytes back down. A bare /api/files/ 404s for a
+ // rule-locked collection even with an admin token — protected files resolve by
+ // the full /collectionId/recordId/filename path, which the SDK builds for us.
+ const token = record.source_pdf as string;
+ const url = pb.files.getURL(record, token);
+ const res = await fetch(url, {
+ headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
+ });
+ if (!res.ok) {
+ const reason = `Could not read the stored document back from storage (HTTP ${res.status})`;
+ await pb.collection("invoice_intake").update(id, { status: "failed", error: reason });
+ return Response.json({ ok: false, error: reason }, { status: 502 });
+ }
+
+ const file = new File([await res.arrayBuffer()], record.file_name || token, {
+ type: "application/pdf",
+ });
+ const fd = new FormData();
+ fd.append("file", file);
+
+ // Re-run the full intake against the same bytes.
+ const intake = await fetch(new URL("/api/invoices/intake", _req.url), {
+ method: "POST",
+ body: fd,
+ });
+ const result = await intake.json().catch(() => ({ ok: false, error: "Unexpected response" }));
+
+ if (result.ok) {
+ await pb.collection("invoice_intake").delete(id);
+ revalidatePath("/income/invoices");
+ revalidatePath("/income");
+ return Response.json({ ok: true, retried: true, ...result });
+ }
+
+ // Still failing — keep it in the inbox with the newest reason, and drop the
+ // duplicate record the retry just created so the inbox stays one row per file.
+ await pb.collection("invoice_intake").delete(id).catch(() => {});
+ revalidatePath("/income/invoices");
+ return Response.json(
+ { ok: false, retried: true, stillFailing: true, error: result.error ?? "Import failed again" },
+ { status: 422 },
+ );
+ } catch (e: any) {
+ return Response.json({ ok: false, error: e?.message || "Retry failed" }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/app/api/invoices/intake/[id]/route.ts b/app/api/invoices/intake/[id]/route.ts
new file mode 100644
index 0000000..2a8e276
--- /dev/null
+++ b/app/api/invoices/intake/[id]/route.ts
@@ -0,0 +1,30 @@
+import { revalidatePath } from "next/cache";
+import { serverAuth } from "@/lib/pb";
+
+export const dynamic = "force-dynamic";
+
+// DELETE /api/invoices/intake/:id
+// Drops a problem invoice from the inbox. Only reachable while it is still `failed` —
+// once a file has been imported it belongs to the invoices table and is removed through
+// the invoice itself, so this cannot be used to orphan a real invoice.
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const pb = await serverAuth();
+ try {
+ const rec = await pb.collection("invoice_intake").getOne(id);
+ if (rec.status === "imported") {
+ return Response.json(
+ { ok: false, error: "This file was already imported — delete the invoice instead" },
+ { status: 400 },
+ );
+ }
+ await pb.collection("invoice_intake").delete(id);
+ revalidatePath("/income/invoices");
+ return Response.json({ ok: true });
+ } catch (e: any) {
+ return Response.json({ ok: false, error: e?.message || "Could not remove it" }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts
new file mode 100644
index 0000000..7916545
--- /dev/null
+++ b/app/api/invoices/intake/route.ts
@@ -0,0 +1,229 @@
+import { revalidatePath } from "next/cache";
+import { serverAuth } from "@/lib/pb";
+import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
+
+export const dynamic = "force-dynamic";
+
+const MAX_BYTES = 20 * 1024 * 1024;
+
+// GET /api/invoices/intake
+// The problem-invoice inbox: every PDF offered to the app and what became of it.
+// `?status=failed` narrows it to the ones that need attention.
+export async function GET(req: Request) {
+ const url = new URL(req.url);
+ const status = url.searchParams.get("status");
+ try {
+ const pb = await serverAuth();
+ // PocketBase rejects an empty filter string, so only send one when filtering.
+ const opts: Record = { sort: "-created" };
+ if (status) opts.filter = `status = "${status}"`;
+ const records = await pb.collection("invoice_intake").getFullList(opts);
+ return Response.json({
+ ok: true,
+ items: records.map((r: any) => ({
+ id: r.id,
+ file_name: r.file_name,
+ file_size: r.file_size,
+ invoice_no: r.invoice_no,
+ status: r.status,
+ error: r.error,
+ parsed: r.parsed,
+ invoice: r.invoice,
+ created: r.created,
+ })),
+ });
+ } catch (e: any) {
+ return Response.json({ ok: false, error: e?.message || "Could not read the inbox" }, { status: 500 });
+ }
+}
+
+// POST /api/invoices/intake
+// Multipart: `file` (a PDF). The PDF is parsed deterministically, checked arithmetically,
+// and only then written. A PDF that does not reconcile is stored in the inbox as a
+// problem invoice with the reason attached — it is never partially imported.
+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");
+ 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 bytes = new Uint8Array(await file.arrayBuffer());
+ const pb = await serverAuth();
+
+ // Always record the attempt, so a bad PDF is never lost.
+ const recordFailure = async (reason: string, parsed?: unknown) => {
+ const form2 = new FormData();
+ form2.append("file_name", file.name);
+ form2.append("file_size", String(file.size));
+ form2.append("status", "failed");
+ form2.append("error", reason);
+ if (parsed) form2.append("parsed", JSON.stringify(parsed));
+ form2.append(
+ "source_pdf",
+ new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
+ );
+ const rec = await pb.collection("invoice_intake").create(form2);
+ revalidatePath("/income/invoices");
+ return Response.json({ ok: false, status: "failed", intakeId: rec.id, error: reason }, { status: 422 });
+ };
+
+ let parsed;
+ try {
+ parsed = await parseInvoicePdf(bytes);
+ } catch (e: any) {
+ if (e instanceof ParseError) return recordFailure(e.reason);
+ return recordFailure(`Could not read the PDF: ${e?.message ?? "unknown error"}`);
+ }
+
+ // Match each line to a plant item by the code the invoice prints. No match means the
+ // line is left unassigned for manual booking — never guessed.
+ const assets = await pb.collection("assets").getFullList();
+ const byCode = new Map();
+ for (const a of assets) if (a.code) byCode.set(a.code, a.id);
+
+ const allInvoices = await pb.collection("invoices").getFullList();
+ if (allInvoices.some((i: any) => i.invoice_no === parsed.invoice_no)) {
+ return recordFailure(
+ `Invoice ${parsed.invoice_no} has already been imported. Delete it first if you want to re-ingest this file.`,
+ parsed,
+ );
+ }
+
+ const periodEnd = new Date(parsed.period_to);
+ const bookedMonth = periodEnd.getUTCMonth() + 1;
+ const bookedYear = periodEnd.getUTCFullYear();
+
+ let invoiceRec;
+ try {
+ invoiceRec = await pb.collection("invoices").create({
+ invoice_no: parsed.invoice_no,
+ invoice_date: parsed.invoice_date,
+ order_no: parsed.order_no,
+ debtor: parsed.debtor,
+ debtor_no: parsed.debtor_no,
+ period_from: parsed.period_from,
+ period_to: parsed.period_to,
+ description: parsed.description,
+ site: parsed.site,
+ section: parsed.section,
+ category: parsed.category,
+ currency: parsed.currency,
+ vat_reg_no: parsed.vat_reg_no,
+ source_file: file.name,
+ subtotal: parsed.subtotal,
+ escalation: parsed.escalation,
+ vat: parsed.vat,
+ vat_pct: parsed.vat_pct,
+ total_excl: parsed.total_excl,
+ total_incl: parsed.total_incl,
+ booked_month: bookedMonth,
+ booked_year: bookedYear,
+ is_booked: false,
+ });
+ } catch (e: any) {
+ return recordFailure(
+ `Parsed correctly but could not save the invoice: ${e?.response?.data?.message || e?.message}`,
+ parsed,
+ );
+ }
+
+ const unassigned: string[] = [];
+ for (let i = 0; i < parsed.lines.length; i++) {
+ const l = parsed.lines[i];
+ const registerCode = registerCodeFrom(l.machine_code);
+ const assetId = byCode.get(registerCode) || "";
+ if (!assetId) unassigned.push(l.machine_code);
+ try {
+ await pb.collection("invoice_lines").create({
+ invoice: invoiceRec.id,
+ line_no: i + 1,
+ machine_code: l.machine_code,
+ asset_code: registerCode,
+ 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: bookedMonth,
+ booked_year: bookedYear,
+ });
+ } catch (e: any) {
+ // A half-written invoice is worse than none — roll it back and report.
+ const written = await pb
+ .collection("invoice_lines")
+ .getFullList({ filter: `invoice = "${invoiceRec.id}"` });
+ for (const w of written) await pb.collection("invoice_lines").delete(w.id);
+ await pb.collection("invoices").delete(invoiceRec.id);
+ return recordFailure(
+ `Parsed correctly but a line could not be saved, so the import was rolled back: ${e?.response?.data?.message || e?.message}`,
+ parsed,
+ );
+ }
+ }
+
+ // Book whatever could be matched. Anything unassigned stays visibly unbooked.
+ const lines = await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${invoiceRec.id}"` });
+ for (const line of lines) {
+ if (!line.booked_asset) continue;
+ await pb.collection("monthly_income").create({
+ asset: line.booked_asset,
+ year: bookedYear,
+ month: bookedMonth,
+ income: line.total_incl,
+ fixed_expenses: 0,
+ is_forecast: false,
+ invoice_line: line.id,
+ });
+ }
+ const allBooked = lines.every((l: any) => l.booked_asset);
+ await pb.collection("invoices").update(invoiceRec.id, { is_booked: allBooked });
+
+ const form2 = new FormData();
+ form2.append("file_name", file.name);
+ form2.append("file_size", String(file.size));
+ form2.append("invoice_no", parsed.invoice_no);
+ form2.append("status", "imported");
+ form2.append("invoice", invoiceRec.id);
+ form2.append("parsed", JSON.stringify(parsed));
+ form2.append(
+ "source_pdf",
+ new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
+ );
+ await pb.collection("invoice_intake").create(form2);
+
+ revalidatePath("/income/invoices");
+ revalidatePath("/income");
+
+ return Response.json({
+ ok: true,
+ status: "imported",
+ invoice_no: parsed.invoice_no,
+ invoiceId: invoiceRec.id,
+ site: parsed.site,
+ total_incl: parsed.total_incl,
+ lines: lines.length,
+ booked: lines.filter((l: any) => l.booked_asset).length,
+ unassigned,
+ fullyBooked: allBooked,
+ });
+}
\ No newline at end of file
diff --git a/app/income/invoices/page.tsx b/app/income/invoices/page.tsx
index bfa2ecd..41d96f8 100644
--- a/app/income/invoices/page.tsx
+++ b/app/income/invoices/page.tsx
@@ -4,6 +4,7 @@ import {
} from "@/lib/data";
import { FileText } from "lucide-react";
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
+import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
export const dynamic = "force-dynamic";
@@ -40,8 +41,11 @@ export default async function InvoicesPage() {
One row per invoice · click to open the line items and book each to a plant item
+
+
+
{invoices.length === 0 ? (
) : (
diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx
new file mode 100644
index 0000000..bec922a
--- /dev/null
+++ b/components/InvoiceIntake.tsx
@@ -0,0 +1,308 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import {
+ FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X,
+} from "lucide-react";
+import { ZAR2 } from "@/lib/utils";
+
+// "Add Invoice" — drop a PDF and the app parses it itself. No agent, no AI: the
+// figures are read by coordinate from a fixed report template and checked
+// arithmetically before anything is written.
+export function AddInvoiceButton() {
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const [result, setResult] = useState(null);
+ const inputRef = useRef(null);
+ const router = useRouter();
+
+ const send = useCallback(
+ async (file: File) => {
+ setBusy(true);
+ setResult(null);
+ const fd = new FormData();
+ fd.append("file", file);
+ try {
+ const res = await fetch("/api/invoices/intake", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
+ if (data.ok) {
+ const unassigned = (data.unassigned ?? []) as string[];
+ setResult({
+ ok: true,
+ title: `${data.invoice_no} imported`,
+ body:
+ `${data.lines} line${data.lines > 1 ? "s" : ""} · ${ZAR2(data.total_incl)} · ` +
+ `${data.booked} booked` +
+ (unassigned.length
+ ? `. Needs a plant item: ${unassigned.join(", ")}`
+ : data.fullyBooked
+ ? ". All lines matched a plant item."
+ : "."),
+ });
+ } else {
+ setResult({
+ ok: false,
+ title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
+ body: data.error ?? "Unknown error",
+ });
+ }
+ router.refresh();
+ } catch {
+ setResult({ ok: false, title: "Could not reach the server", body: "Check the app is running." });
+ } finally {
+ setBusy(false);
+ }
+ },
+ [router],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open]);
+
+ if (!open) {
+ return (
+
+ );
+ }
+
+ return (
+