// 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`); } } }); // --- container auto-allocation -------------------------------------------------- // A container line prints a generic code ("MPLANT05") that the plant register does not // carry, so it has no natural asset. Each site records the container it hires, and the // code the invoice uses for it. These tests pin the lookup — done here rather than by // deleting a real invoice and re-importing it. import { findSiteContainer } from "./sites"; const site = (name: string, code: string, asset: string) => ({ id: name, name, container_code: code, container_asset: asset }) as any; test("a container line is allocated to the container the site hires", () => { const sites = [site("Autumns Gate", "MPLANT05", "a-sc02")]; assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02"); }); test("allocation is scoped to the invoice's own site", () => { // Two sites can each hire an "MPLANT05"; a line must go to its own site's unit. const sites = [site("Autumns Gate", "MPLANT05", "a-sc02"), site("The Hills", "MPLANT05", "h-sc03")]; assert.equal(findSiteContainer(sites, "The Hills", "MPLANT05")?.container_asset, "h-sc03"); assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02"); }); test("no allocation when the site has no matching container", () => { const sites = [site("Pitsana", "WT02", "")]; assert.equal(findSiteContainer(sites, "Pitsana", "MPLANT05"), null, "code does not match"); assert.equal(findSiteContainer(sites, "Pitsana", "WT02"), null, "no asset assigned"); assert.equal(findSiteContainer(sites, "Elsewhere", "WT02"), null, "different site"); assert.equal(findSiteContainer([], "Pitsana", "WT02"), null, "no sites at all"); });