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

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

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

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

Also fixes three things the split would otherwise break:

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

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

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

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

53 lines
No EOL
2.2 KiB
TypeScript

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;
}