mega-plant/lib/invoice-booking-route.test.ts
Dave 7b3ec2fbd8 feat(income): posted income per site, and the LIVE month on the overview
Two changes to the income overview, sharing one rule for what "the month"
means (lib/income-month.ts).

POSTED INCOME PER SITE

/income gains Vehicles / Sites views, and /sites defaults to the SAME
posted-income view with a separate Internal billed invoices view.
buildSiteIncomeRows (lib/site-income.ts) is the shared calculation: actual
monthly_income supplies the amount, year and month, and the linked invoice
supplies its historical site. It never uses assets.site, fuel-log
locations or today's map assignment.

- Unposted invoice lines are billing, not posted income.
- Historical workbook income with no site evidence stays in Unallocated
  historical income; external income, whose site field names the SUPPLIER,
  stays in Unallocated external income.
- Missing source references stay visible as unallocated invoice income.
  No group is dropped to make a site list look cleaner and no site is
  guessed to force a total.
- No site profit is calculated without an explicit cost-allocation rule.
- Invoice writes use revalidateInvoicePages, which invalidates both
  registers, Income, Sites/details, Charts and Dashboard.

THE LIVE MONTH

The tiles show the year-to-date income and profit AND the current month,
and every vehicle card shows the current month income beside its year
figure.

- The month is the live CALENDAR month and it is shown even at R0. Income
  for a month is usually entered near the end of it, so the current month
  is a figure that starts at R0 and grows.
- The month index is computed on the SERVER and passed down as a number.
  The browser never works it out for itself: two clocks disagreeing would
  show two different months on the same page.
- A month profit is a dash until that month costs are posted. September
  and October carry invoice income with no posted fixed costs (their costs
  exist only as forecast rows), so a profit there would be income minus
  zero - a 100% margin that does not tie back to the year-to-date profit.
  It becomes a number by itself the moment the workbook posts that month.
- The 12-month strip shows the live month as R0 rather than a dash, so a
  card cannot contradict itself. Months after it stay a dash.
- The sparkline on a collapsed card is hidden below xl: the content column
  is only ~700px wide between md and xl, and the vehicle name must not be
  truncated to pay for a decorative trend.
2026-11-05 00:19:06 +02:00

161 lines
7.9 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { takenSiteIdsFor } from "./sites";
// Exercise the real route/helper with only PocketBase and Next's cache boundary
// replaced. No credentials, network calls, or live records enter these tests.
function loadSource(file: string, imports: Record<string, unknown>): any {
const source = readFileSync(resolve(process.cwd(), file), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
}).outputText;
const exports = {};
runInNewContext(compiled, {
exports, Response,
require: (name: string) => {
if (!(name in imports)) throw new Error(`Unexpected test dependency: ${name}`);
return imports[name];
},
}, { filename: file });
return exports;
}
function fixture(options: { inclusive?: boolean; failIncomeCreate?: boolean; failRollback?: boolean; failHeader?: boolean } = {}) {
const line = { id: "line1", invoice: "inv1", booked_asset: "old", booked_site: "", booked_year: 2026, booked_month: 9, amount_excl: 100, vat: 15, total_incl: 115 };
const sibling = { id: "line2", invoice: "inv1", booked_asset: "", booked_site: "site2" };
const original = [
{ id: "posted1", asset: "old", year: 2026, month: 9, income: 70, fixed_expenses: 0, is_forecast: false, invoice_line: "line1" },
{ id: "posted2", asset: "old", year: 2026, month: 9, income: 45, fixed_expenses: 0, is_forecast: false, invoice_line: "line1" },
];
const state = { line, invoice: { id: "inv1", is_booked: true }, income: original.map(r => ({ ...r })), invalidations: [] as string[] };
let failedCreate = false, failedHeader = false, nextId = 0;
const pb = {
collection(name: string) {
return {
async getOne(id: string) {
if (name === "invoice_lines") return { ...line };
if (name === "invoices") return { ...state.invoice };
if (name === "assets") return { id, code: "A2", name: "Other machine" };
if (name === "sites") return { id, name: "West Park" };
throw new Error(`Unexpected getOne ${name}`);
},
async getFullList(args?: { filter?: string }) {
if (name === "invoice_lines") return [{ ...line }, { ...sibling }];
if (name === "monthly_income") return state.income.filter(r => !args?.filter || r.invoice_line === "line1").map(r => ({ ...r }));
throw new Error(`Unexpected list ${name}`);
},
async update(_id: string, patch: Record<string, unknown>) {
if (name === "invoices" && options.failHeader && !failedHeader) { failedHeader = true; throw new Error("Header write failed"); }
const target = name === "invoice_lines" ? state.line : state.invoice;
Object.assign(target, patch);
return { ...target };
},
async delete(id: string) {
if (name !== "monthly_income") throw new Error(`Unexpected delete ${name}`);
state.income = state.income.filter(r => r.id !== id);
},
async create(data: Record<string, any>) {
if (name !== "monthly_income") throw new Error(`Unexpected create ${name}`);
if ((options.failIncomeCreate && !failedCreate) || (options.failRollback && failedCreate)) { failedCreate = true; throw new Error("Income storage unavailable"); }
const row = { id: `new${++nextId}`, ...data } as typeof original[number];
state.income.push(row);
return { ...row };
},
};
},
};
const booking = loadSource("lib/invoice-booking.ts", { "./invoice-booking-policy.json": { bookInclusiveVat: options.inclusive ?? true } });
const route = loadSource("app/api/invoice-lines/[id]/book/route.ts", {
"@/lib/pb": { serverAuth: async () => pb },
"@/lib/invoice-revalidate": { revalidateInvoicePages: (...extra: string[]) => state.invalidations.push(...extra) },
"@/lib/invoice-booking": booking,
"@/lib/sites": { takenSiteIdsFor },
});
return {
state, original,
async post(body: unknown) {
const response = await route.POST({ json: async () => body }, { params: Promise.resolve({ id: "line1" }) });
return { status: response.status, body: await response.json() };
},
};
}
for (const payload of [{ clear: true }, { assetId: "" }]) {
test(`clearing with ${JSON.stringify(payload)} removes every posting and recomputes invoice completion`, async () => {
const f = fixture();
const result = await f.post(payload);
assert.equal(result.status, 200);
assert.equal(result.body.booked, false);
assert.equal(f.state.line.booked_asset, "");
assert.equal(f.state.line.booked_site, "");
assert.equal(f.state.income.length, 0);
assert.equal(f.state.invoice.is_booked, false);
});
}
test("machine rebooking moves the posting and follows the shared exclusive VAT policy", async () => {
const f = fixture({ inclusive: false });
const result = await f.post({ assetId: "new" });
assert.equal(result.status, 200);
assert.equal(result.body.income, 100);
assert.equal(f.state.income.length, 1);
assert.equal(f.state.income[0].asset, "new");
assert.equal(f.state.income[0].income, 100);
assert.equal(f.state.income[0].invoice_line, "line1");
assert.equal(f.state.line.booked_site, "");
assert.equal(f.state.invoice.is_booked, true, "site-booked sibling counts as accounted for");
});
test("site-only rebooking removes machine income while keeping the invoice accounted for", async () => {
const f = fixture();
const result = await f.post({ siteId: "site1" });
assert.equal(result.status, 200);
assert.equal(f.state.line.booked_asset, "");
assert.equal(f.state.line.booked_site, "site1");
assert.equal(f.state.income.length, 0);
assert.equal(f.state.invoice.is_booked, true);
});
test("a failed replacement income write restores the previous allocation and all postings", async () => {
const f = fixture({ failIncomeCreate: true });
const result = await f.post({ assetId: "new" });
assert.equal(result.status, 500);
assert.match(result.body.error, /Income storage unavailable/);
assert.equal(f.state.line.booked_asset, "old");
assert.equal(f.state.line.booked_site, "");
assert.deepEqual(f.state.income, f.original);
assert.equal(f.state.invoice.is_booked, true);
});
test("a failed header write rolls a clear back instead of silently losing income", async () => {
const f = fixture({ failHeader: true });
const result = await f.post({ clear: true });
assert.equal(result.status, 500);
assert.equal(f.state.line.booked_asset, "old");
assert.deepEqual(f.state.income, f.original);
assert.equal(f.state.invoice.is_booked, true);
});
test("rollback failure is reported as incomplete recovery rather than success", async () => {
const f = fixture({ failIncomeCreate: true, failRollback: true });
const result = await f.post({ assetId: "new" });
assert.equal(result.status, 500);
assert.equal(result.body.ok, false);
assert.match(result.body.error, /restore|recovery|rollback/i);
});
test("invoice invalidation covers both registers and shared readers once, including dynamic site details", () => {
const calls: [string, string?][] = [];
const helper = loadSource("lib/invoice-revalidate.ts", {
"next/cache": { revalidatePath: (path: string, type?: string) => calls.push([path, type]) },
"./invoice-routes": { INVOICE_PATHS: ["/income/invoices", "/income/external-invoices"] },
});
helper.revalidateInvoicePages("/income", "/sites", "/sites/[id]", "/custom", "/custom");
for (const path of ["/income/invoices", "/income/external-invoices", "/income", "/sites", "/charts", "/", "/custom"])
assert.equal(calls.filter(([p]) => p === path).length, 1, `${path} must invalidate exactly once`);
assert.deepEqual(calls.filter(([p]) => p === "/sites/[id]"), [["/sites/[id]", "page"]]);
});