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.
This commit is contained in:
2026-08-06 17:55:04 +07:00
parent e342cd9a91
commit f272e74b84
19 changed files with 2123 additions and 6 deletions
+189
View File
@@ -0,0 +1,189 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildExpenseCsv } from '../src/server/util/expenseCsv.js';
import { BOM, readCsv } from './helpers/csv.js';
// Unit tests for the pure CSV builder. The endpoint that serves its output is
// covered in tests/expenses-export.test.js.
const TRIP = { id: 3, name: 'Trip', currency: 'EUR' };
const MEMBERS = [{ id: 1, display_name: 'anna' }, { id: 2, display_name: 'ben' }];
function csvRows(rows, members = MEMBERS) {
return readCsv(buildExpenseCsv({ trip: TRIP, members, rows }));
}
function baseRow(over = {}) {
return {
date: '2026-08-01', source: 'expense', category: 'food', description: 'Lunch',
amount: 100, paid_by: 1, split_mode: 'equal', participants: [], ...over,
};
}
test('buildExpenseCsv: header row, BOM and CRLF with no data rows', () => {
const csv = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [] });
assert.ok(csv.startsWith(BOM), 'the builder returns the complete file, BOM included');
assert.ok(csv.endsWith('\r\n'), 'CRLF line endings');
assert.ok(!csv.includes('\n\n'));
const { header, rows } = readCsv(csv);
assert.deepEqual(header, [
'date', 'source', 'category', 'description', 'amount', 'currency',
'paid_by', 'split', 'participants', 'share: anna', 'share: ben',
]);
assert.deepEqual(rows, []);
});
test('buildExpenseCsv: quotes fields containing a comma, a quote, or a newline', () => {
const raw = buildExpenseCsv({
trip: TRIP,
members: MEMBERS,
rows: [
baseRow({ description: 'Dinner, "the good" place' }),
baseRow({ description: 'Two\r\nlines' }),
baseRow({ description: 'Plain' }),
],
});
// Round-trips through a strict reader with the values intact.
const { rows } = readCsv(raw);
assert.deepEqual(rows.map((r) => r.description), [
'Dinner, "the good" place',
'Two\r\nlines',
'Plain',
]);
// And the raw bytes use RFC 4180 escaping, not stripping or backslashes.
assert.ok(raw.includes('"Dinner, ""the good"" place"'), 'embedded quotes are doubled, whole field quoted');
assert.ok(!raw.includes('\\"'), 'no backslash escaping');
assert.ok(raw.includes(',Plain,'), 'a field needing no quoting is left bare');
});
test('buildExpenseCsv: guards every leading formula character, and only the leading one', () => {
const raw = buildExpenseCsv({
trip: TRIP,
members: MEMBERS,
rows: [
baseRow({ description: '=SUM(A1)' }),
baseRow({ description: '+1 tip' }),
baseRow({ description: '-5 refund' }),
baseRow({ description: '@here' }),
// Tab and CR can only arrive through the pure function — the route trims
// them off the description before storing.
baseRow({ description: '\tTabbed' }),
baseRow({ description: '\rCarriage' }),
baseRow({ description: 'Total = 5' }),
baseRow({ description: 'a-b+c' }),
baseRow({ description: '2 coffees' }),
baseRow({ description: 'Lunch' }),
],
});
const { rows } = readCsv(raw);
assert.deepEqual(rows.map((r) => r.description), [
"'=SUM(A1)", "'+1 tip", "'-5 refund", "'@here", "'\tTabbed", "'\rCarriage",
// Values starting with a digit or a letter are left exactly as typed.
'Total = 5', 'a-b+c', '2 coffees', 'Lunch',
]);
assert.deepEqual(rows.map((r) => r.amount), Array(10).fill('100.00'), 'amount is never guarded');
// Unquoted where no RFC 4180 character is present — the guard alone does not
// force quoting.
assert.ok(raw.includes(",'=SUM(A1),"), 'guarded but not quoted');
assert.ok(raw.includes(',Total = 5,'), 'a non-leading = is untouched and unquoted');
});
test('buildExpenseCsv: a guarded field with a comma is both prefixed and quoted', () => {
const raw = buildExpenseCsv({
trip: TRIP,
members: MEMBERS,
rows: [
baseRow({ description: '=A,B' }),
baseRow({ description: '=HYPERLINK("http://evil"), click' }),
],
});
assert.ok(raw.includes('"\'=A,B"'), 'guard runs first, then the whole field is quoted');
assert.ok(
raw.includes('"\'=HYPERLINK(""http://evil""), click"'),
'guard prefix inside the quotes, embedded quotes doubled'
);
const { rows } = readCsv(raw);
assert.deepEqual(rows.map((r) => r.description), [
'\'=A,B',
'\'=HYPERLINK("http://evil"), click',
]);
});
test('buildExpenseCsv: category, payer and participant names are guarded too', () => {
const members = [
{ id: 1, display_name: '=evil-one' },
{ id: 2, display_name: '+evil-two' },
];
const { header, rows } = csvRows(
[baseRow({ category: '=food', paid_by: 1, participants: [1, 2] })],
members
);
assert.equal(rows[0].category, "'=food");
assert.equal(rows[0].paid_by, "'=evil-one");
assert.equal(rows[0].participants, "'=evil-one;+evil-two",
'the joined string is guarded once, on its first character');
// The `share:` header cells are deliberately NOT prefixed: the guard looks at
// the first character of the whole cell, and "share: " already puts a letter
// there, so Excel can never read it as a formula. Prefixing would corrupt the
// column name for no gain.
assert.deepEqual(header.slice(-2), ['share: =evil-one', 'share: +evil-two']);
});
test('buildExpenseCsv: quotes a member display name containing a comma in the header', () => {
const { header } = csvRows([], [{ id: 1, display_name: 'Ann, Marie' }]);
assert.equal(header[header.length - 1], 'share: Ann, Marie');
const raw = buildExpenseCsv({ trip: TRIP, members: [{ id: 1, display_name: 'Ann, Marie' }], rows: [] });
assert.ok(raw.includes('"share: Ann, Marie"'), 'header cell is quoted too');
});
test('buildExpenseCsv: effective totals and per-member shares for each split mode', () => {
const { rows } = csvRows([
baseRow({ description: 'Equal', amount: 100 }),
baseRow({ description: 'Own', amount: 100, split_mode: 'own', paid_by: null }),
baseRow({ description: 'Payer', amount: 100, split_mode: 'payer', paid_by: 2 }),
baseRow({ description: 'Subset', amount: 100, participants: [2] }),
]);
const [equal, own, payer, subset] = rows;
assert.deepEqual([equal.amount, equal['share: anna'], equal['share: ben']], ['100.00', '50.00', '50.00']);
assert.deepEqual([own.amount, own['share: anna'], own['share: ben']], ['200.00', '100.00', '100.00']);
assert.deepEqual([payer.amount, payer['share: anna'], payer['share: ben']], ['100.00', '0.00', '100.00']);
assert.deepEqual([subset.amount, subset['share: anna'], subset['share: ben']], ['100.00', '0.00', '100.00']);
assert.equal(equal.participants, 'all');
assert.equal(subset.participants, 'ben');
assert.equal(own.paid_by, '', 'null payer renders as an empty cell');
assert.equal(equal.currency, 'EUR', 'currency comes from the trip');
});
test('buildExpenseCsv: shares are 2dp even when the split does not divide evenly', () => {
const members = [
{ id: 1, display_name: 'anna' },
{ id: 2, display_name: 'ben' },
{ id: 3, display_name: 'carol' },
];
const { rows } = csvRows([baseRow({ amount: 100, participants: [] })], members);
assert.deepEqual(
[rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']],
['33.33', '33.33', '33.33']
);
assert.equal(rows[0].amount, '100.00');
});
test('buildExpenseCsv: rows are emitted in the order given (the route pre-sorts)', () => {
const { rows } = csvRows([
baseRow({ date: '2026-08-01', description: 'first' }),
baseRow({ date: '2026-08-02', description: 'second', source: 'entry', category: 'stay' }),
baseRow({ date: '2026-08-03', description: 'third' }),
]);
assert.deepEqual(rows.map((r) => r.description), ['first', 'second', 'third']);
assert.deepEqual(rows.map((r) => r.source), ['expense', 'entry', 'expense']);
assert.equal(rows[1].category, 'stay');
});