// 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"); });