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/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/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/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).
|
- `src/server/util/distance.js` — `haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
|
||||||
|
|
||||||
## Data model (SQLite)
|
## 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,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
suggestion_key TEXT, -- non-null = added from packing advice (dedupe key)
|
suggestion_key TEXT, -- non-null = added from packing advice (dedupe key)
|
||||||
created_at TEXT DEFAULT current_timestamp)
|
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`.
|
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
|
### 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.
|
- **Effective participants** of an entry = its `entry_participants` rows, or **all current trip members** if it has none.
|
||||||
- `split_mode`:
|
- `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.
|
- `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>"}`.
|
- 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.
|
- 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)
|
### Route & summary (computed)
|
||||||
|
|
||||||
`GET /api/trips/:id/route` →
|
`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",
|
"currency": "USD",
|
||||||
"totalCost": 1450.0,
|
"totalCost": 1450.0,
|
||||||
"byType": { "flight": 800.0, "transport": 300.0, "stay": 350.0 },
|
"byType": { "flight": 800.0, "transport": 300.0, "expense": 350.0 },
|
||||||
"perUser": [
|
"perUser": [
|
||||||
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
|
{ "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 }
|
{ "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).
|
- `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.
|
- `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.
|
- `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.
|
- All money values rounded to 2 decimals in the response.
|
||||||
|
|
||||||
### Geocoding proxy
|
### 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* Expenses (daily spending log) card. Split out of styles.css — which is at
|
||||||
|
its 500-line cap — following the same per-feature stylesheet convention as
|
||||||
|
checklist.css. Reuses the shared palette/vocabulary rather than
|
||||||
|
re-declaring it: rows are `.entry-row`/`.entry-icon`/`.entry-body`/
|
||||||
|
`.entry-title-row`/`.entry-title`/`.entry-price`/`.entry-sub`/
|
||||||
|
`.entry-actions` (day editor), the total banner is `.costs-total` (costs
|
||||||
|
panel), and the add/edit form reuses `.cost-section`-era `.participants`/
|
||||||
|
`.part-item`/`.part-check` plus the generic `.form-row`/`.form-actions`. */
|
||||||
|
|
||||||
|
.expenses-section { display: flex; flex-direction: column; }
|
||||||
|
.expenses-actions { display: flex; gap: 0.4rem; align-items: center; flex-shrink: 0; }
|
||||||
|
.expenses-actions .btn { white-space: nowrap; }
|
||||||
|
.expenses-sort { min-width: 8.5rem; }
|
||||||
|
|
||||||
|
.expenses-total { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.expenses-days { display: flex; flex-direction: column; gap: 1.1rem; }
|
||||||
|
.expenses-day-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.expenses-day-total { font-size: 0.78rem; font-weight: 700; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); padding: 0.1rem 0.5rem; border-radius: 999px; }
|
||||||
|
|
||||||
|
.expenses-flat { margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
.expenses-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 1.1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.expenses-actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<link rel="stylesheet" href="./css/styles.css" />
|
<link rel="stylesheet" href="./css/styles.css" />
|
||||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||||
<link rel="stylesheet" href="./css/checklist.css" />
|
<link rel="stylesheet" href="./css/checklist.css" />
|
||||||
|
<link rel="stylesheet" href="./css/expenses.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export const api = {
|
|||||||
addSuggestions: (tripId, keys, personal) =>
|
addSuggestions: (tripId, keys, personal) =>
|
||||||
post(`/api/trips/${tripId}/checklist/suggestions`, personal === undefined ? { keys } : { keys, personal }),
|
post(`/api/trips/${tripId}/checklist/suggestions`, personal === undefined ? { keys } : { keys, personal }),
|
||||||
},
|
},
|
||||||
|
expenses: {
|
||||||
|
list: (tripId) => get(`/api/trips/${tripId}/expenses`),
|
||||||
|
create: (tripId, payload) => post(`/api/trips/${tripId}/expenses`, payload),
|
||||||
|
update: (expenseId, patchBody) => patch(`/api/expenses/${expenseId}`, patchBody),
|
||||||
|
remove: (expenseId) => del(`/api/expenses/${expenseId}`),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ export function entryIcon(entry) {
|
|||||||
return typeInfo(entry && entry.type).icon;
|
return typeInfo(entry && entry.type).icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expense categories (daily spending log) — fixed enum, see docs/API.md.
|
||||||
|
// Display metadata lives here per the API contract ("Display metadata (icon +
|
||||||
|
// label + colour) lives in the frontend ... not the API").
|
||||||
|
export const EXPENSE_CATEGORIES = {
|
||||||
|
food: { label: 'Food', icon: '🍽️', color: '#059669' },
|
||||||
|
drinks: { label: 'Drinks', icon: '🍹', color: '#0891b2' },
|
||||||
|
transport: { label: 'Transport', icon: '🚕', color: '#d97706' },
|
||||||
|
activities: { label: 'Activities', icon: '🎟️', color: '#2563eb' },
|
||||||
|
shopping: { label: 'Shopping', icon: '🛍️', color: '#db2777' },
|
||||||
|
accommodation: { label: 'Accommodation', icon: '🏨', color: '#f59e0b' },
|
||||||
|
other: { label: 'Other', icon: '💸', color: '#64748b' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXPENSE_CATEGORY_LIST = Object.entries(EXPENSE_CATEGORIES).map(([value, meta]) => ({
|
||||||
|
value,
|
||||||
|
...meta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function expenseCategoryInfo(category) {
|
||||||
|
return EXPENSE_CATEGORIES[category] || EXPENSE_CATEGORIES.other;
|
||||||
|
}
|
||||||
|
|
||||||
// Split modes with the human labels the day-editor select shows.
|
// Split modes with the human labels the day-editor select shows.
|
||||||
export const SPLIT_MODES = [
|
export const SPLIT_MODES = [
|
||||||
{ value: 'equal', label: 'Split equally' },
|
{ value: 'equal', label: 'Split equally' },
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
import { typeInfo, formatMoney } from '../format.js';
|
import { typeInfo, formatMoney } from '../format.js';
|
||||||
|
|
||||||
|
// byType's "expense" key isn't an entry type (typeInfo only knows
|
||||||
|
// activity/stay/transport/flight/rental/note), so it'd otherwise fall back to
|
||||||
|
// a bare bullet + the raw key. Give it its own icon/label to match the
|
||||||
|
// Expenses card.
|
||||||
|
function byTypeInfo(type) {
|
||||||
|
if (type === 'expense') return { icon: '💸', label: 'Expenses', color: '#64748b' };
|
||||||
|
return typeInfo(type);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderCosts(tctx) {
|
export function renderCosts(tctx) {
|
||||||
const costs = tctx.costs || {};
|
const costs = tctx.costs || {};
|
||||||
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||||
@@ -72,7 +81,7 @@ export function renderCosts(tctx) {
|
|||||||
if (typeKeys.length) {
|
if (typeKeys.length) {
|
||||||
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
||||||
for (const type of typeKeys) {
|
for (const type of typeKeys) {
|
||||||
const info = typeInfo(type);
|
const info = byTypeInfo(type);
|
||||||
bt.appendChild(
|
bt.appendChild(
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Expenses card for the trip detail side column (below Costs, above
|
||||||
|
// Checklist). Self-fetches via GET /api/trips/:id/expenses and re-renders
|
||||||
|
// itself in place after every mutation — it never triggers tctx.refreshTrip().
|
||||||
|
// After add/edit/delete it re-fetches its own data AND calls
|
||||||
|
// tctx.refreshCosts() so the Costs panel (which merges expenses into
|
||||||
|
// settle-up) stays in sync without a full trip refresh.
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { el, mount, loading, errorBox, emptyState, toast } from '../dom.js';
|
||||||
|
import {
|
||||||
|
EXPENSE_CATEGORY_LIST, expenseCategoryInfo, SPLIT_MODES, splitModeLabel,
|
||||||
|
formatMoney, formatDate, ymd,
|
||||||
|
} from '../format.js';
|
||||||
|
|
||||||
|
export function renderExpenses(tctx) {
|
||||||
|
const state = { expenses: [], summary: { total: 0, byCategory: {}, byDay: [] }, sort: 'date-asc', editing: null };
|
||||||
|
const section = el('section', { class: 'card expenses-section' });
|
||||||
|
|
||||||
|
mount(section, loading('Loading expenses…'));
|
||||||
|
reload();
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const data = await api.expenses.list(tctx.tripId);
|
||||||
|
state.expenses = data.expenses || [];
|
||||||
|
state.summary = data.summary || { total: 0, byCategory: {}, byDay: [] };
|
||||||
|
draw();
|
||||||
|
} catch (err) {
|
||||||
|
mount(section, errorBox(err.message, reload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch this card's own data and nudge the Costs panel — never the
|
||||||
|
// whole trip.
|
||||||
|
async function afterMutation() {
|
||||||
|
await reload();
|
||||||
|
if (typeof tctx.refreshCosts === 'function') await tctx.refreshCosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function members() {
|
||||||
|
return tctx.trip.members || [];
|
||||||
|
}
|
||||||
|
function currency() {
|
||||||
|
return (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||||
|
}
|
||||||
|
function memberName(id) {
|
||||||
|
if (id == null) return null;
|
||||||
|
const m = members().find((x) => x.id === id);
|
||||||
|
return m ? m.display_name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateSort() {
|
||||||
|
return state.sort === 'date-asc' || state.sort === 'date-desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRows() {
|
||||||
|
const rows = [...state.expenses];
|
||||||
|
switch (state.sort) {
|
||||||
|
case 'date-desc':
|
||||||
|
rows.sort((a, b) => (a.date === b.date ? b.id - a.id : b.date.localeCompare(a.date)));
|
||||||
|
break;
|
||||||
|
case 'amount-asc':
|
||||||
|
rows.sort((a, b) => a.amount - b.amount);
|
||||||
|
break;
|
||||||
|
case 'amount-desc':
|
||||||
|
rows.sort((a, b) => b.amount - a.amount);
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
rows.sort((a, b) => a.category.localeCompare(b.category) || a.date.localeCompare(b.date));
|
||||||
|
break;
|
||||||
|
case 'payer':
|
||||||
|
rows.sort((a, b) =>
|
||||||
|
(memberName(a.paid_by) || '').localeCompare(memberName(b.paid_by) || '') || a.date.localeCompare(b.date));
|
||||||
|
break;
|
||||||
|
default: // date-asc
|
||||||
|
rows.sort((a, b) => (a.date === b.date ? a.id - b.id : a.date.localeCompare(b.date)));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const head = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'section-head cal-section-head' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{},
|
||||||
|
el('h2', {}, 'Expenses'),
|
||||||
|
el('p', { class: 'muted' }, 'Quick daily spending, split like any other cost.'),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'expenses-actions' },
|
||||||
|
sortSelect(),
|
||||||
|
el('a', {
|
||||||
|
class: 'btn btn-sm btn-ghost',
|
||||||
|
href: `/api/trips/${tctx.tripId}/expenses/export.csv`,
|
||||||
|
}, '⬇ Export CSV'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = [];
|
||||||
|
if (!state.expenses.length) {
|
||||||
|
body.push(emptyState('No expenses logged yet', 'Add one below — lunch, taxi, tickets, whatever.'));
|
||||||
|
} else {
|
||||||
|
body.push(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'costs-total expenses-total' },
|
||||||
|
el('span', { class: 'costs-total-value' }, formatMoney(state.summary.total, currency())),
|
||||||
|
el('span', { class: 'costs-total-label' }, 'total spent'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rows = sortedRows();
|
||||||
|
body.push(isDateSort() ? groupedByDay(rows) : flatList(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(section, head, ...body, formSection());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSelect() {
|
||||||
|
const options = [
|
||||||
|
['date-asc', 'Date ↑'],
|
||||||
|
['date-desc', 'Date ↓'],
|
||||||
|
['amount-asc', 'Amount ↑'],
|
||||||
|
['amount-desc', 'Amount ↓'],
|
||||||
|
['category', 'Category'],
|
||||||
|
['payer', 'Payer'],
|
||||||
|
];
|
||||||
|
const select = el(
|
||||||
|
'select',
|
||||||
|
{
|
||||||
|
class: 'input input-sm expenses-sort',
|
||||||
|
'aria-label': 'Sort expenses',
|
||||||
|
onChange: (e) => { state.sort = e.target.value; draw(); },
|
||||||
|
},
|
||||||
|
...options.map(([value, label]) => el('option', { value }, label)),
|
||||||
|
);
|
||||||
|
select.value = state.sort;
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedByDay(rows) {
|
||||||
|
const dayTotals = new Map((state.summary.byDay || []).map((d) => [d.date, d.total]));
|
||||||
|
const groups = new Map();
|
||||||
|
for (const exp of rows) {
|
||||||
|
if (!groups.has(exp.date)) groups.set(exp.date, []);
|
||||||
|
groups.get(exp.date).push(exp);
|
||||||
|
}
|
||||||
|
const wrap = el('div', { class: 'expenses-days' });
|
||||||
|
for (const [date, items] of groups) {
|
||||||
|
wrap.appendChild(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'expenses-day' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'expenses-day-head' },
|
||||||
|
el('h3', {}, formatDate(date, { weekday: 'short', month: 'short', day: 'numeric' })),
|
||||||
|
el('span', { class: 'expenses-day-total' }, formatMoney(dayTotals.get(date) || 0, currency(), { compact: true })),
|
||||||
|
),
|
||||||
|
el('div', { class: 'entry-list' }, ...items.map((exp) => expenseRow(exp, false))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatList(rows) {
|
||||||
|
return el('div', { class: 'entry-list expenses-flat' }, ...rows.map((exp) => expenseRow(exp, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseRow(exp, showDate) {
|
||||||
|
const info = expenseCategoryInfo(exp.category);
|
||||||
|
const payerName = memberName(exp.paid_by);
|
||||||
|
return el(
|
||||||
|
'div',
|
||||||
|
{ class: 'entry-row', style: { '--chip': info.color } },
|
||||||
|
el('span', { class: 'entry-icon' }, info.icon),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'entry-body' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'entry-title-row' },
|
||||||
|
el('span', { class: 'entry-title' }, exp.description),
|
||||||
|
el('span', { class: 'entry-price' }, formatMoney(exp.amount, currency(), { compact: true })),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'entry-sub muted' },
|
||||||
|
info.label,
|
||||||
|
showDate ? ` · ${formatDate(exp.date)}` : '',
|
||||||
|
` · ${splitModeLabel(exp.split_mode)}`,
|
||||||
|
payerName ? ` · paid by ${payerName}` : ' · no payer set',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'entry-actions' },
|
||||||
|
el('button', { class: 'icon-btn', type: 'button', title: 'Edit', onClick: () => startEdit(exp) }, '✎'),
|
||||||
|
el('button', { class: 'icon-btn danger', type: 'button', title: 'Delete', onClick: () => onDelete(exp) }, '🗑'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(exp) {
|
||||||
|
state.editing = exp;
|
||||||
|
draw();
|
||||||
|
const formEl = section.querySelector('.expenses-form');
|
||||||
|
if (formEl) formEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(exp) {
|
||||||
|
if (!window.confirm(`Delete "${exp.description}"?`)) return;
|
||||||
|
try {
|
||||||
|
await api.expenses.remove(exp.id);
|
||||||
|
toast('Expense deleted', 'success');
|
||||||
|
if (state.editing && state.editing.id === exp.id) state.editing = null;
|
||||||
|
await afterMutation();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today clamped into the trip's date range, for the quick-add default.
|
||||||
|
function defaultDate() {
|
||||||
|
const today = ymd(new Date());
|
||||||
|
const { start_date, end_date } = tctx.trip.trip;
|
||||||
|
if (today < start_date) return start_date;
|
||||||
|
if (today > end_date) return end_date;
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formSection() {
|
||||||
|
const editing = state.editing;
|
||||||
|
const mem = members();
|
||||||
|
const { start_date, end_date } = tctx.trip.trip;
|
||||||
|
|
||||||
|
const dateInput = el('input', {
|
||||||
|
class: 'input', type: 'date', min: start_date, max: end_date,
|
||||||
|
value: editing ? editing.date : defaultDate(),
|
||||||
|
});
|
||||||
|
const descInput = el('input', {
|
||||||
|
class: 'input field-grow', type: 'text', maxlength: '120', placeholder: 'Description',
|
||||||
|
value: editing ? editing.description : '',
|
||||||
|
});
|
||||||
|
const amountInput = el('input', {
|
||||||
|
class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00',
|
||||||
|
value: editing ? String(editing.amount) : '',
|
||||||
|
});
|
||||||
|
const categorySelect = el(
|
||||||
|
'select',
|
||||||
|
{ class: 'input' },
|
||||||
|
...EXPENSE_CATEGORY_LIST.map((c) => el('option', { value: c.value }, `${c.icon} ${c.label}`)),
|
||||||
|
);
|
||||||
|
categorySelect.value = editing ? editing.category : 'other';
|
||||||
|
const payerSelect = el(
|
||||||
|
'select',
|
||||||
|
{ class: 'input' },
|
||||||
|
el('option', { value: '' }, '— unassigned —'),
|
||||||
|
...mem.map((m) => el('option', { value: String(m.id) }, m.display_name)),
|
||||||
|
);
|
||||||
|
payerSelect.value = editing && editing.paid_by != null ? String(editing.paid_by) : '';
|
||||||
|
const modeSelect = el(
|
||||||
|
'select',
|
||||||
|
{ class: 'input' },
|
||||||
|
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
|
||||||
|
);
|
||||||
|
modeSelect.value = editing ? editing.split_mode : 'equal';
|
||||||
|
const editingParticipants = editing && Array.isArray(editing.participants) ? editing.participants : [];
|
||||||
|
const participantChecks = mem.map((m) =>
|
||||||
|
el('input', {
|
||||||
|
type: 'checkbox', class: 'part-check', value: String(m.id),
|
||||||
|
checked: editing ? (editingParticipants.length === 0 || editingParticipants.includes(m.id)) : true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const participantsBox = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'participants' },
|
||||||
|
...mem.map((m, i) => el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorEl = el('p', { class: 'form-error' });
|
||||||
|
const submitBtn = el('button', { class: 'btn btn-primary btn-sm', type: 'submit' }, editing ? 'Save' : '+ Add');
|
||||||
|
const cancelBtn = editing
|
||||||
|
? el('button', {
|
||||||
|
class: 'btn btn-ghost btn-sm', type: 'button',
|
||||||
|
onClick: () => { state.editing = null; draw(); },
|
||||||
|
}, 'Cancel')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.textContent = '';
|
||||||
|
const date = dateInput.value;
|
||||||
|
if (!date) return (errorEl.textContent = 'Date is required.');
|
||||||
|
const description = descInput.value.trim();
|
||||||
|
if (!description) return (errorEl.textContent = 'Description is required.');
|
||||||
|
if (description.length > 120) return (errorEl.textContent = 'Keep it under 120 characters.');
|
||||||
|
const amountRaw = amountInput.value.trim();
|
||||||
|
const amount = Number(amountRaw);
|
||||||
|
if (amountRaw === '' || !Number.isFinite(amount) || amount < 0) {
|
||||||
|
return (errorEl.textContent = 'Amount must be a number ≥ 0.');
|
||||||
|
}
|
||||||
|
const split_mode = modeSelect.value;
|
||||||
|
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
|
||||||
|
if (split_mode === 'payer' && paid_by == null) {
|
||||||
|
return (errorEl.textContent = "Choose who paid for a payer's own expense.");
|
||||||
|
}
|
||||||
|
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
|
||||||
|
if (checked.length === 0) return (errorEl.textContent = 'Select at least one participant.');
|
||||||
|
const participants = checked.length === mem.length ? [] : checked;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
date, description, amount, category: categorySelect.value, paid_by, split_mode, participants,
|
||||||
|
};
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (editing) await api.expenses.update(editing.id, payload);
|
||||||
|
else await api.expenses.create(tctx.tripId, payload);
|
||||||
|
toast(editing ? 'Expense updated' : 'Expense added', 'success');
|
||||||
|
state.editing = null;
|
||||||
|
await afterMutation();
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return el(
|
||||||
|
'form',
|
||||||
|
{ class: 'expenses-form', onSubmit },
|
||||||
|
el('h3', {}, editing ? 'Edit expense' : 'Add expense'),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'form-row' },
|
||||||
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Date'), dateInput),
|
||||||
|
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Description'), descInput),
|
||||||
|
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Amount (${currency()})`), amountInput),
|
||||||
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Category'), categorySelect),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'form-row' },
|
||||||
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
|
||||||
|
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect),
|
||||||
|
),
|
||||||
|
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
|
||||||
|
errorEl,
|
||||||
|
el('div', { class: 'form-actions' }, cancelBtn, submitBtn),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { renderCalendar } from './calendar.js';
|
|||||||
import { renderMap } from './map.js';
|
import { renderMap } from './map.js';
|
||||||
import { renderSummary } from './summary.js';
|
import { renderSummary } from './summary.js';
|
||||||
import { renderCosts } from './costs.js';
|
import { renderCosts } from './costs.js';
|
||||||
|
import { renderExpenses } from './expenses.js';
|
||||||
import { renderChecklist } from './checklist.js';
|
import { renderChecklist } from './checklist.js';
|
||||||
import { renderCountdown } from './flipclock.js';
|
import { renderCountdown } from './flipclock.js';
|
||||||
import { openDayEditor } from './dayEditor.js';
|
import { openDayEditor } from './dayEditor.js';
|
||||||
@@ -24,6 +25,11 @@ export function renderTripDetail(container, ctx, id) {
|
|||||||
_onModalRefresh: null,
|
_onModalRefresh: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The mounted Costs section node, so refreshCosts() can swap it in place
|
||||||
|
// without redrawing the whole page (self-fetching cards like Expenses
|
||||||
|
// trigger this instead of tctx.refreshTrip()).
|
||||||
|
let costsSection = null;
|
||||||
|
|
||||||
mount(container, loading('Loading trip…'));
|
mount(container, loading('Loading trip…'));
|
||||||
init();
|
init();
|
||||||
|
|
||||||
@@ -48,6 +54,23 @@ export function renderTripDetail(container, ctx, id) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lighter-weight than refreshTrip(): re-fetches only /costs and swaps the
|
||||||
|
// Costs section in place, so a self-fetching card (Expenses) can keep the
|
||||||
|
// settle-up numbers current without re-fetching the trip/route or
|
||||||
|
// re-mounting every other panel.
|
||||||
|
tctx.refreshCosts = async () => {
|
||||||
|
try {
|
||||||
|
tctx.costs = await api.trips.costs(id);
|
||||||
|
if (costsSection && costsSection.isConnected) {
|
||||||
|
const next = renderCosts(tctx);
|
||||||
|
costsSection.replaceWith(next);
|
||||||
|
costsSection = next;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tctx.openDay = (date) => openDayEditor(tctx, date);
|
tctx.openDay = (date) => openDayEditor(tctx, date);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -70,12 +93,20 @@ export function renderTripDetail(container, ctx, id) {
|
|||||||
page.appendChild(renderHeader());
|
page.appendChild(renderHeader());
|
||||||
page.appendChild(renderCountdown(tctx));
|
page.appendChild(renderCountdown(tctx));
|
||||||
page.appendChild(renderCalendar(tctx));
|
page.appendChild(renderCalendar(tctx));
|
||||||
|
costsSection = renderCosts(tctx);
|
||||||
page.appendChild(
|
page.appendChild(
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'detail-grid' },
|
{ class: 'detail-grid' },
|
||||||
renderMap(tctx),
|
renderMap(tctx),
|
||||||
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx), renderChecklist(tctx)),
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'detail-side' },
|
||||||
|
renderSummary(tctx),
|
||||||
|
costsSection,
|
||||||
|
renderExpenses(tctx),
|
||||||
|
renderChecklist(tctx),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
mount(container, page);
|
mount(container, page);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import authRoutes from './routes/auth.js';
|
|||||||
import tripsRoutes from './routes/trips.js';
|
import tripsRoutes from './routes/trips.js';
|
||||||
import entriesRoutes from './routes/entries.js';
|
import entriesRoutes from './routes/entries.js';
|
||||||
import checklistRoutes from './routes/checklist.js';
|
import checklistRoutes from './routes/checklist.js';
|
||||||
|
import expensesRoutes from './routes/expenses.js';
|
||||||
import geocodeRoutes from './routes/geocode.js';
|
import geocodeRoutes from './routes/geocode.js';
|
||||||
import airportsRoutes from './routes/airports.js';
|
import airportsRoutes from './routes/airports.js';
|
||||||
import directionsRoutes from './routes/directions.js';
|
import directionsRoutes from './routes/directions.js';
|
||||||
@@ -52,6 +53,7 @@ export function createApp(options = {}) {
|
|||||||
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
||||||
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
||||||
app.use('/api', requireAuth, checklistRoutes(db)); // /trips/:id/checklist* + /checklist/:itemId
|
app.use('/api', requireAuth, checklistRoutes(db)); // /trips/:id/checklist* + /checklist/:itemId
|
||||||
|
app.use('/api', requireAuth, expensesRoutes(db)); // /trips/:id/expenses* + /expenses/:expenseId
|
||||||
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
||||||
app.use('/api/airports', requireAuth, airportsRoutes());
|
app.use('/api/airports', requireAuth, airportsRoutes());
|
||||||
app.use('/api/directions', requireAuth, directionsRoutes());
|
app.use('/api/directions', requireAuth, directionsRoutes());
|
||||||
|
|||||||
@@ -72,10 +72,30 @@ CREATE TABLE IF NOT EXISTS checklist_items (
|
|||||||
created_at TEXT DEFAULT current_timestamp
|
created_at TEXT DEFAULT current_timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'other',
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
paid_by INTEGER REFERENCES users(id),
|
||||||
|
split_mode TEXT NOT NULL DEFAULT 'equal',
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT DEFAULT current_timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expense_participants (
|
||||||
|
expense_id INTEGER NOT NULL REFERENCES expenses(id),
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
PRIMARY KEY (expense_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_checklist_trip ON checklist_items(trip_id, category, sort_order, id);
|
CREATE INDEX IF NOT EXISTS idx_checklist_trip ON checklist_items(trip_id, category, sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expenses_trip ON expenses(trip_id, date, id);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isValidDateStr } from '../util/dates.js';
|
||||||
|
import { membership } from '../util/access.js';
|
||||||
|
import { attachParticipants } from '../util/entrySerialize.js';
|
||||||
|
import { buildExpenseCsv } from '../util/expenseCsv.js';
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION_LEN = 120;
|
||||||
|
const EXPENSE_CATEGORIES = new Set([
|
||||||
|
'food',
|
||||||
|
'drinks',
|
||||||
|
'transport',
|
||||||
|
'activities',
|
||||||
|
'shopping',
|
||||||
|
'accommodation',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
const SPLIT_MODES = new Set(['equal', 'own', 'payer']);
|
||||||
|
|
||||||
|
function round2(v) {
|
||||||
|
return Math.round((v + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseJson(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
trip_id: row.trip_id,
|
||||||
|
date: row.date,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
amount: row.amount,
|
||||||
|
paid_by: row.paid_by ?? null,
|
||||||
|
split_mode: row.split_mode,
|
||||||
|
participants: row.participants ?? [],
|
||||||
|
created_by: row.created_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective participants of an expense: its own list, or all current members
|
||||||
|
// when empty/absent (same default as Cost semantics).
|
||||||
|
function effectiveParticipantIds(row, memberIds) {
|
||||||
|
let eff =
|
||||||
|
Array.isArray(row.participants) && row.participants.length
|
||||||
|
? row.participants.filter((id) => memberIds.has(id))
|
||||||
|
: [...memberIds];
|
||||||
|
if (eff.length === 0) eff = [...memberIds];
|
||||||
|
return eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFor(expenses, memberIds) {
|
||||||
|
let total = 0;
|
||||||
|
const byCategory = {};
|
||||||
|
const byDayMap = new Map();
|
||||||
|
for (const e of expenses) {
|
||||||
|
const eff = effectiveParticipantIds(e, memberIds);
|
||||||
|
const effTotal = e.split_mode === 'own' ? e.amount * eff.length : e.amount;
|
||||||
|
total += effTotal;
|
||||||
|
byCategory[e.category] = (byCategory[e.category] || 0) + effTotal;
|
||||||
|
byDayMap.set(e.date, (byDayMap.get(e.date) || 0) + effTotal);
|
||||||
|
}
|
||||||
|
const byDay = [...byDayMap.entries()]
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||||
|
.map(([date, t]) => ({ date, total: round2(t) }));
|
||||||
|
const roundedByCategory = {};
|
||||||
|
for (const [k, v] of Object.entries(byCategory)) roundedByCategory[k] = round2(v);
|
||||||
|
return { total: round2(total), byCategory: roundedByCategory, byDay };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug for the CSV filename: lowercase, non-alphanumeric runs -> '-', trimmed;
|
||||||
|
// fallback `trip-<id>`.
|
||||||
|
function tripSlug(name, id) {
|
||||||
|
const slug = (name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return slug || `trip-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate an expense body. `partial` = true for PATCH (only provided keys
|
||||||
|
// checked). `existing` is the current row (PATCH merges for the payer rule).
|
||||||
|
// `memberIds` is a Set of the trip's member user ids. Returns { error } or
|
||||||
|
// { fields, participants, hasParticipants } (participants is null [= all
|
||||||
|
// members] or an array of ids).
|
||||||
|
function validateExpense(body, { partial, existing, memberIds }) {
|
||||||
|
const fields = {};
|
||||||
|
const has = (k) => k in body;
|
||||||
|
|
||||||
|
if (!partial || has('date')) {
|
||||||
|
if (!isValidDateStr(body.date)) {
|
||||||
|
return { error: 'date must be a valid YYYY-MM-DD date' };
|
||||||
|
}
|
||||||
|
fields.date = body.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || has('description')) {
|
||||||
|
if (typeof body.description !== 'string' || body.description.trim() === '') {
|
||||||
|
return { error: 'description is required' };
|
||||||
|
}
|
||||||
|
const trimmed = body.description.trim();
|
||||||
|
if (trimmed.length > MAX_DESCRIPTION_LEN) {
|
||||||
|
return { error: `description must be at most ${MAX_DESCRIPTION_LEN} characters` };
|
||||||
|
}
|
||||||
|
fields.description = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('category')) {
|
||||||
|
if (!EXPENSE_CATEGORIES.has(body.category)) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
'category must be one of food, drinks, transport, activities, shopping, accommodation, other',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fields.category = body.category;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.category = 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || has('amount')) {
|
||||||
|
const v = body.amount;
|
||||||
|
if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
||||||
|
return { error: 'amount must be a finite number >= 0' };
|
||||||
|
}
|
||||||
|
fields.amount = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('paid_by')) {
|
||||||
|
const v = body.paid_by;
|
||||||
|
if (v !== null && !(Number.isInteger(v) && memberIds.has(v))) {
|
||||||
|
return { error: 'paid_by must be null or a trip member id' };
|
||||||
|
}
|
||||||
|
fields.paid_by = v;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.paid_by = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('split_mode')) {
|
||||||
|
if (!SPLIT_MODES.has(body.split_mode)) {
|
||||||
|
return { error: 'split_mode must be one of equal, own, payer' };
|
||||||
|
}
|
||||||
|
fields.split_mode = body.split_mode;
|
||||||
|
} else if (!partial) {
|
||||||
|
fields.split_mode = 'equal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'payer' requires an effective paid_by (merging existing values on PATCH).
|
||||||
|
const effMode = 'split_mode' in fields ? fields.split_mode : existing?.split_mode ?? 'equal';
|
||||||
|
const effPaidBy = 'paid_by' in fields ? fields.paid_by : existing?.paid_by ?? null;
|
||||||
|
if (effMode === 'payer' && (effPaidBy === null || effPaidBy === undefined)) {
|
||||||
|
return { error: "split_mode 'payer' requires paid_by" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// participants: null/[] (= all members) or array of trip-member ids.
|
||||||
|
let participants;
|
||||||
|
if (has('participants')) {
|
||||||
|
const v = body.participants;
|
||||||
|
if (v === null || (Array.isArray(v) && v.length === 0)) {
|
||||||
|
participants = null;
|
||||||
|
} else if (Array.isArray(v)) {
|
||||||
|
for (const id of v) {
|
||||||
|
if (!Number.isInteger(id) || !memberIds.has(id)) {
|
||||||
|
return { error: 'participants must be trip member ids' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
participants = [...new Set(v)];
|
||||||
|
} else {
|
||||||
|
return { error: 'participants must be null or an array of member ids' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fields, participants, hasParticipants: has('participants') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function expensesRoutes(db) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getTripMeta = db.prepare('SELECT id, name, currency FROM trips WHERE id = ?');
|
||||||
|
const getMembers = db.prepare(`
|
||||||
|
SELECT u.id, u.display_name
|
||||||
|
FROM trip_members tm JOIN users u ON u.id = tm.user_id
|
||||||
|
WHERE tm.trip_id = ? ORDER BY tm.role = 'owner' DESC, u.display_name
|
||||||
|
`);
|
||||||
|
const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?');
|
||||||
|
const getExpensesForTrip = db.prepare(
|
||||||
|
'SELECT * FROM expenses WHERE trip_id = ? ORDER BY date, id'
|
||||||
|
);
|
||||||
|
const getExpenseById = db.prepare('SELECT * FROM expenses WHERE id = ?');
|
||||||
|
const getExpenseParticipants = db.prepare(
|
||||||
|
'SELECT user_id FROM expense_participants WHERE expense_id = ? ORDER BY user_id'
|
||||||
|
);
|
||||||
|
const insertExpense = db.prepare(`
|
||||||
|
INSERT INTO expenses (trip_id, date, description, category, amount, paid_by, split_mode, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertParticipant = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO expense_participants (expense_id, user_id) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
const clearParticipants = db.prepare('DELETE FROM expense_participants WHERE expense_id = ?');
|
||||||
|
const getPricedEntries = db.prepare(
|
||||||
|
`SELECT id, date, type, title, price, paid_by, split_mode FROM entries
|
||||||
|
WHERE trip_id = ? AND price IS NOT NULL ORDER BY date, id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
||||||
|
|
||||||
|
function attachExpenseParticipants(row) {
|
||||||
|
if (!row) return row;
|
||||||
|
row.participants = getExpenseParticipants.all(row.id).map((r) => r.user_id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeParticipants(expenseId, participants) {
|
||||||
|
clearParticipants.run(expenseId);
|
||||||
|
if (Array.isArray(participants)) {
|
||||||
|
for (const uid of participants) insertParticipant.run(expenseId, uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an expense by id and confirm the caller is a member of its trip.
|
||||||
|
// Sends 404 and returns null otherwise (no leaking).
|
||||||
|
function requireExpense(req, res) {
|
||||||
|
const expenseId = Number(req.params.expenseId);
|
||||||
|
const row = Number.isInteger(expenseId) ? getExpenseById.get(expenseId) : null;
|
||||||
|
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||||
|
res.status(404).json({ error: 'not found' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /trips/:id/expenses
|
||||||
|
router.get('/trips/:id/expenses', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const rows = getExpensesForTrip.all(tripId).map(attachExpenseParticipants);
|
||||||
|
res.status(200).json({
|
||||||
|
expenses: rows.map(expenseJson),
|
||||||
|
summary: summaryFor(rows, memberIdSet(tripId)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /trips/:id/expenses/export.csv
|
||||||
|
router.get('/trips/:id/expenses/export.csv', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const trip = getTripMeta.get(tripId);
|
||||||
|
const members = getMembers.all(tripId);
|
||||||
|
|
||||||
|
const expenseRows = getExpensesForTrip
|
||||||
|
.all(tripId)
|
||||||
|
.map(attachExpenseParticipants)
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date,
|
||||||
|
source: 'expense',
|
||||||
|
category: e.category,
|
||||||
|
description: e.description,
|
||||||
|
amount: e.amount,
|
||||||
|
paid_by: e.paid_by,
|
||||||
|
split_mode: e.split_mode,
|
||||||
|
participants: e.participants,
|
||||||
|
}));
|
||||||
|
const entryRows = getPricedEntries
|
||||||
|
.all(tripId)
|
||||||
|
.map((r) => attachParticipants(db, r))
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date,
|
||||||
|
source: 'entry',
|
||||||
|
category: e.type,
|
||||||
|
description: e.title,
|
||||||
|
amount: e.price,
|
||||||
|
paid_by: e.paid_by,
|
||||||
|
split_mode: e.split_mode,
|
||||||
|
participants: e.participants,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// buildExpenseCsv formats rows as given; interleaving two id namespaces
|
||||||
|
// (expense ids and entry ids) by (date, id) is done here.
|
||||||
|
const rows = [...expenseRows, ...entryRows].sort((a, b) => {
|
||||||
|
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
|
||||||
|
if (a.id !== b.id) return a.id - b.id;
|
||||||
|
return a.source < b.source ? -1 : a.source > b.source ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const csv = buildExpenseCsv({ trip, members, rows });
|
||||||
|
const filename = `${tripSlug(trip.name, trip.id)}-expenses.csv`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.status(200).send(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /trips/:id/expenses { date, description, amount, category?, paid_by?, split_mode?, participants? }
|
||||||
|
router.post('/trips/:id/expenses', (req, res) => {
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
const check = validateExpense(req.body || {}, {
|
||||||
|
partial: false,
|
||||||
|
existing: null,
|
||||||
|
memberIds: memberIdSet(tripId),
|
||||||
|
});
|
||||||
|
if (check.error) return res.status(400).json({ error: check.error });
|
||||||
|
const f = check.fields;
|
||||||
|
const callerId = req.session.userId;
|
||||||
|
|
||||||
|
const expense = db.transaction(() => {
|
||||||
|
const info = insertExpense.run(
|
||||||
|
tripId,
|
||||||
|
f.date,
|
||||||
|
f.description,
|
||||||
|
f.category ?? 'other',
|
||||||
|
f.amount,
|
||||||
|
f.paid_by ?? null,
|
||||||
|
f.split_mode ?? 'equal',
|
||||||
|
callerId
|
||||||
|
);
|
||||||
|
const id = Number(info.lastInsertRowid);
|
||||||
|
if (Array.isArray(check.participants)) writeParticipants(id, check.participants);
|
||||||
|
return attachExpenseParticipants(getExpenseById.get(id));
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.status(201).json({ expense: expenseJson(expense) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /expenses/:expenseId
|
||||||
|
router.patch('/expenses/:expenseId', (req, res) => {
|
||||||
|
const row = requireExpense(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
const check = validateExpense(req.body || {}, {
|
||||||
|
partial: true,
|
||||||
|
existing: row,
|
||||||
|
memberIds: memberIdSet(row.trip_id),
|
||||||
|
});
|
||||||
|
if (check.error) return res.status(400).json({ error: check.error });
|
||||||
|
|
||||||
|
const expense = db.transaction(() => {
|
||||||
|
const keys = Object.keys(check.fields);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const setClause = keys.map((k) => `${k} = ?`).join(', ');
|
||||||
|
const values = keys.map((k) => check.fields[k]);
|
||||||
|
db.prepare(`UPDATE expenses SET ${setClause} WHERE id = ?`).run(...values, row.id);
|
||||||
|
}
|
||||||
|
if (check.hasParticipants) writeParticipants(row.id, check.participants);
|
||||||
|
return attachExpenseParticipants(getExpenseById.get(row.id));
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.status(200).json({ expense: expenseJson(expense) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /expenses/:expenseId
|
||||||
|
router.delete('/expenses/:expenseId', (req, res) => {
|
||||||
|
const row = requireExpense(req, res);
|
||||||
|
if (!row) return;
|
||||||
|
db.transaction(() => {
|
||||||
|
clearParticipants.run(row.id);
|
||||||
|
db.prepare('DELETE FROM expenses WHERE id = ?').run(row.id);
|
||||||
|
})();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -148,6 +148,24 @@ export default function tripsRoutes(db) {
|
|||||||
);
|
);
|
||||||
const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?');
|
const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?');
|
||||||
const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?');
|
const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?');
|
||||||
|
const getExpenses = db.prepare(
|
||||||
|
'SELECT id, amount, paid_by, split_mode FROM expenses WHERE trip_id = ?'
|
||||||
|
);
|
||||||
|
const getExpenseParticipants = db.prepare(
|
||||||
|
'SELECT user_id FROM expense_participants WHERE expense_id = ? ORDER BY user_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expenses merge into computeCosts as pseudo-entries under byType key
|
||||||
|
// 'expense' (see docs/API.md "Costs & splitting (computed)").
|
||||||
|
function expensesAsCostEntries(tripId) {
|
||||||
|
return getExpenses.all(tripId).map((e) => ({
|
||||||
|
type: 'expense',
|
||||||
|
price: e.amount,
|
||||||
|
paid_by: e.paid_by,
|
||||||
|
split_mode: e.split_mode,
|
||||||
|
participants: getExpenseParticipants.all(e.id).map((r) => r.user_id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a join_code not already in use.
|
// Generate a join_code not already in use.
|
||||||
function uniqueJoinCode() {
|
function uniqueJoinCode() {
|
||||||
@@ -259,6 +277,15 @@ export default function tripsRoutes(db) {
|
|||||||
return res.status(403).json({ error: 'only the owner can delete a trip' });
|
return res.status(403).json({ error: 'only the owner can delete a trip' });
|
||||||
}
|
}
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
// Children before parents, per FK constraints (foreign_keys = ON).
|
||||||
|
db.prepare(
|
||||||
|
'DELETE FROM expense_participants WHERE expense_id IN (SELECT id FROM expenses WHERE trip_id = ?)'
|
||||||
|
).run(ctx.tripId);
|
||||||
|
db.prepare('DELETE FROM expenses WHERE trip_id = ?').run(ctx.tripId);
|
||||||
|
db.prepare('DELETE FROM checklist_items WHERE trip_id = ?').run(ctx.tripId);
|
||||||
|
db.prepare(
|
||||||
|
'DELETE FROM entry_participants WHERE entry_id IN (SELECT id FROM entries WHERE trip_id = ?)'
|
||||||
|
).run(ctx.tripId);
|
||||||
db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId);
|
db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId);
|
||||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId);
|
db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId);
|
||||||
db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId);
|
db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId);
|
||||||
@@ -406,7 +433,9 @@ export default function tripsRoutes(db) {
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const trip = getTrip.get(ctx.tripId);
|
const trip = getTrip.get(ctx.tripId);
|
||||||
const members = getMembers.all(ctx.tripId);
|
const members = getMembers.all(ctx.tripId);
|
||||||
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId));
|
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId)).concat(
|
||||||
|
expensesAsCostEntries(ctx.tripId)
|
||||||
|
);
|
||||||
res.status(200).json(
|
res.status(200).json(
|
||||||
computeCosts({ currency: trip.currency, members, entries })
|
computeCosts({ currency: trip.currency, members, entries })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Pure CSV builder for the expense export (see docs/API.md "Expenses (daily
|
||||||
|
// spending log)" -> "CSV export"). Rows unify expenses and priced calendar
|
||||||
|
// entries so the export covers the trip's complete money picture in one file.
|
||||||
|
// Kept side-effect free so it can be unit-tested directly. Sorting rows by
|
||||||
|
// (date, id) across the two source tables is the caller's job (the route) —
|
||||||
|
// this function just formats whatever order it's given.
|
||||||
|
|
||||||
|
function round2(v) {
|
||||||
|
return Math.round((v + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formula-injection guard: a field whose first character is =, +, -, @, tab
|
||||||
|
// or CR is prefixed with a single quote before quoting. Applied to every
|
||||||
|
// free-text column (description, category, payer/participant names) since
|
||||||
|
// trips are multi-user and another member's text lands in the caller's
|
||||||
|
// spreadsheet. Numeric columns (amount, shares) are server-formatted and
|
||||||
|
// never passed through this.
|
||||||
|
function guardFormula(value) {
|
||||||
|
const s = value === null || value === undefined ? '' : String(value);
|
||||||
|
return /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 4180 quoting: quote fields containing a quote, comma, CR or LF, and
|
||||||
|
// double any embedded quotes.
|
||||||
|
function csvField(value) {
|
||||||
|
const s = value === null || value === undefined ? '' : String(value);
|
||||||
|
if (/[",\r\n]/.test(s)) {
|
||||||
|
return '"' + s.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective participants for a row: its own list, or every current member
|
||||||
|
// when empty/absent (same default as Cost semantics).
|
||||||
|
function effectiveParticipants(row, memberIds) {
|
||||||
|
let eff =
|
||||||
|
Array.isArray(row.participants) && row.participants.length
|
||||||
|
? row.participants.filter((id) => memberIds.includes(id))
|
||||||
|
: memberIds;
|
||||||
|
if (eff.length === 0) eff = memberIds;
|
||||||
|
return eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveTotal(row, eff) {
|
||||||
|
return row.split_mode === 'own' ? row.amount * eff.length : row.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-member share of a single row, following the same equal/own/payer rules
|
||||||
|
// as computeCosts (src/server/util/costs.js) but scoped to one row.
|
||||||
|
function sharesFor(row, eff, memberIds) {
|
||||||
|
const shares = new Map(memberIds.map((id) => [id, 0]));
|
||||||
|
if (row.split_mode === 'own') {
|
||||||
|
for (const id of eff) shares.set(id, row.amount);
|
||||||
|
} else if (row.split_mode === 'payer') {
|
||||||
|
if (row.paid_by !== null && row.paid_by !== undefined && shares.has(row.paid_by)) {
|
||||||
|
shares.set(row.paid_by, row.amount);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const per = row.amount / eff.length;
|
||||||
|
for (const id of eff) shares.set(id, per);
|
||||||
|
}
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trip: { currency }
|
||||||
|
// members: [{ id, display_name }] current trip members, in display order.
|
||||||
|
// rows: [{ date, source: 'expense'|'entry', category, description,
|
||||||
|
// amount, paid_by, split_mode, participants }], already sorted
|
||||||
|
// by (date, id) by the caller. `amount` is the raw amount/price (not
|
||||||
|
// yet divided for 'own'); `participants` is an array of user ids or
|
||||||
|
// null/[] (= all members).
|
||||||
|
export function buildExpenseCsv({ trip, members, rows }) {
|
||||||
|
const memberIds = members.map((m) => m.id);
|
||||||
|
const nameById = new Map(members.map((m) => [m.id, m.display_name]));
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
'date',
|
||||||
|
'source',
|
||||||
|
'category',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'paid_by',
|
||||||
|
'split',
|
||||||
|
'participants',
|
||||||
|
...members.map((m) => `share: ${m.display_name}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Guard applied uniformly, including the "share: <name>" header cells —
|
||||||
|
// display names are user-editable free text too.
|
||||||
|
const lines = [header.map((v) => csvField(guardFormula(v))).join(',')];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const eff = effectiveParticipants(row, memberIds);
|
||||||
|
const total = effectiveTotal(row, eff);
|
||||||
|
const shares = sharesFor(row, eff, memberIds);
|
||||||
|
const isAll = !Array.isArray(row.participants) || row.participants.length === 0;
|
||||||
|
const paidByName =
|
||||||
|
row.paid_by !== null && row.paid_by !== undefined ? nameById.get(row.paid_by) || '' : '';
|
||||||
|
const participantsStr = isAll
|
||||||
|
? 'all'
|
||||||
|
: members
|
||||||
|
.filter((m) => eff.includes(m.id))
|
||||||
|
.map((m) => m.display_name)
|
||||||
|
.join(';');
|
||||||
|
|
||||||
|
const cols = [
|
||||||
|
row.date,
|
||||||
|
row.source,
|
||||||
|
guardFormula(row.category),
|
||||||
|
guardFormula(row.description),
|
||||||
|
round2(total).toFixed(2),
|
||||||
|
trip.currency,
|
||||||
|
guardFormula(paidByName),
|
||||||
|
row.split_mode,
|
||||||
|
guardFormula(participantsStr),
|
||||||
|
...members.map((m) => round2(shares.get(m.id) || 0).toFixed(2)),
|
||||||
|
];
|
||||||
|
lines.push(cols.map(csvField).join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTF-8 BOM so Excel opens the file correctly.
|
||||||
|
return String.fromCharCode(0xfeff) + lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
@@ -231,6 +231,20 @@ test('access control: non-member gets 404 on every trip-scoped and item-scoped r
|
|||||||
assert.equal((await outsider.delete(`/api/checklist/${item.id}`)).status, 404);
|
assert.equal((await outsider.delete(`/api/checklist/${item.id}`)).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleting a trip removes its checklist items', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const trip = await makeTrip(agent);
|
||||||
|
await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' });
|
||||||
|
|
||||||
|
// checklist_items.trip_id has a real FK to trips(id) and foreign_keys is ON,
|
||||||
|
// so a trip delete that forgets this table fails the whole transaction (500).
|
||||||
|
assert.equal((await agent.delete(`/api/trips/${trip.id}`)).status, 204);
|
||||||
|
const left = app.locals.db
|
||||||
|
.prepare('SELECT COUNT(*) AS c FROM checklist_items WHERE trip_id = ?')
|
||||||
|
.get(trip.id).c;
|
||||||
|
assert.equal(left, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('access control: unknown item id -> 404', async () => {
|
test('access control: unknown item id -> 404', async () => {
|
||||||
const { agent } = await createAccount();
|
const { agent } = await createAccount();
|
||||||
await makeTrip(agent);
|
await makeTrip(agent);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildExpenseCsv } from '../src/server/util/expenseCsv.js';
|
||||||
|
import { BOM, readCsv } from './helpers/csv.js';
|
||||||
|
|
||||||
|
// Unit tests for the pure CSV builder. The endpoint that serves its output is
|
||||||
|
// covered in tests/expenses-export.test.js.
|
||||||
|
|
||||||
|
const TRIP = { id: 3, name: 'Trip', currency: 'EUR' };
|
||||||
|
const MEMBERS = [{ id: 1, display_name: 'anna' }, { id: 2, display_name: 'ben' }];
|
||||||
|
|
||||||
|
function csvRows(rows, members = MEMBERS) {
|
||||||
|
return readCsv(buildExpenseCsv({ trip: TRIP, members, rows }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseRow(over = {}) {
|
||||||
|
return {
|
||||||
|
date: '2026-08-01', source: 'expense', category: 'food', description: 'Lunch',
|
||||||
|
amount: 100, paid_by: 1, split_mode: 'equal', participants: [], ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildExpenseCsv: header row, BOM and CRLF with no data rows', () => {
|
||||||
|
const csv = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [] });
|
||||||
|
assert.ok(csv.startsWith(BOM), 'the builder returns the complete file, BOM included');
|
||||||
|
assert.ok(csv.endsWith('\r\n'), 'CRLF line endings');
|
||||||
|
assert.ok(!csv.includes('\n\n'));
|
||||||
|
const { header, rows } = readCsv(csv);
|
||||||
|
assert.deepEqual(header, [
|
||||||
|
'date', 'source', 'category', 'description', 'amount', 'currency',
|
||||||
|
'paid_by', 'split', 'participants', 'share: anna', 'share: ben',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(rows, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: quotes fields containing a comma, a quote, or a newline', () => {
|
||||||
|
const raw = buildExpenseCsv({
|
||||||
|
trip: TRIP,
|
||||||
|
members: MEMBERS,
|
||||||
|
rows: [
|
||||||
|
baseRow({ description: 'Dinner, "the good" place' }),
|
||||||
|
baseRow({ description: 'Two\r\nlines' }),
|
||||||
|
baseRow({ description: 'Plain' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-trips through a strict reader with the values intact.
|
||||||
|
const { rows } = readCsv(raw);
|
||||||
|
assert.deepEqual(rows.map((r) => r.description), [
|
||||||
|
'Dinner, "the good" place',
|
||||||
|
'Two\r\nlines',
|
||||||
|
'Plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// And the raw bytes use RFC 4180 escaping, not stripping or backslashes.
|
||||||
|
assert.ok(raw.includes('"Dinner, ""the good"" place"'), 'embedded quotes are doubled, whole field quoted');
|
||||||
|
assert.ok(!raw.includes('\\"'), 'no backslash escaping');
|
||||||
|
assert.ok(raw.includes(',Plain,'), 'a field needing no quoting is left bare');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: guards every leading formula character, and only the leading one', () => {
|
||||||
|
const raw = buildExpenseCsv({
|
||||||
|
trip: TRIP,
|
||||||
|
members: MEMBERS,
|
||||||
|
rows: [
|
||||||
|
baseRow({ description: '=SUM(A1)' }),
|
||||||
|
baseRow({ description: '+1 tip' }),
|
||||||
|
baseRow({ description: '-5 refund' }),
|
||||||
|
baseRow({ description: '@here' }),
|
||||||
|
// Tab and CR can only arrive through the pure function — the route trims
|
||||||
|
// them off the description before storing.
|
||||||
|
baseRow({ description: '\tTabbed' }),
|
||||||
|
baseRow({ description: '\rCarriage' }),
|
||||||
|
baseRow({ description: 'Total = 5' }),
|
||||||
|
baseRow({ description: 'a-b+c' }),
|
||||||
|
baseRow({ description: '2 coffees' }),
|
||||||
|
baseRow({ description: 'Lunch' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = readCsv(raw);
|
||||||
|
assert.deepEqual(rows.map((r) => r.description), [
|
||||||
|
"'=SUM(A1)", "'+1 tip", "'-5 refund", "'@here", "'\tTabbed", "'\rCarriage",
|
||||||
|
// Values starting with a digit or a letter are left exactly as typed.
|
||||||
|
'Total = 5', 'a-b+c', '2 coffees', 'Lunch',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(rows.map((r) => r.amount), Array(10).fill('100.00'), 'amount is never guarded');
|
||||||
|
|
||||||
|
// Unquoted where no RFC 4180 character is present — the guard alone does not
|
||||||
|
// force quoting.
|
||||||
|
assert.ok(raw.includes(",'=SUM(A1),"), 'guarded but not quoted');
|
||||||
|
assert.ok(raw.includes(',Total = 5,'), 'a non-leading = is untouched and unquoted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: a guarded field with a comma is both prefixed and quoted', () => {
|
||||||
|
const raw = buildExpenseCsv({
|
||||||
|
trip: TRIP,
|
||||||
|
members: MEMBERS,
|
||||||
|
rows: [
|
||||||
|
baseRow({ description: '=A,B' }),
|
||||||
|
baseRow({ description: '=HYPERLINK("http://evil"), click' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(raw.includes('"\'=A,B"'), 'guard runs first, then the whole field is quoted');
|
||||||
|
assert.ok(
|
||||||
|
raw.includes('"\'=HYPERLINK(""http://evil""), click"'),
|
||||||
|
'guard prefix inside the quotes, embedded quotes doubled'
|
||||||
|
);
|
||||||
|
const { rows } = readCsv(raw);
|
||||||
|
assert.deepEqual(rows.map((r) => r.description), [
|
||||||
|
'\'=A,B',
|
||||||
|
'\'=HYPERLINK("http://evil"), click',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: category, payer and participant names are guarded too', () => {
|
||||||
|
const members = [
|
||||||
|
{ id: 1, display_name: '=evil-one' },
|
||||||
|
{ id: 2, display_name: '+evil-two' },
|
||||||
|
];
|
||||||
|
const { header, rows } = csvRows(
|
||||||
|
[baseRow({ category: '=food', paid_by: 1, participants: [1, 2] })],
|
||||||
|
members
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].category, "'=food");
|
||||||
|
assert.equal(rows[0].paid_by, "'=evil-one");
|
||||||
|
assert.equal(rows[0].participants, "'=evil-one;+evil-two",
|
||||||
|
'the joined string is guarded once, on its first character');
|
||||||
|
|
||||||
|
// The `share:` header cells are deliberately NOT prefixed: the guard looks at
|
||||||
|
// the first character of the whole cell, and "share: " already puts a letter
|
||||||
|
// there, so Excel can never read it as a formula. Prefixing would corrupt the
|
||||||
|
// column name for no gain.
|
||||||
|
assert.deepEqual(header.slice(-2), ['share: =evil-one', 'share: +evil-two']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: quotes a member display name containing a comma in the header', () => {
|
||||||
|
const { header } = csvRows([], [{ id: 1, display_name: 'Ann, Marie' }]);
|
||||||
|
assert.equal(header[header.length - 1], 'share: Ann, Marie');
|
||||||
|
|
||||||
|
const raw = buildExpenseCsv({ trip: TRIP, members: [{ id: 1, display_name: 'Ann, Marie' }], rows: [] });
|
||||||
|
assert.ok(raw.includes('"share: Ann, Marie"'), 'header cell is quoted too');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: effective totals and per-member shares for each split mode', () => {
|
||||||
|
const { rows } = csvRows([
|
||||||
|
baseRow({ description: 'Equal', amount: 100 }),
|
||||||
|
baseRow({ description: 'Own', amount: 100, split_mode: 'own', paid_by: null }),
|
||||||
|
baseRow({ description: 'Payer', amount: 100, split_mode: 'payer', paid_by: 2 }),
|
||||||
|
baseRow({ description: 'Subset', amount: 100, participants: [2] }),
|
||||||
|
]);
|
||||||
|
const [equal, own, payer, subset] = rows;
|
||||||
|
|
||||||
|
assert.deepEqual([equal.amount, equal['share: anna'], equal['share: ben']], ['100.00', '50.00', '50.00']);
|
||||||
|
assert.deepEqual([own.amount, own['share: anna'], own['share: ben']], ['200.00', '100.00', '100.00']);
|
||||||
|
assert.deepEqual([payer.amount, payer['share: anna'], payer['share: ben']], ['100.00', '0.00', '100.00']);
|
||||||
|
assert.deepEqual([subset.amount, subset['share: anna'], subset['share: ben']], ['100.00', '0.00', '100.00']);
|
||||||
|
|
||||||
|
assert.equal(equal.participants, 'all');
|
||||||
|
assert.equal(subset.participants, 'ben');
|
||||||
|
assert.equal(own.paid_by, '', 'null payer renders as an empty cell');
|
||||||
|
assert.equal(equal.currency, 'EUR', 'currency comes from the trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: shares are 2dp even when the split does not divide evenly', () => {
|
||||||
|
const members = [
|
||||||
|
{ id: 1, display_name: 'anna' },
|
||||||
|
{ id: 2, display_name: 'ben' },
|
||||||
|
{ id: 3, display_name: 'carol' },
|
||||||
|
];
|
||||||
|
const { rows } = csvRows([baseRow({ amount: 100, participants: [] })], members);
|
||||||
|
assert.deepEqual(
|
||||||
|
[rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']],
|
||||||
|
['33.33', '33.33', '33.33']
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].amount, '100.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExpenseCsv: rows are emitted in the order given (the route pre-sorts)', () => {
|
||||||
|
const { rows } = csvRows([
|
||||||
|
baseRow({ date: '2026-08-01', description: 'first' }),
|
||||||
|
baseRow({ date: '2026-08-02', description: 'second', source: 'entry', category: 'stay' }),
|
||||||
|
baseRow({ date: '2026-08-03', description: 'third' }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(rows.map((r) => r.description), ['first', 'second', 'third']);
|
||||||
|
assert.deepEqual(rows.map((r) => r.source), ['expense', 'entry', 'expense']);
|
||||||
|
assert.equal(rows[1].category, 'stay');
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/server/app.js';
|
||||||
|
import { BOM, readCsv } from './helpers/csv.js';
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expense-csv-'));
|
||||||
|
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
if (app.locals.db) app.locals.db.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Account with a fixed display_name so CSV columns and payer cells are deterministic.
|
||||||
|
async function createAccount(displayName) {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const created = await agent.post('/api/auth/account').send({});
|
||||||
|
assert.equal(created.status, 201);
|
||||||
|
const named = await agent.patch('/api/auth/me').send({ display_name: displayName });
|
||||||
|
assert.equal(named.status, 200);
|
||||||
|
return { agent, user: named.body.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTrip(owner, members = [], overrides = {}) {
|
||||||
|
const trip = (await owner.agent.post('/api/trips').send({
|
||||||
|
name: 'Thailand Trip 2026!', start_date: '2026-08-01', end_date: '2026-08-10',
|
||||||
|
currency: 'THB', ...overrides,
|
||||||
|
})).body.trip;
|
||||||
|
for (const m of members) {
|
||||||
|
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
||||||
|
}
|
||||||
|
return trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function memberNames(account, trip) {
|
||||||
|
const res = await account.agent.get(`/api/trips/${trip.id}`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
return res.body.members.map((m) => m.display_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportUrl = (trip) => `/api/trips/${trip.id}/expenses/export.csv`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response headers & encoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('export: content type, filename slug, BOM and CRLF endings', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Lunch', amount: 100, category: 'food',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await anna.agent.get(exportUrl(trip));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers['content-type'], 'text/csv; charset=utf-8');
|
||||||
|
assert.equal(
|
||||||
|
res.headers['content-disposition'],
|
||||||
|
'attachment; filename="thailand-trip-2026-expenses.csv"',
|
||||||
|
'slug lowercases, collapses non-alphanumeric runs to "-" and trims them'
|
||||||
|
);
|
||||||
|
assert.ok(res.text.startsWith(BOM), 'UTF-8 BOM so Excel reads it as UTF-8');
|
||||||
|
assert.ok(res.text.includes('\r\n'), 'CRLF line endings');
|
||||||
|
// parseCsv fails on any bare LF/CR, so this is the real line-ending assertion.
|
||||||
|
const { rows } = readCsv(res.text);
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export: filename falls back to trip-<id> when the name has no alphanumerics', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const trip = await makeTrip(anna, [], { name: '!!! ???' });
|
||||||
|
|
||||||
|
const res = await anna.agent.get(exportUrl(trip));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers['content-disposition'], `attachment; filename="trip-${trip.id}-expenses.csv"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export: an empty trip yields the header row only', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||||
|
assert.deepEqual(header, [
|
||||||
|
'date', 'source', 'category', 'description', 'amount', 'currency',
|
||||||
|
'paid_by', 'split', 'participants', 'share: anna',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(rows, [], 'no totals row — data rows only');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Columns, quoting, interleaving and shares
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('export: expenses and priced entries interleave by date with correct shares', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const ben = await createAccount('ben');
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const entries = `/api/trips/${trip.id}/entries`;
|
||||||
|
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||||
|
|
||||||
|
// 08-01 entry, equal, 900 total -> 450 each.
|
||||||
|
await anna.agent.post(entries).send({
|
||||||
|
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||||
|
});
|
||||||
|
// 08-02 expense with a comma AND a double quote in the description.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-02', description: 'Dinner, "the good" place', amount: 200,
|
||||||
|
category: 'food', paid_by: ben.user.id,
|
||||||
|
});
|
||||||
|
// 08-03 expense, payer split -> ben alone bears it.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-03', description: 'Solo souvenir', amount: 50, category: 'shopping',
|
||||||
|
split_mode: 'payer', paid_by: ben.user.id,
|
||||||
|
});
|
||||||
|
// 08-04 expense, equal but only ben participates -> ben 60, anna 0.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-04', description: 'Taxi', amount: 60, category: 'transport',
|
||||||
|
paid_by: ben.user.id, participants: [ben.user.id],
|
||||||
|
});
|
||||||
|
// 08-05 entry, own split at 300/head -> effective total 600, 300 each.
|
||||||
|
await anna.agent.post(entries).send({
|
||||||
|
date: '2026-08-05', type: 'flight', title: 'Flights', price: 300, split_mode: 'own',
|
||||||
|
});
|
||||||
|
// Unpriced entry — must not appear at all.
|
||||||
|
await anna.agent.post(entries).send({ date: '2026-08-06', type: 'activity', title: 'Free walk' });
|
||||||
|
|
||||||
|
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||||
|
assert.deepEqual(header.slice(-2), ['share: anna', 'share: ben'], 'one share column per member, in display order');
|
||||||
|
assert.deepEqual(await memberNames(anna, trip), ['anna', 'ben']);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => [r.date, r.source, r.description]),
|
||||||
|
[
|
||||||
|
['2026-08-01', 'entry', 'Hotel'],
|
||||||
|
['2026-08-02', 'expense', 'Dinner, "the good" place'],
|
||||||
|
['2026-08-03', 'expense', 'Solo souvenir'],
|
||||||
|
['2026-08-04', 'expense', 'Taxi'],
|
||||||
|
['2026-08-05', 'entry', 'Flights'],
|
||||||
|
],
|
||||||
|
'chronological, sources interleaved, unpriced entry excluded'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [hotel, dinner, souvenir, taxi, flights] = rows;
|
||||||
|
|
||||||
|
assert.deepEqual(hotel, {
|
||||||
|
date: '2026-08-01', source: 'entry', category: 'stay', description: 'Hotel',
|
||||||
|
amount: '900.00', currency: 'THB', paid_by: 'anna', split: 'equal',
|
||||||
|
participants: 'all', 'share: anna': '450.00', 'share: ben': '450.00',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(dinner.category, 'food');
|
||||||
|
assert.equal(dinner.amount, '200.00');
|
||||||
|
assert.equal(dinner.paid_by, 'ben');
|
||||||
|
assert.equal(dinner.participants, 'all');
|
||||||
|
assert.deepEqual([dinner['share: anna'], dinner['share: ben']], ['100.00', '100.00']);
|
||||||
|
|
||||||
|
assert.equal(souvenir.split, 'payer');
|
||||||
|
assert.equal(souvenir.amount, '50.00');
|
||||||
|
assert.deepEqual([souvenir['share: anna'], souvenir['share: ben']], ['0.00', '50.00']);
|
||||||
|
|
||||||
|
assert.equal(taxi.participants, 'ben', 'explicit subset lists display names');
|
||||||
|
assert.deepEqual([taxi['share: anna'], taxi['share: ben']], ['0.00', '60.00'],
|
||||||
|
'non-participants get 0.00, not an empty cell');
|
||||||
|
|
||||||
|
assert.equal(flights.category, 'flight', 'entry rows carry the entry type as category');
|
||||||
|
assert.equal(flights.amount, '600.00', "'own' amount is the effective total (300 x 2)");
|
||||||
|
assert.equal(flights.paid_by, '', 'unassigned payer is an empty cell');
|
||||||
|
assert.deepEqual([flights['share: anna'], flights['share: ben']], ['300.00', '300.00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export: participants column joins several display names with semicolons', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const ben = await createAccount('ben');
|
||||||
|
const carol = await createAccount('carol');
|
||||||
|
const trip = await makeTrip(anna, [ben, carol]);
|
||||||
|
|
||||||
|
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Shared tuk-tuk', amount: 90, category: 'transport',
|
||||||
|
paid_by: anna.user.id, participants: [carol.user.id, anna.user.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||||
|
assert.equal(rows[0].participants, 'anna;carol');
|
||||||
|
assert.deepEqual(
|
||||||
|
[rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']],
|
||||||
|
['45.00', '0.00', '45.00']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export: formula-injection guard on text columns, numbers left alone', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const evil = await createAccount('=evil-payer');
|
||||||
|
const trip = await makeTrip(anna, [evil]);
|
||||||
|
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||||
|
|
||||||
|
const post = (date, description, amount, extra = {}) =>
|
||||||
|
anna.agent.post(expenses).send({
|
||||||
|
date, description, amount, category: 'food', paid_by: evil.user.id, ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
await post('2026-08-01', '=HYPERLINK("http://evil")', 380);
|
||||||
|
await post('2026-08-02', '-50 refund', 50);
|
||||||
|
await post('2026-08-03', '+2 beers', 20);
|
||||||
|
await post('2026-08-04', '@lunch spot', 30);
|
||||||
|
// Leading `=` AND an embedded comma: must be guarded *and* RFC 4180 quoted.
|
||||||
|
await post('2026-08-05', '=SUM(1,2) sneaky', 40, { participants: [evil.user.id] });
|
||||||
|
// `=` that is not the first character must be left exactly as typed.
|
||||||
|
await post('2026-08-06', 'Total = 5 each', 60);
|
||||||
|
|
||||||
|
const res = await anna.agent.get(exportUrl(trip));
|
||||||
|
const { rows } = readCsv(res.text);
|
||||||
|
|
||||||
|
assert.deepEqual(rows.map((r) => r.description), [
|
||||||
|
"'=HYPERLINK(\"http://evil\")",
|
||||||
|
"'-50 refund",
|
||||||
|
"'+2 beers",
|
||||||
|
"'@lunch spot",
|
||||||
|
"'=SUM(1,2) sneaky",
|
||||||
|
'Total = 5 each',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Numeric columns are server-formatted and must never pick up the prefix.
|
||||||
|
assert.deepEqual(rows.map((r) => r.amount), ['380.00', '50.00', '20.00', '30.00', '40.00', '60.00']);
|
||||||
|
assert.ok(
|
||||||
|
rows.every((r) => !r['share: anna'].startsWith("'") && !r['share: =evil-payer'].startsWith("'")),
|
||||||
|
'share columns are numeric and unguarded'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display names reach the spreadsheet through paid_by and participants too.
|
||||||
|
assert.ok(rows.every((r) => r.paid_by === "'=evil-payer"), 'payer name is guarded');
|
||||||
|
assert.equal(rows[4].participants, "'=evil-payer", 'participant names are guarded');
|
||||||
|
|
||||||
|
// The guard runs before quoting, so the comma row carries both.
|
||||||
|
assert.ok(res.text.includes('"\'=SUM(1,2) sneaky"'), 'guard prefix sits inside the quoted field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export: non-member gets 404, unauthenticated gets 401', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const outsider = await createAccount('outsider');
|
||||||
|
|
||||||
|
assert.equal((await outsider.agent.get(exportUrl(trip))).status, 404);
|
||||||
|
assert.equal((await request(app).get(exportUrl(trip))).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Costs merge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('costs: expenses merge with entry costs into one settle-up', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const ben = await createAccount('ben');
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const entries = `/api/trips/${trip.id}/entries`;
|
||||||
|
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||||
|
|
||||||
|
// Entry: 900 equal, anna paid.
|
||||||
|
await anna.agent.post(entries).send({
|
||||||
|
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||||
|
});
|
||||||
|
// Expense: 200 equal, ben paid.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-02', description: 'Dinner', amount: 200, category: 'food', paid_by: ben.user.id,
|
||||||
|
});
|
||||||
|
// Expense: 40 own -> effective 80, each pays their own, no debt.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-02', description: 'Bus', amount: 40, category: 'transport', split_mode: 'own',
|
||||||
|
});
|
||||||
|
// Expense: 300 equal with no payer -> unassigned.
|
||||||
|
await anna.agent.post(expenses).send({
|
||||||
|
date: '2026-08-03', description: 'Cooking class', amount: 300, category: 'activities',
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||||
|
assert.equal(c.currency, 'THB');
|
||||||
|
assert.equal(c.totalCost, 1480, '900 entry + 200 + 80 + 300 expenses');
|
||||||
|
assert.deepEqual(c.byType, { stay: 900, expense: 580 }, 'all expenses land in one `expense` bucket');
|
||||||
|
assert.equal(c.unassigned, 300);
|
||||||
|
|
||||||
|
const a = c.perUser.find((u) => u.userId === anna.user.id);
|
||||||
|
const b = c.perUser.find((u) => u.userId === ben.user.id);
|
||||||
|
assert.deepEqual([a.share, a.paid, a.net], [740, 940, 200]);
|
||||||
|
assert.deepEqual([b.share, b.paid, b.net], [740, 240, -500]);
|
||||||
|
|
||||||
|
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 200 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('costs: a trip with only expenses still settles up', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const ben = await createAccount('ben');
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||||
|
paid_by: anna.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = (await ben.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||||
|
assert.equal(c.totalCost, 500);
|
||||||
|
assert.deepEqual(c.byType, { expense: 500 });
|
||||||
|
assert.equal(c.unassigned, 0);
|
||||||
|
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 250 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('costs: deleting an expense removes it from the settle-up again', async () => {
|
||||||
|
const anna = await createAccount('anna');
|
||||||
|
const ben = await createAccount('ben');
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
const e = (await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||||
|
paid_by: anna.user.id,
|
||||||
|
})).body.expense;
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.delete(`/api/expenses/${e.id}`)).status, 204);
|
||||||
|
|
||||||
|
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||||
|
assert.equal(c.totalCost, 0);
|
||||||
|
assert.deepEqual(c.byType, {});
|
||||||
|
assert.deepEqual(c.settlements, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/server/app.js';
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expenses-'));
|
||||||
|
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
if (app.locals.db) app.locals.db.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAccount() {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const res = await agent.post('/api/auth/account').send({});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
return { agent, user: res.body.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip owned by `owner`; every account in `members` joins via the join code.
|
||||||
|
async function makeTrip(owner, members = []) {
|
||||||
|
const trip = (await owner.agent.post('/api/trips').send({
|
||||||
|
name: 'Spending', start_date: '2026-08-01', end_date: '2026-08-10', currency: 'THB',
|
||||||
|
})).body.trip;
|
||||||
|
for (const m of members) {
|
||||||
|
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
||||||
|
}
|
||||||
|
return trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExpense(account, trip, body) {
|
||||||
|
const res = await account.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Lunch', amount: 100, ...body,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
|
||||||
|
return res.body.expense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('POST expense: defaults applied, created_by is the caller', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
const res = await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: ' Street food dinner ', amount: 380,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const e = res.body.expense;
|
||||||
|
assert.equal(e.trip_id, trip.id);
|
||||||
|
assert.equal(e.date, '2026-08-02');
|
||||||
|
assert.equal(e.description, 'Street food dinner', 'description is trimmed');
|
||||||
|
assert.equal(e.category, 'other');
|
||||||
|
assert.equal(e.amount, 380);
|
||||||
|
assert.equal(e.paid_by, null);
|
||||||
|
assert.equal(e.split_mode, 'equal');
|
||||||
|
assert.deepEqual(e.participants, []);
|
||||||
|
assert.equal(e.created_by, anna.user.id);
|
||||||
|
assert.ok(Number.isInteger(e.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST expense: every field round-trips, participants come back sorted', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
const e = await addExpense(anna, trip, {
|
||||||
|
date: '2026-08-03', description: 'Songthaew', amount: 60.5, category: 'transport',
|
||||||
|
paid_by: ben.user.id, split_mode: 'equal', participants: [ben.user.id, anna.user.id],
|
||||||
|
});
|
||||||
|
assert.equal(e.category, 'transport');
|
||||||
|
assert.equal(e.amount, 60.5);
|
||||||
|
assert.equal(e.paid_by, ben.user.id);
|
||||||
|
assert.deepEqual(e.participants, [anna.user.id, ben.user.id].sort((a, b) => a - b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST expense: amount 0 is allowed; every category in the enum is accepted', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
const zero = await addExpense(anna, trip, { amount: 0 });
|
||||||
|
assert.equal(zero.amount, 0);
|
||||||
|
|
||||||
|
for (const category of ['food', 'drinks', 'transport', 'activities', 'shopping', 'accommodation', 'other']) {
|
||||||
|
const e = await addExpense(anna, trip, { category });
|
||||||
|
assert.equal(e.category, category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List, ordering & summary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('GET expenses: ordered by (date, id)', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
// Created deliberately out of date order.
|
||||||
|
const late = await addExpense(anna, trip, { date: '2026-08-05', description: 'late' });
|
||||||
|
const earlyA = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-a' });
|
||||||
|
const mid = await addExpense(anna, trip, { date: '2026-08-03', description: 'mid' });
|
||||||
|
const earlyB = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-b' });
|
||||||
|
|
||||||
|
const res = await anna.agent.get(`/api/trips/${trip.id}/expenses`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
res.body.expenses.map((e) => e.id),
|
||||||
|
[earlyA.id, earlyB.id, mid.id, late.id],
|
||||||
|
'same-date rows fall back to id order'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET expenses: summary total/byCategory/byDay; `own` counts amount x participants', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
// equal: effective total = amount (100)
|
||||||
|
await addExpense(anna, trip, { date: '2026-08-01', description: 'Dinner', amount: 100, category: 'food', paid_by: anna.user.id });
|
||||||
|
// own with no participants rows = both members: effective total = 50 x 2 = 100
|
||||||
|
await addExpense(anna, trip, { date: '2026-08-01', description: 'Bus tickets', amount: 50, category: 'transport', split_mode: 'own' });
|
||||||
|
// payer: effective total = amount (30)
|
||||||
|
await addExpense(anna, trip, { date: '2026-08-02', description: 'Souvenir', amount: 30, category: 'shopping', split_mode: 'payer', paid_by: ben.user.id });
|
||||||
|
|
||||||
|
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
||||||
|
assert.equal(s.total, 230);
|
||||||
|
assert.deepEqual(s.byCategory, { food: 100, transport: 100, shopping: 30 });
|
||||||
|
assert.deepEqual(s.byDay, [
|
||||||
|
{ date: '2026-08-01', total: 200 },
|
||||||
|
{ date: '2026-08-02', total: 30 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET expenses: `own` with a participants subset scales by that subset only', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const carol = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben, carol]);
|
||||||
|
|
||||||
|
// 3 members, but only 2 participate -> 40 x 2 = 80 (not 120).
|
||||||
|
await addExpense(anna, trip, {
|
||||||
|
date: '2026-08-04', description: 'Cable car', amount: 40, category: 'activities',
|
||||||
|
split_mode: 'own', participants: [anna.user.id, carol.user.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
||||||
|
assert.equal(s.total, 80);
|
||||||
|
assert.deepEqual(s.byCategory, { activities: 80 });
|
||||||
|
assert.deepEqual(s.byDay, [{ date: '2026-08-04', total: 80 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET expenses: empty trip yields an empty list and a zeroed summary', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
const body = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body;
|
||||||
|
assert.deepEqual(body.expenses, []);
|
||||||
|
assert.equal(body.summary.total, 0);
|
||||||
|
assert.deepEqual(body.summary.byCategory, {});
|
||||||
|
assert.deepEqual(body.summary.byDay, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET expenses: all members see all expenses (no personal/hidden rows)', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
const byAnna = await addExpense(anna, trip, { description: 'Anna paid' });
|
||||||
|
const byBen = await addExpense(ben, trip, { description: 'Ben paid' });
|
||||||
|
|
||||||
|
const forBen = (await ben.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
||||||
|
assert.deepEqual(forBen.map((e) => e.id).sort((a, b) => a - b), [byAnna.id, byBen.id].sort((a, b) => a - b));
|
||||||
|
assert.equal(forBen.find((e) => e.id === byAnna.id).created_by, anna.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('PATCH expense: each field individually', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const e = await addExpense(anna, trip, {});
|
||||||
|
const url = `/api/expenses/${e.id}`;
|
||||||
|
|
||||||
|
const date = await anna.agent.patch(url).send({ date: '2026-08-07' });
|
||||||
|
assert.equal(date.status, 200);
|
||||||
|
assert.equal(date.body.expense.date, '2026-08-07');
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.patch(url).send({ description: 'Brunch' })).body.expense.description, 'Brunch');
|
||||||
|
assert.equal((await anna.agent.patch(url).send({ amount: 12.75 })).body.expense.amount, 12.75);
|
||||||
|
assert.equal((await anna.agent.patch(url).send({ category: 'drinks' })).body.expense.category, 'drinks');
|
||||||
|
|
||||||
|
const paid = await anna.agent.patch(url).send({ paid_by: ben.user.id });
|
||||||
|
assert.equal(paid.body.expense.paid_by, ben.user.id);
|
||||||
|
assert.equal((await anna.agent.patch(url).send({ paid_by: null })).body.expense.paid_by, null);
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.patch(url).send({ split_mode: 'own' })).body.expense.split_mode, 'own');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCH expense: participants replaces the whole set; [] means all members', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const carol = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben, carol]);
|
||||||
|
const e = await addExpense(anna, trip, { participants: [anna.user.id, ben.user.id] });
|
||||||
|
const url = `/api/expenses/${e.id}`;
|
||||||
|
|
||||||
|
const swapped = await anna.agent.patch(url).send({ participants: [carol.user.id] });
|
||||||
|
assert.equal(swapped.status, 200);
|
||||||
|
assert.deepEqual(swapped.body.expense.participants, [carol.user.id], 'replaces, does not merge');
|
||||||
|
|
||||||
|
const cleared = await anna.agent.patch(url).send({ participants: [] });
|
||||||
|
assert.deepEqual(cleared.body.expense.participants, [], '[] = all members');
|
||||||
|
|
||||||
|
const nulled = await anna.agent.patch(url).send({ participants: [anna.user.id] });
|
||||||
|
assert.deepEqual(nulled.body.expense.participants, [anna.user.id]);
|
||||||
|
assert.deepEqual((await anna.agent.patch(url).send({ participants: null })).body.expense.participants, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCH expense: any member may edit another member\'s expense; created_by never changes', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const e = await addExpense(anna, trip, { description: 'Anna logged this' });
|
||||||
|
|
||||||
|
const res = await ben.agent.patch(`/api/expenses/${e.id}`).send({ description: 'Ben corrected it' });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.expense.description, 'Ben corrected it');
|
||||||
|
assert.equal(res.body.expense.created_by, anna.user.id, 'created_by is informational and stays put');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('created_by is server-set and ignored in POST/PATCH bodies', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
|
||||||
|
const created = await addExpense(anna, trip, { created_by: ben.user.id });
|
||||||
|
assert.equal(created.created_by, anna.user.id, 'POST body created_by is ignored');
|
||||||
|
|
||||||
|
const patched = await anna.agent.patch(`/api/expenses/${created.id}`).send({ created_by: ben.user.id });
|
||||||
|
assert.equal(patched.status, 200);
|
||||||
|
assert.equal(patched.body.expense.created_by, anna.user.id, 'PATCH body created_by is ignored');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('DELETE expense -> 204, gone from the list, participant rows cleaned up', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const e = await addExpense(anna, trip, { participants: [anna.user.id] });
|
||||||
|
|
||||||
|
const before = app.locals.db
|
||||||
|
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
||||||
|
.get(e.id).c;
|
||||||
|
assert.equal(before, 1);
|
||||||
|
|
||||||
|
assert.equal((await ben.agent.delete(`/api/expenses/${e.id}`)).status, 204, 'any member may delete');
|
||||||
|
|
||||||
|
const list = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
||||||
|
assert.ok(!list.some((x) => x.id === e.id));
|
||||||
|
|
||||||
|
const after = app.locals.db
|
||||||
|
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
||||||
|
.get(e.id).c;
|
||||||
|
assert.equal(after, 0, 'expense_participants rows are deleted too');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('validation: bad date, description, amount and category -> 400', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const base = `/api/trips/${trip.id}/expenses`;
|
||||||
|
const ok = { date: '2026-08-02', description: 'Lunch', amount: 100 };
|
||||||
|
const post = (body) => anna.agent.post(base).send({ ...ok, ...body });
|
||||||
|
|
||||||
|
// date
|
||||||
|
assert.equal((await anna.agent.post(base).send({ description: 'x', amount: 1 })).status, 400, 'date required');
|
||||||
|
assert.equal((await post({ date: 'not-a-date' })).status, 400);
|
||||||
|
assert.equal((await post({ date: '2026-13-01' })).status, 400, 'month 13');
|
||||||
|
assert.equal((await post({ date: '2026-02-30' })).status, 400, 'impossible day');
|
||||||
|
assert.equal((await post({ date: '2026-8-2' })).status, 400, 'unpadded');
|
||||||
|
assert.equal((await post({ date: 20260802 })).status, 400, 'non-string');
|
||||||
|
|
||||||
|
// description
|
||||||
|
assert.equal((await anna.agent.post(base).send({ date: ok.date, amount: 1 })).status, 400, 'description required');
|
||||||
|
assert.equal((await post({ description: '' })).status, 400);
|
||||||
|
assert.equal((await post({ description: ' ' })).status, 400, 'whitespace-only');
|
||||||
|
assert.equal((await post({ description: 'x'.repeat(121) })).status, 400, '>120 chars');
|
||||||
|
assert.equal((await post({ description: 'x'.repeat(120) })).status, 201, '120 chars is the limit, inclusive');
|
||||||
|
|
||||||
|
// amount
|
||||||
|
assert.equal((await anna.agent.post(base).send({ date: ok.date, description: 'x' })).status, 400, 'amount required');
|
||||||
|
assert.equal((await post({ amount: -1 })).status, 400, 'negative');
|
||||||
|
assert.equal((await post({ amount: '100' })).status, 400, 'string is not a number');
|
||||||
|
assert.equal((await post({ amount: null })).status, 400);
|
||||||
|
assert.equal((await post({ amount: {} })).status, 400);
|
||||||
|
|
||||||
|
// category
|
||||||
|
assert.equal((await post({ category: 'gadgets' })).status, 400, 'unknown category');
|
||||||
|
assert.equal((await post({ category: 'Food' })).status, 400, 'enum is case-sensitive');
|
||||||
|
assert.equal((await post({ category: 7 })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation: paid_by, split_mode and participants -> 400', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const outsider = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const base = `/api/trips/${trip.id}/expenses`;
|
||||||
|
const post = (body) => anna.agent.post(base).send({ date: '2026-08-02', description: 'Lunch', amount: 100, ...body });
|
||||||
|
|
||||||
|
assert.equal((await post({ paid_by: outsider.user.id })).status, 400, 'paid_by must be a trip member');
|
||||||
|
assert.equal((await post({ paid_by: 999999 })).status, 400, 'paid_by must exist');
|
||||||
|
assert.equal((await post({ paid_by: 'anna' })).status, 400);
|
||||||
|
|
||||||
|
assert.equal((await post({ split_mode: 'payer' })).status, 400, "'payer' requires paid_by");
|
||||||
|
assert.equal((await post({ split_mode: 'payer', paid_by: ben.user.id })).status, 201);
|
||||||
|
assert.equal((await post({ split_mode: 'sideways' })).status, 400, 'unknown split_mode');
|
||||||
|
|
||||||
|
assert.equal((await post({ participants: [anna.user.id, outsider.user.id] })).status, 400, 'non-member participant');
|
||||||
|
assert.equal((await post({ participants: [999999] })).status, 400);
|
||||||
|
assert.equal((await post({ participants: 5 })).status, 400, 'not an array');
|
||||||
|
assert.equal((await post({ participants: ['1'] })).status, 400, 'not member ids');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation: PATCH applies the same rules and merges for the payer/paid_by rule', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const ben = await createAccount();
|
||||||
|
const outsider = await createAccount();
|
||||||
|
const trip = await makeTrip(anna, [ben]);
|
||||||
|
const e = await addExpense(anna, trip, {});
|
||||||
|
const url = `/api/expenses/${e.id}`;
|
||||||
|
const patch = (body) => anna.agent.patch(url).send(body);
|
||||||
|
|
||||||
|
assert.equal((await patch({ date: '2026-13-01' })).status, 400);
|
||||||
|
assert.equal((await patch({ description: ' ' })).status, 400);
|
||||||
|
assert.equal((await patch({ description: 'x'.repeat(121) })).status, 400);
|
||||||
|
assert.equal((await patch({ amount: -0.01 })).status, 400);
|
||||||
|
assert.equal((await patch({ amount: 'free' })).status, 400);
|
||||||
|
assert.equal((await patch({ category: 'gadgets' })).status, 400);
|
||||||
|
assert.equal((await patch({ paid_by: outsider.user.id })).status, 400);
|
||||||
|
assert.equal((await patch({ participants: [outsider.user.id] })).status, 400);
|
||||||
|
|
||||||
|
// 'payer' is validated against the EFFECTIVE (merged) values, like entries.
|
||||||
|
assert.equal((await patch({ split_mode: 'payer' })).status, 400, 'no existing paid_by to inherit');
|
||||||
|
assert.equal((await patch({ split_mode: 'payer', paid_by: ben.user.id })).status, 200);
|
||||||
|
assert.equal((await patch({ paid_by: null })).status, 400, 'clearing paid_by would strand a payer split');
|
||||||
|
assert.equal((await patch({ split_mode: 'equal', paid_by: null })).status, 200);
|
||||||
|
|
||||||
|
// An empty PATCH body is harmless.
|
||||||
|
assert.equal((await patch({})).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation: a rejected request leaves the stored row untouched', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const e = await addExpense(anna, trip, { description: 'Lunch', amount: 100 });
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.patch(`/api/expenses/${e.id}`).send({
|
||||||
|
description: 'Changed', amount: -5,
|
||||||
|
})).status, 400);
|
||||||
|
|
||||||
|
const stored = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses[0];
|
||||||
|
assert.equal(stored.description, 'Lunch');
|
||||||
|
assert.equal(stored.amount, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Access control
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('access control: non-member gets 404 (never 403) everywhere', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const e = await addExpense(anna, trip, {});
|
||||||
|
const outsider = await createAccount();
|
||||||
|
|
||||||
|
const list = await outsider.agent.get(`/api/trips/${trip.id}/expenses`);
|
||||||
|
assert.equal(list.status, 404);
|
||||||
|
assert.deepEqual(list.body, { error: 'not found' }, 'no existence leak');
|
||||||
|
|
||||||
|
assert.equal((await outsider.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||||
|
date: '2026-08-02', description: 'Sneaky', amount: 1,
|
||||||
|
})).status, 404);
|
||||||
|
assert.equal((await outsider.agent.get(`/api/trips/${trip.id}/expenses/export.csv`)).status, 404);
|
||||||
|
assert.equal((await outsider.agent.patch(`/api/expenses/${e.id}`).send({ amount: 1 })).status, 404);
|
||||||
|
assert.equal((await outsider.agent.delete(`/api/expenses/${e.id}`)).status, 404);
|
||||||
|
|
||||||
|
// The expense really does still exist — the 404 was about access, not absence.
|
||||||
|
assert.equal((await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('access control: unknown / malformed ids -> 404', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.patch('/api/expenses/999999').send({ amount: 1 })).status, 404);
|
||||||
|
assert.equal((await anna.agent.delete('/api/expenses/999999')).status, 404);
|
||||||
|
assert.equal((await anna.agent.patch('/api/expenses/not-a-number').send({ amount: 1 })).status, 404);
|
||||||
|
assert.equal((await anna.agent.get(`/api/trips/999999/expenses`)).status, 404);
|
||||||
|
assert.equal((await anna.agent.get(`/api/trips/${trip.id}x/expenses`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('access control: unauthenticated -> 401 on every expense route', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const e = await addExpense(anna, trip, {});
|
||||||
|
|
||||||
|
for (const res of [
|
||||||
|
await request(app).get(`/api/trips/${trip.id}/expenses`),
|
||||||
|
await request(app).post(`/api/trips/${trip.id}/expenses`).send({ date: '2026-08-02', description: 'x', amount: 1 }),
|
||||||
|
await request(app).get(`/api/trips/${trip.id}/expenses/export.csv`),
|
||||||
|
await request(app).patch(`/api/expenses/${e.id}`).send({ amount: 1 }),
|
||||||
|
await request(app).delete(`/api/expenses/${e.id}`),
|
||||||
|
]) {
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.deepEqual(res.body, { error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trip lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('deleting a trip removes its expenses', async () => {
|
||||||
|
const anna = await createAccount();
|
||||||
|
const trip = await makeTrip(anna);
|
||||||
|
const e = await addExpense(anna, trip, {});
|
||||||
|
|
||||||
|
assert.equal((await anna.agent.delete(`/api/trips/${trip.id}`)).status, 204);
|
||||||
|
const left = app.locals.db.prepare('SELECT COUNT(*) AS c FROM expenses WHERE id = ?').get(e.id).c;
|
||||||
|
assert.equal(left, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Shared CSV reader for the expense-export tests (tests/expenses-export.test.js
|
||||||
|
// and tests/expense-csv.test.js). Not a test file — it registers no tests and
|
||||||
|
// is therefore not listed in tests/index.js.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// U+FEFF. buildExpenseCsv returns the complete file including the BOM, so both
|
||||||
|
// the pure function's output and the HTTP body must carry it.
|
||||||
|
export const BOM = '';
|
||||||
|
|
||||||
|
// A strict RFC 4180 reader. It doubles as an assertion: it rejects a bare LF or
|
||||||
|
// CR outside a quoted field, so a file with LF endings fails here.
|
||||||
|
export function parseCsv(text) {
|
||||||
|
assert.ok(text.startsWith(BOM), 'CSV must be prefixed with the UTF-8 BOM');
|
||||||
|
const body = text.slice(1);
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let field = '';
|
||||||
|
let quoted = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < body.length; i += 1) {
|
||||||
|
const ch = body[i];
|
||||||
|
if (quoted) {
|
||||||
|
if (ch !== '"') { field += ch; continue; }
|
||||||
|
if (body[i + 1] === '"') { field += '"'; i += 1; continue; }
|
||||||
|
quoted = false;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
quoted = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
row.push(field);
|
||||||
|
field = '';
|
||||||
|
} else if (ch === '\r' && body[i + 1] === '\n') {
|
||||||
|
row.push(field);
|
||||||
|
field = '';
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
i += 1;
|
||||||
|
} else if (ch === '\n' || ch === '\r') {
|
||||||
|
assert.fail('bare CR/LF outside a quoted field — line endings must be CRLF');
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field !== '' || row.length) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
assert.equal(quoted, false, 'unterminated quoted field');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn the parsed grid into header + row objects keyed by column name.
|
||||||
|
export function readCsv(text) {
|
||||||
|
const grid = parseCsv(text);
|
||||||
|
const header = grid[0];
|
||||||
|
const rows = grid.slice(1).map((cells) => {
|
||||||
|
assert.equal(cells.length, header.length, `row has ${cells.length} cells, header has ${header.length}`);
|
||||||
|
return Object.fromEntries(cells.map((c, i) => [header[i], c]));
|
||||||
|
});
|
||||||
|
return { header, rows };
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import './api.test.js';
|
|||||||
import './checklist.test.js';
|
import './checklist.test.js';
|
||||||
import './checklist-suggestions.test.js';
|
import './checklist-suggestions.test.js';
|
||||||
import './costs.test.js';
|
import './costs.test.js';
|
||||||
|
import './expense-csv.test.js';
|
||||||
|
import './expenses.test.js';
|
||||||
|
import './expenses-export.test.js';
|
||||||
import './flights.test.js';
|
import './flights.test.js';
|
||||||
import './rental.test.js';
|
import './rental.test.js';
|
||||||
import './stays.test.js';
|
import './stays.test.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user