mega-plant/lib/invoice-party.test.ts
David 6f66dff2a6 Split the invoice register into Internal and External Invoices
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.

Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.

lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.

The split is 16 internal / 3 external across the 19 invoices in the register.

Also fixes three things the split would otherwise break:

- Booking a line, importing a PDF and retrying an intake can all land on either
  side depending on the debtor, so the five write paths only revalidated
  /income/invoices and would have left the other page stale. They now call
  revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
  lib/invoice-routes.ts, which imports no next/* so a client component may import
  it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
  outstanding lines sit on external invoices would dead-end. SiteSummary now
  carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.

The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.

Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.

Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 11:49:48 +02:00

69 lines
No EOL
3 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { filterByParty, isClassified, isInternalParty, partyOf, unclassifiedCount } from "./invoice-party";
import type { Invoice } from "./data";
const inv = (debtor: string, debtor_no = "") =>
({ debtor, debtor_no }) as Pick<Invoice, "debtor" | "debtor_no">;
// Every invoice seeded to date names Mega Infrastructure (09MIS001) — a Mega group entity —
// so the whole register as it stands is INTERNAL. That is the whole reason the two pages
// split cleanly today.
const SEEDED = [
inv("Mega Infrastructure", "09MIS001"),
inv("Mega Infrastructure", "09MIS001"),
];
test("an invoice billed to a group company is internal", () => {
for (const i of SEEDED) {
assert.equal(isInternalParty(i), true);
assert.equal(partyOf(i), "internal");
}
});
test("an invoice billed by a third party is external", () => {
const ext = inv("Civilsure Plant Hire", "CIV001");
assert.equal(isInternalParty(ext), false);
assert.equal(partyOf(ext), "external");
});
test("the group entity matches on name OR debtor number alone", () => {
// Invoices are transcribed by hand, so one may print the name and the other only the
// number. Requiring both would drop a real invoice onto the wrong page.
assert.equal(isInternalParty(inv("Mega Infrastructure", "")), true);
assert.equal(isInternalParty(inv("", "09MIS001")), true);
assert.equal(isInternalParty(inv(" MEGA INFRASTRUCTURE ", "09MIS001")), true);
});
test("a similar name outside the policy is NOT internal", () => {
// A near-miss must not be swept in: misfiling a third party's money as our own
// intercompany billing is exactly the error the split exists to prevent.
assert.equal(isInternalParty(inv("Mega Infrastructure CC", "")), false);
assert.equal(isInternalParty(inv("Megainfra", "")), false);
});
test("an invoice naming no debtor is never internal and is reported as unclassified", () => {
const blank = inv("", "");
assert.equal(isInternalParty(blank), false);
assert.equal(isClassified(blank), false);
// It still appears on the external page — nothing may be invisible on both screens.
assert.equal(partyOf(blank), "external");
});
test("filterByParty splits without losing or duplicating a row", () => {
const all = [...SEEDED, inv("Civilsure Plant Hire", "CIV001"), inv("", "")];
const internal = filterByParty(all, "internal");
const external = filterByParty(all, "external");
assert.equal(internal.length, 2);
assert.equal(external.length, 2);
// Every row appears on exactly one page: the halves partition the register.
assert.equal(internal.length + external.length, all.length);
for (const i of internal) assert.equal(partyOf(i), "internal");
for (const e of external) assert.equal(partyOf(e), "external");
});
test("only the no-debtor row is counted as unclassified", () => {
const all = [...SEEDED, inv("Civilsure Plant Hire", "CIV001"), inv("", "")];
assert.equal(unclassifiedCount(all as Invoice[]), 1);
});