diff --git a/apps/server/src/engine/finance.ts b/apps/server/src/engine/finance.ts index f7704ee7c..e786fb9b9 100644 --- a/apps/server/src/engine/finance.ts +++ b/apps/server/src/engine/finance.ts @@ -2,7 +2,8 @@ import { z } from "zod"; /** CSV amounts use positive expenses and negative income. No currency conversion is inferred. */ export function analyzeSpending(csv: string) { - if (csv.length > 500000) throw new Error("Import at most 500 KB of transaction CSV"); + if (Buffer.byteLength(csv, "utf8") > 500000) + throw new Error("Import at most 500 KB of transaction CSV"); // Excel and many bank exports start a UTF-8 CSV with a byte order mark. It is not part of the first header. if (csv.charCodeAt(0) === 0xfeff) csv = csv.slice(1); const rows: string[][] = []; diff --git a/tests/finance-byte-limit.test.ts b/tests/finance-byte-limit.test.ts new file mode 100644 index 000000000..e7c895ddb --- /dev/null +++ b/tests/finance-byte-limit.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analyzeSpending } from "../apps/server/src/engine/finance.ts"; + +const header = "date,description,amount,category\n2026-09-01,"; +const suffix = ",10,Food"; + +test("transaction CSV size limits count UTF-8 bytes rather than UTF-16 units", () => { + const csv = `${header}${"中".repeat(167000)}${suffix}`; + assert.ok(csv.length < 500000); + assert.ok(Buffer.byteLength(csv) > 500000); + assert.throws(() => analyzeSpending(csv), /at most 500 KB/); +}); + +test("transaction CSV byte limit accepts the exact boundary for ASCII and Unicode", () => { + const available = 500000 - Buffer.byteLength(header + suffix); + for (const character of ["x", "中", "😀"]) { + const width = Buffer.byteLength(character); + const description = + character.repeat(Math.floor(available / width)) + "x".repeat(available % width); + const csv = `${header}${description}${suffix}`; + assert.equal(Buffer.byteLength(csv), 500000); + const report = analyzeSpending(csv); + assert.equal(report.count, 1); + assert.equal(report.spending, 10); + assert.equal(report.transactions[0].description, description); + assert.throws(() => analyzeSpending(`${csv}\n`), /at most 500 KB/); + } +});