From 6f66dff2a64daf8c6e51abd1c89b37b1fced3207 Mon Sep 17 00:00:00 2001
From: David
Date: Thu, 8 Oct 2026 11:49:48 +0200
Subject: [PATCH] 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.
---
AGENTS.md | 42 ++++++
.../income/external-invoices/page.tsx | 12 ++
app/(dashboard)/income/invoices/page.tsx | 64 +-------
app/(dashboard)/layout.tsx | 20 ++-
app/(dashboard)/sites/[id]/page.tsx | 10 +-
app/api/invoice-lines/[id]/book/route.ts | 12 +-
app/api/invoices/intake/[id]/retry/route.ts | 7 +-
app/api/invoices/intake/[id]/route.ts | 4 +-
app/api/invoices/intake/route.ts | 7 +-
app/api/invoices/upload/route.ts | 4 +-
components/InvoiceList.tsx | 15 +-
components/InvoiceRegisterPage.tsx | 138 ++++++++++++++++++
lib/invoice-party-policy.json | 12 ++
lib/invoice-party.test.ts | 69 +++++++++
lib/invoice-party.ts | 53 +++++++
lib/invoice-revalidate.ts | 19 +++
lib/invoice-routes.ts | 12 ++
lib/sites.ts | 14 ++
scripts/verify-auth-routes.mjs | 10 +-
19 files changed, 438 insertions(+), 86 deletions(-)
create mode 100644 app/(dashboard)/income/external-invoices/page.tsx
create mode 100644 components/InvoiceRegisterPage.tsx
create mode 100644 lib/invoice-party-policy.json
create mode 100644 lib/invoice-party.test.ts
create mode 100644 lib/invoice-party.ts
create mode 100644 lib/invoice-revalidate.ts
create mode 100644 lib/invoice-routes.ts
diff --git a/AGENTS.md b/AGENTS.md
index 95c4c9b..b274713 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/app/(dashboard)/income/external-invoices/page.tsx b/app/(dashboard)/income/external-invoices/page.tsx
new file mode 100644
index 0000000..3a26af2
--- /dev/null
+++ b/app/(dashboard)/income/external-invoices/page.tsx
@@ -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 ;
+}
\ No newline at end of file
diff --git a/app/(dashboard)/income/invoices/page.tsx b/app/(dashboard)/income/invoices/page.tsx
index d9d5725..beabdf9 100644
--- a/app/(dashboard)/income/invoices/page.tsx
+++ b/app/(dashboard)/income/invoices/page.tsx
@@ -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> = {};
- const joined = joinBookedLines(lines, assets);
- for (const l of joined) {
- (linesByInvoice[l.invoice] ||= []).push(l);
- }
-
- const unbookedCount = joined.filter((l) => !l.isBooked).length;
-
- return (
-
-
-
-
-
-
-
-
Invoices
-
-
- One row per invoice · click to open the line items and book each to a plant item
-
- {unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
- unbooked above — open one and pick a plant
- item in the Booked to column
- and it posts to that machine's income immediately. To make it the default, set the asset in{" "}
- data/source/invoice-*.json and re-run{" "}
- npm run seed.
-
- )}
-
- );
+// 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 ;
}
\ No newline at end of file
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index e4eb416..b6bd5cf 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -34,10 +34,26 @@ const navItems: NavItem[] = [
icon: ,
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: ,
+ children: [
+ { href: "/running", label: "Overview" },
+ { href: "/running/insurance", label: "Insurance" },
],
},
- { kind: "link", href: "/running", label: "Running Costs", icon: },
{
kind: "group",
label: "Expenses",
diff --git a/app/(dashboard)/sites/[id]/page.tsx b/app/(dashboard)/sites/[id]/page.tsx
index 961cfcc..580d55d 100644
--- a/app/(dashboard)/sites/[id]/page.tsx
+++ b/app/(dashboard)/sites/[id]/page.tsx
@@ -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.
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
diff --git a/app/api/invoice-lines/[id]/book/route.ts b/app/api/invoice-lines/[id]/book/route.ts
index a9fc073..ee9b8e5 100644
--- a/app/api/invoice-lines/[id]/book/route.ts
+++ b/app/api/invoice-lines/[id]/book/route.ts
@@ -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 } });
}
diff --git a/app/api/invoices/intake/[id]/retry/route.ts b/app/api/invoices/intake/[id]/retry/route.ts
index e813ce9..bea8ff7 100644
--- a/app/api/invoices/intake/[id]/retry/route.ts
+++ b/app/api/invoices/intake/[id]/retry/route.ts
@@ -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 },
diff --git a/app/api/invoices/intake/[id]/route.ts b/app/api/invoices/intake/[id]/route.ts
index 2a8e276..3ca0431 100644
--- a/app/api/invoices/intake/[id]/route.ts
+++ b/app/api/invoices/intake/[id]/route.ts
@@ -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 });
diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts
index adcd068..b2b74be 100644
--- a/app/api/invoices/intake/route.ts
+++ b/app/api/invoices/intake/route.ts
@@ -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,
diff --git a/app/api/invoices/upload/route.ts b/app/api/invoices/upload/route.ts
index 3e8cff0..a10cd0c 100644
--- a/app/api/invoices/upload/route.ts
+++ b/app/api/invoices/upload/route.ts
@@ -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({
diff --git a/components/InvoiceList.tsx b/components/InvoiceList.tsx
index f4a209f..e53d636 100644
--- a/components/InvoiceList.tsx
+++ b/components/InvoiceList.tsx
@@ -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 (
No invoices yet.
- Add a data/source/invoice-*.json then run{" "}
- npm run seed.
+ {hint ?? (
+ <>
+ Add a data/source/invoice-*.json then run{" "}
+ npm run seed.
+ >
+ )}
);
diff --git a/components/InvoiceRegisterPage.tsx b/components/InvoiceRegisterPage.tsx
new file mode 100644
index 0000000..e84d477
--- /dev/null
+++ b/components/InvoiceRegisterPage.tsx
@@ -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 = {
+ 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 data/source/invoice-*.json then run{" "}
+ npm run seed.
+ >
+ ),
+ },
+ 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 Add Invoice 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> = {};
+ 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 (
+
+ {unclassified} invoice{unclassified > 1 ? "s" : ""} name no debtor, so{" "}
+ no party could be
+ matched —{" "}
+ {unclassified > 1 ? "they are" : "it is"} listed here rather than filed as internal. Add{" "}
+ the entity to lib/invoice-party-policy.json{" "}
+ if {unclassified > 1 ? "they are" : "it is"} in fact a group company.
+
+ )}
+
+ {unbookedCount > 0 && (
+
+ {unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
+ unbooked above — open one and pick a plant
+ item in the Booked to column
+ and it posts to that machine's income immediately. To make it the default, set the asset in{" "}
+ data/source/invoice-*.json and re-run{" "}
+ npm run seed.
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/lib/invoice-party-policy.json b/lib/invoice-party-policy.json
new file mode 100644
index 0000000..235acac
--- /dev/null
+++ b/lib/invoice-party-policy.json
@@ -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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/lib/invoice-party.test.ts b/lib/invoice-party.test.ts
new file mode 100644
index 0000000..871c272
--- /dev/null
+++ b/lib/invoice-party.test.ts
@@ -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;
+
+// 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);
+});
\ No newline at end of file
diff --git a/lib/invoice-party.ts b/lib/invoice-party.ts
new file mode 100644
index 0000000..32f1317
--- /dev/null
+++ b/lib/invoice-party.ts
@@ -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;
+
+// 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(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;
+}
\ No newline at end of file
diff --git a/lib/invoice-revalidate.ts b/lib/invoice-revalidate.ts
new file mode 100644
index 0000000..1e6445a
--- /dev/null
+++ b/lib/invoice-revalidate.ts
@@ -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);
+}
\ No newline at end of file
diff --git a/lib/invoice-routes.ts b/lib/invoice-routes.ts
new file mode 100644
index 0000000..eb2012b
--- /dev/null
+++ b/lib/invoice-routes.ts
@@ -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;
\ No newline at end of file
diff --git a/lib/sites.ts b/lib/sites.ts
index 0dab618..8c07e98 100644
--- a/lib/sites.ts
+++ b/lib/sites.ts
@@ -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;
// 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)
diff --git a/scripts/verify-auth-routes.mjs b/scripts/verify-auth-routes.mjs
index a37be0b..6ad125d 100644
--- a/scripts/verify-auth-routes.mjs
+++ b/scripts/verify-auth-routes.mjs
@@ -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"));