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
+456
View File
@@ -0,0 +1,456 @@
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';
let tmpDir;
let app;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expenses-'));
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 });
});
async function createAccount() {
const agent = request.agent(app);
const res = await agent.post('/api/auth/account').send({});
assert.equal(res.status, 201);
return { agent, user: res.body.user };
}
// Trip owned by `owner`; every account in `members` joins via the join code.
async function makeTrip(owner, members = []) {
const trip = (await owner.agent.post('/api/trips').send({
name: 'Spending', start_date: '2026-08-01', end_date: '2026-08-10', currency: 'THB',
})).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 addExpense(account, trip, body) {
const res = await account.agent.post(`/api/trips/${trip.id}/expenses`).send({
date: '2026-08-02', description: 'Lunch', amount: 100, ...body,
});
assert.equal(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
return res.body.expense;
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
test('POST expense: defaults applied, created_by is the caller', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const res = await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
date: '2026-08-02', description: ' Street food dinner ', amount: 380,
});
assert.equal(res.status, 201);
const e = res.body.expense;
assert.equal(e.trip_id, trip.id);
assert.equal(e.date, '2026-08-02');
assert.equal(e.description, 'Street food dinner', 'description is trimmed');
assert.equal(e.category, 'other');
assert.equal(e.amount, 380);
assert.equal(e.paid_by, null);
assert.equal(e.split_mode, 'equal');
assert.deepEqual(e.participants, []);
assert.equal(e.created_by, anna.user.id);
assert.ok(Number.isInteger(e.id));
});
test('POST expense: every field round-trips, participants come back sorted', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const e = await addExpense(anna, trip, {
date: '2026-08-03', description: 'Songthaew', amount: 60.5, category: 'transport',
paid_by: ben.user.id, split_mode: 'equal', participants: [ben.user.id, anna.user.id],
});
assert.equal(e.category, 'transport');
assert.equal(e.amount, 60.5);
assert.equal(e.paid_by, ben.user.id);
assert.deepEqual(e.participants, [anna.user.id, ben.user.id].sort((a, b) => a - b));
});
test('POST expense: amount 0 is allowed; every category in the enum is accepted', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const zero = await addExpense(anna, trip, { amount: 0 });
assert.equal(zero.amount, 0);
for (const category of ['food', 'drinks', 'transport', 'activities', 'shopping', 'accommodation', 'other']) {
const e = await addExpense(anna, trip, { category });
assert.equal(e.category, category);
}
});
// ---------------------------------------------------------------------------
// List, ordering & summary
// ---------------------------------------------------------------------------
test('GET expenses: ordered by (date, id)', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
// Created deliberately out of date order.
const late = await addExpense(anna, trip, { date: '2026-08-05', description: 'late' });
const earlyA = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-a' });
const mid = await addExpense(anna, trip, { date: '2026-08-03', description: 'mid' });
const earlyB = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-b' });
const res = await anna.agent.get(`/api/trips/${trip.id}/expenses`);
assert.equal(res.status, 200);
assert.deepEqual(
res.body.expenses.map((e) => e.id),
[earlyA.id, earlyB.id, mid.id, late.id],
'same-date rows fall back to id order'
);
});
test('GET expenses: summary total/byCategory/byDay; `own` counts amount x participants', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
// equal: effective total = amount (100)
await addExpense(anna, trip, { date: '2026-08-01', description: 'Dinner', amount: 100, category: 'food', paid_by: anna.user.id });
// own with no participants rows = both members: effective total = 50 x 2 = 100
await addExpense(anna, trip, { date: '2026-08-01', description: 'Bus tickets', amount: 50, category: 'transport', split_mode: 'own' });
// payer: effective total = amount (30)
await addExpense(anna, trip, { date: '2026-08-02', description: 'Souvenir', amount: 30, category: 'shopping', split_mode: 'payer', paid_by: ben.user.id });
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
assert.equal(s.total, 230);
assert.deepEqual(s.byCategory, { food: 100, transport: 100, shopping: 30 });
assert.deepEqual(s.byDay, [
{ date: '2026-08-01', total: 200 },
{ date: '2026-08-02', total: 30 },
]);
});
test('GET expenses: `own` with a participants subset scales by that subset only', async () => {
const anna = await createAccount();
const ben = await createAccount();
const carol = await createAccount();
const trip = await makeTrip(anna, [ben, carol]);
// 3 members, but only 2 participate -> 40 x 2 = 80 (not 120).
await addExpense(anna, trip, {
date: '2026-08-04', description: 'Cable car', amount: 40, category: 'activities',
split_mode: 'own', participants: [anna.user.id, carol.user.id],
});
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
assert.equal(s.total, 80);
assert.deepEqual(s.byCategory, { activities: 80 });
assert.deepEqual(s.byDay, [{ date: '2026-08-04', total: 80 }]);
});
test('GET expenses: empty trip yields an empty list and a zeroed summary', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const body = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body;
assert.deepEqual(body.expenses, []);
assert.equal(body.summary.total, 0);
assert.deepEqual(body.summary.byCategory, {});
assert.deepEqual(body.summary.byDay, []);
});
test('GET expenses: all members see all expenses (no personal/hidden rows)', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const byAnna = await addExpense(anna, trip, { description: 'Anna paid' });
const byBen = await addExpense(ben, trip, { description: 'Ben paid' });
const forBen = (await ben.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
assert.deepEqual(forBen.map((e) => e.id).sort((a, b) => a - b), [byAnna.id, byBen.id].sort((a, b) => a - b));
assert.equal(forBen.find((e) => e.id === byAnna.id).created_by, anna.user.id);
});
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
test('PATCH expense: each field individually', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const e = await addExpense(anna, trip, {});
const url = `/api/expenses/${e.id}`;
const date = await anna.agent.patch(url).send({ date: '2026-08-07' });
assert.equal(date.status, 200);
assert.equal(date.body.expense.date, '2026-08-07');
assert.equal((await anna.agent.patch(url).send({ description: 'Brunch' })).body.expense.description, 'Brunch');
assert.equal((await anna.agent.patch(url).send({ amount: 12.75 })).body.expense.amount, 12.75);
assert.equal((await anna.agent.patch(url).send({ category: 'drinks' })).body.expense.category, 'drinks');
const paid = await anna.agent.patch(url).send({ paid_by: ben.user.id });
assert.equal(paid.body.expense.paid_by, ben.user.id);
assert.equal((await anna.agent.patch(url).send({ paid_by: null })).body.expense.paid_by, null);
assert.equal((await anna.agent.patch(url).send({ split_mode: 'own' })).body.expense.split_mode, 'own');
});
test('PATCH expense: participants replaces the whole set; [] means all members', async () => {
const anna = await createAccount();
const ben = await createAccount();
const carol = await createAccount();
const trip = await makeTrip(anna, [ben, carol]);
const e = await addExpense(anna, trip, { participants: [anna.user.id, ben.user.id] });
const url = `/api/expenses/${e.id}`;
const swapped = await anna.agent.patch(url).send({ participants: [carol.user.id] });
assert.equal(swapped.status, 200);
assert.deepEqual(swapped.body.expense.participants, [carol.user.id], 'replaces, does not merge');
const cleared = await anna.agent.patch(url).send({ participants: [] });
assert.deepEqual(cleared.body.expense.participants, [], '[] = all members');
const nulled = await anna.agent.patch(url).send({ participants: [anna.user.id] });
assert.deepEqual(nulled.body.expense.participants, [anna.user.id]);
assert.deepEqual((await anna.agent.patch(url).send({ participants: null })).body.expense.participants, []);
});
test('PATCH expense: any member may edit another member\'s expense; created_by never changes', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const e = await addExpense(anna, trip, { description: 'Anna logged this' });
const res = await ben.agent.patch(`/api/expenses/${e.id}`).send({ description: 'Ben corrected it' });
assert.equal(res.status, 200);
assert.equal(res.body.expense.description, 'Ben corrected it');
assert.equal(res.body.expense.created_by, anna.user.id, 'created_by is informational and stays put');
});
test('created_by is server-set and ignored in POST/PATCH bodies', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const created = await addExpense(anna, trip, { created_by: ben.user.id });
assert.equal(created.created_by, anna.user.id, 'POST body created_by is ignored');
const patched = await anna.agent.patch(`/api/expenses/${created.id}`).send({ created_by: ben.user.id });
assert.equal(patched.status, 200);
assert.equal(patched.body.expense.created_by, anna.user.id, 'PATCH body created_by is ignored');
});
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
test('DELETE expense -> 204, gone from the list, participant rows cleaned up', async () => {
const anna = await createAccount();
const ben = await createAccount();
const trip = await makeTrip(anna, [ben]);
const e = await addExpense(anna, trip, { participants: [anna.user.id] });
const before = app.locals.db
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
.get(e.id).c;
assert.equal(before, 1);
assert.equal((await ben.agent.delete(`/api/expenses/${e.id}`)).status, 204, 'any member may delete');
const list = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
assert.ok(!list.some((x) => x.id === e.id));
const after = app.locals.db
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
.get(e.id).c;
assert.equal(after, 0, 'expense_participants rows are deleted too');
});
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
test('validation: bad date, description, amount and category -> 400', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const base = `/api/trips/${trip.id}/expenses`;
const ok = { date: '2026-08-02', description: 'Lunch', amount: 100 };
const post = (body) => anna.agent.post(base).send({ ...ok, ...body });
// date
assert.equal((await anna.agent.post(base).send({ description: 'x', amount: 1 })).status, 400, 'date required');
assert.equal((await post({ date: 'not-a-date' })).status, 400);
assert.equal((await post({ date: '2026-13-01' })).status, 400, 'month 13');
assert.equal((await post({ date: '2026-02-30' })).status, 400, 'impossible day');
assert.equal((await post({ date: '2026-8-2' })).status, 400, 'unpadded');
assert.equal((await post({ date: 20260802 })).status, 400, 'non-string');
// description
assert.equal((await anna.agent.post(base).send({ date: ok.date, amount: 1 })).status, 400, 'description required');
assert.equal((await post({ description: '' })).status, 400);
assert.equal((await post({ description: ' ' })).status, 400, 'whitespace-only');
assert.equal((await post({ description: 'x'.repeat(121) })).status, 400, '>120 chars');
assert.equal((await post({ description: 'x'.repeat(120) })).status, 201, '120 chars is the limit, inclusive');
// amount
assert.equal((await anna.agent.post(base).send({ date: ok.date, description: 'x' })).status, 400, 'amount required');
assert.equal((await post({ amount: -1 })).status, 400, 'negative');
assert.equal((await post({ amount: '100' })).status, 400, 'string is not a number');
assert.equal((await post({ amount: null })).status, 400);
assert.equal((await post({ amount: {} })).status, 400);
// category
assert.equal((await post({ category: 'gadgets' })).status, 400, 'unknown category');
assert.equal((await post({ category: 'Food' })).status, 400, 'enum is case-sensitive');
assert.equal((await post({ category: 7 })).status, 400);
});
test('validation: paid_by, split_mode and participants -> 400', async () => {
const anna = await createAccount();
const ben = await createAccount();
const outsider = await createAccount();
const trip = await makeTrip(anna, [ben]);
const base = `/api/trips/${trip.id}/expenses`;
const post = (body) => anna.agent.post(base).send({ date: '2026-08-02', description: 'Lunch', amount: 100, ...body });
assert.equal((await post({ paid_by: outsider.user.id })).status, 400, 'paid_by must be a trip member');
assert.equal((await post({ paid_by: 999999 })).status, 400, 'paid_by must exist');
assert.equal((await post({ paid_by: 'anna' })).status, 400);
assert.equal((await post({ split_mode: 'payer' })).status, 400, "'payer' requires paid_by");
assert.equal((await post({ split_mode: 'payer', paid_by: ben.user.id })).status, 201);
assert.equal((await post({ split_mode: 'sideways' })).status, 400, 'unknown split_mode');
assert.equal((await post({ participants: [anna.user.id, outsider.user.id] })).status, 400, 'non-member participant');
assert.equal((await post({ participants: [999999] })).status, 400);
assert.equal((await post({ participants: 5 })).status, 400, 'not an array');
assert.equal((await post({ participants: ['1'] })).status, 400, 'not member ids');
});
test('validation: PATCH applies the same rules and merges for the payer/paid_by rule', async () => {
const anna = await createAccount();
const ben = await createAccount();
const outsider = await createAccount();
const trip = await makeTrip(anna, [ben]);
const e = await addExpense(anna, trip, {});
const url = `/api/expenses/${e.id}`;
const patch = (body) => anna.agent.patch(url).send(body);
assert.equal((await patch({ date: '2026-13-01' })).status, 400);
assert.equal((await patch({ description: ' ' })).status, 400);
assert.equal((await patch({ description: 'x'.repeat(121) })).status, 400);
assert.equal((await patch({ amount: -0.01 })).status, 400);
assert.equal((await patch({ amount: 'free' })).status, 400);
assert.equal((await patch({ category: 'gadgets' })).status, 400);
assert.equal((await patch({ paid_by: outsider.user.id })).status, 400);
assert.equal((await patch({ participants: [outsider.user.id] })).status, 400);
// 'payer' is validated against the EFFECTIVE (merged) values, like entries.
assert.equal((await patch({ split_mode: 'payer' })).status, 400, 'no existing paid_by to inherit');
assert.equal((await patch({ split_mode: 'payer', paid_by: ben.user.id })).status, 200);
assert.equal((await patch({ paid_by: null })).status, 400, 'clearing paid_by would strand a payer split');
assert.equal((await patch({ split_mode: 'equal', paid_by: null })).status, 200);
// An empty PATCH body is harmless.
assert.equal((await patch({})).status, 200);
});
test('validation: a rejected request leaves the stored row untouched', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const e = await addExpense(anna, trip, { description: 'Lunch', amount: 100 });
assert.equal((await anna.agent.patch(`/api/expenses/${e.id}`).send({
description: 'Changed', amount: -5,
})).status, 400);
const stored = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses[0];
assert.equal(stored.description, 'Lunch');
assert.equal(stored.amount, 100);
});
// ---------------------------------------------------------------------------
// Access control
// ---------------------------------------------------------------------------
test('access control: non-member gets 404 (never 403) everywhere', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const e = await addExpense(anna, trip, {});
const outsider = await createAccount();
const list = await outsider.agent.get(`/api/trips/${trip.id}/expenses`);
assert.equal(list.status, 404);
assert.deepEqual(list.body, { error: 'not found' }, 'no existence leak');
assert.equal((await outsider.agent.post(`/api/trips/${trip.id}/expenses`).send({
date: '2026-08-02', description: 'Sneaky', amount: 1,
})).status, 404);
assert.equal((await outsider.agent.get(`/api/trips/${trip.id}/expenses/export.csv`)).status, 404);
assert.equal((await outsider.agent.patch(`/api/expenses/${e.id}`).send({ amount: 1 })).status, 404);
assert.equal((await outsider.agent.delete(`/api/expenses/${e.id}`)).status, 404);
// The expense really does still exist — the 404 was about access, not absence.
assert.equal((await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses.length, 1);
});
test('access control: unknown / malformed ids -> 404', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
assert.equal((await anna.agent.patch('/api/expenses/999999').send({ amount: 1 })).status, 404);
assert.equal((await anna.agent.delete('/api/expenses/999999')).status, 404);
assert.equal((await anna.agent.patch('/api/expenses/not-a-number').send({ amount: 1 })).status, 404);
assert.equal((await anna.agent.get(`/api/trips/999999/expenses`)).status, 404);
assert.equal((await anna.agent.get(`/api/trips/${trip.id}x/expenses`)).status, 404);
});
test('access control: unauthenticated -> 401 on every expense route', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const e = await addExpense(anna, trip, {});
for (const res of [
await request(app).get(`/api/trips/${trip.id}/expenses`),
await request(app).post(`/api/trips/${trip.id}/expenses`).send({ date: '2026-08-02', description: 'x', amount: 1 }),
await request(app).get(`/api/trips/${trip.id}/expenses/export.csv`),
await request(app).patch(`/api/expenses/${e.id}`).send({ amount: 1 }),
await request(app).delete(`/api/expenses/${e.id}`),
]) {
assert.equal(res.status, 401);
assert.deepEqual(res.body, { error: 'unauthorized' });
}
});
// ---------------------------------------------------------------------------
// Trip lifecycle
// ---------------------------------------------------------------------------
test('deleting a trip removes its expenses', async () => {
const anna = await createAccount();
const trip = await makeTrip(anna);
const e = await addExpense(anna, trip, {});
assert.equal((await anna.agent.delete(`/api/trips/${trip.id}`)).status, 204);
const left = app.locals.db.prepare('SELECT COUNT(*) AS c FROM expenses WHERE id = ?').get(e.id).c;
assert.equal(left, 0);
});