Commit graph

6 commits

Author SHA1 Message Date
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
David
ad4ac9173b Invoices: one line per charge, and fold an abbreviated site
PHI 179 was refused: "Machine lines total R5265 but the invoice subtotal is
R13730". Three separate faults, each of which only this invoice exposed.

1. The parser read ONE line per machine. A machine hired over more than one
   period is printed as one heading followed by a repeating block per charge,
   and PHI 179 is a single CT01 crane truck billed on 4, 16 and 18 Sep at
   R5 265 + R3 200 + R5 265. The parser only ever looked at the first block,
   so two thirds of the invoice went missing and the arithmetic check — which
   did its job — rejected it. extractLines() now walks y-banded rows and emits
   one line per money row, all carrying the machine's code. A machine billed
   once still yields exactly one line, which is what PHI 168/169 do.

   The banding matters: pdfjs splits one printed row across two y positions
   (the TOTAL sits ~2pt above the rest), so the fixed +-12pt window the old
   code used was never a row, it was a coincidence.

2. siteFromDescription required the description to START with "Hire Plant" or
   "Plant Hire". PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" — it leads
   with the machine type — so the regex returned "" and the invoice linked to
   no site at all. The month/year pair is the part every description shares,
   so that is the anchor now.

3. Even read correctly the site is "Cosmo", where every other invoice says
   "Cosmo City". Sites are DERIVED from the invoice wording, so saving the
   abbreviation would create a second "Cosmo" and split this customer across
   two rows on /sites. resolveSiteName() folds a shorter whole-word prefix
   onto a site already in the register, one direction only, so a genuinely new
   site is still recorded as new.

Also: a problem invoice stored its PDF with no way to open it. The inbox
reported the failure but not the document behind it, and /api/invoices/:id/
document only serves imported invoices — the one case you most want to inspect.
New GET /api/invoices/intake/:id/document, plus a PDF link on each inbox row.

Verified: PHI 179 reconciles in full (R13 730 + R2 059.50 VAT = R15 789.50,
three lines, each balancing). PHI 168 and PHI 169 still reproduce their hand
extraction field for field, including line count. 40 tests, typecheck clean.
2026-10-06 11:25:10 +02:00
David
293276ccce Sites: auto-allocate container lines to the container the site hires
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.

The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.

A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.

Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:32:12 +02:00
David
14ec899ca9 Sites: a real Sites list, with the unbooked lines bookable to a site
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.

`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.

/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.

Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.

One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.

The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.

Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:24:06 +02:00
David
58c972c4c9 Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.

Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.

Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:08 +02:00
David
5caa923375 Invoices: the app ingests PDFs itself, with a problem-invoice inbox
Extraction was previously manual: an agent read each PDF, mapped the columns
by hand and wrote data/source/invoice-*.json. That was the only way anything
got in, and the upload button just filed the document without reading it.

That supplier's report is one fixed template — the columns sit at identical
x-positions on every invoice, and only the line count and page count vary —
so the figures are now read by coordinate instead. Deliberately not an AI
extractor: this input does not need one, and a deterministic parse that fails
loudly is worth more here than a flexible one that can hallucinate a total.

lib/invoice-parser.ts reads the header, the machine lines across all pages,
the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT /
INVOICE TOTAL block, then applies the same arithmetic gates the seed does:
lines must sum to the subtotal, each line must satisfy qty x rate = amount
and amount + VAT = total, and VAT must match the stated rate. Anything that
does not reconcile is refused with the reason — never partially imported.

Validated by parsing all five existing invoices and comparing every field
against the hand-extracted JSON: identical, including the R24,205.13-VAT
column that a plain text read gets wrong. PHI 169 was then deleted and
re-ingested through the HTTP endpoint alone and came back field-for-field
identical, with no agent involved.

- "Add Invoice" drops a PDF in and imports it; lines are matched to plant
  items by the code the invoice prints, and an unmatched line (the
  container codes MPLANT04/MPLANT05) is left unassigned rather than guessed.
- A "Problem invoices" inbox lists every PDF that failed, with the reason,
  and Retry re-reads the stored document and re-runs the parse. Retry
  re-reports rather than claiming success, and only files imported data.
- invoice_intake collection records each attempt.

pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy
worker import to a .next path absent from the standalone image and every
parse dies with "Setting up fake worker failed". The parser also copies the
buffer before handing it over, since pdfjs transfers and detaches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:30:17 +02:00