Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/engine/finance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[][] = [];
Expand Down
29 changes: 29 additions & 0 deletions tests/finance-byte-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
}
});
Loading