mega-plant/AGENTS.md
David baf0bddde6 Invoices: line-item extraction, booking to plant items, VAT-inclusive income
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.

Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
  scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
  validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
  one file

Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
  second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
  monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
  read by both the app and the CLI so they cannot disagree

Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
  seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
  VAT stays visible rather than hidden.

Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
  so a direct PocketBase /api/files link 404s in a browser

Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.

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

7.8 KiB
Raw Blame History

AGENTS.md — Mega Plant

Mobile checklist (Round 4 standing rule)

Every page must pass the mobile checklist before it is considered complete:

  1. No horizontal page scroll at 375px. Individual tables may scroll inside their own container; the page itself may not.
  2. No text under 12px on mobile. Bump text-[10px] labels up.
  3. No tap target under 44×44px. Keep the visual size if you like — add padding to the hit area.
  4. Wide tables scroll inside their own container with a sticky first column and -webkit-overflow-scrolling: touch. Add a visible scroll affordance (e.g. right-edge fade).
  5. Filter controls collapse below lg — pills become a <select> dropdown. Don't stack 13 pills into 13 rows.
  6. Verify at 375×812 and 768×1024. Run document.documentElement.scrollWidth > document.documentElement.clientWidth and confirm it returns false. Count tap targets and text sizes. Screenshot every route.

If a route fails any of the above, it is not done.

Build / dev

  • npm run dev → port 6007 (nohup-detached: nohup npm run dev > /tmp/plant-dev.log 2>&1 & disown)
  • npm run build → 15 routes, webpack mode (PWA is webpack-only)
  • npm run typecheck → npx tsc --noEmit
  • npm run seed → wipe + re-import data/source/*.json into PocketBase. Fails loudly on any unmatched row or broken VAT.
  • node scripts/setup-invoice-collections.mjs → create the invoices / invoice_lines collections (idempotent).
  • node scripts/book-invoice.mjs → post invoice lines into monthly_income as additional rows.

Invoices — adding another one

scripts/seed.mjs imports every data/source/invoice-*.json automatically, so a new invoice is one file in that folder — no script edit. Each needs invoice_no, the period, vat_pct, the header totals and a lines array. The seed re-checks the arithmetic on every invoice and refuses any one that does not balance, while still seeding the others.

⚠️ npm run seed deletes and recreates the invoices rows, so any PDF uploaded through /income/invoices is lost on the next seed and must be re-attached. Copy the PDFs somewhere durable first — they are not in the repo.

invoices.site is a plain text label transcribed from the invoice's own description (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.

Invoice booking — run order matters

npm run seed wipes monthly_income and re-imports only the workbook, so invoice-derived income is destroyed on every seed. Always:

npm run seed && node scripts/book-invoice.mjs

Booking is idempotent: each posted row is stamped with monthly_income.invoice_line, and re-running removes the prior invoice-derived rows before re-inserting. Rows with an empty invoice_line are workbook data and are never touched.

An invoice line with no booked_asset is left unbooked and reported loudly — never guessed at. Assign it from /income/invoices (the Booked to column), or set asset_code in the relevant data/source/invoice-*.json and re-run the pair above.

One machine, many sites — not a duplicate

A machine hired to more than one site in the same month is normal use, not a duplicate billing. Client ruling, 2026-10-01. Such a line is booked to the same machine as income, and every hire accumulates on that one asset.

  • MPLANT05 (SC01) → PHI 166 West Park and PHI 171 Cosmo City, same period.
  • SS-01-CASE (SS01) → PHI 167 Halfway Gardens and PHI 171 Cosmo City, same period.

findDuplicateMachineCodes still finds these overlaps, but the result is informational, not a problem: it must never tint a row red, badge it as unbooked, or block booking. The income figure's hover/tap breakdown shows each machine's full hire history across sites, which is where a multi-site machine should be read. Only an unbooked line is a real problem.

The register has no MPLANT04 / MPLANT05 codes, so those map to OC01 / SC01 as the consistent target — chosen, not derived. Confirm with the client if either should differ.

Money on invoices — VAT is NOT revenue

On PHI 166 the totals block reads SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT 24,205.13 / INVOICE TOTAL 185,572.63. The R24,205.13 sits under the column headed PLUS VAT — it is 15% VAT, not an escalation, and escalation is R0.00 on every line.

  • The invoice total (total_incl) is VAT-inclusive — that is the headline figure.
  • total_excl (hire) + vat = total_incl, checked per line and on the header by the seed.
  • Booked amount follows lib/invoice-booking-policy.json → bookInclusiveVat. It is currently true: amounts are booked VAT-inclusive, per the client's ruling of 2026-10-01. That means SARS output VAT of R29,935.13 (Sept 2026 set) sits inside the R229,502.63 shown as income on /income, so those figures are a cash/billing view, not a VAT-excl. P&L. Flip the flag to false and re-run npm run seed && node scripts/book-invoice.mjs to book ex-VAT instead. Both the app and the CLI script read that one file, so they cannot disagree.
  • The VAT stays visible rather than hidden: /income shows "includes R 29 935 VAT" and each machine card shows "incl R X VAT" (IncomeRow.invoiceVat). Do not collapse the two.

Extract these by coordinate (pdfplumber, x-positions) or read the rendered page visually. Plain text extraction reorders these columns and silently turns VAT into escalation.

Manual invoice actions

  • POST /api/invoice-lines/:id/book body { assetId } ("" clears) — sets booked_asset and moves the monthly_income row to the new machine rather than adding a second one.
  • POST /api/invoices/upload multipart file + invoiceNo — PDF only, 20 MB cap, stored on invoices.source_pdf.
  • GET /api/invoices/:id/document — proxies the PDF. Do not link to NEXT_PUBLIC_PB_URL/api/files/<filename>: the invoices collection is rule-locked and that path 404s in a browser. PocketBase also resolves protected files by the full /api/files/<collectionId>/<recordId>/<filename> path — build it with pb.files.getURL().

Color palette — never change

Coolors:

  • #EDAE49 Sunny — orange/positive accent
  • #D1495B Brick Red — deficit/destructive
  • #00798C Notartis Seaweed — teal/stable
  • #30638E Lights Casual — main blue/brand
  • #003D5B Indigo Dye — deep navy

Chart marks (deeper steps of the same hues — see HANDOFF-PROMPT-3 §A.4):

  • --color-chart-income (light #0090A6, dark #00A6BE)
  • --color-chart-fixed (#BE851F both)
  • --color-chart-running (light #C13B4D, dark #D94F60)

Cost model

  • lib/profit.ts is the single source of truth for profit. Default includeRunningCost: false (matches workbook). To flip: one-line change in lib/profit.ts.
  • Term 1 = instalment ✅ confirmed.
  • Term 2 = insurance ✅ confirmed.
  • Term 3 = operator wages — varies per month per machine — STILL NEEDS CLIENT INPUT.

Open questions

  • Q1: Does profit include running cost? (default: no, matches workbook).
  • Q2: What is term 3 of the fixed-expense formula? (See Round 1/2/3 HANDOFF-PROMPT files.)

PocketBase

  • URL: process.env.NEXT_PUBLIC_PB_URL (env or .env.local)
  • Auth: pb.collection("_superusers").authWithPassword(env) (Round 1 §5.3)
  • Collections: assets, monthly_income, monthly_running_costs, overheads, cost_lines, invoices, invoice_lines
  • monthly_income no longer carries running_cost — derive from monthly_running_costs at read time
  • monthly_income.invoice_line → invoice_lines, nullable. Empty = workbook row, set = invoice-derived row
  • invoice_lines.booked_asset → assets, nullable. Empty = line not yet booked to a machine