diff --git a/apps/server/src/engine/finance.ts b/apps/server/src/engine/finance.ts index f7704ee7c..98d67163c 100644 --- a/apps/server/src/engine/finance.ts +++ b/apps/server/src/engine/finance.ts @@ -20,21 +20,17 @@ export function analyzeSpending(csv: string) { quoted = !quoted; if (!quoted) { const next = csv[i + 1]; - if ( - next !== undefined && - next !== "," && - next !== "\n" && - !(next === "\r" && (csv[i + 2] === "\n" || csv[i + 2] === undefined)) - ) + if (next !== undefined && next !== "," && next !== "\n" && next !== "\r") throw new Error("Invalid quoted CSV field"); } } - } else if (!quoted && (c === "," || c === "\n" || c === undefined)) { - row.push(cell.replace(/\r$/, "")); + } else if (!quoted && (c === "," || c === "\r" || c === "\n" || c === undefined)) { + row.push(cell); cell = ""; if (c !== ",") { if (row.some((v) => v.trim())) rows.push(row); row = []; + if (c === "\r" && csv[i + 1] === "\n") i++; } } else if (c !== undefined) cell += c; } diff --git a/tests/finance.test.ts b/tests/finance.test.ts index e26e1214e..67130a5dd 100644 --- a/tests/finance.test.ts +++ b/tests/finance.test.ts @@ -41,3 +41,33 @@ test("transaction imports accept a UTF-8 byte order mark before the header", () assert.equal(report.spending, 10.1); } }); + +test("transaction imports accept CR, LF and CRLF record separators", () => { + for (const newline of ["\r", "\n", "\r\n"]) { + const report = analyzeSpending( + [ + '"date","description","amount","category"', + '2026-09-01,"Coffee, local",10.10,Food', + '2026-09-02,"Salary",-100.00,Income', + "", + ].join(newline), + ); + assert.equal(report.count, 2); + assert.equal(report.spending, 10.1); + assert.equal(report.income, 100); + assert.equal(report.transactions[0].description, "Coffee, local"); + } +}); + +test("CR record separators do not split quoted multiline transaction text", () => { + const report = analyzeSpending( + 'date,description,amount,category\r2026-09-01,"Coffee\rshop\r\nreceipt",10,Food\r', + ); + assert.equal(report.count, 1); + assert.equal(report.transactions[0].description, "Coffee\rshop\r\nreceipt"); + assert.equal(report.spending, 10); + assert.throws( + () => analyzeSpending('date,description,amount,category\r2026-09-01,"Coffee"shop,10,Food'), + /Invalid quoted CSV field/, + ); +});