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:
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user