Files
grabowski f272e74b84 Add daily expense tracking with sort/split and CSV export
Standalone expenses (date, description, category, amount) with the same
equal/own/payer split machinery as entry costs, merged into the costs
panel and settle-up as a single expense bucket. Self-fetching card
with per-day grouping, client-side sorting, and quick-add. CSV export
interleaves expenses with priced entries, one share column per member
(UTF-8 BOM, RFC 4180, formula-injection guard on text cells).

Also fixes trip deletion, which hit a foreign-key violation and rolled
back for any trip with checklist items.
2026-08-06 17:55:04 +07:00

62 lines
2.0 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared CSV reader for the expense-export tests (tests/expenses-export.test.js
// and tests/expense-csv.test.js). Not a test file — it registers no tests and
// is therefore not listed in tests/index.js.
import assert from 'node:assert/strict';
// U+FEFF. buildExpenseCsv returns the complete file including the BOM, so both
// the pure function's output and the HTTP body must carry it.
export const BOM = '';
// A strict RFC 4180 reader. It doubles as an assertion: it rejects a bare LF or
// CR outside a quoted field, so a file with LF endings fails here.
export function parseCsv(text) {
assert.ok(text.startsWith(BOM), 'CSV must be prefixed with the UTF-8 BOM');
const body = text.slice(1);
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < body.length; i += 1) {
const ch = body[i];
if (quoted) {
if (ch !== '"') { field += ch; continue; }
if (body[i + 1] === '"') { field += '"'; i += 1; continue; }
quoted = false;
} else if (ch === '"') {
quoted = true;
} else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\r' && body[i + 1] === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i += 1;
} else if (ch === '\n' || ch === '\r') {
assert.fail('bare CR/LF outside a quoted field — line endings must be CRLF');
} else {
field += ch;
}
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
assert.equal(quoted, false, 'unterminated quoted field');
return rows;
}
// Turn the parsed grid into header + row objects keyed by column name.
export function readCsv(text) {
const grid = parseCsv(text);
const header = grid[0];
const rows = grid.slice(1).map((cells) => {
assert.equal(cells.length, header.length, `row has ${cells.length} cells, header has ${header.length}`);
return Object.fromEntries(cells.map((c, i) => [header[i], c]));
});
return { header, rows };
}