feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)

* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
This commit is contained in:
Maurice
2026-06-05 01:38:25 +02:00
committed by GitHub
parent 6ef3c7ae6b
commit 247433fb2a
159 changed files with 3354 additions and 156 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
import { validatePassword } from './passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
import { getAllPermissions } from './permissions';
@@ -177,7 +177,7 @@ export function generateToken(user: { id: number | bigint; password_version?: nu
return jwt.sign(
{ id: user.id, pv },
JWT_SECRET,
{ expiresIn: '24h', algorithm: 'HS256' }
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
);
}
+196 -28
View File
@@ -1,5 +1,5 @@
import { db } from '../db/database';
import { BudgetItem, BudgetItemMember } from '../types';
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
import { avatarUrl } from './avatarUrl';
// ---------------------------------------------------------------------------
@@ -19,6 +19,30 @@ function loadItemMembers(itemId: number | string) {
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
}
function loadItemPayers(itemId: number | string) {
const rows = db.prepare(`
SELECT bp.user_id, bp.amount, u.username, u.avatar
FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id = ?
`).all(itemId) as BudgetItemPayer[];
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
}
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
let total = 0;
for (const p of payers) {
if (!(p.amount > 0)) continue;
insert.run(itemId, p.user_id, p.amount);
total += p.amount;
}
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
return total;
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
@@ -50,20 +74,45 @@ export function listBudgetItems(tripId: string | number) {
}
}
items.forEach(item => { item.members = membersByItem[item.id] || []; });
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allPayers = db.prepare(`
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];
for (const p of allPayers) {
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
payersByItem[p.budget_item_id].push({
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
});
}
}
items.forEach(item => {
item.members = membersByItem[item.id] || [];
item.payers = payersByItem[item.id] || [];
});
return items;
}
export function createBudgetItem(
tripId: string | number,
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
data: {
category?: string; name: string; total_price?: number;
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
},
) {
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
).get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const cat = data.category || 'Other';
const cat = data.category || 'other';
// Ensure category has a sort_order entry
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
@@ -73,22 +122,37 @@ export function createBudgetItem(
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
}
// total_price is derived from explicit payers when given; otherwise the caller
// value (planning entries, or a bill no one has paid yet).
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
cat,
data.name,
data.total_price || 0,
data.persons != null ? data.persons : null,
total,
data.currency || null,
data.exchange_rate != null ? data.exchange_rate : 1,
data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
sortOrder,
data.expense_date || null,
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
item.members = [];
const itemId = result.lastInsertRowid as number;
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
if (data.member_ids && data.member_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
for (const uid of data.member_ids) insert.run(itemId, uid);
}
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
item.members = loadItemMembers(itemId);
item.payers = loadItemPayers(itemId);
return item;
}
@@ -106,7 +170,12 @@ export function linkBudgetItemToReservation(
export function updateBudgetItem(
id: string | number,
tripId: string | number,
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
data: {
category?: string; name?: string; total_price?: number;
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
},
) {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
@@ -116,6 +185,8 @@ export function updateBudgetItem(
category = COALESCE(?, category),
name = COALESCE(?, name),
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
currency = CASE WHEN ? THEN ? ELSE currency END,
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
@@ -126,6 +197,8 @@ export function updateBudgetItem(
data.category || null,
data.name || null,
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
@@ -134,6 +207,15 @@ export function updateBudgetItem(
id,
);
// Optional inline payer/member replacement (the edit modal saves all at once).
if (data.payers !== undefined) writeItemPayers(id, data.payers);
if (data.member_ids !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
for (const uid of data.member_ids) insert.run(id, uid);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
}
// If category changed, update category order table
if (data.category) {
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
@@ -144,8 +226,23 @@ export function updateBudgetItem(
}
}
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
updated.members = loadItemMembers(id);
updated.payers = loadItemPayers(id);
return updated;
}
// ---------------------------------------------------------------------------
// Payers
// ---------------------------------------------------------------------------
export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
writeItemPayers(id, payers);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
updated.members = loadItemMembers(id);
updated.payers = loadItemPayers(id);
return updated;
}
@@ -220,37 +317,65 @@ export function getPerPersonSummary(tripId: string | number) {
// Settlement calculation (greedy debt matching)
// ---------------------------------------------------------------------------
export function calculateSettlement(tripId: string | number) {
export function calculateSettlement(
tripId: string | number,
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
) {
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
const rates = opts.rates ?? null;
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
// means "the trip's own currency". rates[X] = units of X per 1 base.
const toBase = (amount: number, itemCurrency: string | null | undefined): number => {
const cur = (itemCurrency || tripCurrency).toUpperCase();
if (cur === base || !rates) return amount;
const r = rates[cur];
return r && r > 0 ? amount / r : amount;
};
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
const allPayers = db.prepare(`
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];
// Calculate net balance per user: positive = is owed money, negative = owes money
// Net balance per user, in the requested base currency: positive = is owed
// money, negative = owes money. Each expense's amounts are converted from their
// own currency to the base with live rates, so mixed-currency trips net correctly.
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
return balances[id];
};
for (const item of items) {
const members = allMembers.filter(m => m.budget_item_id === item.id);
if (members.length === 0) continue;
const payers = allPayers.filter(p => p.budget_item_id === item.id);
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
const payers = members.filter(m => m.paid);
if (payers.length === 0) continue; // no one marked as paid
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
const sharePerMember = paidBase / members.length;
const sharePerMember = item.total_price / members.length;
const paidPerPayer = item.total_price / payers.length;
// Payers are credited what they actually paid (converted to base)…
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency);
// …and every split participant owes an equal share of the base total.
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
}
for (const m of members) {
if (!balances[m.user_id]) {
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
}
// Everyone owes their share
balances[m.user_id].balance -= sharePerMember;
// Payers get credited what they paid
if (m.paid) balances[m.user_id].balance += paidPerPayer;
}
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
// the receiver's credit shrinks, so the corresponding flow disappears.
const settlements = listSettlements(tripId);
for (const s of settlements) {
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
}
// Calculate optimized payment flows (greedy algorithm)
@@ -283,9 +408,52 @@ export function calculateSettlement(tripId: string | number) {
return {
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
flows,
settlements,
};
}
// ---------------------------------------------------------------------------
// Settlements (persisted settle-up transfers — history + undo)
// ---------------------------------------------------------------------------
export function listSettlements(tripId: string | number) {
const rows = db.prepare(`
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
fu.username AS from_username, fu.avatar AS from_avatar,
tu.username AS to_username, tu.avatar AS to_avatar
FROM budget_settlements s
JOIN users fu ON s.from_user_id = fu.id
JOIN users tu ON s.to_user_id = tu.id
WHERE s.trip_id = ?
ORDER BY s.created_at DESC, s.id DESC
`).all(tripId) as any[];
return rows.map(r => ({
id: r.id, trip_id: r.trip_id,
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
}));
}
export function createSettlement(
tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number },
createdByUserId?: number,
) {
const result = db.prepare(
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
}
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!row) return false;
db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
return true;
}
// ---------------------------------------------------------------------------
// Reorder
// ---------------------------------------------------------------------------
+2 -1
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { SESSION_DURATION_MS } from '../config';
const COOKIE_NAME = 'trek_session';
@@ -32,7 +33,7 @@ function buildOptions(clear: boolean, secure: boolean) {
secure,
sameSite: 'lax' as const,
path: '/',
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
};
}
@@ -0,0 +1,68 @@
/**
* Live exchange rates for the Costs/Budget money conversion.
*
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
* dashboard widget) and caches per base currency in-memory for a few hours so a
* settlement request never hammers the upstream. Rates are "units of X per 1
* base", so an amount in currency C converts to base as `amount / rates[C]`.
*
* Everything degrades gracefully: if the fetch fails (offline, upstream down),
* callers get `null`/identity conversion and amounts are treated as already in
* the base currency rather than throwing.
*/
const TTL_MS = 6 * 60 * 60 * 1000; // 6h
const cache = new Map<string, { rates: Record<string, number>; ts: number }>();
const inflight = new Map<string, Promise<Record<string, number> | null>>();
async function fetchRates(base: string): Promise<Record<string, number> | null> {
try {
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
if (!res.ok) return null;
const data = (await res.json()) as { rates?: Record<string, number> };
return data.rates && typeof data.rates === 'object' ? data.rates : null;
} catch {
return null;
}
}
/** Rates map for `base` (cached). Returns null if unavailable. */
export async function getRates(base: string): Promise<Record<string, number> | null> {
const key = (base || 'EUR').toUpperCase();
const hit = cache.get(key);
const now = Date.now();
if (hit && now - hit.ts < TTL_MS) return hit.rates;
// Coalesce concurrent fetches for the same base.
let p = inflight.get(key);
if (!p) {
p = fetchRates(key).then(rates => {
if (rates) cache.set(key, { rates, ts: Date.now() });
inflight.delete(key);
return rates;
});
inflight.set(key, p);
}
const rates = await p;
// On failure fall back to the last cached value if we have one.
if (!rates && hit) return hit.rates;
return rates;
}
/**
* Convert `amount` from `from` currency into `base` using a rates map obtained
* from getRates(base). Identity when same currency or the rate is missing.
*/
export function convertWithRates(
amount: number,
from: string | null | undefined,
base: string,
rates: Record<string, number> | null,
): number {
const fromCur = (from || base).toUpperCase();
const baseCur = base.toUpperCase();
if (fromCur === baseCur || !rates) return amount;
const r = rates[fromCur];
if (!r || r <= 0) return amount;
return amount / r;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
import { User } from '../types';
import { decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
@@ -200,7 +200,7 @@ export function frontendUrl(path: string): string {
}
export function generateToken(user: { id: number }): string {
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
}
// ---------------------------------------------------------------------------