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:
@@ -0,0 +1,334 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
import { BOM, readCsv } from './helpers/csv.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expense-csv-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Account with a fixed display_name so CSV columns and payer cells are deterministic.
|
||||
async function createAccount(displayName) {
|
||||
const agent = request.agent(app);
|
||||
const created = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(created.status, 201);
|
||||
const named = await agent.patch('/api/auth/me').send({ display_name: displayName });
|
||||
assert.equal(named.status, 200);
|
||||
return { agent, user: named.body.user };
|
||||
}
|
||||
|
||||
async function makeTrip(owner, members = [], overrides = {}) {
|
||||
const trip = (await owner.agent.post('/api/trips').send({
|
||||
name: 'Thailand Trip 2026!', start_date: '2026-08-01', end_date: '2026-08-10',
|
||||
currency: 'THB', ...overrides,
|
||||
})).body.trip;
|
||||
for (const m of members) {
|
||||
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
async function memberNames(account, trip) {
|
||||
const res = await account.agent.get(`/api/trips/${trip.id}`);
|
||||
assert.equal(res.status, 200);
|
||||
return res.body.members.map((m) => m.display_name);
|
||||
}
|
||||
|
||||
const exportUrl = (trip) => `/api/trips/${trip.id}/expenses/export.csv`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response headers & encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('export: content type, filename slug, BOM and CRLF endings', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Lunch', amount: 100, category: 'food',
|
||||
});
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'text/csv; charset=utf-8');
|
||||
assert.equal(
|
||||
res.headers['content-disposition'],
|
||||
'attachment; filename="thailand-trip-2026-expenses.csv"',
|
||||
'slug lowercases, collapses non-alphanumeric runs to "-" and trims them'
|
||||
);
|
||||
assert.ok(res.text.startsWith(BOM), 'UTF-8 BOM so Excel reads it as UTF-8');
|
||||
assert.ok(res.text.includes('\r\n'), 'CRLF line endings');
|
||||
// parseCsv fails on any bare LF/CR, so this is the real line-ending assertion.
|
||||
const { rows } = readCsv(res.text);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('export: filename falls back to trip-<id> when the name has no alphanumerics', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna, [], { name: '!!! ???' });
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-disposition'], `attachment; filename="trip-${trip.id}-expenses.csv"`);
|
||||
});
|
||||
|
||||
test('export: an empty trip yields the header row only', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.deepEqual(header, [
|
||||
'date', 'source', 'category', 'description', 'amount', 'currency',
|
||||
'paid_by', 'split', 'participants', 'share: anna',
|
||||
]);
|
||||
assert.deepEqual(rows, [], 'no totals row — data rows only');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns, quoting, interleaving and shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('export: expenses and priced entries interleave by date with correct shares', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const entries = `/api/trips/${trip.id}/entries`;
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
// 08-01 entry, equal, 900 total -> 450 each.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||
});
|
||||
// 08-02 expense with a comma AND a double quote in the description.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Dinner, "the good" place', amount: 200,
|
||||
category: 'food', paid_by: ben.user.id,
|
||||
});
|
||||
// 08-03 expense, payer split -> ben alone bears it.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-03', description: 'Solo souvenir', amount: 50, category: 'shopping',
|
||||
split_mode: 'payer', paid_by: ben.user.id,
|
||||
});
|
||||
// 08-04 expense, equal but only ben participates -> ben 60, anna 0.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-04', description: 'Taxi', amount: 60, category: 'transport',
|
||||
paid_by: ben.user.id, participants: [ben.user.id],
|
||||
});
|
||||
// 08-05 entry, own split at 300/head -> effective total 600, 300 each.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-05', type: 'flight', title: 'Flights', price: 300, split_mode: 'own',
|
||||
});
|
||||
// Unpriced entry — must not appear at all.
|
||||
await anna.agent.post(entries).send({ date: '2026-08-06', type: 'activity', title: 'Free walk' });
|
||||
|
||||
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.deepEqual(header.slice(-2), ['share: anna', 'share: ben'], 'one share column per member, in display order');
|
||||
assert.deepEqual(await memberNames(anna, trip), ['anna', 'ben']);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.date, r.source, r.description]),
|
||||
[
|
||||
['2026-08-01', 'entry', 'Hotel'],
|
||||
['2026-08-02', 'expense', 'Dinner, "the good" place'],
|
||||
['2026-08-03', 'expense', 'Solo souvenir'],
|
||||
['2026-08-04', 'expense', 'Taxi'],
|
||||
['2026-08-05', 'entry', 'Flights'],
|
||||
],
|
||||
'chronological, sources interleaved, unpriced entry excluded'
|
||||
);
|
||||
|
||||
const [hotel, dinner, souvenir, taxi, flights] = rows;
|
||||
|
||||
assert.deepEqual(hotel, {
|
||||
date: '2026-08-01', source: 'entry', category: 'stay', description: 'Hotel',
|
||||
amount: '900.00', currency: 'THB', paid_by: 'anna', split: 'equal',
|
||||
participants: 'all', 'share: anna': '450.00', 'share: ben': '450.00',
|
||||
});
|
||||
|
||||
assert.equal(dinner.category, 'food');
|
||||
assert.equal(dinner.amount, '200.00');
|
||||
assert.equal(dinner.paid_by, 'ben');
|
||||
assert.equal(dinner.participants, 'all');
|
||||
assert.deepEqual([dinner['share: anna'], dinner['share: ben']], ['100.00', '100.00']);
|
||||
|
||||
assert.equal(souvenir.split, 'payer');
|
||||
assert.equal(souvenir.amount, '50.00');
|
||||
assert.deepEqual([souvenir['share: anna'], souvenir['share: ben']], ['0.00', '50.00']);
|
||||
|
||||
assert.equal(taxi.participants, 'ben', 'explicit subset lists display names');
|
||||
assert.deepEqual([taxi['share: anna'], taxi['share: ben']], ['0.00', '60.00'],
|
||||
'non-participants get 0.00, not an empty cell');
|
||||
|
||||
assert.equal(flights.category, 'flight', 'entry rows carry the entry type as category');
|
||||
assert.equal(flights.amount, '600.00', "'own' amount is the effective total (300 x 2)");
|
||||
assert.equal(flights.paid_by, '', 'unassigned payer is an empty cell');
|
||||
assert.deepEqual([flights['share: anna'], flights['share: ben']], ['300.00', '300.00']);
|
||||
});
|
||||
|
||||
test('export: participants column joins several display names with semicolons', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const carol = await createAccount('carol');
|
||||
const trip = await makeTrip(anna, [ben, carol]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Shared tuk-tuk', amount: 90, category: 'transport',
|
||||
paid_by: anna.user.id, participants: [carol.user.id, anna.user.id],
|
||||
});
|
||||
|
||||
const { rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.equal(rows[0].participants, 'anna;carol');
|
||||
assert.deepEqual(
|
||||
[rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']],
|
||||
['45.00', '0.00', '45.00']
|
||||
);
|
||||
});
|
||||
|
||||
test('export: formula-injection guard on text columns, numbers left alone', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const evil = await createAccount('=evil-payer');
|
||||
const trip = await makeTrip(anna, [evil]);
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
const post = (date, description, amount, extra = {}) =>
|
||||
anna.agent.post(expenses).send({
|
||||
date, description, amount, category: 'food', paid_by: evil.user.id, ...extra,
|
||||
});
|
||||
|
||||
await post('2026-08-01', '=HYPERLINK("http://evil")', 380);
|
||||
await post('2026-08-02', '-50 refund', 50);
|
||||
await post('2026-08-03', '+2 beers', 20);
|
||||
await post('2026-08-04', '@lunch spot', 30);
|
||||
// Leading `=` AND an embedded comma: must be guarded *and* RFC 4180 quoted.
|
||||
await post('2026-08-05', '=SUM(1,2) sneaky', 40, { participants: [evil.user.id] });
|
||||
// `=` that is not the first character must be left exactly as typed.
|
||||
await post('2026-08-06', 'Total = 5 each', 60);
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
const { rows } = readCsv(res.text);
|
||||
|
||||
assert.deepEqual(rows.map((r) => r.description), [
|
||||
"'=HYPERLINK(\"http://evil\")",
|
||||
"'-50 refund",
|
||||
"'+2 beers",
|
||||
"'@lunch spot",
|
||||
"'=SUM(1,2) sneaky",
|
||||
'Total = 5 each',
|
||||
]);
|
||||
|
||||
// Numeric columns are server-formatted and must never pick up the prefix.
|
||||
assert.deepEqual(rows.map((r) => r.amount), ['380.00', '50.00', '20.00', '30.00', '40.00', '60.00']);
|
||||
assert.ok(
|
||||
rows.every((r) => !r['share: anna'].startsWith("'") && !r['share: =evil-payer'].startsWith("'")),
|
||||
'share columns are numeric and unguarded'
|
||||
);
|
||||
|
||||
// Display names reach the spreadsheet through paid_by and participants too.
|
||||
assert.ok(rows.every((r) => r.paid_by === "'=evil-payer"), 'payer name is guarded');
|
||||
assert.equal(rows[4].participants, "'=evil-payer", 'participant names are guarded');
|
||||
|
||||
// The guard runs before quoting, so the comma row carries both.
|
||||
assert.ok(res.text.includes('"\'=SUM(1,2) sneaky"'), 'guard prefix sits inside the quoted field');
|
||||
});
|
||||
|
||||
test('export: non-member gets 404, unauthenticated gets 401', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
const outsider = await createAccount('outsider');
|
||||
|
||||
assert.equal((await outsider.agent.get(exportUrl(trip))).status, 404);
|
||||
assert.equal((await request(app).get(exportUrl(trip))).status, 401);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('costs: expenses merge with entry costs into one settle-up', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const entries = `/api/trips/${trip.id}/entries`;
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
// Entry: 900 equal, anna paid.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||
});
|
||||
// Expense: 200 equal, ben paid.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Dinner', amount: 200, category: 'food', paid_by: ben.user.id,
|
||||
});
|
||||
// Expense: 40 own -> effective 80, each pays their own, no debt.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Bus', amount: 40, category: 'transport', split_mode: 'own',
|
||||
});
|
||||
// Expense: 300 equal with no payer -> unassigned.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-03', description: 'Cooking class', amount: 300, category: 'activities',
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.currency, 'THB');
|
||||
assert.equal(c.totalCost, 1480, '900 entry + 200 + 80 + 300 expenses');
|
||||
assert.deepEqual(c.byType, { stay: 900, expense: 580 }, 'all expenses land in one `expense` bucket');
|
||||
assert.equal(c.unassigned, 300);
|
||||
|
||||
const a = c.perUser.find((u) => u.userId === anna.user.id);
|
||||
const b = c.perUser.find((u) => u.userId === ben.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [740, 940, 200]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [740, 240, -500]);
|
||||
|
||||
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 200 }]);
|
||||
});
|
||||
|
||||
test('costs: a trip with only expenses still settles up', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||
paid_by: anna.user.id,
|
||||
});
|
||||
|
||||
const c = (await ben.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 500);
|
||||
assert.deepEqual(c.byType, { expense: 500 });
|
||||
assert.equal(c.unassigned, 0);
|
||||
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 250 }]);
|
||||
});
|
||||
|
||||
test('costs: deleting an expense removes it from the settle-up again', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
const e = (await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||
paid_by: anna.user.id,
|
||||
})).body.expense;
|
||||
|
||||
assert.equal((await anna.agent.delete(`/api/expenses/${e.id}`)).status, 204);
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 0);
|
||||
assert.deepEqual(c.byType, {});
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
Reference in New Issue
Block a user