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 -

-
- -
- - - - {invoices.length === 0 ? ( - - ) : ( - - )} - - {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. -

- )} -
- ); +// 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 ( +
+
+
+
+
+ +
+

{copy.title}

+
+

{copy.subtitle}

+
+ +
+ + + + {invoices.length === 0 ? ( + + ) : ( + + )} + + {unclassified > 0 && ( +

+ {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"));