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.
This commit is contained in:
David 2026-10-08 11:49:48 +02:00
parent ad4ac9173b
commit 6f66dff2a6
19 changed files with 438 additions and 86 deletions

View file

@ -37,6 +37,48 @@ durable first — they are not in the repo.
(`PHI 166` → "West Park", `PHI 167` → "Halfway Gardens"). There is **no `sites` collection
yet** — build one when the client has sites to list, and back-fill `site` from this field.
## Internal vs external invoices — one collection, two pages
The register is split in two by the **debtor printed on the invoice**:
- **`/income/invoices` — Internal Invoices.** The debtor is a Mega group company.
- **`/income/external-invoices` — External Invoices.** The debtor is a third party.
**This is one `invoices` collection filtered two ways, never two collections.** Do not
"fix" it by giving each page its own collection — that would let a row exist on neither
page and make the totals unable to reconcile against `/income`. The split lives in
`lib/invoice-party-policy.json` (the entity list) and `lib/invoice-party.ts` (the matching),
and both pages render through the single `components/InvoiceRegisterPage.tsx`, so they
cannot drift apart the way two hand-written pages would.
As of 2026-10-08 this splits the 19 invoices in the register **16 internal / 3 external**:
- Internal — debtor `Mega Infrastructure`, no `09MIS001`. All the seeded `data/source/invoice-*.json` files.
- External — `PHI 178` (`09CIG001`), `PHI 186` (`09DTE001`), `PHI 192` (`09DAS001`).
⚠️ **A third-party invoice may carry an empty `debtor` name and only a `debtor_no`.** All three
external invoices do exactly that. So the policy matches on **name OR debtor number**, never
name alone — matching by name only would misfile every one of them. An invoice naming neither is
never internal: it lands on External and the page reports it as unclassified.
Three consequences that are easy to get wrong:
- **Every write must revalidate BOTH pages.** Booking a line, importing a PDF and retrying an
intake can land on either side depending on the debtor, so a bare
`revalidatePath("/income/invoices")` leaves the other page showing stale rows. Use
`revalidateInvoicePages(...)` from `lib/invoice-revalidate.ts` — never a bare
`revalidatePath` for anything touching `invoices` or `invoice_lines`.
- **Route paths are client-safe; the revalidate helper is not.** `lib/invoice-routes.ts` holds
the path constants and imports no `next/*`, so a client component may import it.
`lib/invoice-revalidate.ts` imports `next/cache` and is server-only.
- **The two routes are siblings, never nested.** `isGroupActive` in `Sidebar.tsx` matches a
child by path prefix, so `/income/invoices/external` would satisfy the prefix test for both
leaves and highlight "Internal" and "External" together.
`invoices.category` is `"External Plant Hire"` on **every** invoice, internal ones included —
it is the supplier's own wording transcribed from the PDF, **not** this internal/external
classification. Never derive the split from it.
## Invoice booking — run order matters
`npm run seed` **wipes `monthly_income`** and re-imports only the workbook, so invoice-derived

View file

@ -0,0 +1,12 @@
import { InvoiceRegisterPage } from "@/components/InvoiceRegisterPage";
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
export const dynamic = "force-dynamic";
// The external half of the register: invoices billed by a third party outside the Mega
// group. Deliberately a SIBLING of /income/invoices, not a child of it: a nested path
// would satisfy `isGroupActive`'s prefix test for BOTH leaves at once, so "Internal" and
// "External" would highlight together. As siblings, exactly one leaf is ever active.
export default function ExternalInvoicesPage() {
return <InvoiceRegisterPage scope="external" />;
}

View file

@ -1,63 +1,11 @@
import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/server-data";
import { FileText } from "lucide-react";
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
import { InvoiceRegisterPage } from "@/components/InvoiceRegisterPage";
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
export const dynamic = "force-dynamic";
export default async function InvoicesPage() {
const [invoices, lines, assets] = await Promise.all([
getInvoices(),
getInvoiceLines(),
getAssets(),
]);
// Group the lines per invoice once, so the client table gets plain data rather than
// making it re-derive the grouping on every render.
const linesByInvoice: Record<string, ReturnType<typeof joinBookedLines>> = {};
const joined = joinBookedLines(lines, assets);
for (const l of joined) {
(linesByInvoice[l.invoice] ||= []).push(l);
}
const unbookedCount = joined.filter((l) => !l.isBooked).length;
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#003D5B]/15 grid place-items-center text-[#00798C]">
<FileText className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Invoices</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
One row per invoice · click to open the line items and book each to a plant item
</p>
</div>
<AddInvoiceButton />
</div>
<ProblemInbox />
{invoices.length === 0 ? (
<EmptyInvoices />
) : (
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
)}
{unbookedCount > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
{unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
<span className="font-medium text-[#D1495B]">unbooked</span> above — open one and pick a plant
item in the <span className="font-medium text-[#003D5B] dark:text-white">Booked to</span> column
and it posts to that machine&apos;s income immediately. To make it the default, set the asset in{" "}
<code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> and re-run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
)}
</div>
);
// The internal half of the register: invoices billed to a Mega group company.
// The external half is a sibling route, NOT a child — see the note in Sidebar.tsx on
// `isGroupActive` matching a parent's path prefix.
export default function InternalInvoicesPage() {
return <InvoiceRegisterPage scope="internal" />;
}

View file

@ -34,10 +34,26 @@ const navItems: NavItem[] = [
icon: <TrendingUp className="w-4 h-4" />,
children: [
{ href: "/income", label: "Overview" },
{ href: "/income/invoices", label: "Invoices" },
// The invoice register is split in two by the debtor on the invoice — see
// lib/invoice-party-policy.json. The two are SIBLING routes on purpose: a nested
// path would satisfy isGroupActive's prefix test for both leaves at once and
// highlight both at the same time.
{ href: "/income/invoices", label: "Internal Invoices" },
{ href: "/income/external-invoices", label: "External Invoices" },
],
},
// Running Costs became a group so Insurance could sit under it. The Overview
// leaf stays at the EXISTING /running path — the current page becomes the
// overview, it does not move, so no bookmark or GlobalSearch target breaks.
{
kind: "group",
label: "Running Costs",
icon: <Wrench className="w-4 h-4" />,
children: [
{ href: "/running", label: "Overview" },
{ href: "/running/insurance", label: "Insurance" },
],
},
{ kind: "link", href: "/running", label: "Running Costs", icon: <Wrench className="w-4 h-4" /> },
{
kind: "group",
label: "Expenses",

View file

@ -3,6 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } from "@/lib/server-data";
import { buildSiteSummaries } from "@/lib/sites";
import { INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH } from "@/lib/invoice-routes";
import { ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
import { ArrowLeft, MapPin, FileText } from "lucide-react";
@ -240,7 +241,14 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT.
</p>
<Link
href="/income/invoices"
href={
// Send the reader to the half of the register that actually holds these
// lines. Internal wins a tie because it is where every seeded invoice
// lives today, so this preserves the existing destination.
summary.unbookedExternalCount > summary.unbookedInternalCount
? EXTERNAL_INVOICES_PATH
: INTERNAL_INVOICES_PATH
}
className="inline-flex items-center min-h-[44px] text-sm font-medium text-[#D1495B] hover:underline mt-1"
>
Go and assign them

View file

@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
import { takenSiteIdsFor } from "@/lib/sites";
export const dynamic = "force-dynamic";
@ -32,8 +32,7 @@ export async function POST(
if (body.clear) {
await pb.collection("invoice_lines").update(id, { booked_asset: "", booked_site: "" });
revalidatePath("/income/invoices");
revalidatePath("/sites");
revalidateInvoicePages("/sites");
return Response.json({ ok: true, booked: false });
}
@ -57,9 +56,7 @@ export async function POST(
invoice_line: id,
});
await refreshBookedState(pb, line.invoice);
revalidatePath("/income/invoices");
revalidatePath("/income");
revalidatePath("/sites");
revalidateInvoicePages("/income", "/sites");
return Response.json({
ok: true,
booked: true,
@ -86,8 +83,7 @@ export async function POST(
for (const row of existing) await pb.collection("monthly_income").delete(row.id);
await pb.collection("invoice_lines").update(id, { booked_site: body.siteId, booked_asset: "" });
await refreshBookedState(pb, line.invoice);
revalidatePath("/income/invoices");
revalidatePath("/sites");
revalidateInvoicePages("/sites");
return Response.json({ ok: true, booked: true, target: "site", site: { id: site.id, name: site.name } });
}

View file

@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
import { canReplaceRetrySource, intakeRetryHeaders } from "@/lib/intake-retry";
export const dynamic = "force-dynamic";
@ -62,15 +62,14 @@ export async function POST(
if (result.ok) {
await pb.collection("invoice_intake").delete(id);
revalidatePath("/income/invoices");
revalidatePath("/income");
revalidateInvoicePages("/income");
return Response.json({ ok: true, retried: true, ...result });
}
// Still failing — keep it in the inbox with the newest reason, and drop the
// duplicate record the retry just created so the inbox stays one row per file.
await pb.collection("invoice_intake").delete(id).catch(() => {});
revalidatePath("/income/invoices");
revalidateInvoicePages();
return Response.json(
{ ok: false, retried: true, stillFailing: true, error: result.error ?? "Import failed again" },
{ status: 422 },

View file

@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
export const dynamic = "force-dynamic";
@ -22,7 +22,7 @@ export async function DELETE(
);
}
await pb.collection("invoice_intake").delete(id);
revalidatePath("/income/invoices");
revalidateInvoicePages();
return Response.json({ ok: true });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not remove it" }, { status: 500 });

View file

@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites";
@ -80,7 +80,7 @@ export async function POST(req: Request) {
new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
);
const rec = await pb.collection("invoice_intake").create(form2);
revalidatePath("/income/invoices");
revalidateInvoicePages();
return Response.json({ ok: false, status: "failed", intakeId: rec.id, error: reason }, { status: 422 });
};
@ -251,8 +251,7 @@ export async function POST(req: Request) {
);
await pb.collection("invoice_intake").create(form2);
revalidatePath("/income/invoices");
revalidatePath("/income");
revalidateInvoicePages("/income");
return Response.json({
ok: true,

View file

@ -1,5 +1,5 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
export const dynamic = "force-dynamic";
@ -78,7 +78,7 @@ export async function POST(req: Request) {
const updated = await pb.collection("invoices").update(target.id, form2);
revalidatePath("/income/invoices");
revalidateInvoicePages();
const stored = updated.source_pdf as string | undefined;
return Response.json({

View file

@ -285,15 +285,22 @@ function Stat({ label, value, tone }: { label: string; value: string; tone: "tea
);
}
// Kept so an empty folder still renders a usable page.
export function EmptyInvoices() {
// Kept so an empty folder still renders a usable page. `hint` is supplied by the shared
// register page because the two halves are filled differently — internal invoices are
// seeded from data/source, external ones arrive through the upload dialog — so the same
// "run npm run seed" advice would be wrong on one of them.
export function EmptyInvoices({ hint }: { hint?: React.ReactNode }) {
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm p-10 text-center">
<FileText className="w-8 h-8 mx-auto text-gray-300 dark:text-text-soft mb-3" />
<p className="text-gray-500 dark:text-gray-400">No invoices yet.</p>
<p className="text-sm text-gray-400 dark:text-text-soft mt-1">
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
{hint ?? (
<>
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</>
)}
</p>
</div>
);

View file

@ -0,0 +1,138 @@
import { Building2, FileText } from "lucide-react";
import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/server-data";
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
import { filterByParty, unclassifiedCount, type PartyScope } from "@/lib/invoice-party";
// Both invoice screens render through this one component, split by `scope`.
//
// The register is ONE PocketBase collection. `scope` only decides which half is shown,
// so the two pages cannot drift apart the way two hand-written pages would — the AGENTS.md
// rule for /sites (keep the cards in step with /income) applies here for the same reason.
//
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
// `export const dynamic` deliberately does NOT live here: Next.js only honours that flag on
// a route entry (page/layout/route), so it is declared in both pages that render this.
const COPY: Record<PartyScope, {
title: string;
subtitle: string;
icon: typeof FileText;
iconFrom: string;
iconTo: string;
iconText: string;
addLabel: string;
emptyHint: React.ReactNode;
}> = {
internal: {
title: "Internal Invoices",
subtitle:
"Billed to a Mega group company · one row per invoice · click to open the line items and book each to a plant item",
icon: FileText,
iconFrom: "from-[#00798C]/15",
iconTo: "to-[#003D5B]/15",
iconText: "text-[#00798C]",
addLabel: "Add Invoice",
emptyHint: (
<>
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</>
),
},
external: {
title: "External Invoices",
subtitle:
"Billed by a third party outside the Mega group · one row per invoice · click to open the line items and book each to a plant item",
icon: Building2,
iconFrom: "from-[#30638E]/15",
iconTo: "to-[#003D5B]/15",
iconText: "text-[#30638E]",
addLabel: "Add Invoice",
emptyHint: (
<>
No invoice from outside the Mega group yet. Use <strong>Add Invoice</strong> to import
one — the party is read from the debtor it names.
</>
),
},
};
export async function InvoiceRegisterPage({ scope }: { scope: PartyScope }) {
const copy = COPY[scope];
const [allInvoices, lines, assets] = await Promise.all([
getInvoices(),
getInvoiceLines(),
getAssets(),
]);
// Filter BEFORE grouping, so each page only carries its own half to the client. The
// unbooked count below is derived from that same half — an unbooked line on the
// external page must never inflate the internal page's footer.
const invoices = filterByParty(allInvoices, scope);
// Group the lines per invoice once, so the client table gets plain data rather than
// making it re-derive the grouping on every render.
const wanted = new Set(invoices.map((i) => i.id));
const linesByInvoice: Record<string, ReturnType<typeof joinBookedLines>> = {};
const joined = joinBookedLines(lines, assets).filter((l) => wanted.has(l.invoice));
for (const l of joined) {
(linesByInvoice[l.invoice] ||= []).push(l);
}
const unbookedCount = joined.filter((l) => !l.isBooked).length;
// An invoice that names no debtor could not be placed by evidence. It is shown here
// rather than filed as internal, and counted so the reader knows why it is in this list.
const unclassified = scope === "external" ? unclassifiedCount(invoices) : 0;
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${copy.iconFrom} ${copy.iconTo} grid place-items-center ${copy.iconText}`}
>
<copy.icon className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{copy.title}</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">{copy.subtitle}</p>
</div>
<AddInvoiceButton />
</div>
<ProblemInbox />
{invoices.length === 0 ? (
<EmptyInvoices hint={copy.emptyHint} />
) : (
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
)}
{unclassified > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
{unclassified} invoice{unclassified > 1 ? "s" : ""} name no debtor, so{" "}
<span className="font-medium text-[#003D5B] dark:text-white">no party</span> could be
matched —{" "}
{unclassified > 1 ? "they are" : "it is"} listed here rather than filed as internal. Add{" "}
the entity to <code className="font-mono text-[#00798C]">lib/invoice-party-policy.json</code>{" "}
if {unclassified > 1 ? "they are" : "it is"} in fact a group company.
</p>
)}
{unbookedCount > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
{unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
<span className="font-medium text-[#D1495B]">unbooked</span> above — open one and pick a plant
item in the <span className="font-medium text-[#003D5B] dark:text-white">Booked to</span> column
and it posts to that machine&apos;s income immediately. To make it the default, set the asset in{" "}
<code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> and re-run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,12 @@
{
"_comment": "SINGLE SOURCE OF TRUTH for splitting the invoice register into INTERNAL and EXTERNAL. An invoice is INTERNAL when the debtor it names is a Mega group entity — billing between our own companies. Everything else is EXTERNAL: a third party outside the group. Read by both invoice pages (components/InvoiceRegisterPage.tsx) through lib/invoice-party.ts, so the two can never disagree about which side of the split a row is on.",
"_why": "Every invoice in the register as of 2026-10-08 is billed to Mega Infrastructure (09MIS001) — a Mega group entity — so the whole register was internal. The two pages are a split of ONE collection, never two collections: an invoice can never be invisible on both, and moving a debtor here reclassifies it on both screens at once.",
"_matching": "An internal debtor matches on EITHER name or debtor number, case-insensitively, with surrounding whitespace ignored. An invoice naming no debtor at all is NOT internal — it falls to the external page and is marked unclassified rather than quietly filed as internal.",
"_addAnEntity": "Add another row when Mega invoices a sister company. Do not add individual sites or projects here — a site is not a legal entity and would misfile the whole invoice.",
"internalDebtors": [
{
"name": "Mega Infrastructure",
"debtorNo": "09MIS001"
}
]
}

69
lib/invoice-party.test.ts Normal file
View file

@ -0,0 +1,69 @@
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);
});

53
lib/invoice-party.ts Normal file
View file

@ -0,0 +1,53 @@
import policy from "./invoice-party-policy.json";
import type { Invoice } from "./data";
// Which half of the register an invoice belongs to. Both pages are the SAME PocketBase
// collection split by this module — see ./invoice-party-policy.json for why it is one
// collection and not two.
//
// Deliberately pure and import-safe from client components: no server-only, no PB. The
// pages filter on the server before handing plain data to the client list, and this
// module is the only thing that decides which side a row lands on.
export type PartyScope = "internal" | "external";
const norm = (v: unknown) => String(v ?? "").trim().toLowerCase();
// The group entities that count as internal. Matched on name OR debtor number, so an
// invoice that prints "Mega Infrastructure" and one that prints only "09MIS001" both
// land on the internal page.
const INTERNAL = policy.internalDebtors.map((d) => ({
name: norm(d.name),
debtorNo: norm(d.debtorNo),
}));
type PartyBearing = Pick<Invoice, "debtor" | "debtor_no">;
// True when the debtor is a known group entity.
export function isInternalParty(inv: PartyBearing): boolean {
const name = norm(inv.debtor);
const no = norm(inv.debtor_no);
if (!name && !no) return false;
return INTERNAL.some((d) => (name && name === d.name) || (no && no === d.debtorNo));
}
// An invoice naming no debtor cannot be placed by evidence, so it is reported as
// unclassified rather than assumed internal. It still appears on the external page —
// nothing may be invisible on both screens.
export function isClassified(inv: PartyBearing): boolean {
return Boolean(norm(inv.debtor) || norm(inv.debtor_no));
}
export function partyOf(inv: PartyBearing): PartyScope {
return isInternalParty(inv) ? "internal" : "external";
}
export function filterByParty<T extends PartyBearing>(invoices: T[], scope: PartyScope): T[] {
return invoices.filter((i) => partyOf(i) === scope);
}
// Shown on the external page when a row landed there only because it named no debtor.
// Keeps an unclassifiable invoice visible as a question rather than a silent filing.
export function unclassifiedCount(invoices: Invoice[]): number {
return invoices.filter((i) => partyOf(i) === "external" && !isClassified(i)).length;
}

19
lib/invoice-revalidate.ts Normal file
View file

@ -0,0 +1,19 @@
import { revalidatePath } from "next/cache";
import { INVOICE_PATHS } from "./invoice-routes";
// SERVER-SIDE ONLY — imports `next/cache`, so nothing client-side may pull this in. Route
// paths live in ./invoice-routes.ts for exactly that reason.
//
// Every mutation path must refresh BOTH halves of the register. Booking a line, importing
// a PDF and retrying an intake can all land on either page depending on the debtor, so
// revalidating only one leaves the other showing stale rows.
//
// Revalidating a page that did not change costs a re-render, not correctness — so the
// asymmetry is free to fix and expensive to miss. Use `revalidateInvoicePages` instead of a
// bare `revalidatePath` for anything that writes to `invoices` or `invoice_lines`.
// `extra` covers other routes a write may have moved, e.g. /income, whose totals shift
// when income posts.
export function revalidateInvoicePages(...extra: string[]) {
for (const p of [...INVOICE_PATHS, ...extra]) revalidatePath(p);
}

12
lib/invoice-routes.ts Normal file
View file

@ -0,0 +1,12 @@
// Route paths only — deliberately free of any `next/*` import so a CLIENT component may
// import these without dragging server code into the browser bundle. The revalidating
// helper lives in ./invoice-revalidate.ts and is server-side.
//
// The two halves of the invoice register. Both pages read the SAME `invoices` collection
// and differ only in which debtors they show (see lib/invoice-party-policy.json). They are
// SIBLING routes: a nested path would satisfy `isGroupActive`'s prefix test for both leaves
// at once, so "Internal" and "External" would highlight together.
export const INTERNAL_INVOICES_PATH = "/income/invoices";
export const EXTERNAL_INVOICES_PATH = "/income/external-invoices";
export const INVOICE_PATHS = [INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH] as const;

View file

@ -1,4 +1,10 @@
import type { Asset, BookedLine, Invoice, Site } from "./data";
import { isInternalParty } from "./invoice-party";
// A line whose invoice is missing cannot be classified, so it must not be counted as
// internal by accident. An invoice with no debtor is never internal — see
// lib/invoice-party.ts — so an empty placeholder counts as external.
const EMPTY_INVOICE = { debtor: "", debtor_no: "" } as Pick<Invoice, "debtor" | "debtor_no">;
// Sites are DERIVED from the invoices: each distinct invoice site becomes a site.
// They are not typed in by hand, so this is the only place that creates them.
@ -72,6 +78,12 @@ export type SiteSummary = {
/** Lines with neither a plant item nor a site — the work still outstanding. */
unbookedCount: number;
unbookedAmount: number;
/** The outstanding lines split by which half of the register they live on. The site's
* "assign them" link has to pick a page, and the register is split in two — a site
* whose only unbooked lines are on external invoices must be sent to the external
* page, or the link lands on a page that does not contain them. */
unbookedInternalCount: number;
unbookedExternalCount: number;
};
/** The year the chart should show: the most recent one any invoice was booked into.
@ -189,6 +201,8 @@ export function buildSiteSummaries(
pinned: Boolean(site.latitude),
unbookedCount: unbooked.length,
unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0),
unbookedInternalCount: unbooked.filter((l) => isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length,
unbookedExternalCount: unbooked.filter((l) => !isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length,
};
})
.filter((s) => s.invoices.length > 0 || s.lineCount > 0)

View file

@ -4,7 +4,15 @@ import assert from "node:assert/strict";
const base = process.env.MEGA_AUTH_BASE_URL || "http://localhost:6007";
test("anonymous dashboard and app API requests are denied, including IDs ending in .json", async () => {
for (const path of ["/", "/settings", "/plant", "/income/invoices", "/sites"]) {
for (const path of [
"/",
"/settings",
"/plant",
// Both halves of the invoice register — a new page must be guarded like any other.
"/income/invoices",
"/income/external-invoices",
"/sites",
]) {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
assert.equal(response.status, 307);
assert.ok(response.headers.get("location")?.endsWith("/login"));