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:
2026-08-06 17:55:04 +07:00
parent e342cd9a91
commit f272e74b84
19 changed files with 2123 additions and 6 deletions
+69 -3
View File
@@ -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.