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
+62
View File
@@ -2278,6 +2278,68 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('no such table')) throw err;
}
},
// Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively
// and without touching existing rows:
// - per-expense currency + exchange_rate, so an expense can be entered in a
// foreign currency and converted to the trip base currency (NULL currency =
// base currency; rate 1.0). Closes the multi-currency request (#551).
// - budget_item_payers: several people can each have paid part of one expense
// (amounts in the expense currency), replacing the single paid_by_user_id.
// - budget_settlements: persisted "X paid Y" transfers so the settle-up
// history (with undo) is shared across all trip members.
// The equal-split participants stay in budget_item_members. The single legacy
// payer is backfilled into budget_item_payers as one payer covering the total.
() => {
try { db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE TABLE IF NOT EXISTS budget_item_payers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount REAL NOT NULL DEFAULT 0,
UNIQUE(budget_item_id, user_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)');
db.exec(`
CREATE TABLE IF NOT EXISTS budget_settlements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount REAL NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by_user_id INTEGER REFERENCES users(id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)');
// Backfill the legacy single payer: that person paid the full total of the
// expense, in the (base) currency the existing amount was already stored in.
try {
db.exec(`
INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount)
SELECT id, paid_by_user_id, total_price
FROM budget_items
WHERE paid_by_user_id IS NOT NULL
`);
} catch (err: any) {
if (!err.message?.includes('no such column')) throw err;
}
},
// Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This
// is a display rename only — the addon id, tables, permissions and MCP tools
// all stay 'budget'. Scoped to the default name so a customised one is kept.
() => {
db.prepare(
"UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'",
).run();
},
];
if (currentVersion < migrations.length) {
+1 -1
View File
@@ -90,7 +90,7 @@ function seedAddons(db: Database.Database): void {
try {
const defaultAddons = [
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },