Invoices: the app ingests PDFs itself, with a problem-invoice inbox
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) <noreply@anthropic.com>
2026-10-02 12:30:17 +00:00
|
|
|
// 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";
|
Invoices: one line per charge, and fold an abbreviated site
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.
2026-10-06 09:25:10 +00:00
|
|
|
import { parseInvoicePdf, registerCodeFrom, siteFromDescription, isoDate, extractLines, ParseError } from "./invoice-parser";
|
Invoices: the app ingests PDFs itself, with a problem-invoice inbox
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) <noreply@anthropic.com>
2026-10-02 12:30:17 +00:00
|
|
|
|
|
|
|
|
// --- 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"), "");
|
Invoices: one line per charge, and fold an abbreviated site
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.
2026-10-06 09:25:10 +00:00
|
|
|
// 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");
|
Invoices: the app ingests PDFs itself, with a problem-invoice inbox
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) <noreply@anthropic.com>
2026-10-02 12:30:17 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
Invoices: one line per charge, and fold an abbreviated site
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.
2026-10-06 09:25:10 +00:00
|
|
|
// --- 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");
|
|
|
|
|
});
|
|
|
|
|
|
Invoices: the app ingests PDFs itself, with a problem-invoice inbox
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) <noreply@anthropic.com>
2026-10-02 12:30:17 +00:00
|
|
|
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`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-02 16:32:12 +00:00
|
|
|
});
|
|
|
|
|
// --- 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.
|
|
|
|
|
|
Invoices: one line per charge, and fold an abbreviated site
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.
2026-10-06 09:25:10 +00:00
|
|
|
import { findSiteContainer, resolveSiteName } from "./sites";
|
2026-10-02 16:32:12 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
Invoices: one line per charge, and fold an abbreviated site
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.
2026-10-06 09:25:10 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
2026-10-08 17:06:30 +00:00
|
|
|
|
|
|
|
|
test("a site typed without its space is NOT folded — that would merge two real sites", () => {
|
|
|
|
|
// PHI 180 prints "Westpark" where the register says "West Park", so this looks like a
|
|
|
|
|
// spacing typo worth folding. It is deliberately left alone: the register ALSO carries
|
|
|
|
|
// both "Autumns Gate" and "Autumnsgate", and a spacing-insensitive rule cannot tell a
|
|
|
|
|
// typo from two genuinely different places — it would silently merge that pair.
|
|
|
|
|
//
|
|
|
|
|
// PHI 180's stored name was corrected to "West Park" by hand instead. Merging the two
|
|
|
|
|
// Autumn sites is the client's call, not a side effect of a re-import.
|
|
|
|
|
const known = ["West Park", "Autumns Gate", "Autumnsgate"];
|
|
|
|
|
assert.equal(resolveSiteName("Westpark", known), "Westpark");
|
|
|
|
|
assert.equal(resolveSiteName("Autumnsgate", known), "Autumnsgate");
|
|
|
|
|
assert.equal(resolveSiteName("Autumns Gate", known), "Autumns Gate");
|
|
|
|
|
});
|