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.
282 lines
15 KiB
TypeScript
282 lines
15 KiB
TypeScript
// 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, extractLines, 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"), "");
|
|
// The wording BEFORE the date is not fixed: PHI 179 leads with the machine type.
|
|
// Anchoring on "Hire Plant"/"Plant Hire" returned "" and left the invoice unlinked.
|
|
assert.equal(siteFromDescription("Crane Truck Hire Sep 2026 Cosmo"), "Cosmo");
|
|
assert.equal(siteFromDescription("Boom Lift Hire October 2026 The Hills"), "The Hills");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// --- the line table: one line per CHARGE -------------------------------------
|
|
//
|
|
// PHI 179 is a single CT01 crane truck hired on 4 Sep, 16 Sep and 18 Sep. The report
|
|
// prints one machine heading followed by a repeating block per charge. The parser used
|
|
// to read exactly one block per machine, so it returned R5 265 against a R13 730
|
|
// subtotal and the invoice was refused — two thirds of a real invoice went missing
|
|
// because it had been hired over more than one period.
|
|
//
|
|
// These are the REAL word positions from that PDF, including the detail that pdfjs
|
|
// splits one printed row across two y positions (the TOTAL sits ~2pt above the rest).
|
|
|
|
const w = (x: number, y: number, str: string) => ({ x, y, str });
|
|
|
|
const PHI179_LINES = [
|
|
w(29.6, 441, "CT01-Crane Truck ISUZU FREIGHTER 1800"),
|
|
|
|
// charge 1 — 4 Sep, dry rate
|
|
w(29.6, 457, "SEP"), w(68.7, 457, "04-Sep-2026"), w(119.4, 457, "-"), w(546.8, 457, "6,054.75"),
|
|
w(142.2, 458, "04-Sep-2026"),
|
|
w(211.5, 459, "1.00"), w(255.6, 459, "-"), w(278.5, 459, "8.00"), w(306.7, 459, "100.00"),
|
|
w(357.8, 459, "5,265.00"), w(426.6, 459, "-"), w(459.0, 459, "0.00"), w(500.2, 459, "789.75"),
|
|
w(313.7, 485, "Rate"), w(359.3, 485, "Amount"),
|
|
w(159.8, 486, "Description"), w(234.4, 486, "Qty"), w(270.4, 486, "Unit"), w(395.5, 486, "Activity"),
|
|
w(159.8, 498, "Dry Rate"), w(233.9, 498, "1.00"), w(273.1, 498, "Day"),
|
|
w(300.8, 498, "5,265.00"), w(357.8, 498, "5,265.00"), w(395.5, 498, "0001"),
|
|
|
|
// charge 2 — 16 Sep, idle time
|
|
w(29.6, 511, "SEP"), w(68.6, 511, "16-Sep-2026"), w(119.3, 511, "-"), w(546.8, 511, "3,680.00"),
|
|
w(142.1, 512, "16-Sep-2026"),
|
|
w(211.5, 513, "1.00"), w(255.6, 513, "-"), w(278.5, 513, "8.00"), w(306.7, 513, "100.00"),
|
|
w(357.8, 513, "3,200.00"), w(426.6, 513, "-"), w(459.0, 513, "0.00"), w(500.2, 513, "480.00"),
|
|
w(159.8, 540, "Description"), w(234.4, 540, "Qty"), w(270.4, 540, "Unit"),
|
|
w(313.7, 540, "Rate"), w(359.3, 540, "Amount"), w(395.5, 540, "Activity"),
|
|
w(159.8, 553, "Idle Time"), w(233.9, 553, "1.00"), w(273.1, 553, "Day"),
|
|
w(300.8, 553, "3,200.00"), w(357.8, 553, "3,200.00"), w(395.5, 553, "0001"),
|
|
|
|
// charge 3 — 18 Sep, dry rate
|
|
w(29.6, 566, "SEP"), w(68.6, 566, "18-Sep-2026"), w(119.3, 566, "-"), w(546.8, 566, "6,054.75"),
|
|
w(142.1, 567, "18-Sep-2026"),
|
|
w(211.5, 567, "1.00"), w(255.6, 567, "-"), w(278.5, 567, "8.00"), w(306.7, 567, "100.00"),
|
|
w(357.8, 567, "5,265.00"), w(426.6, 567, "-"), w(459.0, 567, "0.00"), w(500.2, 567, "789.75"),
|
|
w(313.7, 594, "Rate"), w(359.3, 594, "Amount"),
|
|
w(159.8, 595, "Description"), w(234.4, 595, "Qty"), w(270.4, 595, "Unit"), w(395.5, 595, "Activity"),
|
|
w(159.8, 607, "Dry Rate"), w(233.9, 607, "1.00"), w(273.1, 607, "Day"),
|
|
w(300.8, 607, "5,265.00"), w(357.8, 607, "5,265.00"), w(395.5, 607, "0001"),
|
|
|
|
// the grand total — it is a money row too, and must not become a fourth line
|
|
w(245.5, 628, "Plant Hire Total"),
|
|
w(354.9, 628, "13,730.00"), w(420.7, 628, "0.00"), w(462.0, 628, "0.00"),
|
|
w(494.9, 628, "2,059.50"), w(543.9, 628, "15,789.50"),
|
|
];
|
|
|
|
test("a machine hired over several periods yields one line per charge", () => {
|
|
const lines = extractLines(PHI179_LINES);
|
|
assert.equal(lines.length, 3, "three charges, so three lines — not one per machine");
|
|
assert.deepEqual(lines.map((l) => l.amount_excl), [5265, 3200, 5265]);
|
|
assert.deepEqual(lines.map((l) => l.vat), [789.75, 480, 789.75]);
|
|
assert.deepEqual(lines.map((l) => l.total_incl), [6054.75, 3680, 6054.75]);
|
|
// All three belong to the same plant item, and each must reconcile on its own.
|
|
assert.deepEqual(lines.map((l) => l.machine_code), ["CT01", "CT01", "CT01"]);
|
|
for (const l of lines) {
|
|
assert.equal(l.qty * l.rate, l.amount_excl, `${l.rate_type}: qty x rate != amount`);
|
|
assert.ok(Math.abs(l.amount_excl + l.vat - l.total_incl) < 0.01, "line does not balance");
|
|
}
|
|
});
|
|
|
|
test("the grand total row is never emitted as a line", () => {
|
|
// It carries a figure in HIRE, VAT and TOTAL exactly like a charge does, so only its
|
|
// position after the machine block keeps it out of the invoice.
|
|
const withTotal = PHI179_LINES.filter(
|
|
(x) => !["Plant Hire Total", "13,730.00", "2,059.50", "15,789.50"].includes(x.str),
|
|
);
|
|
assert.equal(extractLines(withTotal).length, 3);
|
|
assert.equal(extractLines(PHI179_LINES).length, 3);
|
|
});
|
|
|
|
test("a machine billed once still yields exactly one line", () => {
|
|
// The single-period invoices (PHI 166-171) all have one charge per machine, and that
|
|
// behaviour must not change.
|
|
const single = PHI179_LINES.filter((x) => x.y >= 441 && x.y <= 498);
|
|
const lines = extractLines(single);
|
|
assert.equal(lines.length, 1);
|
|
assert.equal(lines[0].amount_excl, 5265);
|
|
assert.equal(lines[0].rate_type, "Dry Rate");
|
|
});
|
|
|
|
test("the activity header is not mistaken for the activity row", () => {
|
|
// "Description Qty Unit Rate Amount" has no NUMBER in the Qty column; the rate type
|
|
// comes from the row below it, so a wrong pick yields an empty label, not a wrong one.
|
|
const [first] = extractLines(PHI179_LINES);
|
|
assert.equal(first.rate_type, "Dry Rate");
|
|
assert.equal(first.unit, "Day");
|
|
});
|
|
|
|
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, resolveSiteName } 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");
|
|
});
|
|
|
|
test("an abbreviated site folds onto the name the register already uses", () => {
|
|
// PHI 179 prints "Crane Truck Hire Sep 2026 Cosmo" for the site every other invoice
|
|
// calls "Cosmo City". Sites are derived from the invoice wording, so taking it
|
|
// literally would create a SECOND "Cosmo" and split the customer across two rows.
|
|
const known = ["West Park", "Halfway Gardens", "Cosmo City"];
|
|
assert.equal(resolveSiteName("Cosmo", known), "Cosmo City");
|
|
assert.equal(resolveSiteName("Cosmo City", known), "Cosmo City");
|
|
assert.equal(resolveSiteName("cosmo city", known), "Cosmo City");
|
|
assert.equal(resolveSiteName("West Park", known), "West Park");
|
|
});
|
|
|
|
test("folding never invents a site or splits a different one", () => {
|
|
const known = ["Cosmo City", "West Park"];
|
|
// No match at all — a genuinely new site keeps the invoice's own wording.
|
|
assert.equal(resolveSiteName("Rooihuiskraal", known), "Rooihuiskraal");
|
|
assert.equal(resolveSiteName("", known), "");
|
|
assert.equal(resolveSiteName("Cosmo", []), "Cosmo");
|
|
// A partial word must not match — "Cos" is not a shorter name for "Cosmo City".
|
|
assert.equal(resolveSiteName("Cos", known), "Cos");
|
|
// Folding runs one way only. An invoice that names a LONGER place is not an
|
|
// abbreviation of the shorter one, and merging the two would lose a real site.
|
|
assert.equal(resolveSiteName("Cosmo City Extension", known), "Cosmo City Extension");
|
|
assert.equal(resolveSiteName("Cosmo City North", known), "Cosmo City North");
|
|
});
|