mega-plant/AGENTS.md

264 lines
16 KiB
Markdown
Raw Normal View History

2026-09-12 10:05:43 +00:00
# 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.
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 13:59:30 +00:00
- `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.
## Site monthly chart — which month an invoice lands in
`/sites` plots one sparkline per site (billed per month) and a site's row expands to the
invoices behind each month. `buildSiteSummaries` (`lib/sites.ts`) produces both.
**The month is always the invoice's `booked_month`, never `invoice_date` and never
`period_from`.** `seed.mjs` sets `booked_month`/`booked_year` from the month of
`period_to`, which is the same rule that posts the income into `monthly_income`. So a
site's September total on `/sites` is the same rand as the September column on `/income`
— the two screens cannot disagree. PHI 166 (period 21 Aug → 18 Sep) therefore books to
**September**. Re-deriving the month any other way is the one way to break this.
Other rules worth not re-learning:
- A month total is the **sum of that site's lines**, not the invoice header total. One
invoice carries many lines to a site (PHI 166 has five), so an invoice is listed once
per month with its lines accumulated — never one card per line.
- `months[]` is 12 slots of `null` for "no invoice", never `0`. A site whose billing
starts in September is not eight months of zero.
- The series covers ONE year — `latestInvoiceYear(invoices)`, the max `booked_year`.
Months from other years are dropped, not folded in, and the panel says so.
- `SiteSummary.pinned` exists because the table is a client component and cannot look
the site up in the `sites` collection itself.
- **`/sites` defaults to the Cards view, deliberately mirroring `IncomeView`** — same
toolbar, same card shape, same 12-month strip on expand, same three-up footer. The
client likes the vehicles overview and asked for the sites to read the same way, so
keep the two in step when either changes. Grid is the old table, kept as a toggle.
The site detail link lives **inside the expanded panel only** — a card is a
`<button>`, so a nested `<a>` would be invalid HTML.
Invoices: fix the tablet layout and make booking an unbooked line obvious On /income/invoices at tablet width the expanded invoice detail was unreadable and the booking control was effectively unreachable. - The line-item table is rendered inside a <td> of the invoice list's own scrollable table, so its 1200px width stretched the OUTER table to 1234px and the whole list scrolled sideways whenever a row was open. The detail is now measured with a ResizeObserver and given that pixel width, so it can no longer influence the outer table. - Both tables' first columns are sticky left-0, and two nested stickies at the same offset stack on top of one another: the invoice number, the site and the period printed over each other. The detail wrapper is now sticky left-0 z-20, above the outer cell's z-10, so the inner table scrolls in its own box. - The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every column a sticky cell covered bled through it on scroll. The tint is now the solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells. - The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked to income" tile pushed the third one onto a second row below ~644px. It is a grid-cols-3 now, with the figures sized to the column. - The machine picker and the site dropdown in the Booked to cell were flex children and compressed into each other by ~12px instead of sitting side by side. Both are shrink-0 now. - Booking a line always saved on selection with no Save button, which read as "nothing happened". Unmatched rows now say so. The booking API was already correct: a machine booking clears the site and posts income, a site booking posts none. Verified at 768x1024 and 375x812.
2026-10-03 07:59:05 +00:00
## Nested wide tables — the sticky-column trap
`/income/invoices` expands an invoice row into a line-item table **inside a `<td>` of the
invoice list's own scrollable table**. Two things break if this is not pinned down, and
both look like a rendering glitch rather than a layout bug:
- The inner table's `min-width` propagates up and **stretches the outer table** (820px
became 1234px). The whole list then scrolled sideways whenever a row was open. The
detail is now measured with a `ResizeObserver` and given that pixel width, so it
cannot influence the outer table.
- Both tables' first columns are `sticky left-0`, and **two nested stickies at the same
offset stack on top of each other** — the invoice number, the site and the period all
printed over one another. The detail wrapper is `sticky left-0 z-20` (above the outer
cell's `z-10`) and holds a fixed width, so the inner table scrolls inside its own box
and is the only sticky in it.
**A sticky cell must have an OPAQUE background.** The unbooked rows were tinted
`bg-[#D1495B]/[0.04]` — 96% transparent — so every column they covered bled through them
on scroll. The row tint is now the solid `#FDF3F4` (dark: `#292934`) and the sticky
cells repeat it exactly.
Two more traps on this screen: a `flex-wrap` KPI row sizes tiles by CONTENT, so the wide
"booked to income" tile pushed the third one onto a second row below ~644px — it is a
Invoices: a machine chosen in the dropdown was never actually booked Selecting a plant item in the Booked to dropdown silently did nothing. The menu opened, the option highlighted, and clicking it produced no request at all — the menu just closed. Reproduced with a real browser click: no POST to /book. The menu is rendered with createPortal(..., document.body) because the line table lives in an overflow-x-auto container that would clip an in-place dropdown. A portal is not a DOM descendant of the component that made it, so the click-away handler's `boxRef.contains(target)` reported every option as an outside click: mousedown closed the menu, React unmounted the button, and the click that would have booked it never landed. The menu now has its own ref and is treated as part of the control. Verified with a real click — the booking posts, the site clears, and the unbooked badge goes. The site dropdown was never affected; it is a native <select>, outside the portal. Also on this screen: - The Booked to column needs min-w-[240px] with the two pickers stacked inside it. At 768px the column is only ~166px, so two 200px controls could not sit side by side; as flex children they compressed into each other by ~12px, and once they were not allowed to shrink they spilled past the edge of the table. - Dropped the "Booked to income / VAT to SARS / Billed to customer / Still unbooked" money block, its component and the variables feeding it. The status line stays: "1 line not yet booked to a machine". Typecheck and build green. Checked at 768x1024 and 375x812: no page overflow, no control past the table edge, no tap target under 44px.
2026-10-03 13:43:46 +00:00
`grid-cols-3` now. And the **Booked to** column needs `min-w-[240px]` with the two
pickers stacked inside it: at 768px the column is only ~166px, so two 200px controls
could not sit side by side, and when they were not allowed to shrink they spilled past
the edge of the table.
## The machine dropdown — a portal is NOT inside its trigger
`MachineCombobox` renders its menu with `createPortal(..., document.body)`, because the
line table lives in an `overflow-x-auto` container that would clip an in-place dropdown.
**A portal is not a DOM descendant of the component that made it.** The click-away
handler tested `boxRef.contains(target)`, so every option in the menu read as an outside
click: `mousedown` closed the menu, React unmounted the button, and the `click` that
would have booked it never fired. Selecting a machine silently did nothing — the menu
just closed. The menu now has its own ref and is treated as part of the control.
Symptom to recognise: a dropdown opens, options highlight, and choosing one does nothing
at all — no request, no error, no change. Suspect the outside-click test before the API.
Invoices: fix the tablet layout and make booking an unbooked line obvious On /income/invoices at tablet width the expanded invoice detail was unreadable and the booking control was effectively unreachable. - The line-item table is rendered inside a <td> of the invoice list's own scrollable table, so its 1200px width stretched the OUTER table to 1234px and the whole list scrolled sideways whenever a row was open. The detail is now measured with a ResizeObserver and given that pixel width, so it can no longer influence the outer table. - Both tables' first columns are sticky left-0, and two nested stickies at the same offset stack on top of one another: the invoice number, the site and the period printed over each other. The detail wrapper is now sticky left-0 z-20, above the outer cell's z-10, so the inner table scrolls in its own box. - The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every column a sticky cell covered bled through it on scroll. The tint is now the solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells. - The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked to income" tile pushed the third one onto a second row below ~644px. It is a grid-cols-3 now, with the figures sized to the column. - The machine picker and the site dropdown in the Booked to cell were flex children and compressed into each other by ~12px instead of sitting side by side. Both are shrink-0 now. - Booking a line always saved on selection with no Save button, which read as "nothing happened". Unmatched rows now say so. The booking API was already correct: a machine booking clears the site and posts income, a site booking posts none. Verified at 768x1024 and 375x812.
2026-10-03 07:59:05 +00:00
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 13:59:30 +00:00
## 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()`.
2026-09-12 10:05:43 +00:00
## 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` (`#8B5CF6` both) — the project's existing
`--color-brand-purple`, so Running introduces no new hue. **Client ruling, 2026-10-02:**
Running was moved off red because red is now reserved for a month that made a loss, and a
red Running line sitting next to a red loss segment was unreadable. Yellow was considered
and rejected — `--color-chart-fixed` is already a gold.
- `--color-chart-loss` (light `#C13B4D`, dark `#D94F60`) — a month that did not make
profit. **It has its own token on purpose:** it used to read
`--color-chart-running`, so recolouring Running silently dragged the loss red with it.
- `--color-chart-profit` (`#7A9E06` both) — the profit line's green. **Client ruling,
2026-10-02: this is a second deliberate exception to the "never change the palette" rule.**
It is a bright yellow-green, chosen so the profit line reads as its own series instead of
blending into the teal income line.
`globals.css` hard-codes `.recharts-cartesian-axis-tick text { font-size: 11px }`, which
**beats Recharts' `tick.fontSize` prop**. Any "bump chart text to 12px on mobile" work has to
happen in that CSS rule or it silently does nothing.
## The profit line on `/charts` — Recharts constraints, do not re-derive
`MachineLineChart` draws profit as teal where the month made money and red where it did not,
fading across each sign crossing. Three things make this fragile:
- **A `Line` is one `<Curve>` path.** `<Cell>` does **not** recolour a segment — Cells only
work for `Bar`. Per-segment colour therefore requires **one `<Line>` per same-sign run**,
plus one extra `<Line>` per crossing for the gradient.
- **Never give a `Line` its own `data` prop inside a shared chart.** Recharts builds a
categorical axis by concatenating the data of *every* series, so a short sliced `data`
re-adds the month labels: the axis renders "Jan … Aug, Jan … Aug" while that series plots
at the wrong months. Put each variant on the shared dataset as its own key
(`profitRun0`, `profitCross0`) and null it outside its run; `connectNulls={false}` then
draws just that run in the right place.
- **Profit `Line`s must be direct children of the chart.** Recharts parses only known child
types, so wrapping them in a `<g>` makes the entire profit line disappear without an error.
- **The tooltip needs a custom `content` component** (`ChartTooltip` in `MachineLineChart`).
Recharts feeds the tooltip one entry per series, so the per-run keys would surface as
`profitRun0` / `profitCross0` and "profit" would appear three or four times. The component
drops every profit-backed series and adds one computed row, coloured by the month's sign.
The base series carry an explicit `name`, because Recharts otherwise labels them by key
(`running` reads as "Running cost", which is what the key is called).
Two deliberate honesty rules: profit stays **null** in a month with no income figure (never
treated as 0, which would draw a green dot for a month that was never billed), and a month
with no `monthly_running_costs` row gets a **hollow dot** because its profit is optimistic.
`/charts` alone reads profit including running cost — `lib/profit.ts` keeps its workbook
default for every other surface, and the card badge, the profit line and the Profit bars
view all share the one flag in `ChartsGrid`.
The toolbar's **Series** button toggles which lines a card draws (`mp:chartsSeries` in
localStorage, so the choice survives a reload). Two rules keep it honest: a hidden series
leaves the legend **and its Y axis** with it, so there is never an axis for a line that is
not drawn; and the last visible series cannot be switched off, because an empty chart reads
as broken. The Profit toggle also owns the loss colour, since red is a sign of the profit
line rather than a series of its own.
2026-09-12 10:05:43 +00:00
## 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)
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 13:59:30 +00:00
- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines`, `invoices`, `invoice_lines`
2026-09-12 10:05:43 +00:00
- `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time
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 13:59:30 +00:00
- `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