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.
161 lines
7.9 KiB
TypeScript
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"]]);
|
|
});
|