mega-plant/lib/rows.test.ts
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

108 lines
3.8 KiB
TypeScript

// Regression tests for buildIncomeRows month aggregation.
//
// Run: node --import tsx --test lib/rows.test.ts
//
// Background: a machine can legitimately have SEVERAL income rows in one month — the
// workbook row plus one row per invoice line booked to it (one machine hired to two sites
// in a month produces two invoice rows). buildIncomeRows used to assign into months[i],
// so the last row read won and the others vanished from the month view while still being
// counted in the YTD total. SS01 September showed R20,700 when it was really R35,305.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildIncomeRows } from "./rows";
const asset = (id: string, code = "SS01") => ({
id,
code,
name: code,
category: "Plant",
type: "",
gl_account: "",
supplier: "",
monthly_excl: 0,
monthly_incl: 0,
purchase_value: 0,
is_provisional: false,
is_active: true,
}) as any;
const inc = (assetId: string, month: number, income: number, forecast = false) =>
({
id: `${assetId}-${month}-${income}`,
asset: assetId,
year: 2026,
month,
income,
fixed_expenses: 0,
is_forecast: forecast,
}) as any;
test("two invoice rows in one month are summed, not overwritten", () => {
// The real SS01 September 2026 case: PHI 167 + PHI 171 booked to the same machine.
const rows = buildIncomeRows(
[asset("a1")],
[inc("a1", 9, 14605), inc("a1", 9, 20700)],
);
assert.equal(rows.length, 1);
const r = rows[0];
assert.equal(r.months[8], 35305, "September cell must be both invoices");
assert.equal(r.income, 35305, "YTD total must agree with the month cell");
// The invariant that was broken: the cells must add up to the headline.
const sumOfMonths = r.months.reduce((s: number, v: any) => s + (v ?? 0), 0);
assert.equal(sumOfMonths, r.income, "month cells must sum to YTD income");
});
test("workbook row plus invoice rows in the same month all count", () => {
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 0), inc("a1", 9, 14605), inc("a1", 9, 20700)]);
assert.equal(rows[0].months[8], 35305);
});
test("months with no actual data stay null", () => {
// getActualMonthlyIncome() filters forecast rows out before this point, so the guard
// here is simply "no row for that month -> render a dash".
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 14605)]);
assert.equal(rows[0].months[8], 14605);
assert.equal(rows[0].months[9], null, "a month with no actual row must render as null");
assert.equal(rows[0].months[11], null);
});
test("per-month profit uses the accumulated month, not the last row", () => {
// Default: profit = income - fixed. Both invoices must be in the month's profit.
const rows = buildIncomeRows([asset("a1")], [inc("a1", 9, 14605), inc("a1", 9, 20700)]);
assert.equal(rows[0].profit[8], 35305);
});
test("multi-site machine attributes the VAT across both invoices", () => {
const line = (id: string, amount: number, vat: number) =>
({
id,
invoice: "i1",
line_no: 1,
machine_code: "SS-01-CASE",
asset_code: "SS01",
description: "430 Skidsteer",
qty: 1,
unit: "Hr",
rate: amount,
rate_type: "Dry Rate",
amount_excl: amount - vat,
vat,
total_incl: amount,
booked_asset: "a1",
booked_month: 9,
booked_year: 2026,
}) as any;
const rows = buildIncomeRows(
[asset("a1")],
[inc("a1", 9, 14605), inc("a1", 9, 20700)],
[],
{},
[line("l1", 14605, 1905), line("l2", 20700, 2700)],
);
const r = rows[0];
assert.equal(r.invoiceIncome, 35305, "combined invoice total for the vehicle");
assert.equal(r.invoiceVat, 4605, "VAT inside that total, across both invoices");
assert.equal(r.invoiceIncome, r.months[8], "invoice total matches the September cell");
});