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.
62 lines
2.0 KiB
JavaScript
62 lines
2.0 KiB
JavaScript
// 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 };
|
||
}
|