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 ( +
+
+

Add invoice

+ +
+ +
!busy && inputRef.current?.click()} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && !busy) { + e.preventDefault(); + inputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files?.[0]; + if (f) send(f); + }} + className={`flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-4 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]" + } ${busy ? "opacity-60 pointer-events-none" : ""}`} + > + {busy ? ( + + ) : ( + + )} +
+
+ {busy ? "Reading the invoice…" : "Drop the invoice PDF here, or tap to browse"} +
+
+ PDF only · up to 20 MB · figures are read from the invoice and checked before saving +
+
+
+ + { + const f = e.target.files?.[0]; + if (f) send(f); + e.target.value = ""; + }} + /> + + {result && ( +
+ {result.ok ? ( + + ) : ( + + )} +
+
{result.title}
+
{result.body}
+
+
+ )} +
+ ); +} + +type IntakeItem = { + id: string; + file_name: string; + file_size: number; + invoice_no: string; + status: "imported" | "failed"; + error: string; + created: string; +}; + +// The problem-invoice inbox. Anything that could not be parsed or imported lands here +// with the reason, so it can be inspected and retried instead of disappearing. +export function ProblemInbox() { + const [items, setItems] = useState(null); + const [retrying, setRetrying] = useState(null); + const [msg, setMsg] = useState(null); + const router = useRouter(); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/invoices/intake?status=failed", { cache: "no-store" }); + const data = await res.json().catch(() => ({ ok: false, items: [] })); + setItems(data.ok ? data.items : []); + } catch { + setItems([]); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const retry = async (id: string) => { + setRetrying(id); + setMsg(null); + try { + const res = await fetch(`/api/invoices/intake/${id}/retry`, { method: "POST" }); + const data = await res.json().catch(() => ({ ok: false })); + setMsg( + data.ok + ? `${data.invoice_no} imported on retry.` + : `Still failing: ${data.error ?? "unknown reason"}`, + ); + await load(); + router.refresh(); + } catch { + setMsg("Could not reach the server"); + } finally { + setRetrying(null); + } + }; + + const discard = async (id: string) => { + setRetrying(id); + try { + await fetch(`/api/invoices/intake/${id}`, { method: "DELETE" }); + await load(); + } catch { + setMsg("Could not reach the server"); + } finally { + setRetrying(null); + } + }; + + return ( +
+
+ +

Problem invoices

+ + {items?.length ?? 0} + +
+ + {msg && ( +
{msg}
+ )} + + {items === null ? ( +

Checking…

+ ) : items.length === 0 ? ( +

+ Nothing in the inbox — every PDF offered so far imported cleanly. +

+ ) : ( +
    + {items.map((it) => ( +
  • +
    +
    +
    + + {it.file_name} + + {it.invoice_no && ( + + reads as {it.invoice_no} + + )} +
    +

    {it.error}

    +

    + {(it.file_size / 1024).toFixed(0)} KB + {it.created ? ` · ${new Date(it.created).toLocaleString("en-ZA")}` : ""} +

    +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/lib/invoice-parser.test.ts b/lib/invoice-parser.test.ts new file mode 100644 index 0000000..546710b --- /dev/null +++ b/lib/invoice-parser.test.ts @@ -0,0 +1,129 @@ +// Deterministic-parser tests. +// +// The parser reads figures by coordinate from a fixed report template, so the risk is +// silent drift: a column moves and it starts returning a plausible wrong number. These +// tests pin the behaviour that matters — the column mapping, the code/description split, +// date formats, and the fact that anything which does not reconcile is refused. +// +// Run: node --import tsx --test lib/invoice-parser.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseInvoicePdf, registerCodeFrom, siteFromDescription, isoDate, ParseError } from "./invoice-parser"; + +// --- unit level: the pieces that do not need a PDF ------------------------------- + +test("registerCodeFrom strips punctuation and suffix from an invoice code", () => { + assert.equal(registerCodeFrom("SS-01-CASE"), "SS01"); + assert.equal(registerCodeFrom("TP601-TATA"), "TP601"); + assert.equal(registerCodeFrom("MG-01-DEZI"), "MG01"); + assert.equal(registerCodeFrom("RV-03-CAT"), "RV03"); + assert.equal(registerCodeFrom("TLB07-CASE"), "TLB07"); + // No register equivalent — must NOT be silently coerced into something plausible. + assert.equal(registerCodeFrom("MPLANT05"), "MPLANT05"); +}); + +test("site is read from the invoice description", () => { + assert.equal(siteFromDescription("Plant Hire Sep 2026 West Park"), "West Park"); + assert.equal(siteFromDescription("Hire Plant Sep 2026 Halfway Gardens"), "Halfway Gardens"); + assert.equal(siteFromDescription("Hire Plant Sep 2026 Rooihuiskraal"), "Rooihuiskraal"); + assert.equal(siteFromDescription("Plant Hire SEp 2026 Cosmo City"), "Cosmo City"); + assert.equal(siteFromDescription("something else entirely"), ""); +}); + +test("dates accept the report's written formats", () => { + assert.equal(isoDate("18-September-2026"), "2026-09-18"); + assert.equal(isoDate("21/08/2026"), "2026-08-21"); + assert.throws(() => isoDate("not a date"), ParseError); +}); + +test("a PDF with no recognisable content is refused, not guessed", async () => { + const notAPdf = new TextEncoder().encode("%PDF-1.4\nthis is not a real invoice\n"); + await assert.rejects(() => parseInvoicePdf(notAPdf), /PDF|Could not/i); +}); + +// --- integration level: the real invoices ----------------------------------------- +// +// The source PDFs are not committed (they are client documents), so these skip when +// they are absent. Point FIXTURE_DIR at a folder holding PHI*.pdf to run them. + +const DIR = process.env.FIXTURE_DIR ?? "/tmp"; +const REAL: [string, string][] = [ + ["PHI166.pdf", "invoice-phi166.json"], + ["PHI167.pdf", "invoice-phi167.json"], + ["PHI168.pdf", "invoice-phi168.json"], + ["PHI169.pdf", "invoice-phi169.json"], + ["PHI171.pdf", "invoice-phi171.json"], +]; + +const available = REAL.filter(([pdf]) => existsSync(resolve(DIR, pdf))); + +test("parser reproduces the hand-extracted invoices exactly", { skip: available.length ? false : "no source PDFs present" }, async () => { + const failures: string[] = []; + for (const [pdfName, jsonName] of available) { + const src = JSON.parse(readFileSync(resolve("data/source", jsonName), "utf8")); + const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName)))); + + const cmp = (label: string, a: unknown, b: unknown) => { + const ok = typeof a === "number" && typeof b === "number" + ? Math.abs(a - b) < 0.01 + : String(a ?? "") === String(b ?? ""); + if (!ok) failures.push(`${jsonName} ${label}: parser=${a} source=${b}`); + }; + + cmp("invoice_no", got.invoice_no, src.invoice_no); + cmp("invoice_date", got.invoice_date, src.invoice_date); + cmp("order_no", got.order_no, src.order_no); + cmp("debtor", got.debtor, src.debtor); + cmp("debtor_no", got.debtor_no, src.debtor_no); + cmp("period_from", got.period_from, src.period_from); + cmp("period_to", got.period_to, src.period_to); + cmp("description", got.description, src.description); + cmp("site", got.site, src.site); + cmp("currency", got.currency, src.currency); + cmp("vat_pct", got.vat_pct, src.vat_pct); + cmp("subtotal", got.subtotal, src.subtotal); + cmp("escalation", got.escalation, src.escalation); + cmp("vat", got.vat, src.vat); + cmp("total_excl", got.total_excl, src.total_excl); + cmp("total_incl", got.total_incl, src.total_incl); + + if (got.lines.length !== src.lines.length) { + failures.push(`${jsonName} line count: ${got.lines.length} vs ${src.lines.length}`); + continue; + } + got.lines.forEach((l, i) => { + const s = src.lines[i]; + cmp(`line${i + 1}.amount_excl`, l.amount_excl, s.amount_excl); + cmp(`line${i + 1}.vat`, l.vat, s.vat); + cmp(`line${i + 1}.total_incl`, l.total_incl, s.total_incl); + cmp(`line${i + 1}.qty`, l.qty, s.qty); + cmp(`line${i + 1}.unit`, l.unit, s.unit); + cmp(`line${i + 1}.rate`, l.rate, s.rate); + // machine_code is compared through registerCodeFrom: one input ("RV-01-CATCB22") + // has no separator between code and description, so the parser's split differs + // cosmetically from the hand extraction. What must match is the plant item the + // line books to, which is derived from exactly this value. + cmp(`line${i + 1}.register code`, registerCodeFrom(l.machine_code), registerCodeFrom(s.machine_code)); + }); + } + assert.deepEqual(failures, [], `parser disagreed with the hand extraction:\n${failures.join("\n")}`); +}); + +test("the parser never returns an invoice whose totals do not reconcile", { skip: available.length ? false : "no source PDFs present" }, async () => { + // Every successful parse must satisfy the balance rules on its own — this is the + // guarantee the intake route relies on before it is allowed to write anything. + for (const [pdfName] of available) { + const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName)))); + const sumHire = Math.round(got.lines.reduce((s, l) => s + l.amount_excl, 0) * 100) / 100; + const sumVat = Math.round(got.lines.reduce((s, l) => s + l.vat, 0) * 100) / 100; + assert.ok(Math.abs(sumHire - got.subtotal) < 0.01, `${pdfName}: lines != subtotal`); + assert.ok(Math.abs(sumVat - got.vat) < 0.02, `${pdfName}: line VAT != header VAT`); + assert.ok(Math.abs(got.total_excl + got.vat - got.total_incl) < 0.01, `${pdfName}: totals do not balance`); + for (const l of got.lines) { + assert.ok(Math.abs(l.amount_excl + l.vat - l.total_incl) < 0.01, `${pdfName}: ${l.machine_code} does not balance`); + assert.ok(Math.abs(l.qty * l.rate - l.amount_excl) < 0.01, `${pdfName}: ${l.machine_code} qty x rate != amount`); + } + } +}); \ No newline at end of file diff --git a/lib/invoice-parser.ts b/lib/invoice-parser.ts new file mode 100644 index 0000000..1a8319a --- /dev/null +++ b/lib/invoice-parser.ts @@ -0,0 +1,423 @@ +// Deterministic parser for Mega Plant "Std_ReqHireInvoice_DN.rpt" VAT invoices. +// +// Deliberately NOT an AI extractor. That supplier's report is a fixed template: the +// columns sit at identical x-positions on every invoice and only the number of +// machine lines and pages varies. So the figures are read by coordinate, exactly the +// way they were transcribed by hand, and then checked arithmetically. +// +// Anything that does not reconcile is reported as a reason and lands in the intake +// inbox as a problem invoice. It never guesses a total and never writes a partial +// invoice. +// +// Calibrated against PHI 166 / 167 / 168 / 169 / 171. + +export type ParsedLine = { + machine_code: string; + description: string; + qty: number; + unit: string; + rate: number; + rate_type: string; + amount_excl: number; + vat: number; + total_incl: number; +}; + +export type ParsedInvoice = { + invoice_no: string; + invoice_date: string; + order_no: string; + debtor: string; + debtor_no: string; + period_from: string; + period_to: string; + description: string; + site: string; + category: string; + section: string; + currency: string; + vat_reg_no: string; + vat_pct: number; + subtotal: number; + escalation: number; + vat: number; + total_excl: number; + total_incl: number; + lines: ParsedLine[]; +}; + +export class ParseError extends Error { + constructor(public reason: string) { + super(reason); + this.name = "ParseError"; + } +} + +// Column left-edges, calibrated from the report template. Numbers are right-aligned +// within their column, so each value's LEFT edge lands in a stable band. +const COL = { + HIRE: [340, 400], + PENALTY2: [400, 440], + ESCALATION: [440, 490], + VAT: [490, 534], + TOTAL: [534, 1e9], +} as const; + +// Activity sub-row ("Dry Rate 0001"). +const ACT = { + QTY: [220, 262], + UNIT: [262, 300], + RATE: [300, 350], + AMOUNT: [350, 392], +} as const; + +type Word = { x: number; y: number; str: string }; + +const inBand = (x: number, [lo, hi]: readonly [number, number]) => x >= lo && x < hi; + +// "1,234.50" / "1.234,50" / "1 234,50" / "-" / "" +function num(s: string | undefined): number | null { + if (!s) return null; + const t = s.replace(/\s/g, "").replace(/,/g, "").replace(/ /g, ""); + if (!t || t === "-" || t === "–") return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; +} + +const r2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Splits a machine row into its code and description. + * + * The code is a leading alpha block plus a 1-3 digit block, optionally followed by a + * dash and an upper-case token that must end at whitespace. That rule resolves every + * recurring case correctly, including the two that look alike but aren't: + * "MPLANT05-Storage Container" -> MPLANT05 / Storage Container (suffix ends in lowercase) + * "RV-01-CATCB22 2.2 TON ..." -> RV-01 / CATCB22 2.2 TON ... (suffix runs into a digit) + * The second yields a shorter code, which is cosmetic — no amount depends on it, and + * that code does not repeat across invoices, so duplicate detection is unaffected. + */ +function splitMachineRow(text: string): { code: string; description: string } { + const trimmed = text.trim(); + const core = /^([A-Za-z]{2,8}-?[0-9]{2,3})/.exec(trimmed); + if (!core) { + const token = trimmed.split(/\s+/)[0] ?? trimmed; + return { code: token, description: "" }; + } + const base = core[1]; + let rest = trimmed.slice(base.length); + // An optional -SUFFIX belongs to the code only when it ends at whitespace. That is + // what separates the two look-alike cases: + // "MPLANT05-Storage Container" -> MPLANT05 / Storage Container (suffix ends lowercase) + // "MG-01-DEZI MG80 ..." -> MG-01-DEZI / MG80 ... (suffix ends at a space) + // The suffix is always UPPER CASE (codes are) and must be bounded, which is what + // separates a real code suffix from the start of a description word. Two bounds occur + // in this report: + // 1. the suffix ends at whitespace "MG-01-DEZI MG80 Motor Grader" + // 2. the suffix runs into a digit group that ends at whitespace + // "RV-01-CATCB22 2.2 TON Roller" + // Known limit: "RV-01-CATCB22 2.2 TON" has no separator between the code and the + // description ("CB22" is the machine model), so the split point is a judgement call. + // The parser takes the maximal upper-case run, giving RV-01-CATCB. This is a display + // label only — no amount depends on it, and registerCodeFrom() still resolves it to + // RV01, so booking is unaffected. The hand extraction records RV-01-CAT. + // Case matters: matching [A-Za-z] here would swallow the "Office" of + // "MPLANT04-Office Container" and lose the code. + const sfx = + /^-([A-Z0-9]{1,6})(?=\s|$)/.exec(rest) ?? + /^-([A-Z]{2,5})(?=[A-Z]*[0-9]+[\s,])/.exec(rest); + let code = base; + if (sfx) { + code = `${base}-${sfx[1]}`; + rest = rest.slice(sfx[0].length); + } + return { code, description: rest.trim().replace(/^[-–]\s*/, "") }; +} + +// The code an invoice uses maps to a register code by taking the leading letters+digits +// and dropping punctuation: "SS-01-CASE" -> "SS01", "TP601-TATA" -> "TP601". +export function registerCodeFrom(invoiceCode: string): string { + const m = /^([A-Za-z]+)[- ]?([0-9]{1,3})/.exec(invoiceCode.trim()); + return m ? `${m[1].toUpperCase()}${m[2]}` : ""; +} + +// "Hire Plant Sep 2026 Rooihuiskraal" / "Plant Hire SEP 2026 West Park" -> the site. +export function siteFromDescription(desc: string): string { + const m = /^(?:hire\s+plant|plant\s+hire)\s+\S+\s+\d{4}\s+(.+)$/i.exec(desc.trim()); + return m ? m[1].trim() : ""; +} + +const MONTHS: Record = { + jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", + jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", +}; + +/** "18-September-2026" / "18/09/2026" -> "2026-09-18" */ +export function isoDate(s: string): string { + const t = s.trim(); + let m = /^(\d{1,2})[/-]([A-Za-z]{3,})[/-](\d{4})$/.exec(t); + if (m) { + const mo = MONTHS[m[2].slice(0, 3).toLowerCase()]; + if (!mo) throw new ParseError(`Unrecognised month "${m[2]}" in date "${s}"`); + return `${m[3]}-${mo}-${m[1].padStart(2, "0")}`; + } + m = /^(\d{1,2})\/(\d{2})\/(\d{4})$/.exec(t); + if (m) return `${m[3]}-${m[2]}-${m[1].padStart(2, "0")}`; + m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t); + if (m) return t; + throw new ParseError(`Unrecognised date format "${s}"`); +} + +// pdfjs resolves its worker lazily at runtime. Left to its own devices under Next it +// falls back to a dynamic import of the worker module, which the bundler rewrites to a +// .next path that does not exist in the standalone image — every parse then dies with +// "Setting up fake worker failed". Pinning the worker to the real installed file avoids +// that. pdfjs-dist is listed in serverExternalPackages so it is required (and traced) +// from node_modules rather than bundled. +let workerPinned = false; +async function loadPdfjs() { + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + if (!workerPinned) { + workerPinned = true; + try { + const { createRequire } = await import("node:module"); + const req = createRequire(import.meta.url); + pdfjs.GlobalWorkerOptions.workerSrc = req.resolve( + "pdfjs-dist/legacy/build/pdf.worker.mjs", + ); + } catch { + // Leave pdfjs's own default in place; it is correct for unbundled Node. + } + } + return pdfjs; +} + +export async function parseInvoicePdf(bytes: Uint8Array): Promise { + const pdfjs = await loadPdfjs(); + // pdfjs TRANSFERS the buffer it is handed, which detaches it. Callers keep their own + // copy (to store the original file), so parse a duplicate rather than the original. + const doc = await pdfjs.getDocument({ + data: new Uint8Array(bytes), + useSystemFonts: true, + }).promise; + + const pages: Word[][] = []; + try { + for (let p = 1; p <= doc.numPages; p++) { + const page = await doc.getPage(p); + const vp = page.getViewport({ scale: 1 }); + const content = await page.getTextContent(); + const words: Word[] = []; + for (const it of content.items as any[]) { + if (typeof it.str !== "string" || !it.str.trim()) continue; + words.push({ x: it.transform[4], y: vp.height - it.transform[5], str: it.str }); + } + pages.push(words); + } + + // ---- header (page 1) ------------------------------------------------- + const h1 = pages[0] ?? []; + const onRow = (words: Word[], label: string) => words.filter((w) => w.str.startsWith(label)); + + const after = (label: string, page = 0): string => { + const ws = onRow(pages[page] ?? [], label); + if (!ws.length) return ""; + const y = ws[0].y; + const same = (pages[page] ?? []).filter((w) => Math.abs(w.y - y) < 3 && w.x > ws[0].x + ws[0].str.length); + return same.map((w) => w.str).join(" ").trim(); + }; + + const invoiceNo = after("Invoice") || ""; + if (!invoiceNo) throw new ParseError('Could not find "Invoice No." in the header'); + const orderNo = after("ORDER NUMBER") || ""; + const invoiceDateRaw = after("DATE") || ""; + if (!invoiceDateRaw) throw new ParseError('Could not find "DATE" in the header'); + const debtorNo = after("DEBTOR NUMBER") || ""; + const currency = after("CURRENCY") || "ZAR"; + const vatReg = after("VAT Reg. No.") || ""; + + // Debtor name sits at the far left of the block above ORDER NUMBER. + const debtorWord = h1.find((w) => w.x < 100 && /^Mega |Infrastructure/i.test(w.str) && w.y > 180 && w.y < 220); + const debtor = debtorWord ? debtorWord.str.replace(/^Mega\s+/i, "Mega ") : ""; + + // "Description :" and the free text beside it. + const descRow = h1.filter((w) => Math.abs(w.y - (h1.find((x) => x.str === "Description")?.y ?? -999)) < 3); + const descWord = descRow.find((w) => w.str === "Description"); + const description = descWord + ? descRow + .filter( + (w) => + w.x > descWord.x + 12 && + w.x < 460 && + !w.str.endsWith(":") && + !/^\d{1,2}\/\d{2}\/\d{4}$/.test(w.str) && + !/Date$/.test(w.str), + ) + .sort((a, b) => a.x - b.x) + .map((w) => w.str) + .join(" ") + .trim() + : ""; + + const dates = h1 + .filter((w) => /^\d{1,2}\/\d{2}\/\d{4}$/.test(w.str)) + .sort((a, b) => a.y - b.y || a.x - b.x) + .map((w) => w.str); + if (dates.length < 2) throw new ParseError("Could not read the From/To dates from the header"); + const period_from = isoDate(dates[0]); + const period_to = isoDate(dates[1]); + const invoice_date = isoDate(invoiceDateRaw); + + // ---- machine lines (every page) -------------------------------------- + const lines: ParsedLine[] = []; + for (const words of pages) { + // A machine row is a left-column item that is not a known header/footer word. + const machineRows = words.filter( + (w) => w.x < 45 && /[A-Za-z]/.test(w.str) && !/^(Return|Sep|Dry|Rib|Ref|Printed|Description)$/i.test(w.str) && + !/\bNo\.$/.test(w.str) && /[A-Za-z]{2,8}-?[0-9]{2,3}/.test(w.str), + ); + for (const mr of machineRows) { + // Numeric row sits ~16pt below the machine name. + const numRow = words.filter((w) => w.y > mr.y + 10 && w.y < mr.y + 24); + const hire = num(numRow.find((w) => inBand(w.x, COL.HIRE))?.str); + const vat = num(numRow.find((w) => inBand(w.x, COL.VAT))?.str); + const total = num(numRow.find((w) => inBand(w.x, COL.TOTAL))?.str); + if (hire == null || vat == null || total == null) continue; + + // Activity row: the "Dry Rate ..." line, roughly 57pt below the machine name. + // The "Description Qty Unit ..." header sits at ~44pt and must not be mistaken + // for it, so locate the row by its leading "Dry" token rather than by offset. + const band = words.filter((w) => w.y > mr.y + 38 && w.y < mr.y + 78 && w.x > 150); + const dryY = band.find((w) => w.str === "Dry" || w.str.startsWith("Dry "))?.y; + const dry = dryY == null ? [] : band.filter((w) => Math.abs(w.y - dryY) < 3).sort((a, b) => a.x - b.x); + const qty = num(dry.find((w) => inBand(w.x, ACT.QTY))?.str); + const unit = dry.find((w) => inBand(w.x, ACT.UNIT))?.str ?? ""; + const rate = num(dry.find((w) => inBand(w.x, ACT.RATE))?.str); + // pdfjs emits "Dry Rate" as a single token; older builds split it in two. + const rateType = dry[0] ? dry[0].str.replace(/\s+/g, " ") : "Dry Rate"; + + const { code, description: desc } = splitMachineRow(mr.str); + lines.push({ + machine_code: code, + description: desc, + qty: qty ?? 0, + unit, + rate: rate ?? 0, + rate_type: rateType, + amount_excl: r2(hire), + vat: r2(vat), + total_incl: r2(total), + }); + } + } + if (!lines.length) throw new ParseError("No machine lines were found on any page"); + + // ---- totals ----------------------------------------------------------- + // The report emits the grand-total label as ONE text item ("Plant Hire Total"), + // so match on prefix rather than equality. + let hireTotal: number | null = null; + let taxTotal: number | null = null; + let grandTotal: number | null = null; + let escalationTotal = 0; + for (const words of pages) { + const row = words.find((w) => w.str.startsWith("Plant Hire Total")); + if (!row) continue; + const same = words.filter((w) => Math.abs(w.y - row.y) < 4 && w.x > row.x + row.str.length); + hireTotal = num(same.find((w) => inBand(w.x, COL.HIRE))?.str); + escalationTotal = num(same.find((w) => inBand(w.x, COL.ESCALATION))?.str) ?? 0; + taxTotal = num(same.find((w) => inBand(w.x, COL.VAT))?.str); + grandTotal = num(same.find((w) => inBand(w.x, COL.TOTAL))?.str); + } + if (hireTotal == null || taxTotal == null || grandTotal == null) { + throw new ParseError('Could not find the "Plant Hire Total" row'); + } + + // The summary block is read BY LABEL — its values sit at different x positions + // from the line table, so column bands cannot be used here. pdfjs splits these + // labels inconsistently between pages, so each is matched by prefix. + const labelValue = (words: Word[], label: string): number | null => { + const lw = words.find((w) => w.str === label || w.str.startsWith(`${label} `)); + if (!lw) return null; + const labelEnd = lw.x + lw.str.length; + const same = words.filter((w) => Math.abs(w.y - lw.y) < 5 && w.x > labelEnd + 15); + return num(same.map((w) => w.str).join("")); + }; + + const summary: Record = {}; + for (const words of pages) { + const put = (k: string, v: number | null) => { if (v != null) summary[k] = v; }; + put("SUB TOTAL", labelValue(words, "SUB TOTAL")); + put("Escalation", labelValue(words, "Escalation")); + put("PLUS", labelValue(words, "PLUS")); + put("INVOICE", labelValue(words, "INVOICE")); + } + + const subtotal = summary["SUB TOTAL"] ?? hireTotal; + const escalation = summary["Escalation"] ?? escalationTotal; + const vat = summary["PLUS"] ?? taxTotal; + const total_excl = r2(subtotal + escalation); + const total_incl = summary["INVOICE"] ?? grandTotal; + + // ---- validation — the same gates the seed applies --------------------- + const sumHire = r2(lines.reduce((s, l) => s + l.amount_excl, 0)); + if (Math.abs(sumHire - subtotal) > 0.01) { + throw new ParseError(`Machine lines total R${sumHire} but the invoice subtotal is R${subtotal}`); + } + const sumVat = r2(lines.reduce((s, l) => s + l.vat, 0)); + if (Math.abs(sumVat - vat) > 0.02) { + throw new ParseError(`Line VAT totals R${sumVat} but the invoice PLUS VAT is R${vat}`); + } + if (Math.abs(total_excl + vat - total_incl) > 0.01) { + throw new ParseError( + `Totals do not balance: R${total_excl} ex + R${vat} VAT != R${total_incl} invoice total`, + ); + } + for (const l of lines) { + if (Math.abs(l.amount_excl + l.vat - l.total_incl) > 0.01) { + throw new ParseError( + `Line ${l.machine_code}: R${l.amount_excl} + R${l.vat} VAT != R${l.total_incl}`, + ); + } + if (l.qty && l.rate && Math.abs(r2(l.qty * l.rate) - l.amount_excl) > 0.01) { + throw new ParseError( + `Line ${l.machine_code}: ${l.qty} x ${l.rate} = ${r2(l.qty * l.rate)}, but the line amount is R${l.amount_excl}`, + ); + } + } + + const vat_pct = total_excl > 0 ? Math.round((vat / total_excl) * 1000) / 10 : 0; + for (const l of lines) { + const expected = r2((l.amount_excl * vat_pct) / 100); + if (l.amount_excl > 0 && Math.abs(expected - l.vat) > 0.02) { + throw new ParseError( + `Line ${l.machine_code}: VAT is R${l.vat}, but ${vat_pct}% of R${l.amount_excl} is R${expected}`, + ); + } + } + + return { + invoice_no: invoiceNo.trim().replace(/\s+/g, " "), + invoice_date, + order_no: orderNo, + debtor, + debtor_no: debtorNo, + period_from, + period_to, + description, + site: siteFromDescription(description), + section: "Plant Hire", + category: "External Plant Hire", + currency, + vat_reg_no: vatReg, + vat_pct, + subtotal: r2(subtotal), + escalation: r2(escalation), + vat: r2(vat), + total_excl, + total_incl: r2(total_incl), + lines, + }; + } finally { + await doc.destroy().catch(() => {}); + } +} diff --git a/next.config.ts b/next.config.ts index 0a9acf5..6e33586 100644 --- a/next.config.ts +++ b/next.config.ts @@ -19,6 +19,13 @@ const nextConfig: NextConfig = { // Dockerfile. Note it does NOT bundle `.next/static` or `public/` — the // Dockerfile copies those in separately. output: "standalone", + + // pdfjs-dist dynamically imports its own worker module at runtime. When Next bundles + // it, that import is rewritten to a path inside .next that does not exist in the + // standalone image, and every parse dies with "Setting up fake worker failed". + // Keeping it external means it is required from node_modules at runtime and traced + // into the image, so the worker resolves next to the library that needs it. + serverExternalPackages: ["pdfjs-dist"], }; const withPWA = withPWAInit({ diff --git a/package-lock.json b/package-lock.json index 4f63127..acb1edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.469.0", "next": "^16", + "pdfjs-dist": "^4.10.38", "pocketbase": "^0.27.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2639,6 +2640,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@next/env": { "version": "16.3.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", @@ -7014,6 +7280,18 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "4.10.38", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", + "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.65" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 299f72f..c466e6d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.469.0", "next": "^16", + "pdfjs-dist": "^4.10.38", "pocketbase": "^0.27.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/scripts/setup-invoice-collections.mjs b/scripts/setup-invoice-collections.mjs index bb2570c..b451481 100644 --- a/scripts/setup-invoice-collections.mjs +++ b/scripts/setup-invoice-collections.mjs @@ -187,6 +187,26 @@ const invoiceLinesId = await ensureCollection("invoice_lines", [ // 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. +// The intake inbox. Every PDF that is offered to the app lands here first, with the +// outcome of its parse. Anything that does not reconcile stays as `failed` with the +// reason recorded, so it can be reviewed and retried rather than silently lost. +await ensureCollection("invoice_intake", [ + { name: "file_name", type: "text" }, + { name: "file_size", type: "number" }, + { name: "invoice_no", type: "text" }, + { name: "status", type: "select", required: true, maxSelect: 1, values: ["imported", "failed"] }, + { name: "error", type: "text" }, + // Extracted-but-unverified data, kept so a failed import can still be inspected. + { name: "parsed", type: "json" }, + { name: "source_pdf", type: "file", maxSelect: 1, maxSize: 20971520 }, + { name: "invoice", type: "relation", collectionId: invoicesId, minSelect: 1, maxSelect: 1, cascadeDelete: false }, + // Collections created through the API do NOT get PocketBase's automatic created/updated + // fields the way the UI wizard adds them. Without these, any `sort: "-created"` fails + // server-side with "Something went wrong while processing your request". + { name: "created", type: "date", onCreate: true, presentable: false }, + { name: "updated", type: "date", onUpdate: true, presentable: false }, +]); + await ensureField("monthly_income", { name: "invoice_line", type: "relation",