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

139 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```bash
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