diff --git a/apps/server/src/engine/finance.ts b/apps/server/src/engine/finance.ts index c614ebcc4..4e7273456 100644 --- a/apps/server/src/engine/finance.ts +++ b/apps/server/src/engine/finance.ts @@ -28,6 +28,12 @@ export function analyzeSpending(csv: string) { const header = rows.shift()?.map((v) => v.trim().toLowerCase()); if (!header || !["date", "description", "amount", "category"].every((v) => header.includes(v))) throw new Error("CSV needs date,description,amount,category columns"); + if ( + ["date", "description", "amount", "category"].some( + (name) => header.indexOf(name) !== header.lastIndexOf(name), + ) + ) + throw new Error("CSV has a duplicate required column"); if (!rows.length || rows.length > 5000) throw new Error("Import between 1 and 5,000 transactions"); const transactions = rows.map((r, index) => { diff --git a/tests/finance-headers.test.ts b/tests/finance-headers.test.ts new file mode 100644 index 000000000..62de874a9 --- /dev/null +++ b/tests/finance-headers.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analyzeSpending } from "../apps/server/src/engine/finance.ts"; + +test("duplicate required CSV headers do not silently discard financial values", () => { + for (const column of ["date", "description", "amount", "category"]) { + assert.throws( + () => + analyzeSpending( + `date,description,amount,category,${column}\n2026-09-01,Purchase,10,Food,1000`, + ), + /duplicate.*column/i, + ); + } +}); +test("header uniqueness is checked after the same normalization used for lookup", () => { + assert.throws( + () => + analyzeSpending( + "date,description,amount,category, Amount \n2026-09-01,Purchase,10,Food,1000", + ), + /duplicate.*column/i, + ); +}); +test("distinct reordered and extra CSV columns still work", () => { + const report = analyzeSpending( + "note,category,amount,description,date\nIgnored,Food,10,Purchase,2026-09-01", + ); + assert.equal(report.spending, 10); + assert.equal(report.count, 1); + assert.equal(report.transactions[0].description, "Purchase"); +}); + +test("duplicate irrelevant CSV columns remain accepted", () => { + const report = analyzeSpending( + "note,date,description,amount,category,note\nFirst,2026-09-01,Purchase,10,Food,Second", + ); + assert.equal(report.spending, 10); + assert.equal(report.count, 1); +});