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
+2
View File
@@ -8,6 +8,7 @@ import authRoutes from './routes/auth.js';
import tripsRoutes from './routes/trips.js';
import entriesRoutes from './routes/entries.js';
import checklistRoutes from './routes/checklist.js';
import expensesRoutes from './routes/expenses.js';
import geocodeRoutes from './routes/geocode.js';
import airportsRoutes from './routes/airports.js';
import directionsRoutes from './routes/directions.js';
@@ -52,6 +53,7 @@ export function createApp(options = {}) {
app.use('/api/trips', requireAuth, tripsRoutes(db));
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
app.use('/api', requireAuth, checklistRoutes(db)); // /trips/:id/checklist* + /checklist/:itemId
app.use('/api', requireAuth, expensesRoutes(db)); // /trips/:id/expenses* + /expenses/:expenseId
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
app.use('/api/airports', requireAuth, airportsRoutes());
app.use('/api/directions', requireAuth, directionsRoutes());
+20
View File
@@ -72,10 +72,30 @@ CREATE TABLE IF NOT EXISTS checklist_items (
created_at TEXT DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
amount REAL NOT NULL,
paid_by INTEGER REFERENCES users(id),
split_mode TEXT NOT NULL DEFAULT 'equal',
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TEXT DEFAULT current_timestamp
);
CREATE TABLE IF NOT EXISTS expense_participants (
expense_id INTEGER NOT NULL REFERENCES expenses(id),
user_id INTEGER NOT NULL REFERENCES users(id),
PRIMARY KEY (expense_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
CREATE INDEX IF NOT EXISTS idx_checklist_trip ON checklist_items(trip_id, category, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_expenses_trip ON expenses(trip_id, date, id);
`;
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
+367
View File
@@ -0,0 +1,367 @@
import express from 'express';
import { isValidDateStr } from '../util/dates.js';
import { membership } from '../util/access.js';
import { attachParticipants } from '../util/entrySerialize.js';
import { buildExpenseCsv } from '../util/expenseCsv.js';
const MAX_DESCRIPTION_LEN = 120;
const EXPENSE_CATEGORIES = new Set([
'food',
'drinks',
'transport',
'activities',
'shopping',
'accommodation',
'other',
]);
const SPLIT_MODES = new Set(['equal', 'own', 'payer']);
function round2(v) {
return Math.round((v + Number.EPSILON) * 100) / 100;
}
function expenseJson(row) {
return {
id: row.id,
trip_id: row.trip_id,
date: row.date,
description: row.description,
category: row.category,
amount: row.amount,
paid_by: row.paid_by ?? null,
split_mode: row.split_mode,
participants: row.participants ?? [],
created_by: row.created_by,
};
}
// Effective participants of an expense: its own list, or all current members
// when empty/absent (same default as Cost semantics).
function effectiveParticipantIds(row, memberIds) {
let eff =
Array.isArray(row.participants) && row.participants.length
? row.participants.filter((id) => memberIds.has(id))
: [...memberIds];
if (eff.length === 0) eff = [...memberIds];
return eff;
}
function summaryFor(expenses, memberIds) {
let total = 0;
const byCategory = {};
const byDayMap = new Map();
for (const e of expenses) {
const eff = effectiveParticipantIds(e, memberIds);
const effTotal = e.split_mode === 'own' ? e.amount * eff.length : e.amount;
total += effTotal;
byCategory[e.category] = (byCategory[e.category] || 0) + effTotal;
byDayMap.set(e.date, (byDayMap.get(e.date) || 0) + effTotal);
}
const byDay = [...byDayMap.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([date, t]) => ({ date, total: round2(t) }));
const roundedByCategory = {};
for (const [k, v] of Object.entries(byCategory)) roundedByCategory[k] = round2(v);
return { total: round2(total), byCategory: roundedByCategory, byDay };
}
// Slug for the CSV filename: lowercase, non-alphanumeric runs -> '-', trimmed;
// fallback `trip-<id>`.
function tripSlug(name, id) {
const slug = (name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || `trip-${id}`;
}
// Validate an expense body. `partial` = true for PATCH (only provided keys
// checked). `existing` is the current row (PATCH merges for the payer rule).
// `memberIds` is a Set of the trip's member user ids. Returns { error } or
// { fields, participants, hasParticipants } (participants is null [= all
// members] or an array of ids).
function validateExpense(body, { partial, existing, memberIds }) {
const fields = {};
const has = (k) => k in body;
if (!partial || has('date')) {
if (!isValidDateStr(body.date)) {
return { error: 'date must be a valid YYYY-MM-DD date' };
}
fields.date = body.date;
}
if (!partial || has('description')) {
if (typeof body.description !== 'string' || body.description.trim() === '') {
return { error: 'description is required' };
}
const trimmed = body.description.trim();
if (trimmed.length > MAX_DESCRIPTION_LEN) {
return { error: `description must be at most ${MAX_DESCRIPTION_LEN} characters` };
}
fields.description = trimmed;
}
if (has('category')) {
if (!EXPENSE_CATEGORIES.has(body.category)) {
return {
error:
'category must be one of food, drinks, transport, activities, shopping, accommodation, other',
};
}
fields.category = body.category;
} else if (!partial) {
fields.category = 'other';
}
if (!partial || has('amount')) {
const v = body.amount;
if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
return { error: 'amount must be a finite number >= 0' };
}
fields.amount = v;
}
if (has('paid_by')) {
const v = body.paid_by;
if (v !== null && !(Number.isInteger(v) && memberIds.has(v))) {
return { error: 'paid_by must be null or a trip member id' };
}
fields.paid_by = v;
} else if (!partial) {
fields.paid_by = null;
}
if (has('split_mode')) {
if (!SPLIT_MODES.has(body.split_mode)) {
return { error: 'split_mode must be one of equal, own, payer' };
}
fields.split_mode = body.split_mode;
} else if (!partial) {
fields.split_mode = 'equal';
}
// 'payer' requires an effective paid_by (merging existing values on PATCH).
const effMode = 'split_mode' in fields ? fields.split_mode : existing?.split_mode ?? 'equal';
const effPaidBy = 'paid_by' in fields ? fields.paid_by : existing?.paid_by ?? null;
if (effMode === 'payer' && (effPaidBy === null || effPaidBy === undefined)) {
return { error: "split_mode 'payer' requires paid_by" };
}
// participants: null/[] (= all members) or array of trip-member ids.
let participants;
if (has('participants')) {
const v = body.participants;
if (v === null || (Array.isArray(v) && v.length === 0)) {
participants = null;
} else if (Array.isArray(v)) {
for (const id of v) {
if (!Number.isInteger(id) || !memberIds.has(id)) {
return { error: 'participants must be trip member ids' };
}
}
participants = [...new Set(v)];
} else {
return { error: 'participants must be null or an array of member ids' };
}
}
return { fields, participants, hasParticipants: has('participants') };
}
export default function expensesRoutes(db) {
const router = express.Router();
const getTripMeta = db.prepare('SELECT id, name, currency FROM trips WHERE id = ?');
const getMembers = db.prepare(`
SELECT u.id, u.display_name
FROM trip_members tm JOIN users u ON u.id = tm.user_id
WHERE tm.trip_id = ? ORDER BY tm.role = 'owner' DESC, u.display_name
`);
const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?');
const getExpensesForTrip = db.prepare(
'SELECT * FROM expenses WHERE trip_id = ? ORDER BY date, id'
);
const getExpenseById = db.prepare('SELECT * FROM expenses WHERE id = ?');
const getExpenseParticipants = db.prepare(
'SELECT user_id FROM expense_participants WHERE expense_id = ? ORDER BY user_id'
);
const insertExpense = db.prepare(`
INSERT INTO expenses (trip_id, date, description, category, amount, paid_by, split_mode, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertParticipant = db.prepare(
'INSERT OR IGNORE INTO expense_participants (expense_id, user_id) VALUES (?, ?)'
);
const clearParticipants = db.prepare('DELETE FROM expense_participants WHERE expense_id = ?');
const getPricedEntries = db.prepare(
`SELECT id, date, type, title, price, paid_by, split_mode FROM entries
WHERE trip_id = ? AND price IS NOT NULL ORDER BY date, id`
);
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
function attachExpenseParticipants(row) {
if (!row) return row;
row.participants = getExpenseParticipants.all(row.id).map((r) => r.user_id);
return row;
}
function writeParticipants(expenseId, participants) {
clearParticipants.run(expenseId);
if (Array.isArray(participants)) {
for (const uid of participants) insertParticipant.run(expenseId, uid);
}
}
// Resolve an expense by id and confirm the caller is a member of its trip.
// Sends 404 and returns null otherwise (no leaking).
function requireExpense(req, res) {
const expenseId = Number(req.params.expenseId);
const row = Number.isInteger(expenseId) ? getExpenseById.get(expenseId) : null;
if (!row || !membership(db, row.trip_id, req.session.userId)) {
res.status(404).json({ error: 'not found' });
return null;
}
return row;
}
// GET /trips/:id/expenses
router.get('/trips/:id/expenses', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const rows = getExpensesForTrip.all(tripId).map(attachExpenseParticipants);
res.status(200).json({
expenses: rows.map(expenseJson),
summary: summaryFor(rows, memberIdSet(tripId)),
});
});
// GET /trips/:id/expenses/export.csv
router.get('/trips/:id/expenses/export.csv', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const trip = getTripMeta.get(tripId);
const members = getMembers.all(tripId);
const expenseRows = getExpensesForTrip
.all(tripId)
.map(attachExpenseParticipants)
.map((e) => ({
id: e.id,
date: e.date,
source: 'expense',
category: e.category,
description: e.description,
amount: e.amount,
paid_by: e.paid_by,
split_mode: e.split_mode,
participants: e.participants,
}));
const entryRows = getPricedEntries
.all(tripId)
.map((r) => attachParticipants(db, r))
.map((e) => ({
id: e.id,
date: e.date,
source: 'entry',
category: e.type,
description: e.title,
amount: e.price,
paid_by: e.paid_by,
split_mode: e.split_mode,
participants: e.participants,
}));
// buildExpenseCsv formats rows as given; interleaving two id namespaces
// (expense ids and entry ids) by (date, id) is done here.
const rows = [...expenseRows, ...entryRows].sort((a, b) => {
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
if (a.id !== b.id) return a.id - b.id;
return a.source < b.source ? -1 : a.source > b.source ? 1 : 0;
});
const csv = buildExpenseCsv({ trip, members, rows });
const filename = `${tripSlug(trip.name, trip.id)}-expenses.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(csv);
});
// POST /trips/:id/expenses { date, description, amount, category?, paid_by?, split_mode?, participants? }
router.post('/trips/:id/expenses', (req, res) => {
const tripId = Number(req.params.id);
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
return res.status(404).json({ error: 'not found' });
}
const check = validateExpense(req.body || {}, {
partial: false,
existing: null,
memberIds: memberIdSet(tripId),
});
if (check.error) return res.status(400).json({ error: check.error });
const f = check.fields;
const callerId = req.session.userId;
const expense = db.transaction(() => {
const info = insertExpense.run(
tripId,
f.date,
f.description,
f.category ?? 'other',
f.amount,
f.paid_by ?? null,
f.split_mode ?? 'equal',
callerId
);
const id = Number(info.lastInsertRowid);
if (Array.isArray(check.participants)) writeParticipants(id, check.participants);
return attachExpenseParticipants(getExpenseById.get(id));
})();
res.status(201).json({ expense: expenseJson(expense) });
});
// PATCH /expenses/:expenseId
router.patch('/expenses/:expenseId', (req, res) => {
const row = requireExpense(req, res);
if (!row) return;
const check = validateExpense(req.body || {}, {
partial: true,
existing: row,
memberIds: memberIdSet(row.trip_id),
});
if (check.error) return res.status(400).json({ error: check.error });
const expense = db.transaction(() => {
const keys = Object.keys(check.fields);
if (keys.length > 0) {
const setClause = keys.map((k) => `${k} = ?`).join(', ');
const values = keys.map((k) => check.fields[k]);
db.prepare(`UPDATE expenses SET ${setClause} WHERE id = ?`).run(...values, row.id);
}
if (check.hasParticipants) writeParticipants(row.id, check.participants);
return attachExpenseParticipants(getExpenseById.get(row.id));
})();
res.status(200).json({ expense: expenseJson(expense) });
});
// DELETE /expenses/:expenseId
router.delete('/expenses/:expenseId', (req, res) => {
const row = requireExpense(req, res);
if (!row) return;
db.transaction(() => {
clearParticipants.run(row.id);
db.prepare('DELETE FROM expenses WHERE id = ?').run(row.id);
})();
res.status(204).end();
});
return router;
}
+30 -1
View File
@@ -148,6 +148,24 @@ export default function tripsRoutes(db) {
);
const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?');
const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?');
const getExpenses = db.prepare(
'SELECT id, amount, paid_by, split_mode FROM expenses WHERE trip_id = ?'
);
const getExpenseParticipants = db.prepare(
'SELECT user_id FROM expense_participants WHERE expense_id = ? ORDER BY user_id'
);
// Expenses merge into computeCosts as pseudo-entries under byType key
// 'expense' (see docs/API.md "Costs & splitting (computed)").
function expensesAsCostEntries(tripId) {
return getExpenses.all(tripId).map((e) => ({
type: 'expense',
price: e.amount,
paid_by: e.paid_by,
split_mode: e.split_mode,
participants: getExpenseParticipants.all(e.id).map((r) => r.user_id),
}));
}
// Generate a join_code not already in use.
function uniqueJoinCode() {
@@ -259,6 +277,15 @@ export default function tripsRoutes(db) {
return res.status(403).json({ error: 'only the owner can delete a trip' });
}
db.transaction(() => {
// Children before parents, per FK constraints (foreign_keys = ON).
db.prepare(
'DELETE FROM expense_participants WHERE expense_id IN (SELECT id FROM expenses WHERE trip_id = ?)'
).run(ctx.tripId);
db.prepare('DELETE FROM expenses WHERE trip_id = ?').run(ctx.tripId);
db.prepare('DELETE FROM checklist_items WHERE trip_id = ?').run(ctx.tripId);
db.prepare(
'DELETE FROM entry_participants WHERE entry_id IN (SELECT id FROM entries WHERE trip_id = ?)'
).run(ctx.tripId);
db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId);
db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId);
db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId);
@@ -406,7 +433,9 @@ export default function tripsRoutes(db) {
if (!ctx) return;
const trip = getTrip.get(ctx.tripId);
const members = getMembers.all(ctx.tripId);
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId));
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId)).concat(
expensesAsCostEntries(ctx.tripId)
);
res.status(200).json(
computeCosts({ currency: trip.currency, members, entries })
);
+124
View File
@@ -0,0 +1,124 @@
// Pure CSV builder for the expense export (see docs/API.md "Expenses (daily
// spending log)" -> "CSV export"). Rows unify expenses and priced calendar
// entries so the export covers the trip's complete money picture in one file.
// Kept side-effect free so it can be unit-tested directly. Sorting rows by
// (date, id) across the two source tables is the caller's job (the route) —
// this function just formats whatever order it's given.
function round2(v) {
return Math.round((v + Number.EPSILON) * 100) / 100;
}
// Formula-injection guard: a field whose first character is =, +, -, @, tab
// or CR is prefixed with a single quote before quoting. Applied to every
// free-text column (description, category, payer/participant names) since
// trips are multi-user and another member's text lands in the caller's
// spreadsheet. Numeric columns (amount, shares) are server-formatted and
// never passed through this.
function guardFormula(value) {
const s = value === null || value === undefined ? '' : String(value);
return /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
}
// RFC 4180 quoting: quote fields containing a quote, comma, CR or LF, and
// double any embedded quotes.
function csvField(value) {
const s = value === null || value === undefined ? '' : String(value);
if (/[",\r\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// Effective participants for a row: its own list, or every current member
// when empty/absent (same default as Cost semantics).
function effectiveParticipants(row, memberIds) {
let eff =
Array.isArray(row.participants) && row.participants.length
? row.participants.filter((id) => memberIds.includes(id))
: memberIds;
if (eff.length === 0) eff = memberIds;
return eff;
}
function effectiveTotal(row, eff) {
return row.split_mode === 'own' ? row.amount * eff.length : row.amount;
}
// Per-member share of a single row, following the same equal/own/payer rules
// as computeCosts (src/server/util/costs.js) but scoped to one row.
function sharesFor(row, eff, memberIds) {
const shares = new Map(memberIds.map((id) => [id, 0]));
if (row.split_mode === 'own') {
for (const id of eff) shares.set(id, row.amount);
} else if (row.split_mode === 'payer') {
if (row.paid_by !== null && row.paid_by !== undefined && shares.has(row.paid_by)) {
shares.set(row.paid_by, row.amount);
}
} else {
const per = row.amount / eff.length;
for (const id of eff) shares.set(id, per);
}
return shares;
}
// trip: { currency }
// members: [{ id, display_name }] current trip members, in display order.
// rows: [{ date, source: 'expense'|'entry', category, description,
// amount, paid_by, split_mode, participants }], already sorted
// by (date, id) by the caller. `amount` is the raw amount/price (not
// yet divided for 'own'); `participants` is an array of user ids or
// null/[] (= all members).
export function buildExpenseCsv({ trip, members, rows }) {
const memberIds = members.map((m) => m.id);
const nameById = new Map(members.map((m) => [m.id, m.display_name]));
const header = [
'date',
'source',
'category',
'description',
'amount',
'currency',
'paid_by',
'split',
'participants',
...members.map((m) => `share: ${m.display_name}`),
];
// Guard applied uniformly, including the "share: <name>" header cells —
// display names are user-editable free text too.
const lines = [header.map((v) => csvField(guardFormula(v))).join(',')];
for (const row of rows) {
const eff = effectiveParticipants(row, memberIds);
const total = effectiveTotal(row, eff);
const shares = sharesFor(row, eff, memberIds);
const isAll = !Array.isArray(row.participants) || row.participants.length === 0;
const paidByName =
row.paid_by !== null && row.paid_by !== undefined ? nameById.get(row.paid_by) || '' : '';
const participantsStr = isAll
? 'all'
: members
.filter((m) => eff.includes(m.id))
.map((m) => m.display_name)
.join(';');
const cols = [
row.date,
row.source,
guardFormula(row.category),
guardFormula(row.description),
round2(total).toFixed(2),
trip.currency,
guardFormula(paidByName),
row.split_mode,
guardFormula(participantsStr),
...members.map((m) => round2(shares.get(m.id) || 0).toFixed(2)),
];
lines.push(cols.map(csvField).join(','));
}
// UTF-8 BOM so Excel opens the file correctly.
return String.fromCharCode(0xfeff) + lines.join('\r\n') + '\r\n';
}