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.
69 lines
No EOL
3 KiB
TypeScript
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);
|
|
}); |