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:
+69
-3
@@ -16,7 +16,7 @@ All endpoints are JSON over REST, prefixed with `/api`. This document is the **b
|
||||
- `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests).
|
||||
- `src/server/index.js` — reads env (`PORT` default 3000, `DATA_DIR` default `./data`, `SESSION_SECRET` default dev value with console warning), ensures DATA_DIR exists, starts listener, serves `public/` statically.
|
||||
- `src/server/db.js` — `better-sqlite3` connection + schema creation (idempotent `CREATE TABLE IF NOT EXISTS`).
|
||||
- `src/server/routes/` — `auth.js`, `trips.js`, `entries.js`, `geocode.js`, `checklist.js`.
|
||||
- `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)
|
||||
@@ -64,6 +64,18 @@ checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
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`.
|
||||
@@ -145,7 +157,9 @@ Backed by a bundled dataset at `src/server/data/airports.json` generated from th
|
||||
|
||||
### Cost semantics
|
||||
|
||||
- Only entries with non-null `price` count toward costs.
|
||||
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 `price` count toward costs. Every expense counts (amount is required).
|
||||
- **Effective participants** of an entry = its `entry_participants` rows, or **all current trip members** if it has none.
|
||||
- `split_mode`:
|
||||
- `equal` — `price` is the TOTAL; split equally among effective participants ("rental car 50/50"). `paid_by` is credited with having paid the total.
|
||||
@@ -258,6 +272,56 @@ Item routes resolve the trip via the item, then require membership. A non-existe
|
||||
- Bulk-adds the given suggestions as checklist items (unchecked, `suggestion_key` set, appended in the current suggestion order). `keys` must 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 `skipped` instead 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`
|
||||
|
||||
```json
|
||||
{
|
||||
"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)`. `participants` is 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: `own` counts `amount × participants`, else `amount`). `byCategory` groups the same; `byDay` is 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; fallback `trip-<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` = `expense` or `entry`.
|
||||
- `category` = the expense category, or the entry `type` for 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` = `all` or 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` →
|
||||
@@ -296,7 +360,7 @@ Item routes resolve the trip via the item, then require membership. A non-existe
|
||||
{
|
||||
"currency": "USD",
|
||||
"totalCost": 1450.0,
|
||||
"byType": { "flight": 800.0, "transport": 300.0, "stay": 350.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 }
|
||||
@@ -313,6 +377,7 @@ Computation (see Cost semantics above):
|
||||
- `own`: adds `price` to each participant's `share` AND `paid` (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; `byType` groups the same by entry type.
|
||||
- **Expenses are merged in**: each expense participates exactly like a priced entry (`amount` → price, `expense_participants` → participants) and lands in `byType` under the single key `expense`. `perUser`, `settlements`, `unassigned` and `totalCost` therefore cover entry costs and expenses together — one settle-up for the whole trip. The per-category expense breakdown is NOT here; it lives in `GET /api/trips/:id/expenses` `summary.byCategory`.
|
||||
- All money values rounded to 2 decimals in the response.
|
||||
|
||||
### Geocoding proxy
|
||||
@@ -338,4 +403,5 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept
|
||||
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with 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/me` on 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 from `GET .../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 as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../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.js` `enableReorder`, 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.
|
||||
|
||||
Reference in New Issue
Block a user