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.
39 KiB
Trip Plan — API Contract
All endpoints are JSON over REST, prefixed with /api. This document is the binding contract between backend and frontend. Do not deviate; extend only by agreement.
Conventions
- Dates:
YYYY-MM-DDstrings. Times:HH:MM24h strings (optional fields). - Auth: cookie session (
cookie-session). All endpoints exceptaccount(create) /loginrequire auth → otherwise401 {"error":"unauthorized"}. - Identity is Mullvad-style: an account is a random account token (the only credential — no username/password). Users additionally have a non-unique, editable
display_namefor humans. - Trip access: user must be a member of the trip → otherwise
404 {"error":"not found"}(don't leak existence). - Errors:
4xx/5xxwith body{"error": "<human readable message>"}. - IDs are integers.
Server layout
src/server/app.js— builds and exports the Express app (export function createApp(dbPath)andexport defaulta ready app is fine, butcreateAppmust exist for tests).src/server/index.js— reads env (PORTdefault 3000,DATA_DIRdefault./data,SESSION_SECRETdefault dev value with console warning), ensures DATA_DIR exists, starts listener, servespublic/statically.src/server/db.js—better-sqlite3connection + schema creation (idempotentCREATE TABLE IF NOT EXISTS).src/server/routes/—auth.js,trips.js,entries.js,geocode.js,checklist.js,expenses.js.src/server/util/distance.js—haversineKm(lat1, lng1, lat2, lng2)returns km (number).
Data model (SQLite)
users (id INTEGER PK, token_hash TEXT UNIQUE NOT NULL, -- sha256 hex of the account token
display_name TEXT NOT NULL, -- auto-generated, editable, NOT unique
created_at TEXT DEFAULT current_timestamp)
trips (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
currency TEXT NOT NULL DEFAULT 'USD', -- 3-letter code, display only (no FX)
join_code TEXT UNIQUE NOT NULL, -- share code for joining the trip
created_at TEXT DEFAULT current_timestamp)
trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor'
sort_order INTEGER NOT NULL DEFAULT 0, -- per-user dashboard order (drag & drop)
PRIMARY KEY (trip_id, user_id))
entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL, -- start date
end_date TEXT, -- optional inclusive end date (multi-day entries; null = single day)
type TEXT NOT NULL,
title TEXT NOT NULL, details TEXT DEFAULT '',
start_time TEXT, end_time TEXT,
location_name TEXT, lat REAL, lng REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
price REAL, -- null = no cost tracked
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
segments TEXT, -- JSON array, flight entries only (see below)
rental TEXT, -- JSON object, rental entries only (see below)
transport_mode TEXT, -- transport entries only (see below)
auto_ref TEXT, -- JSON {from,to} stay ids; non-null = auto-created transport (see below)
waypoints TEXT, -- JSON [{lat,lng,name?}] scenic via-points, transport entries only (see below)
created_at TEXT DEFAULT current_timestamp)
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
PRIMARY KEY (entry_id, user_id))
-- no rows for an entry = "all trip members participate" (dynamic default)
checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
user_id INTEGER REFERENCES users(id), -- null = shared trip item; else personal to that user
text TEXT NOT NULL, -- what to pack/do, ≤120 chars
category TEXT NOT NULL DEFAULT 'General',
qty INTEGER, -- optional count (null = unspecified)
checked INTEGER NOT NULL DEFAULT 0, -- 0/1
checked_by INTEGER REFERENCES users(id),-- who ticked it (null when unchecked)
sort_order INTEGER NOT NULL DEFAULT 0,
suggestion_key TEXT, -- non-null = added from packing advice (dedupe key)
created_at TEXT DEFAULT current_timestamp)
expenses (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL, -- YYYY-MM-DD, the day it was spent
description TEXT NOT NULL, -- what it was, ≤120 chars
category TEXT NOT NULL DEFAULT 'other', -- fixed enum, see Expenses section
amount REAL NOT NULL, -- ≥ 0, always in the trip currency
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer' (same semantics as entries)
created_by INTEGER NOT NULL REFERENCES users(id), -- who logged it
created_at TEXT DEFAULT current_timestamp)
expense_participants (expense_id INTEGER REFERENCES expenses(id), user_id INTEGER REFERENCES users(id),
PRIMARY KEY (expense_id, user_id))
-- no rows for an expense = "all trip members participate" (dynamic default)
Entry type ∈ flight | transport | activity | rental | stay | note.
Legacy types (removed 2026-07): hotel, travel, immigration. Idempotent data migrations run at startup: hotel rows become stay, travel rows become transport, immigration rows become activity with the title prefixed 🛂 . POST/PATCH with a legacy type is a 400 (invalid entry type).
Transport entries
transport covers ground/sea travel (the old travel type). Optional transport_mode ∈ train | bus | ferry | taxi | drive | other (nullable; drives the icon in the UI). Validation: only allowed when the effective type === 'transport' (else 400 transport_mode is only allowed on transport entries); PATCH transport_mode: null clears it. Entry JSON always includes transport_mode (string or null).
Scenic waypoints — a transport entry may carry waypoints: an ordered JSON array of via-points the road route should pass through (e.g. a mountain pass instead of the highway). Shape [{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]. Validation: only allowed when the effective type === 'transport' (else 400 waypoints are only allowed on transport entries); array of 0–8 objects; each needs lat/lng (finite, lat [-90,90], lng [-180,180]); name optional string ≤120 chars; waypoints: null or [] clears them. Entry JSON always includes waypoints (parsed array or null; [] is normalized to null on store). Not accepted on non-transport types.
Waypoints attach to a route leg (see Route below): a ground leg between two located stops is drawn through the via-points of the transport entry that bridges it.
Auto-transport between stays: when a stay entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by date), the server auto-creates one transport entry per neighbour pair — titled "<from short name> → <to short name>" (short name = first comma-segment of the stay's location_name, fallback title), dated on the later stay's date, transport_mode null, no price/location, sort_order 0, and auto_ref set to {"from": <earlier stay id>, "to": <later stay id>} (stored JSON, returned parsed; null on every other entry — the marker for "auto-created"). Skipped when any transport or flight entry already exists with date between the earlier stay's end (end_date or date) and the later stay's date (inclusive). One-shot: fires only on stay creation (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (201 {entry} = the stay); clients should refetch the entry list.
Regenerating auto-transports — POST /api/trips/:id/transports/regenerate (any member, no body) reconciles auto transports after stays have been moved/reshuffled:
- Compute the desired pairs: stays in chronological
(date, id)order → each adjacent pair(earlier, later), skipping pairs whose window (earlier.end_date||date..later.date, inclusive; skip if inverted/overlapping) contains a manual transport (auto_refnull) or any flight. - Existing auto transports (
type='transport',auto_refnon-null) whose{from,to}matches a desired pair are kept but re-synced:dateset to the pair's current transition date (later.date), title regenerated to the current"A → B";transport_mode, price/split, times, details are preserved. Auto transports matching no desired pair are deleted. - Missing desired pairs get a fresh auto transport (same shape as creation-time).
Response: 200 {created, updated, deleted} (counts). Auto transports from databases predating auto_ref have it null and are treated as manual (never touched). Stays MAY overlap in dates (two locations on the same day, e.g. temporarily while reshuffling) — overlapping adjacent pairs simply produce no auto transport, and the calendar renders overlapping stays in stacked band lanes.
Multi-day entries & stays
Any entry may carry an optional end_date (inclusive, YYYY-MM-DD, must be ≥ date; PATCH end_date: null clears it). Use cases:
- Flights/travel longer than 24 h — dep on
dateatstart_time, arr onend_dateatend_time. stayentries ("area blocks") — "3 days Venice", "7 days Berlin": a location plus a date range. The frontend renders stays as continuous bands across the calendar days (all-day-event style), not as chips; other multi-day entries show a chip on the start day and lightweight "…continues" markers on the days up toend_date.
Stays behave like normal entries otherwise: optional geocoded location (feeds the route as one stop, positioned at the start date), optional price/split (e.g. an apartment for the whole block).
Summary additions: stays (count) and areas — stay entries in chronological order as [{ "name": "Venice", "days": 3 }] where name is the stay's location_name (fallback: title) and days is the inclusive span (end_date null → 1). Example: "areas": [{"name":"Venice","days":3},{"name":"Berlin","days":7}].
Rental car details
rental-type entries can carry structured rental data in entries.rental (TEXT, JSON — same storage pattern as flight segments). null/absent = plain entry.
"rental": {
"brand": "Toyota", "model": "Yaris Cross", "car_type": "SUV",
"booking_ref": "RC-889231", "included_km": 1500,
"pickup": { "date": "2026-08-01", "time": "09:00", "location_name": "CNX Airport",
"lat": 18.77, "lng": 98.96 },
"dropoff": { "date": "2026-08-07", "time": "18:00", "location_name": "Chiang Mai Old Town",
"lat": 18.79, "lng": 98.98 }
}
Validation (POST/PATCH entries): only allowed when type === 'rental' (else 400); all fields optional; brand/model ≤60, car_type ≤40, booking_ref ≤60 chars; included_km null (= unlimited/unknown) or number ≥ 0; pickup/dropoff objects with date (YYYY-MM-DD, required within the object), optional time HH:MM, location_name ≤120, lat/lng both-or-neither in range. PATCH rental: null clears. Entry JSON always includes rental (parsed or null). The entry's own date should be the pickup date (frontend sets this); the entry's price/split fields carry the rental cost as usual.
Summary impact: summary.rentals counts rental entries; summary.includedKm = sum of non-null included_km across rentals, or null when no rental specifies one — the frontend pairs it with kmDriven ("≈ 520 km driven / 1,500 km included"). Rental pickup/dropoff locations do NOT feed the route (use the entry's normal location field for a map point if wanted).
Flight segments
Flight entries can carry structured segments for multi-leg itineraries (e.g. CNX→BKK→DXB→FRA = 3 segments). Stored as JSON in entries.segments, returned parsed. null/absent = no segments (plain entry).
"segments": [
{ "flight_no": "TG103", "dep_time": "10:30", "arr_time": "11:45",
"from": {"code": "CNX", "name": "Chiang Mai Intl", "lat": 18.77, "lng": 98.96},
"to": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75} },
{ "flight_no": "EK385", "dep_time": "13:05", "arr_time": "17:10",
"from": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75},
"to": {"code": "DXB", "name": "Dubai Intl", "lat": 25.25, "lng": 55.36} }
]
Validation (POST/PATCH entries): only allowed when type === 'flight' (else 400); array of 1–8 objects; each needs from and to with code (2–4 uppercase alphanumerics); name optional ≤80 chars; lat/lng both-or-neither, same ranges as entry lat/lng; flight_no optional ≤12 chars; dep_time/arr_time optional HH:MM. PATCH with segments: null clears them.
Route expansion: an entry whose segments have coordinates contributes airport stops to /route instead of its own lat/lng — from of the first segment, then to of every segment, in order (airports without coords are skipped; consecutive duplicate coords are collapsed). Stops derived this way have "kind": "airport" and include "code". So CNX-BKK-DXB-FRA yields 4 stops and 3 measured legs on the map.
Airports lookup
GET /api/airports?q=<query> → 200 {results: [{code, name, city, country, lat, lng}]} (max 8; requires auth).
Backed by a bundled dataset at src/server/data/airports.json generated from the public-domain OurAirports data (filter: has IATA code + scheduled service; keep code/name/municipality/iso_country/lat/lng). Matching: exact/prefix IATA code match first (case-insensitive), then name/city substring. This makes CNX → Chiang Mai Intl instant and offline.
Cost semantics
These rules apply identically to priced entries (entries.price non-null) and expenses (the standalone daily-spending rows — see the Expenses section). For an expense read amount for price and expense_participants for entry_participants below.
- Only entries with non-null
pricecount toward costs. Every expense counts (amount is required). - Effective participants of an entry = its
entry_participantsrows, or all current trip members if it has none. split_mode:equal—priceis the TOTAL; split equally among effective participants ("rental car 50/50").paid_byis credited with having paid the total.own—priceis PER PERSON; each effective participant owes and pays their own ("everyone pays their own flight"). No debt created. Effective total = price × participant count.payer— personal expense:paid_byowes and pays the whole price alone (requirespaid_by; validation error otherwise).
Endpoints
Auth (Mullvad-style account tokens)
| Method & path | Body | Response |
|---|---|---|
POST /api/auth/account |
{} (empty) |
201 {user: {id, display_name}, token: "XXXX-XXXX-XXXX-XXXX"} — creates an account, logs it in. The raw token is returned ONLY here, never again. |
POST /api/auth/login |
{token} |
200 {user}; 401 on unknown token |
POST /api/auth/logout |
— | 204 |
GET /api/auth/me |
— | 200 {user: {id, display_name}} or 401 |
PATCH /api/auth/me |
{display_name} |
200 {user}; validates 1–40 chars after trim |
Token rules:
- 16 chars from the unambiguous alphabet
ABCDEFGHJKMNPQRSTUVWXYZ23456789(no I/L/O/0/1), generated withcrypto.randomBytes, displayed groupedXXXX-XXXX-XXXX-XXXX. - Login normalizes input: strip dashes/spaces, uppercase, then compare
sha256(token)againstusers.token_hash. Only the hash is stored. display_nameis auto-generated at account creation asadjective-animal(e.g.brave-otter) from two small built-in word lists; not unique; user-editable via PATCH.
User JSON shape everywhere: {id, display_name}.
Trips
| Method & path | Body | Response |
|---|---|---|
GET /api/trips |
— | 200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, sort_order, member_count, entry_count}]} (trips where user is member, ordered by the caller's trip_members.sort_order, then newest first for ties) |
POST /api/trips |
{name, start_date, end_date, currency?} |
201 {trip}; validates: name non-empty ≤120 chars, valid dates, end_date >= start_date, range ≤ 365 days, currency (if given) matches ^[A-Z]{3}$ (default USD). Creator becomes member with role owner. |
GET /api/trips/:id |
— | 200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]} entries ordered by (date, sort_order, id) |
PATCH /api/trips/:id |
any of {name, start_date, end_date, currency} |
200 {trip} (same validation; entries outside new range are kept) |
PATCH /api/trips/:id/order |
{sort_order} |
200 {sort_order} — updates the CALLER's own trip_members.sort_order for this trip (any member; integer required, else 400). Per-user: does not affect other members' dashboard order. |
DELETE /api/trips/:id |
— | 204 — owner only, else 403. Deletes members + entries too. |
POST /api/trips/join |
{code} |
200 {trip} — joins the trip with that join_code as editor (idempotent: already a member → still 200 {trip}); 404 {"error":"not found"} on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase). |
POST /api/trips/:id/join-code |
— | 200 {trip} — regenerates join_code, owner only (403 otherwise) |
DELETE /api/trips/:id/members/:userId |
— | 204 — owner only (owner cannot remove self) |
join_code: 8 chars, same alphabet as account tokens, generated at trip creation, displayed grouped XXXX-XXXX. Members see it in trip JSON (it's how they invite others); it is NOT a credential — it only grants membership of that one trip.
Entries
| Method & path | Body | Response |
|---|---|---|
POST /api/trips/:id/entries |
{date, end_date?, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?} |
201 {entry}; validates type enum, date format, end_date null or valid date ≥ date, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ equal|own|payer (payer requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above; transport_mode per "Transport entries" above (POSTing a stay may auto-create a transport entry, see same section) |
PATCH /api/entries/:id |
any subset of the above | 200 {entry} (member of the entry's trip required; participants replaces the whole set) |
DELETE /api/entries/:id |
— | 204 (also deletes its entry_participants rows) |
Entry JSON shape (always full row): {id, trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode, auto_ref, waypoints} where participants is an array of user ids ([] = all members), segments is the parsed array or null, auto_ref is the parsed {from,to} object or null, and waypoints is the parsed [{lat,lng,name?}] array or null. auto_ref is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its auto_ref).
Checklist (packing list & advice)
Each trip has one flat checklist whose items are grouped by a free-text category ("Documents", "Clothing", …). Items are either shared (user_id null — every member sees and can tick them, e.g. "First-aid kit") or personal (user_id = the owner — only that user sees them, e.g. their own medication). There is no separate list entity: category is the grouping.
GET /api/trips/:id/checklist → 200
{
"items": [
{ "id": 7, "trip_id": 3, "text": "Passport (valid 6+ months)", "category": "Documents",
"qty": null, "checked": true, "checked_by": 1, "personal": false,
"user_id": null, "sort_order": 0, "suggestion_key": "doc-passport" },
{ "id": 9, "trip_id": 3, "text": "T-shirts", "category": "Clothing",
"qty": 8, "checked": false, "checked_by": null, "personal": true,
"user_id": 1, "sort_order": 1, "suggestion_key": "clothing-tshirts" }
],
"progress": { "total": 2, "checked": 1, "byCategory": [ { "category": "Documents", "total": 1, "checked": 1 } ] }
}
- Returns shared items plus the caller's own personal items — never another member's personal items.
- Item JSON is always the full row plus the derived boolean
personal(user_id !== null);checkedis a real boolean (not 0/1) andqty/checked_by/suggestion_keyarenullwhen unset. - Order: by
categoryfirst (fixed orderDocuments, Clothing, Toiletries, Health, Electronics, Extras, then any other category alphabetically), then(sort_order, id). progress.byCategoryfollows the same category order and covers only categories present initems.
| Method & path | Body | Response |
|---|---|---|
POST /api/trips/:id/checklist |
{text, category?, qty?, personal?, checked?, sort_order?} |
201 {item} — validates: text non-empty ≤120 chars after trim; category ≤40 chars after trim (default General); qty null or integer 1–99; personal boolean (default false) → sets user_id to the caller; checked boolean (default false); sort_order integer (default: max within the trip's list + 1). Member of the trip required (else 404). |
PATCH /api/checklist/:itemId |
any subset of {text, category, qty, checked, personal, sort_order} |
200 {item} — same validation. Ticking (checked: true) sets checked_by to the caller; checked: false clears it. personal: true claims the item for the caller (user_id = caller), personal: false makes it shared (user_id null). |
DELETE /api/checklist/:itemId |
— | 204 |
POST /api/trips/:id/checklist/reset |
{} |
200 {unchecked: <count>} — unticks every item visible to the caller (shared + own personal) and clears their checked_by. For re-using a list on the return trip. |
Item routes resolve the trip via the item, then require membership. A non-existent item, an item in a trip the caller is not a member of, or another user's personal item all return 404 {"error":"not found"} (no leaking).
Packing advice (suggestions) — deterministic, offline rules derived from the trip itself (duration, months, entry types, destination latitudes/countries). No external service, no LLM.
GET /api/trips/:id/checklist/suggestions → 200
{
"suggestions": [
{ "key": "doc-passport", "text": "Passport (valid 6+ months)", "category": "Documents",
"qty": null, "reason": "You have 2 flights", "added": true },
{ "key": "clothing-tshirts", "text": "T-shirts", "category": "Clothing",
"qty": 8, "reason": "9 nights", "added": false }
],
"context": { "days": 10, "nights": 9, "months": [8], "countries": ["Thailand"],
"climate": ["tropical"], "flights": 2, "rentals": 1, "transportModes": ["ferry"] }
}
keyis a stable identifier (kebab-case) — it is whatPOSTtakes and what gets stored inchecklist_items.suggestion_key.added= an item with thatsuggestion_keyalready exists among the items visible to the caller (shared or own personal), so the UI can grey it out.reasonis a short human string explaining why it was suggested ("9 nights", "Ferry crossing", "Thailand uses type A/B/C sockets"). Suggestions are ordered by category (same fixed order as items).- Rules live in
src/server/util/packing.jsas a purebuildSuggestions({trip, entries, days, nights})so they are unit-testable and stable across calls with the same input. Rough rule set: always-on basics (ID/passport, cards+cash, medication, toothbrush, phone+charger, water bottle, day bag); nights-scaled clothing quantities (qty = min(nights + 1, 10)for t-shirts/underwear/socks, laundry kit over 7 nights); flight entries → liquids ≤100 ml, power bank in cabin, check-in done, plus neck pillow/compression socks on long-haul (any air leg > 5000 km); rental entries → driving licence, international driving permit, phone mount; transport modes → ferry ⇒ motion-sickness tablets, train ⇒ snacks + luggage lock; destination latitude/month → tropical (|lat| < 23.5) ⇒ sun cream, insect repellent, rain jacket, rehydration salts; cold (|lat| > 55 year-round, or |lat| ≥ 35 when the trip falls in that hemisphere's winter — the latitude floor stops a December trip to Bangkok being tagged both tropical and cold) ⇒ warm layers, hat + gloves; ≥3 stays ⇒ packing cubes; destination country → power-adapter suggestion naming the socket types (small built-in country table; unknown/mixed ⇒ "Universal travel adapter"). Countries come from the last comma-segment oflocation_nameand from flight-segment airport codes. For flights only the arrival airport of each segment counts (for both climate and country): the first segment's departure airport is where the traveller starts out, not a destination — counting it would put "warm layers, hat & gloves" and a home-country plug adapter on a December Frankfurt→Bangkok beach trip. Flying back into a cold place later is still covered, since that arrival is a segment'sto.
POST /api/trips/:id/checklist/suggestions {keys: ["doc-passport", …], personal?: false} → 201 {created: [item…], skipped: ["doc-passport"]}
- Bulk-adds the given suggestions as checklist items (unchecked,
suggestion_keyset, appended in the current suggestion order).keysmust be a non-empty array of ≤60 strings; an unknown key →400 {"error":"unknown suggestion key: <key>"}. - Keys already present among the caller's visible items go to
skippedinstead of being duplicated.
Expenses (daily spending log)
Standalone quick expenses (lunch, taxi, museum tickets) logged against a date without creating a calendar entry. They use the exact same split machinery as entry costs (see Cost semantics) and are merged into GET /api/trips/:id/costs and settle-up. All amounts are in the trip currency (no FX).
category ∈ food | drinks | transport | activities | shopping | accommodation | other (fixed enum; default other). Display metadata (icon + label + colour) lives in the frontend (format.js expenseCategoryInfo()), not the API.
GET /api/trips/:id/expenses → 200
{
"expenses": [
{ "id": 4, "trip_id": 3, "date": "2026-12-06", "description": "Street food dinner",
"category": "food", "amount": 380.0, "paid_by": 1, "split_mode": "equal",
"participants": [], "created_by": 1 }
],
"summary": {
"total": 380.0,
"byCategory": { "food": 380.0 },
"byDay": [ { "date": "2026-12-06", "total": 380.0 } ]
}
}
- Expenses ordered by
(date, id).participantsis an array of user ids ([]= all members). All members see all expenses (shared trip data, like entries — there are no personal/hidden expenses). summary.total= sum of effective totals (same rule as costs:owncountsamount × participants, elseamount).byCategorygroups the same;byDayis ordered by date and covers only dates that have expenses.
| Method & path | Body | Response |
|---|---|---|
POST /api/trips/:id/expenses |
{date, description, amount, category?, paid_by?, split_mode?, participants?} |
201 {expense} — validates: valid date string; description non-empty ≤120 chars after trim; amount finite number ≥ 0; category in the enum (default other); paid_by null or a trip member's user id; split_mode ∈ equal|own|payer (payer requires paid_by); participants null/[] (= all members) or an array of trip-member user ids. created_by = the caller (server-set, not accepted in the body). Member of the trip required (else 404). |
PATCH /api/expenses/:expenseId |
any subset of the POST fields | 200 {expense} — same validation; participants replaces the whole set. Any trip member may edit any expense (created_by is informational and never changes). |
DELETE /api/expenses/:expenseId |
— | 204 (also deletes its expense_participants rows) |
GET /api/trips/:id/expenses/export.csv |
— | 200 CSV download, see below |
Expense routes resolve the trip via the expense, then require membership; non-existent expense or non-member → 404 {"error":"not found"} (no leaking).
Server layout: routes in src/server/routes/expenses.js; the CSV is built by a pure buildExpenseCsv({trip, members, rows}) in src/server/util/expenseCsv.js so it is unit-testable.
CSV export — one file covering the trip's complete money picture: every expense and every priced calendar entry, one row each, sorted by (date, id) with expenses and entries interleaved chronologically.
- Headers:
Content-Type: text/csv; charset=utf-8,Content-Disposition: attachment; filename="<trip-name-slug>-expenses.csv"(slug = lowercase, non-alphanumeric runs →-, trimmed; fallbacktrip-<id>). - Encoding: UTF-8 with BOM (so Excel opens it correctly), CRLF line endings, RFC 4180 quoting (quote fields containing
",,, CR or LF; double embedded quotes). No totals row — data rows only. - Formula-injection guard: any field whose first character is
=,+,-,@, tab or CR is prefixed with a single quote'before quoting, uniformly across all text columns (description, category, payer/participant/member names). Rationale: trips are multi-user, so another member's text lands in the caller's spreadsheet — without the guard a description like=HYPERLINK(…)would execute in Excel. Numeric columns (amount, shares) are server-formatted and never guarded. - Columns:
date, source, category, description, amount, currency, paid_by, split, participants, share: <member display name>…(one trailing column per current trip member, in member display order).source=expenseorentry.category= the expense category, or the entrytypefor entry rows.description= expense description / entry title.amount= the effective total (per Cost semantics), 2 decimals.paid_by= display name, empty when unassigned.split= the split_mode.participants=allor semicolon-joined display names of the effective participants.share: <name>= that member's share of this row, 2 decimals (0.00 when not a participant) — the per-row breakdown that the Costs panel aggregates.
Route & summary (computed)
GET /api/trips/:id/route →
{
"stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
"title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
"legs": [ {"fromEntryId": 1, "toEntryId": 4, "km": 587.3, "mode": "ground",
"waypoints": [{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]} ],
"totalKm": 587.3,
"summary": {
"days": 10, "nights": 9,
"flights": 2, "flightSegments": 4, "transports": 1, "activities": 4,
"rentals": 1, "includedKm": 1500,
"stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}],
"kmAir": 0, "kmDriven": 587.3,
"locations": ["Bangkok", "Chiang Mai"]
}
}
stops= entries having lat/lng, ordered by(date, sort_order, id); flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" —kind: "airport", includescode). Stops also carrytransport_mode(the entry's value, or null) so the map can show mode-specific icons.legs= consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.- Each ground leg carries
waypoints: the via-points of the transport entry that bridges it, or[]. Bridging entry = the earliest (bydate,id)transportentry with a non-emptywaypointsarray whosedatefalls within[fromStop.date, toStop.date]inclusive. Air legs always havewaypoints: []. The map passes these to the directions proxy'sviaso the drawn road route detours through them;kmin/routestays great-circle regardless (the routed distance is computed client-side). - Every leg has
mode:"air"when BOTH endpoints arekind:"airport"stops expanded from the SAME flight entry (i.e. actual flight segments);"ground"for everything else (stay→airport transfers, city-to-city drives).summary.kmAir/summary.kmDrivenare the per-mode sums (1 decimal).kmDrivenis the rough rental-car figure: great-circle, so real road km will be somewhat higher. days= inclusive count from start_date to end_date;nights = days - 1(0 for single-day trips).locations= uniquelocation_namevalues in stop order.- km rounded to 1 decimal.
Costs & splitting (computed)
GET /api/trips/:id/costs →
{
"currency": "USD",
"totalCost": 1450.0,
"byType": { "flight": 800.0, "transport": 300.0, "expense": 350.0 },
"perUser": [
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
{ "userId": 2, "displayName": "calm-heron", "share": 725.0, "paid": 500.0, "net": -225.0 }
],
"settlements": [ { "fromUserId": 2, "toUserId": 1, "amount": 225.0 } ],
"unassigned": 0.0
}
Computation (see Cost semantics above):
share= what the user's part of the trip costs;paid= what they actually paid;net = paid - share(positive → others owe them).equalwithpaid_bynull: the cost still counts into shares andbyType, but nobody is credited as payer — accumulate that amount intounassigned(frontend shows a hint to assign payers).own: addspriceto each participant'sshareANDpaid(self-paid, no debt).settlements: minimal-transfer greedy — repeatedly match the largest debtor with the largest creditor until all nets are settled; amounts rounded to 2 decimals, drop transfers < 0.01.totalCost= sum of effective totals of all priced entries;byTypegroups the same by entry type.- Expenses are merged in: each expense participates exactly like a priced entry (
amount→ price,expense_participants→ participants) and lands inbyTypeunder the single keyexpense.perUser,settlements,unassignedandtotalCosttherefore cover entry costs and expenses together — one settle-up for the whole trip. The per-category expense breakdown is NOT here; it lives inGET /api/trips/:id/expensessummary.byCategory. - All money values rounded to 2 decimals in the response.
Geocoding proxy
GET /api/geocode?q=<query> → 200 {results: [{name, lat, lng}]} (max 5).
Proxies https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=… server-side with header User-Agent: trip-plan-app/0.1 (self-hosted) (accept-language=en so results come back in Latin script, not the local language). Map Nominatim's display_name→name, parse lat/lon to numbers. On upstream failure return 502 {"error":"geocoding unavailable"}. Cache identical queries in-memory for 10 minutes.
Directions proxy (OSRM)
GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>[&via=<lat>,<lng>|<lat>,<lng>…] → 200 {km, geometry} (requires auth).
- Optional
via= up to 8lat,lngpairs separated by|, routed in order betweenfromandto(scenic waypoints). Each pair validated like from/to → any malformed/out-of-range pair or more than 8 →400. The cache key includes the via-points. - Proxies
${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{viaLng},{viaLat};…;{toLng},{toLat}?overview=full&geometries=geojsonserver-side.OSRM_URLenv var, defaulthttps://router.project-osrm.org(the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the sameUser-Agentheader as the geocode proxy. - Validation:
from/tomust each belat,lngwith finite numbers in range (lat [-90,90], lng [-180,180]) → else400. Upstream failure, non-Ok OSRM code, or no route →502 {"error":"directions unavailable"}. - Response
km= route distance / 1000 rounded to 1 decimal;geometry= the GeoJSON coordinates converted to[[lat,lng], …](Leaflet order). - In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest).
Consumer contract (map): the frontend requests directions for each mode: "ground" route leg and, on success, draws the road-following polyline instead of the straight line and shows the routed km for that leg ("via road"); on 502/failure it keeps the straight great-circle line silently. Air legs stay straight/dashed. /route itself (and summary.kmDriven) remains great-circle — the server does not call OSRM during route computation.
Frontend contract notes
- SPA served from
public/; all non-/apiGETs fall back topublic/index.htmlis NOT required — a singleindex.htmlwith hash-based routing (#/login,#/trips,#/trip/:id) is the expected design, so no server-side fallback is needed. - Leaflet 1.9.x via unpkg CDN in
index.html. - Session cookie is httpOnly; frontend detects auth state via
GET /api/auth/meon load. - Expenses UI lives in
public/js/views/expenses.js(+public/css/expenses.css— styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching fromGET .../expenses(never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a sort control (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern ascostForm.js); edit + delete per row; and an Export CSV button that simply navigates toGET .../expenses/export.csv(cookie auth makes a plain link work). - Checklist UI lives in
public/js/views/checklist.js, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (dragdrop.jsenableReorder, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure.