diff --git a/README.md b/README.md index debe7d7..22910ec 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ A private, self-hosted household budgeting app: import bank and card statements, categorize in a tap, budget by month, and keep an eye on subscriptions. Runs as a single container on arm64 or amd64. -Status: milestones 0 to 2 are built (foundation, money in, the full picture). -Milestone 3, launch hardening, is next. +Status: milestones 0 to 3 are built: foundation, money in, the full picture, +and launch (accessibility, speed, backup and restore, an in-app guide). ## What it does - **Import** CSV statements from Bank of America, Discover, Chase, Capital One, Citi and Apple Card, plus any other bank through a one-time column mapper. + OFX, QFX and QBO files (Quicken, Money, Web Connect) work too. Duplicates are skipped, card payments and transfers are paired, and any import can be undone. - **Review** new transactions with one-tap category suggestions that learn @@ -38,6 +39,22 @@ scripts/deploy.sh # builds a versioned image and starts it HTTPS comes from your own reverse proxy (see `deploy/nginx.conf.example`). The API is documented at `/api/docs` on a running instance. +## Backups and restoring + +Every night at 02:30 (`BACKUP_TIME`) a complete copy of the database lands in +the backups folder, plus one before every upgrade and every restore. Point any +backup tool at that folder to keep copies off the server. + +- **Everyday restore:** Settings → Backups → Restore, as an admin. Spendlight + checks the file, saves a "before restore" copy of the current data, and + switches over. Restoring that copy undoes it. "Restore from a file" takes a + backup from somewhere else, such as another machine. +- **When the app can't be used:** put the backup file in the backups folder and run + `scripts/restore.sh spendlight-nightly-20261007-023000.db`. It stops the app, + restores, and starts it again. +- **Moving to a new machine:** copy the data folder, or start with an empty one + and run the restore script before the first sign-in. + ## Developing The host only needs Docker; Node 24 runs in a container. diff --git a/VERSION b/VERSION index f794101..713b640 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20261007.12 +20261008.05 diff --git a/package-lock.json b/package-lock.json index b243ed1..49ef9e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -960,15 +960,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource-variable/inter": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", - "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@hexagon/base64": { "version": "1.1.28", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", @@ -6417,22 +6408,6 @@ } } }, - "node_modules/sonner": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", - "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7205,7 +7180,6 @@ "web": { "name": "@spendlight/web", "dependencies": { - "@fontsource-variable/inter": "^5.3.0", "@simplewebauthn/browser": "^14.0.0", "@spendlight/shared": "*", "@tanstack/react-query": "^5.104.1", @@ -7218,7 +7192,6 @@ "react-is": "^19.3.0", "react-router": "^8.4.0", "recharts": "^3.10.1", - "sonner": "^2.0.8", "tailwind-merge": "^3.7.0" }, "devDependencies": { diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..9c6510c --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Restore Spendlight from a backup when the app itself can't be used. For an +# everyday restore, use Settings → Backups instead. +# +# scripts/restore.sh spendlight-nightly-20261007-023000.db +# +# The file must be in the backups folder. A "before restore" copy of the +# current database is saved there first. +set -euo pipefail +cd "$(dirname "$0")/.." + +name="$(basename "${1:?Usage: scripts/restore.sh }")" +docker compose stop spendlight +if ! docker compose run --rm --no-deps spendlight spendlight restore "/backups/${name}"; then + docker compose start spendlight + exit 1 +fi +docker compose start spendlight +echo "Spendlight is running again on the restored data." diff --git a/server/bench/speed.bench.ts b/server/bench/speed.bench.ts new file mode 100644 index 0000000..344bd43 --- /dev/null +++ b/server/bench/speed.bench.ts @@ -0,0 +1,79 @@ +// Speed check at scale: years of heavy use (about 50,000 made-up +// transactions) and the time each screen's data takes. Run with +// scripts/dev.sh npx vitest run --root server --config server/bench/vitest.config.ts +import { it } from 'vitest'; +import { makeApp, setUpAdmin } from '../test/helpers'; + +it('answers every screen quickly with years of data', async () => { + const t = await makeApp(); + const admin = await setUpAdmin(t); + const { sqlite } = t.ctx; + const cats = (sqlite.prepare("SELECT id FROM category WHERE kind = 'expense'").all() as { id: number }[]).map((c) => c.id); + const income = (sqlite.prepare("SELECT id FROM category WHERE kind = 'income'").get() as { id: number }).id; + const now = new Date().toISOString(); + const accounts = [1, 2, 3, 4, 5].map( + (i) => (sqlite.prepare("INSERT INTO account (name, kind, color, created_at, updated_at, balance_cents, balance_as_of) VALUES (?, ?, 'indigo', ?, ?, 100000, '2022-01-01') RETURNING id").get(`Account ${i}`, i === 1 ? 'checking' : 'credit_card', now, now) as { id: number }).id, + ); + const merchants = Array.from({ length: 400 }, (_, i) => `Merchant ${i}`); + let seed = 7; + const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); + const insert = sqlite.prepare( + 'INSERT INTO txn (account_id, date, amount_cents, description, merchant, category_id, needs_review, dedupe_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)', + ); + const start = Date.UTC(2022, 0, 1); + const days = Math.floor((Date.now() - start) / 86_400_000); + sqlite.transaction(() => { + for (let i = 0; i < 50_000; i++) { + const date = new Date(start + Math.floor(rnd() * days) * 86_400_000).toISOString().slice(0, 10); + const m = merchants[Math.floor(rnd() * merchants.length)]!; + const isIncome = rnd() < 0.03; + insert.run(accounts[Math.floor(rnd() * 5)], date, isIncome ? 250000 : -Math.round(500 + rnd() * 15000), m, m, isIncome ? income : cats[Math.floor(rnd() * cats.length)], `k${i}`, now, now); + } + // Monthly subscriptions, so the scan and alerts have real work. + for (let s = 0; s < 25; s++) { + for (let d = new Date(2024, 0, 3 + s); d < new Date(); d.setMonth(d.getMonth() + 1)) { + insert.run(accounts[2], d.toISOString().slice(0, 10), -(999 + s * 100), `SERVICE ${s}`, `Service ${s}`, cats[0], `s${s}-${d.getTime()}`, now, now); + } + } + })(); + const year = new Date().getFullYear(); + await admin.post('/api/v1/budgets/fill', { month: `${year}-${String(new Date().getMonth() + 1).padStart(2, '0')}`, source: 'average3' }); + + const time = async (label: string, run: () => Promise<{ status: number }>) => { + const times: number[] = []; + for (let i = 0; i < 5; i++) { + const t0 = performance.now(); + const res = await run(); + times.push(performance.now() - t0); + if (res.status !== 200) throw new Error(`${label}: ${res.status}`); + } + times.sort((a, b) => a - b); + console.log(`${label.padEnd(34)} first ${times[0]!.toFixed(0).padStart(5)} ms median ${times[2]!.toFixed(0).padStart(5)} ms worst ${times[4]!.toFixed(0).padStart(5)} ms`); + }; + + const t0 = performance.now(); + await admin.post('/api/v1/subscriptions/scan'); + console.log(`subscription scan (first, 50k rows) ${(performance.now() - t0).toFixed(0)} ms`); + await time('Home: overview', () => admin.get('/api/v1/reports/overview')); + await time('Home: alerts (data changed)', async () => { + sqlite.prepare("UPDATE txn SET updated_at = ? WHERE id = 1").run(new Date().toISOString() + Math.random()); + return admin.get('/api/v1/alerts?limit=3'); + }); + await time('Home: alerts (unchanged)', () => admin.get('/api/v1/alerts?limit=3')); + await time('Budget: year of months', () => admin.get(`/api/v1/reports/months?from=${year}-01&to=${year}-12`)); + await time('Budget: budgets', () => admin.get(`/api/v1/budgets?year=${year}`)); + await time('Insights: merchants', () => admin.get(`/api/v1/reports/merchants?from=${year}-01-01&to=${year}-12-31&limit=100`)); + await time('Insights: balances', () => admin.get('/api/v1/reports/balances?months=12')); + await time('Insights: tags', () => admin.get('/api/v1/reports/tags')); + await time('Activity: first 100', () => admin.get('/api/v1/transactions?limit=100')); + await time('Activity: search', () => admin.get('/api/v1/transactions?q=Merchant%2012&limit=100')); + await time('Review', () => admin.get('/api/v1/review')); + await time('Subscriptions', () => admin.get('/api/v1/subscriptions')); + await time('Accounts', () => admin.get('/api/v1/accounts')); + const e0 = performance.now(); + const res = await t.app.request('http://localhost/api/v1/export/everything.xlsx', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + const size = (await res.arrayBuffer()).byteLength; + console.log(`export everything (Excel) ${(performance.now() - e0).toFixed(0)} ms, ${(size / 1024 / 1024).toFixed(1)} MB`); + console.log(`memory: rss ${(process.memoryUsage().rss / 1048576).toFixed(0)} MB, heap ${(process.memoryUsage().heapUsed / 1048576).toFixed(0)} MB`); + t.cleanup(); +}, 600_000); diff --git a/server/bench/vitest.config.ts b/server/bench/vitest.config.ts new file mode 100644 index 0000000..22eb52a --- /dev/null +++ b/server/bench/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ test: { include: ['bench/**/*.bench.ts'], testTimeout: 600_000 } }); diff --git a/server/drizzle/0003_split_tags.sql b/server/drizzle/0003_split_tags.sql new file mode 100644 index 0000000..a0dfc57 --- /dev/null +++ b/server/drizzle/0003_split_tags.sql @@ -0,0 +1,9 @@ +CREATE TABLE `split_tag` ( + `split_id` integer NOT NULL, + `tag_id` integer NOT NULL, + FOREIGN KEY (`split_id`) REFERENCES `split`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `split_tag_pk` ON `split_tag` (`split_id`,`tag_id`);--> statement-breakpoint +CREATE INDEX `split_tag_tag_idx` ON `split_tag` (`tag_id`); \ No newline at end of file diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..81bad70 --- /dev/null +++ b/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2079 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "32209082-b057-4505-8c21-4d5425278b6a", + "prevId": "12dd39b8-6a3f-405a-8ea8-7ae5a62c8b48", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'indigo'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "balance_cents": { + "name": "balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "balance_as_of": { + "name": "balance_as_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance_source": { + "name": "balance_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_name_pattern": { + "name": "file_name_pattern", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert": { + "name": "alert", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'money'" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "alert_unique": { + "name": "alert_unique", + "columns": [ + "kind", + "subject_type", + "subject_id", + "period" + ], + "isUnique": true + }, + "alert_open_idx": { + "name": "alert_open_idx", + "columns": [ + "resolved_at", + "dismissed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "budget": { + "name": "budget", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "budget_unique": { + "name": "budget_unique", + "columns": [ + "category_id", + "year", + "month" + ], + "isUnique": true + }, + "budget_year_idx": { + "name": "budget_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "budget_category_id_category_id_fk": { + "name": "budget_category_id_category_id_fk", + "tableFrom": "budget", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category": { + "name": "category", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'expense'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'circle'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "category_group_idx": { + "name": "category_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "category_group_id_category_group_id_fk": { + "name": "category_group_id_category_group_id_fk", + "tableFrom": "category", + "tableTo": "category_group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category_group": { + "name": "category_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "change_log": { + "name": "change_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "at": { + "name": "at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "change_log_at_idx": { + "name": "change_log_at_idx", + "columns": [ + "at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "change_log_user_id_user_id_fk": { + "name": "change_log_user_id_user_id_fk", + "tableFrom": "change_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "idea": { + "name": "idea", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idea_status_idx": { + "name": "idea_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "idea_user_id_user_id_fk": { + "name": "idea_user_id_user_id_fk", + "tableFrom": "idea", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_layout": { + "name": "import_layout", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import": { + "name": "import", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_key": { + "name": "layout_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_from": { + "name": "date_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_to": { + "name": "date_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicate_count": { + "name": "duplicate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transfer_count": { + "name": "transfer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committed'" + }, + "previous_balance": { + "name": "previous_balance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "import_account_idx": { + "name": "import_account_idx", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "import_account_id_account_id_fk": { + "name": "import_account_id_account_id_fk", + "tableFrom": "import", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "import_user_id_user_id_fk": { + "name": "import_user_id_user_id_fk", + "tableFrom": "import", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rule": { + "name": "rule", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rename_to": { + "name": "rename_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'learned'" + }, + "hits": { + "name": "hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rule_merchant_idx": { + "name": "rule_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rule_account_id_account_id_fk": { + "name": "rule_account_id_account_id_fk", + "tableFrom": "rule", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rule_category_id_category_id_fk": { + "name": "rule_category_id_category_id_fk", + "tableFrom": "rule", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'browser'" + }, + "device_label": { + "name": "device_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "setting": { + "name": "setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "split_tag": { + "name": "split_tag", + "columns": { + "split_id": { + "name": "split_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "split_tag_pk": { + "name": "split_tag_pk", + "columns": [ + "split_id", + "tag_id" + ], + "isUnique": true + }, + "split_tag_tag_idx": { + "name": "split_tag_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "split_tag_split_id_split_id_fk": { + "name": "split_tag_split_id_split_id_fk", + "tableFrom": "split_tag", + "tableTo": "split", + "columnsFrom": [ + "split_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "split_tag_tag_id_tag_id_fk": { + "name": "split_tag_tag_id_tag_id_fk", + "tableFrom": "split_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "split": { + "name": "split", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "split_txn_idx": { + "name": "split_txn_idx", + "columns": [ + "transaction_id" + ], + "isUnique": false + }, + "split_category_idx": { + "name": "split_category_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "split_transaction_id_txn_id_fk": { + "name": "split_transaction_id_txn_id_fk", + "tableFrom": "split", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "split_category_id_category_id_fk": { + "name": "split_category_id_category_id_fk", + "tableFrom": "split", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'subscription'" + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monthly'" + }, + "expected_cents": { + "name": "expected_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'suggested'" + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'likely'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'detected'" + }, + "trial_ends_on": { + "name": "trial_ends_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_on": { + "name": "cancelled_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_charge_on": { + "name": "first_charge_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_charge_on": { + "name": "last_charge_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_expected_on": { + "name": "next_expected_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manage_url": { + "name": "manage_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alerts_on": { + "name": "alerts_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subscription_merchant_idx": { + "name": "subscription_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + }, + "subscription_status_idx": { + "name": "subscription_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscription_account_id_account_id_fk": { + "name": "subscription_account_id_account_id_fk", + "tableFrom": "subscription", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_category_id_category_id_fk": { + "name": "subscription_category_id_category_id_fk", + "tableFrom": "subscription", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'slate'" + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn_tag": { + "name": "txn_tag", + "columns": { + "transaction_id": { + "name": "transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_tag_pk": { + "name": "txn_tag_pk", + "columns": [ + "transaction_id", + "tag_id" + ], + "isUnique": true + }, + "txn_tag_tag_idx": { + "name": "txn_tag_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_tag_transaction_id_txn_id_fk": { + "name": "txn_tag_transaction_id_txn_id_fk", + "tableFrom": "txn_tag", + "tableTo": "txn", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "txn_tag_tag_id_tag_id_fk": { + "name": "txn_tag_tag_id_tag_id_fk", + "tableFrom": "txn_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "txn": { + "name": "txn", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "posted_date": { + "name": "posted_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "merchant": { + "name": "merchant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "suggested_by": { + "name": "suggested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_split": { + "name": "is_split", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bank_category": { + "name": "bank_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_type": { + "name": "bank_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transfer_peer_id": { + "name": "transfer_peer_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'import'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "txn_account_date_idx": { + "name": "txn_account_date_idx", + "columns": [ + "account_id", + "date" + ], + "isUnique": false + }, + "txn_date_idx": { + "name": "txn_date_idx", + "columns": [ + "date" + ], + "isUnique": false + }, + "txn_category_date_idx": { + "name": "txn_category_date_idx", + "columns": [ + "category_id", + "date" + ], + "isUnique": false + }, + "txn_merchant_idx": { + "name": "txn_merchant_idx", + "columns": [ + "merchant" + ], + "isUnique": false + }, + "txn_dedupe_idx": { + "name": "txn_dedupe_idx", + "columns": [ + "dedupe_key" + ], + "isUnique": false + }, + "txn_review_idx": { + "name": "txn_review_idx", + "columns": [ + "needs_review" + ], + "isUnique": false + }, + "txn_import_idx": { + "name": "txn_import_idx", + "columns": [ + "import_id" + ], + "isUnique": false + }, + "txn_subscription_idx": { + "name": "txn_subscription_idx", + "columns": [ + "subscription_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "txn_account_id_account_id_fk": { + "name": "txn_account_id_account_id_fk", + "tableFrom": "txn", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_import_id_import_id_fk": { + "name": "txn_import_id_import_id_fk", + "tableFrom": "txn", + "tableTo": "import", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "txn_category_id_category_id_fk": { + "name": "txn_category_id_category_id_fk", + "tableFrom": "txn", + "tableTo": "category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 0c83e03..4e0924b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1791417594406, "tag": "0002_full_picture", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1791473201347, + "tag": "0003_split_tags", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index 2f64f3f..ded739c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { MiddlewareHandler } from 'hono'; import { HTTPException } from 'hono/http-exception'; +import { compress } from 'hono/compress'; import { secureHeaders } from 'hono/secure-headers'; import { sessionMiddleware } from './auth/guards'; import type { AppContext } from './context'; @@ -82,6 +83,8 @@ export function createApp(ctx: AppContext) { await next(); c.header('Cache-Control', 'no-store'); }); + // JSON answers can be large (a year of reports, 500 transactions); send them gzipped. + app.use('/api/*', compress()); app.use('/api/*', sessionMiddleware(ctx)); app.use('/api/*', sameOriginWrites(ctx)); diff --git a/server/src/backups/backup.ts b/server/src/backups/backup.ts index 226530d..b2d4359 100644 --- a/server/src/backups/backup.ts +++ b/server/src/backups/backup.ts @@ -1,4 +1,5 @@ import type { BackupInfo, BackupKind } from '@spendlight/shared'; +import Database from 'better-sqlite3'; import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import type { Sqlite } from '../db/client'; @@ -48,6 +49,11 @@ export async function createBackup( } const partial = join(dir, `.${name}.partial`); await sqlite.backup(partial); + // A backup is one self-contained file: no write-ahead log, so reading it + // later never leaves -wal or -shm files beside it in the backup folder. + const copy = new Database(partial); + copy.pragma('journal_mode = DELETE'); + copy.close(); renameSync(partial, join(dir, name)); return { name, kind, size: statSync(join(dir, name)).size, createdAt: when.toISOString() }; } @@ -122,7 +128,8 @@ export function backupPath(dir: string, name: string): string | null { /** Run a nightly backup at the configured local time, plus a catch-up if the last one is overdue. */ export function scheduleNightlyBackups(options: { - sqlite: Sqlite; + /** The open database; a function, because a restore swaps it for another. */ + sqlite: () => Sqlite; dir: string; at: { hour: number; minute: number }; policy: RetentionPolicy; @@ -133,7 +140,7 @@ export function scheduleNightlyBackups(options: { const run = async () => { try { - const backup = await createBackup(sqlite, dir, 'nightly'); + const backup = await createBackup(sqlite(), dir, 'nightly'); const removed = pruneBackups(dir, policy); log.info(`Nightly backup ${backup.name} (${removed.length} old copies removed)`); } catch (error) { diff --git a/server/src/backups/restore.ts b/server/src/backups/restore.ts new file mode 100644 index 0000000..6a8c8f0 --- /dev/null +++ b/server/src/backups/restore.ts @@ -0,0 +1,131 @@ +import Database from 'better-sqlite3'; +import { randomUUID } from 'node:crypto'; +import { closeSync, copyFileSync, existsSync, openSync, readFileSync, readSync, renameSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { BackupInfo } from '@spendlight/shared'; +import { APP_MARKER, migrateDatabase, migrationStatus, openDatabase, type DB, type Sqlite } from '../db/client'; +import type { SessionRow } from '../db/schema'; +import { createBackup, pruneBackups, type RetentionPolicy } from './backup'; + +// Restoring a backup replaces the whole database. Before anything changes, +// the file is checked (a SQLite database, intact, made by Spendlight, and not +// by a newer version), and a "before restore" copy of the current database is +// saved, so a restore can itself be undone. + +export interface BackupCheck { + /** Database upgrades the copy has had; newer ones run after restoring. */ + applied: number; + counts: { transactions: number; accounts: number; people: number; budgets: number; subscriptions: number }; + newestTransaction: string | null; +} + +export class RestoreError extends Error { + constructor( + public code: string, + message: string, + ) { + super(message); + } +} + +const SQLITE_HEADER = 'SQLite format 3\u0000'; + +/** Check a backup by reading a throwaway copy, so the original is never touched. */ +export function checkBackup(file: string, migrationsDir: string, scratchDir = tmpdir()): BackupCheck { + if (!existsSync(file)) throw new RestoreError('not_found', 'That backup wasn’t found.'); + const copy = join(scratchDir, `.check-${randomUUID()}.db`); + copyFileSync(file, copy); + try { + return inspect(copy, migrationsDir); + } finally { + for (const extra of ['', '-wal', '-shm', '-journal']) rmSync(copy + extra, { force: true }); + } +} + +function inspect(file: string, migrationsDir: string): BackupCheck { + const head = Buffer.alloc(16); + const fd = openSync(file, 'r'); + try { + readSync(fd, head, 0, 16, 0); + } finally { + closeSync(fd); + } + if (head.toString('latin1') !== SQLITE_HEADER) throw new RestoreError('not_sqlite', 'That file isn’t a Spendlight backup.'); + + let sqlite: Sqlite | null = null; + try { + sqlite = new Database(file, { fileMustExist: true }); + const ok = sqlite.pragma('quick_check', { simple: true }); + if (ok !== 'ok') throw new RestoreError('damaged', 'That backup is damaged and can’t be restored.'); + const table = (name: string) => !!sqlite!.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); + const marker = table('setting') ? (sqlite.prepare('SELECT value FROM setting WHERE key = ?').get(APP_MARKER.key) as { value: string } | undefined) : undefined; + if (marker?.value !== APP_MARKER.value) throw new RestoreError('not_spendlight', 'That file isn’t a Spendlight backup.'); + const known = (JSON.parse(readFileSync(join(migrationsDir, 'meta', '_journal.json'), 'utf8')) as { entries: unknown[] }).entries.length; + const { applied } = migrationStatus(sqlite, migrationsDir); + if (applied > known) throw new RestoreError('too_new', 'That backup was made by a newer version of Spendlight. Update Spendlight first, then restore it.'); + const count = (name: string) => (table(name) ? (sqlite!.prepare(`SELECT count(*) AS n FROM ${name}`).get() as { n: number }).n : 0); + const people = table('user') ? (sqlite.prepare('SELECT count(*) AS n FROM user WHERE deleted_at IS NULL').get() as { n: number }).n : 0; + if (!people) throw new RestoreError('empty', 'That backup has nobody who can sign in, so it can’t be restored.'); + return { + applied, + counts: { transactions: count('txn'), accounts: count('account'), people, budgets: count('budget'), subscriptions: count('subscription') }, + newestTransaction: table('txn') ? ((sqlite.prepare('SELECT max(date) AS d FROM txn').get() as { d: string | null }).d ?? null) : null, + }; + } catch (error) { + if (error instanceof RestoreError) throw error; + throw new RestoreError('unreadable', 'That backup couldn’t be read.'); + } finally { + sqlite?.close(); + } +} + +export interface RestoreOptions { + dataDir: string; + backupDir: string; + migrationsDir: string; + policy: RetentionPolicy; + /** The open database, which gets closed and replaced. Null when the app isn't running. */ + current: Sqlite | null; + /** The session of the person restoring, carried into the restored copy. */ + keepSession?: { userId: number; username: string; session: SessionRow }; +} + +/** Replace the database with a backup. Returns the newly opened database. */ +export async function restoreDatabase(source: string, options: RestoreOptions): Promise<{ sqlite: Sqlite; db: DB; safety: BackupInfo | null; check: BackupCheck }> { + const check = checkBackup(source, options.migrationsDir, options.dataDir); + const file = join(options.dataDir, 'spendlight.db'); + + // 1. A safety copy of how things are now. + let safety: BackupInfo | null = null; + const current = options.current ?? (existsSync(file) ? openDatabase(file).sqlite : null); + if (current) { + safety = await createBackup(current, options.backupDir, 'pre-restore'); + pruneBackups(options.backupDir, options.policy); + } + + // 2. Stage the copy next to the database, so the swap is a single rename. + const staged = join(options.dataDir, '.restore.db'); + rmSync(staged, { force: true }); + copyFileSync(source, staged); + + // 3. Swap: close, replace, and drop the old write-ahead log with it. + current?.close(); + for (const extra of ['-wal', '-shm']) rmSync(file + extra, { force: true }); + renameSync(staged, file); + + // 4. Open it and bring it up to date with this version. + const { sqlite, db } = openDatabase(file); + await migrateDatabase(db, sqlite, options.migrationsDir); + // Whoever restored stays signed in, if they're in the restored copy. + const keep = options.keepSession; + if (keep) { + const same = sqlite.prepare('SELECT 1 FROM user WHERE id = ? AND username = ? AND deleted_at IS NULL').get(keep.userId, keep.username); + if (same) { + sqlite + .prepare('INSERT OR IGNORE INTO session (id, user_id, kind, device_label, user_agent, ip, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run(keep.session.id, keep.session.userId, keep.session.kind, keep.session.deviceLabel, keep.session.userAgent, keep.session.ip, keep.session.createdAt, keep.session.lastSeenAt, keep.session.expiresAt); + } + } + return { sqlite, db, safety, check }; +} diff --git a/server/src/cli.ts b/server/src/cli.ts index a4d47d3..a367966 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { hashPassword, temporaryPassword } from './auth/password'; import { revokeUserSessions } from './auth/sessions'; import { createBackup, pruneBackups } from './backups/backup'; +import { checkBackup, RestoreError, restoreDatabase } from './backups/restore'; import { loadConfig } from './config'; import type { AppContext } from './context'; import { openDatabase } from './db/client'; @@ -12,12 +13,14 @@ import { users } from './db/schema'; import { ideasToMarkdown } from './ideas/markdown'; import { listIdeas } from './ideas/queries'; import { silentLogger } from './log'; +import { MIGRATIONS_DIR } from './paths'; import { findActiveUser } from './users'; // Admin commands, run inside the container: // spendlight ideas [--status=new|planned|done|declined] // spendlight reset-password // spendlight backup +// spendlight restore // spendlight version const HELP = `Spendlight admin commands @@ -25,6 +28,7 @@ const HELP = `Spendlight admin commands spendlight ideas [--status=new] Print ideas as Markdown (all statuses by default) spendlight reset-password Give someone a temporary password spendlight backup Back up the database now + spendlight restore Replace the database with a backup (stop the app first) spendlight version Show the running version `; @@ -81,6 +85,38 @@ switch (command) { console.log(`Backed up to ${join(config.backupDir, backup.name)}`); break; } + case 'restore': { + const source = args[0]; + if (!source) { + console.error('Usage: spendlight restore (for example /backups/spendlight-nightly-20261007-023000.db)'); + process.exit(1); + } + // Restoring underneath a running app would lose whatever it writes next. + const running = await fetch(`http://127.0.0.1:${config.port}/api/v1/health`) + .then((r) => r.ok) + .catch(() => false); + if (running) { + console.error('Spendlight is running. Restore from Settings → Backups instead, or stop the app first.'); + process.exit(1); + } + try { + const check = checkBackup(source, MIGRATIONS_DIR); + console.log(`Backup holds ${check.counts.transactions} transactions, ${check.counts.accounts} accounts and ${check.counts.people} people.`); + const { sqlite, safety } = await restoreDatabase(source, { + dataDir: config.dataDir, + backupDir: config.backupDir, + migrationsDir: MIGRATIONS_DIR, + policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }, + current: null, + }); + sqlite.close(); + console.log(`Restored. The previous database is saved as ${safety ? join(config.backupDir, safety.name) : '(there was none)'}.`); + } catch (error) { + console.error(error instanceof RestoreError ? error.message : `Restore failed: ${(error as Error).message}`); + process.exit(1); + } + break; + } case 'version': console.log(config.version); break; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8d51b5e..477989e 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -271,6 +271,20 @@ export const transactionTags = sqliteTable( (t) => [uniqueIndex('txn_tag_pk').on(t.transactionId, t.tagId), index('txn_tag_tag_idx').on(t.tagId)], ); +/** Tags on one part of a split purchase, such as "Reimbursable" on the work lunch only. */ +export const splitTags = sqliteTable( + 'split_tag', + { + splitId: integer('split_id') + .notNull() + .references(() => splits.id, { onDelete: 'cascade' }), + tagId: integer('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (t) => [uniqueIndex('split_tag_pk').on(t.splitId, t.tagId), index('split_tag_tag_idx').on(t.tagId)], +); + /** "Always file this merchant this way." Created when someone says so, or by hand. */ export const rules = sqliteTable( 'rule', diff --git a/server/src/import/ofx.ts b/server/src/import/ofx.ts new file mode 100644 index 0000000..d0b4b70 --- /dev/null +++ b/server/src/import/ofx.ts @@ -0,0 +1,99 @@ +import type { AccountKind } from '@spendlight/shared'; + +// OFX, QFX and QBO files ("Quicken", "Money" or "Web Connect" downloads). Both +// flavors are read: the older SGML one, where tags aren't closed, and XML. +// Amounts are already signed from the account holder's side: money out is +// negative, for bank accounts and cards alike. + +export interface OfxTransaction { + date: string; + postedDate: string | null; + amountCents: number; + name: string; + memo: string; + type: string; + id: string | null; +} + +export interface OfxStatement { + kind: AccountKind; + institution: string | null; + accountLast4: string | null; + transactions: OfxTransaction[]; + ledger: { cents: number; date: string } | null; +} + +export const looksLikeOfx = (text: string) => /OFXHEADER\s*:|/i.test(text.slice(0, 4000)); + +const decode = (s: string) => + s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&') + .trim(); + +/** The value of a leaf tag: -12.34 (SGML) or -12.34 (XML). */ +function leaf(block: string, tag: string): string | null { + const m = new RegExp(`<${tag}>([^<\\r\\n]*)`, 'i').exec(block); + return m ? decode(m[1]!) : null; +} + +/** 20261007, 20261007120000, 20261007120000.000[-5:EST] → 2026-10-07 */ +function ofxDate(value: string | null): string | null { + const m = value ? /^(\d{4})(\d{2})(\d{2})/.exec(value) : null; + if (!m) return null; + const [, y, mo, d] = m; + const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d))); + return date.getUTCMonth() === Number(mo) - 1 ? `${y}-${mo}-${d}` : null; +} + +function cents(value: string | null): number | null { + if (!value) return null; + // Some banks write decimal commas: "-12,34". + const clean = value.replace(/\s/g, '').replace(/,(\d{1,2})$/, '.$1').replace(/,/g, ''); + if (!/^[+-]?\d*(\.\d+)?$/.test(clean) || clean === '' || clean === '-' || clean === '+') return null; + return Math.round(Number(clean) * 100); +} + +export function parseOfx(text: string): OfxStatement { + const card = /|/i.test(text); + const acctType = leaf(text, 'ACCTTYPE')?.toUpperCase(); + const kind: AccountKind = card ? 'credit_card' : acctType === 'SAVINGS' || acctType === 'MONEYMRKT' ? 'savings' : 'checking'; + const acctId = leaf(text, 'ACCTID'); + const accountLast4 = acctId ? (/(\d{4})\D*$/.exec(acctId)?.[1] ?? null) : null; + const institution = leaf(text, 'ORG'); + + const transactions: OfxTransaction[] = []; + const blocks = text.split(//i).slice(1); + for (const raw of blocks) { + const block = raw.split(/<\/STMTTRN>|<\/BANKTRANLIST>/i)[0]!; + const posted = ofxDate(leaf(block, 'DTPOSTED')); + const user = ofxDate(leaf(block, 'DTUSER')); + const amount = cents(leaf(block, 'TRNAMT')); + const date = user ?? posted; + if (!date || amount === null) continue; + transactions.push({ + date, + postedDate: user && posted && posted !== user ? posted : null, + amountCents: amount, + name: leaf(block, 'NAME') ?? leaf(block, 'PAYEE') ?? '', + memo: leaf(block, 'MEMO') ?? '', + type: (leaf(block, 'TRNTYPE') ?? '').toUpperCase(), + id: leaf(block, 'FITID'), + }); + } + + const ledgerBlock = /([\s\S]*?)(<\/LEDGERBAL>||<\/STMTRS>|<\/CCSTMTRS>|$)/i.exec(text)?.[1] ?? ''; + const ledgerCents = cents(leaf(ledgerBlock, 'BALAMT')); + const ledgerDate = ofxDate(leaf(ledgerBlock, 'DTASOF')); + return { + kind, + institution: institution && !/^\d+$/.test(institution) ? institution : null, + accountLast4, + transactions, + ledger: ledgerCents !== null && ledgerDate ? { cents: ledgerCents, date: ledgerDate } : null, + }; +} diff --git a/server/src/import/service.ts b/server/src/import/service.ts index a0438e9..2a98f73 100644 --- a/server/src/import/service.ts +++ b/server/src/import/service.ts @@ -13,6 +13,7 @@ import { scanSubscriptions } from '../subscriptions/engine'; import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts'; import { cleanMerchant, dedupeKey } from './merchant'; import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse'; +import { looksLikeOfx, parseOfx } from './ofx'; import { pairTransfers } from './transfers'; // The import pipeline: stage a file, preview what it would add, then commit. @@ -53,6 +54,11 @@ export class StagingStore { return staged; } + /** Forget every staged file, as after a restore replaces the accounts they were meant for. */ + clear(): void { + this.files.clear(); + } + delete(id: string): void { this.files.delete(id); } @@ -93,7 +99,52 @@ function fileNameLast4(fileName: string): string | null { /** The file name with its digits generalized, so "Chase1234_Activity_20261007.csv" matches next month's file. */ const fileNamePattern = (fileName: string) => fileName.replace(/\d{5,}/g, '#').replace(/\(\d+\)/g, ''); +/** OFX, QFX and QBO downloads carry their own structure, so they need no layout or mapping. */ +function parseOfxFile(staged: Staged): Parsed { + const statement = parseOfx(staged.text); + const rows: Row[] = statement.transactions.map((t) => { + const description = [t.name, t.memo && !t.name.includes(t.memo) ? t.memo : ''].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || 'Transaction'; + const { merchant, mentionsLast4 } = cleanMerchant(description); + return { + date: t.date, + postedDate: t.postedDate, + description, + merchant, + mentionsLast4, + amountCents: t.amountCents, + bankCategory: null, + bankType: t.type === 'XFER' ? 'Payment' : null, + balanceCents: null, + }; + }); + // The statement's ending balance, for checking and savings; card files differ on its sign, so cards are left to the person. + if (statement.ledger && statement.kind !== 'credit_card' && rows.length) { + const last = rows.reduce((a, r) => (r.date > a.date ? r : a)); + if (last.date <= statement.ledger.date) last.balanceCents = statement.ledger.cents; + } + const institution = statement.institution; + return { + layout: { + key: 'ofx', + name: institution ? `${institution} (OFX/QFX)` : 'OFX/QFX file', + institution, + kind: statement.kind, + headerRow: 0, + map: { date: 0, description: 0, amount: 0, positiveIs: 'in', dateOrder: 'YMD' }, + }, + rows, + skipped: 0, + header: null, + sample: [], + suggestedMap: null, + signature: 'ofx', + last4Hint: statement.accountLast4 ?? fileNameLast4(staged.fileName), + openingBalance: null, + }; +} + function parseFile(ctx: AppContext, staged: Staged, mapping?: ImportColumnMap): Parsed { + if (looksLikeOfx(staged.text)) return parseOfxFile(staged); const table = readTable(staged.text); let layout = mapping ? null : detectBuiltIn(table); const headerRow = layout?.headerRow ?? findHeaderRow(table); diff --git a/server/src/index.ts b/server/src/index.ts index b60ce10..35483ad 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,7 +9,7 @@ const ctx = await createContext(config); const app = createApp(ctx); const backups = scheduleNightlyBackups({ - sqlite: ctx.sqlite, + sqlite: () => ctx.sqlite, dir: config.backupDir, at: config.backupTime, policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly }, diff --git a/server/src/routes/backups.ts b/server/src/routes/backups.ts index bf7e585..9257224 100644 --- a/server/src/routes/backups.ts +++ b/server/src/routes/backups.ts @@ -1,13 +1,32 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; -import { createReadStream, existsSync, statSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { createReadStream, existsSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { Readable } from 'node:stream'; +import { bodyLimit } from 'hono/body-limit'; import { logChange } from '../audit'; import { requireAdmin } from '../auth/guards'; import { backupPath, createBackup, listBackups, pruneBackups } from '../backups/backup'; +import { checkBackup, RestoreError, restoreDatabase } from '../backups/restore'; import type { AppContext } from '../context'; +import type { SessionRow, UserRow } from '../db/schema'; import { fail, type Env } from '../http'; +import { refreshAlerts } from '../insights/engine'; +import { MIGRATIONS_DIR } from '../paths'; import { BackupSchema, jsonResponse, validationHook } from '../schemas'; +const MAX_RESTORE_BYTES = 200 * 1024 * 1024; + +const CheckSchema = z + .object({ + applied: z.number().int(), + counts: z.object({ transactions: z.number().int(), accounts: z.number().int(), people: z.number().int(), budgets: z.number().int(), subscriptions: z.number().int() }), + newestTransaction: z.string().nullable(), + }) + .openapi('BackupCheck'); +const RestoredSchema = z.object({ check: CheckSchema, safetyCopy: z.string().nullable() }).openapi('Restored'); +const NameParam = z.object({ name: z.string().openapi({ param: { name: 'name', in: 'path' } }) }); + const pad = (n: number) => String(n).padStart(2, '0'); export function backupRoutes(ctx: AppContext) { @@ -58,6 +77,100 @@ export function backupRoutes(ctx: AppContext) { }, ); + const guard = (run: () => T): T => { + try { + return run(); + } catch (error) { + if (error instanceof RestoreError) fail(400, error.code, error.message); + throw error; + } + }; + + /** Swap the app over to a backup; everyone keeps working against the restored data. */ + const restore = async (source: string, admin: UserRow, session: SessionRow | null, label: string) => { + const adminId = admin.id; + let result; + try { + result = await restoreDatabase(source, { + dataDir: ctx.config.dataDir, + backupDir, + migrationsDir: MIGRATIONS_DIR, + policy: { keepDaily, keepMonthly }, + current: ctx.sqlite, + keepSession: session ? { userId: admin.id, username: admin.username, session } : undefined, + }); + } catch (error) { + if (error instanceof RestoreError) fail(400, error.code, error.message); + throw error; + } + ctx.sqlite = result.sqlite; + ctx.db = result.db; + ctx.staging.clear(); + try { + refreshAlerts(ctx); + } catch (error) { + ctx.log.error('Could not refresh alerts after restoring', error); + } + ctx.log.info(`Restored ${label}; the previous database is saved as ${result.safety?.name ?? '(none)'}`); + // The person who restored may not exist in the copy; the log entry goes in only if they do. + try { + logChange(ctx, adminId, 'restore', 'backup', label, { safetyCopy: result.safety?.name ?? null }); + } catch { + logChange(ctx, null, 'restore', 'backup', label, { safetyCopy: result.safety?.name ?? null }); + } + return { check: result.check, safetyCopy: result.safety?.name ?? null }; + }; + + r.openapi( + createRoute({ + method: 'get', + path: '/backups/{name}/check', + tags: ['Backups'], + summary: 'What a backup holds, checked before restoring it', + request: { params: NameParam }, + responses: jsonResponse(CheckSchema, 'Check'), + }), + (c) => { + requireAdmin(c); + const path = backupPath(backupDir, c.req.valid('param').name); + if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.'); + return c.json(guard(() => checkBackup(path, MIGRATIONS_DIR, ctx.config.dataDir)), 200); + }, + ); + + r.openapi( + createRoute({ + method: 'post', + path: '/backups/{name}/restore', + tags: ['Backups'], + summary: 'Replace everything with a backup from the backup folder', + description: 'A "before restore" copy of the current data is saved first, so a restore can be undone.', + request: { params: NameParam }, + responses: jsonResponse(RestoredSchema, 'Restored'), + }), + async (c) => { + const admin = requireAdmin(c); + const { name } = c.req.valid('param'); + const path = backupPath(backupDir, name); + if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.'); + return c.json(await restore(path, admin, c.get('session'), name), 200); + }, + ); + + r.post('/backups/restore-upload', bodyLimit({ maxSize: MAX_RESTORE_BYTES, onError: () => fail(413, 'too_big', 'That file is larger than 200 MB, which is far bigger than any Spendlight backup.') }), async (c) => { + const admin = requireAdmin(c); + const form = await c.req.parseBody(); + const file = form.file; + if (!(file instanceof File)) fail(400, 'no_file', 'Please choose a backup file.'); + const upload = join(ctx.config.dataDir, `.upload-${randomUUID()}.db`); + writeFileSync(upload, Buffer.from(await file.arrayBuffer())); + try { + return c.json(await restore(upload, admin, c.get('session'), file.name || 'uploaded file'), 200); + } finally { + rmSync(upload, { force: true }); + } + }); + // A file download, so it sits outside the JSON API description. r.get('/backups/:name/download', (c) => { requireAdmin(c); diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts index 630b2b2..3adbf08 100644 --- a/server/src/routes/categories.ts +++ b/server/src/routes/categories.ts @@ -4,7 +4,7 @@ import { logChange } from '../audit'; import { requireUser, requireWrite } from '../auth/guards'; import { rememberRule } from '../categorize/suggest'; import type { AppContext } from '../context'; -import { budgets, categories, categoryGroups, rules, splits, subscriptions, tags, transactions, transactionTags, type CategoryRow } from '../db/schema'; +import { budgets, categories, categoryGroups, rules, splits, splitTags, subscriptions, tags, transactions, transactionTags, type CategoryRow } from '../db/schema'; import { fail, nowIso, type Env } from '../http'; import { CategoryGroupSchema, CategorySchema, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas'; import { listCategoryGroups } from '../transactions/dto'; @@ -249,13 +249,14 @@ export function categoryRoutes(ctx: AppContext) { }), (c) => { requireUser(c); - const rows = ctx.db - .select({ id: tags.id, name: tags.name, count: count(transactionTags.transactionId) }) - .from(tags) - .leftJoin(transactionTags, eq(transactionTags.tagId, tags.id)) - .groupBy(tags.id) - .orderBy(desc(count(transactionTags.transactionId)), tags.name) - .all(); + // Uses: transactions tagged, plus purchases with a tagged part. + const rows = ctx.sqlite + .prepare( + `SELECT t.id, t.name, (SELECT count(*) FROM txn_tag tt WHERE tt.tag_id = t.id) + + (SELECT count(DISTINCT s.transaction_id) FROM split_tag st JOIN split s ON s.id = st.split_id WHERE st.tag_id = t.id) AS count + FROM tag t ORDER BY count DESC, t.name`, + ) + .all() as { id: number; name: string; count: number }[]; return c.json(rows, 200); }, ); @@ -281,6 +282,9 @@ export function categoryRoutes(ctx: AppContext) { for (const link of ctx.db.select().from(transactionTags).where(eq(transactionTags.tagId, id)).all()) { ctx.db.insert(transactionTags).values({ transactionId: link.transactionId, tagId: other.id }).onConflictDoNothing().run(); } + for (const link of ctx.db.select().from(splitTags).where(eq(splitTags.tagId, id)).all()) { + ctx.db.insert(splitTags).values({ splitId: link.splitId, tagId: other.id }).onConflictDoNothing().run(); + } ctx.db.delete(tags).where(eq(tags.id, id)).run(); result = { id: other.id, name: other.name }; } else ctx.db.update(tags).set({ name }).where(eq(tags.id, id)).run(); diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 9f62e85..2ecfe6f 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -5,7 +5,7 @@ import { currentBalance } from '../accounts/balance'; import { requireUser } from '../auth/guards'; import { budgetYear, effective } from '../budgets/budgets'; import type { AppContext } from '../context'; -import { accounts, budgets, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; +import { accounts, budgets, rules, splits, splitTags, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; import { today } from '../dates'; import { buildCsv, buildXlsx, type Cell, type Sheet } from '../export/xlsx'; import type { Env } from '../http'; @@ -27,9 +27,19 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { const chunks = (list: T[], size = 900) => Array.from({ length: Math.ceil(list.length / size) }, (_, i) => list.slice(i * size, i * size + size)); const parts = new Map(); const tagNames = new Map(); + const partTags = new Map(); for (const chunk of chunks(ids)) { - for (const s of ctx.db.select().from(splits).where(inArray(splits.transactionId, chunk)).orderBy(asc(splits.id)).all()) { - parts.set(s.transactionId, [...(parts.get(s.transactionId) ?? []), s]); + const partRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, chunk)).orderBy(asc(splits.id)).all(); + for (const s of partRows) parts.set(s.transactionId, [...(parts.get(s.transactionId) ?? []), s]); + for (const partChunk of chunks(partRows.map((s) => s.id))) { + for (const pt of ctx.db + .select({ id: splitTags.splitId, name: tags.name }) + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .where(inArray(splitTags.splitId, partChunk)) + .all()) { + partTags.set(pt.id, [...(partTags.get(pt.id) ?? []), pt.name]); + } } for (const t of ctx.db .select({ id: transactionTags.transactionId, name: tags.name }) @@ -48,7 +58,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { const out: Cell[][] = []; for (const t of rows) { - const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null): Cell[] => { + const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null, extraTags: string[] = []): Cell[] => { const cat = categoryId ? cats.get(categoryId) : undefined; return [ { date: t.date }, @@ -59,7 +69,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { { money: cents }, splitOf === null ? '' : { money: splitOf }, t.transferPeerId ? (accountNames.get(peers.get(t.transferPeerId) ?? -1) ?? '') : '', - (tagNames.get(t.id) ?? []).join(', '), + [...new Set([...(tagNames.get(t.id) ?? []), ...extraTags])].join(', '), note ?? '', t.subscriptionId ? (subNames.get(t.subscriptionId) ?? '') : '', t.needsReview ? 'To review' : 'Filed', @@ -67,7 +77,7 @@ function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet { ]; }; const p = parts.get(t.id); - if (t.isSplit && p?.length) for (const part of p) out.push(base(part.categoryId, part.amountCents, part.note ?? t.note, t.amountCents)); + if (t.isSplit && p?.length) for (const part of p) out.push(base(part.categoryId, part.amountCents, part.note ?? t.note, t.amountCents, partTags.get(part.id))); else out.push(base(t.categoryId, t.amountCents, t.note, null)); } return { diff --git a/server/src/routes/reports.ts b/server/src/routes/reports.ts index a7c1d96..27613a7 100644 --- a/server/src/routes/reports.ts +++ b/server/src/routes/reports.ts @@ -211,16 +211,20 @@ export function reportRoutes(ctx: AppContext) { const rows = ctx.sqlite .prepare( `SELECT tag, categoryId, sum(net) AS net, count(DISTINCT txnId) AS n FROM ( - SELECT tg.name AS tag, t.category_id AS categoryId, t.amount_cents AS net, t.id AS txnId + SELECT tg.name AS tag, 'txn' AS unitKind, t.id AS unit, t.category_id AS categoryId, t.amount_cents AS net, t.id AS txnId FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id WHERE t.is_split = 0 AND t.transfer_peer_id IS NULL AND t.date >= ? AND t.date <= ? - UNION ALL - SELECT tg.name, s.category_id, s.amount_cents, t.id + UNION + SELECT tg.name, 'part', s.id, s.category_id, s.amount_cents, t.id FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id JOIN split s ON s.transaction_id = t.id WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ? + UNION + SELECT tg.name, 'part', s.id, s.category_id, s.amount_cents, t.id + FROM split_tag st JOIN tag tg ON tg.id = st.tag_id JOIN split s ON s.id = st.split_id JOIN txn t ON t.id = s.transaction_id + WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ? ) GROUP BY tag, categoryId`, ) - .all(from, to, from, to) as { tag: string; categoryId: number | null; net: number; n: number }[]; + .all(from, to, from, to, from, to) as { tag: string; categoryId: number | null; net: number; n: number }[]; const byTag = new Map(); for (const row of rows) { const kind = row.categoryId === null ? (row.net < 0 ? 'expense' : 'income') : kinds.get(row.categoryId); diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts index bcce07f..f4cc1e1 100644 --- a/server/src/routes/transactions.ts +++ b/server/src/routes/transactions.ts @@ -5,7 +5,7 @@ import { logChange } from '../audit'; import { requireUser, requireWrite } from '../auth/guards'; import { rememberRule, Suggester } from '../categorize/suggest'; import type { AppContext } from '../context'; -import { accounts, categories, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema'; +import { accounts, categories, rules, splits, splitTags, subscriptions, transactions, transactionTags, type TransactionRow } from '../db/schema'; import { fail, nowIso, type Env } from '../http'; import { dedupeKey } from '../import/merchant'; import { unpair } from '../import/transfers'; @@ -19,6 +19,7 @@ import { TransactionSchema, validationHook, } from '../schemas'; +import { tagIdsFor } from '../tags'; import { newestFirst, toTransaction, toTransactions } from '../transactions/dto'; import { TransactionQuery, transactionWhere } from '../transactions/filter'; @@ -43,19 +44,7 @@ export function transactionRoutes(ctx: AppContext) { function setTags(transactionId: number, names: string[]) { ctx.db.delete(transactionTags).where(eq(transactionTags.transactionId, transactionId)).run(); - // The first spelling of a tag wins: "Back to school" and "back to school" are one tag. - const byKey = new Map(); - for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim()); - const unique = [...byKey.values()]; - for (const name of unique) { - const existing = ctx.db - .select() - .from(tags) - .where(sql`lower(${tags.name}) = ${name.toLowerCase()}`) - .get(); - const tagId = existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id; - ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run(); - } + for (const tagId of tagIdsFor(ctx, names)) ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run(); } /** Other transactions from the same merchant still waiting in Review, for "also file these?". */ @@ -214,7 +203,7 @@ export function transactionRoutes(ctx: AppContext) { ...jsonBody( z.object({ parts: z - .array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional() })) + .array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional(), tags: TagList.optional() })) .min(2, 'A split needs at least two parts.') .max(20), }), @@ -236,7 +225,10 @@ export function transactionRoutes(ctx: AppContext) { ctx.sqlite.transaction(() => { if (t.transferPeerId) unpair(ctx, t.id); ctx.db.delete(splits).where(eq(splits.transactionId, id)).run(); - for (const p of parts) ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).run(); + for (const p of parts) { + const part = ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).returning().get(); + for (const tagId of tagIdsFor(ctx, p.tags ?? [])) ctx.db.insert(splitTags).values({ splitId: part.id, tagId }).onConflictDoNothing().run(); + } ctx.db .update(transactions) .set({ isSplit: true, categoryId: null, needsReview: false, suggestedBy: null, updatedAt: nowIso() }) diff --git a/server/src/schemas.ts b/server/src/schemas.ts index 5a75fd2..8c1b670 100644 --- a/server/src/schemas.ts +++ b/server/src/schemas.ts @@ -167,7 +167,7 @@ export const CategoryGroupSchema = z .openapi('CategoryGroup'); export const SplitSchema = z - .object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable() }) + .object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable(), tags: z.array(z.string()) }) .openapi('Split'); export const TransactionSchema = z diff --git a/server/src/static.ts b/server/src/static.ts index 389839c..3836d24 100644 --- a/server/src/static.ts +++ b/server/src/static.ts @@ -5,7 +5,8 @@ import { Readable } from 'node:stream'; // Serves the built web app. Hashed files under /assets are cached for a year; // everything else revalidates so a new version shows up straight away. Any -// path that isn't a file gets index.html, so the app can route it. +// path that isn't a file gets index.html, so the app can route it. Text files +// go out pre-compressed (brotli, else gzip) when the browser accepts it. const TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -31,13 +32,25 @@ function cacheControlFor(urlPath: string): string { return 'public, max-age=86400'; } -function fileResponse(path: string, urlPath: string, head: boolean): Response { - const headers = { +function fileResponse(path: string, urlPath: string, head: boolean, acceptEncoding = ''): Response { + const headers: Record = { 'Content-Type': TYPES[extname(path)] ?? 'application/octet-stream', - 'Content-Length': String(statSync(path).size), 'Cache-Control': cacheControlFor(urlPath), }; - const body = head ? null : (Readable.toWeb(createReadStream(path)) as ReadableStream); + let file = path; + for (const [encoding, suffix] of [ + ['br', '.br'], + ['gzip', '.gz'], + ] as const) { + if (new RegExp(`\\b${encoding}\\b`).test(acceptEncoding) && existsSync(path + suffix)) { + file = path + suffix; + headers['Content-Encoding'] = encoding; + break; + } + } + if (existsSync(path + '.br') || existsSync(path + '.gz')) headers.Vary = 'Accept-Encoding'; + headers['Content-Length'] = String(statSync(file).size); + const body = head ? null : (Readable.toWeb(createReadStream(file)) as ReadableStream); return new Response(body, { headers }); } @@ -52,12 +65,12 @@ export function webApp(root: string): MiddlewareHandler { const candidate = normalize(join(root, urlPath)); if (candidate.startsWith(rootWithSep) && existsSync(candidate) && statSync(candidate).isFile()) { - return fileResponse(candidate, urlPath, method === 'HEAD'); + return fileResponse(candidate, urlPath, method === 'HEAD', c.req.header('accept-encoding')); } if (extname(urlPath)) return c.text('Not found', 404); if (!existsSync(index)) { return c.text('The Spendlight web app is not built yet. Run "npm run build" (or "npm run dev:web" while developing).', 503); } - return fileResponse(index, '/index.html', method === 'HEAD'); + return fileResponse(index, '/index.html', method === 'HEAD', c.req.header('accept-encoding')); }; } diff --git a/server/src/tags.ts b/server/src/tags.ts new file mode 100644 index 0000000..4cb0920 --- /dev/null +++ b/server/src/tags.ts @@ -0,0 +1,17 @@ +import { sql } from 'drizzle-orm'; +import type { AppContext } from './context'; +import { tags } from './db/schema'; + +/** Tag IDs for these names, creating any that are new. The first spelling of a tag wins: "Back to school" and "back to school" are one tag. */ +export function tagIdsFor(ctx: AppContext, names: string[]): number[] { + const byKey = new Map(); + for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim()); + return [...byKey.values()].map((name) => { + const existing = ctx.db + .select() + .from(tags) + .where(sql`lower(${tags.name}) = ${name.toLowerCase()}`) + .get(); + return existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id; + }); +} diff --git a/server/src/transactions/dto.ts b/server/src/transactions/dto.ts index 4af87b0..a46bf87 100644 --- a/server/src/transactions/dto.ts +++ b/server/src/transactions/dto.ts @@ -2,13 +2,21 @@ import type { Account, Category, CategoryGroup, PaletteColor, Transaction } from import { and, asc, count, desc, eq, inArray, max } from 'drizzle-orm'; import { currentBalance } from '../accounts/balance'; import type { AppContext } from '../context'; -import { accounts, categories, categoryGroups, imports, splits, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema'; +import { accounts, categories, categoryGroups, imports, splits, splitTags, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema'; /** Full transactions, with splits, tags and the other half of any transfer, loaded in a few queries. */ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transaction[] { if (!rows.length) return []; const ids = rows.map((r) => r.id); const splitRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, ids)).orderBy(asc(splits.id)).all(); + const splitTagRows = splitRows.length + ? ctx.db + .select({ splitId: splitTags.splitId, name: tags.name }) + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .where(inArray(splitTags.splitId, splitRows.map((s) => s.id))) + .all() + : []; const tagRows = ctx.db .select({ transactionId: transactionTags.transactionId, name: tags.name }) .from(transactionTags) @@ -38,7 +46,7 @@ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transac isSplit: r.isSplit, splits: splitRows .filter((s) => s.transactionId === r.id) - .map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note })), + .map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note, tags: splitTagRows.filter((x) => x.splitId === s.id).map((x) => x.name) })), note: r.note, tags: tagRows.filter((t) => t.transactionId === r.id).map((t) => t.name), transferPeer: peers.find((p) => p.id === r.transferPeerId) ?? null, diff --git a/server/src/transactions/filter.ts b/server/src/transactions/filter.ts index 8085182..0a509b2 100644 --- a/server/src/transactions/filter.ts +++ b/server/src/transactions/filter.ts @@ -1,7 +1,7 @@ import { z } from '@hono/zod-openapi'; import { and, eq, exists, gte, isNull, like, lte, or, sql, type SQL } from 'drizzle-orm'; import type { AppContext } from '../context'; -import { splits, tags, transactions, transactionTags } from '../db/schema'; +import { splits, splitTags, tags, transactions, transactionTags } from '../db/schema'; import { DateString } from '../schemas'; /** The search and filters shared by the transaction list and its exports. */ @@ -36,14 +36,26 @@ export function transactionWhere(ctx: AppContext, q: TransactionFilter): SQL | u ); } if (q.tag) { + const name = sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`; + // On the transaction itself, or on any part of a split. where.push( - exists( - ctx.db - .select() - .from(transactionTags) - .innerJoin(tags, eq(tags.id, transactionTags.tagId)) - .where(and(eq(transactionTags.transactionId, transactions.id), sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`)), - ), + or( + exists( + ctx.db + .select() + .from(transactionTags) + .innerJoin(tags, eq(tags.id, transactionTags.tagId)) + .where(and(eq(transactionTags.transactionId, transactions.id), name)), + ), + exists( + ctx.db + .select() + .from(splitTags) + .innerJoin(tags, eq(tags.id, splitTags.tagId)) + .innerJoin(splits, eq(splits.id, splitTags.splitId)) + .where(and(eq(splits.transactionId, transactions.id), name)), + ), + )!, ); } if (q.from) where.push(gte(transactions.date, q.from)); diff --git a/server/test/full-picture.test.ts b/server/test/full-picture.test.ts index 591c9f1..067bbc7 100644 --- a/server/test/full-picture.test.ts +++ b/server/test/full-picture.test.ts @@ -333,6 +333,38 @@ describe('managing categories, rules and tags', () => { }); }); +describe('tags on split parts', () => { + it('finds, totals and exports a tag on one part of a purchase', async () => { + const page = (await admin.get('/api/v1/transactions?q=Kroger&limit=1')).json; + const kroger = page.items[0]; + const groceries = await category('Groceries'); + const household = await category('Household Supplies'); + const split = await admin.call('PUT', `/api/v1/transactions/${kroger.id}/split`, { + parts: [ + { categoryId: groceries, amountCents: kroger.amountCents + 1500 }, + { categoryId: household, amountCents: -1500, tags: ['Reimbursable'] }, + ], + }); + expect(split.json.splits.map((x: { tags: string[] }) => x.tags)).toEqual([[], ['Reimbursable']]); + + const found = (await admin.get('/api/v1/transactions?tag=reimbursable')).json; + expect(found.items.map((x: { id: number }) => x.id)).toEqual([kroger.id]); + const report = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number; count: number; byCategory: { categoryId: number }[] }[]; + expect(report.find((r) => r.tag === 'Reimbursable')).toMatchObject({ spent: 1500, count: 1, byCategory: [{ categoryId: household, cents: 1500 }] }); + expect((await admin.get('/api/v1/tags')).json).toEqual([{ id: expect.any(Number), name: 'Reimbursable', count: 1 }]); + + // Tagging the whole purchase too doesn't count the tagged part twice. + await admin.patch(`/api/v1/transactions/${kroger.id}`, { tags: ['Reimbursable'] }); + const both = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number }[]; + expect(both.find((r) => r.tag === 'Reimbursable')!.spent).toBe(-kroger.amountCents); + + const res = await t.app.request(`http://localhost/api/v1/export/transactions.csv?tag=Reimbursable`, { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + const lines = Buffer.from(await res.arrayBuffer()).subarray(3).toString('utf8').trim().split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines.every((l, i) => i === 0 || l.includes('Reimbursable'))).toBe(true); + }); +}); + describe('export', () => { it('writes CSV that defuses formulas and splits rows by part', async () => { expect(buildCsv(['A', 'B'], [['=HYPERLINK("x")', { money: -1234 }], ['a,b', 5]])).toBe('\uFEFFA,B\r\n"\'=HYPERLINK(""x"")",-12.34\r\n"a,b",5\r\n'); diff --git a/server/test/ofx.test.ts b/server/test/ofx.test.ts new file mode 100644 index 0000000..cb3e0d3 --- /dev/null +++ b/server/test/ofx.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseOfx } from '../src/import/ofx'; +import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; + +// Made-up OFX files in both flavors banks send: the older SGML style with +// unclosed tags (bank account), and XML (credit card, as in a .qfx). + +const SGML = `OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 + + +0INFO20261007120000ENGBank of America5959 +10INFO +USD000000000000099991234CHECKING +2026090120260930 +CREDIT20260905120000.000[-5:EST]3000.00A1ACME CORP PAYROLLPPD ID: 123 +DEBIT20260912-54.20A2KROGER #123 CHARLOTTE NC +XFER20260915-300.00A3Online Banking transfer to SAV5678 Confirmation# 1 +DEBIT20260920-12,50A4JOES COFFEE & BAGELS +4633.30202609304633.3020260930 +`; + +const XML = ` + +1USD +4111XXXXXXXX9876 +2026090120260930 +DEBIT2026090320260902-23.99C1AMAZON MKTPL*AB12CD3E4 +CREDIT2026090718.50C2AMAZON MKTPL*QR56ST7U8Return +DEBIT20260912-15.49C3NETFLIX.COM +-412.3320260930 +`; + +describe('reading OFX', () => { + it('reads the older SGML style', () => { + const s = parseOfx(SGML); + expect(s).toMatchObject({ kind: 'checking', institution: 'Bank of America', accountLast4: '1234', ledger: { cents: 463330, date: '2026-09-30' } }); + expect(s.transactions.map((t) => [t.date, t.amountCents, t.name])).toEqual([ + ['2026-09-05', 300000, 'ACME CORP PAYROLL'], + ['2026-09-12', -5420, 'KROGER #123 CHARLOTTE NC'], + ['2026-09-15', -30000, 'Online Banking transfer to SAV'], + ['2026-09-20', -1250, 'JOES COFFEE & BAGELS'], + ]); + }); + + it('reads XML, using the purchase date when there is one', () => { + const s = parseOfx(XML); + expect(s).toMatchObject({ kind: 'credit_card', accountLast4: '9876' }); + expect(s.transactions[0]).toMatchObject({ date: '2026-09-02', postedDate: '2026-09-03', amountCents: -2399 }); + expect(s.transactions[1]).toMatchObject({ amountCents: 1850, memo: 'Return' }); + }); +}); + +describe('importing OFX', () => { + let t: TestApp; + let admin: Client; + beforeEach(async () => { + t = await makeApp(); + admin = await setUpAdmin(t); + }); + afterEach(() => t.cleanup()); + + it('imports with no mapping, matches the account by its last digits, and skips repeats', async () => { + const checking = (await admin.post('/api/v1/accounts', { name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' })).json.id; + await admin.post('/api/v1/accounts', { name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }); + + const preview = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); + expect(preview.json).toMatchObject({ layout: { key: 'ofx' }, account: { id: checking, confidence: 'certain' }, counts: { new: 4 } }); + const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: checking }); + expect(done.status).toBe(200); + + const page = (await admin.get(`/api/v1/transactions?accountId=${checking}`)).json; + expect(page.items.map((x: { merchant: string }) => x.merchant)).toContain('Kroger'); + const account = ((await admin.get('/api/v1/accounts')).json as { id: number; balanceCents: number; balanceConfirmedOn: string }[]).find((a) => a.id === checking)!; + expect(account).toMatchObject({ balanceCents: 463330, balanceConfirmedOn: '2026-09-20' }); + + const again = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); + expect(again.json.counts).toMatchObject({ new: 0, duplicates: 4 }); + + const card = await admin.upload('/api/v1/imports/files', 'Statement.qfx', XML); + expect(card.json.account.confidence).toBe('certain'); + expect(card.json.accountHint).toMatchObject({ kind: 'credit_card' }); + }); +}); diff --git a/server/test/restore.test.ts b/server/test/restore.test.ts new file mode 100644 index 0000000..6f29a8c --- /dev/null +++ b/server/test/restore.test.ts @@ -0,0 +1,128 @@ +import Database from 'better-sqlite3'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { listBackups } from '../src/backups/backup'; +import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; +import { discoverCsv } from './synthetic'; + +// The milestone 3 drill, automated: back up, change things, restore, and get +// everything back exactly. All data here is made up. + +let t: TestApp; +let admin: Client; + +const snapshot = async () => { + const page = (await admin.get('/api/v1/transactions?limit=500')).json; + return { + total: page.total, + sums: page.sums, + accounts: ((await admin.get('/api/v1/accounts')).json as { name: string; balanceCents: number }[]).map((a) => [a.name, a.balanceCents]), + budgets: (await admin.get('/api/v1/budgets?year=2026')).json, + subscriptions: ((await admin.get('/api/v1/subscriptions')).json as { name: string; status: string }[]).map((s) => [s.name, s.status]), + }; +}; + +beforeEach(async () => { + t = await makeApp(); + admin = await setUpAdmin(t); + const card = (await admin.post('/api/v1/accounts', { name: 'Discover', kind: 'credit_card', institution: 'Discover' })).json.id; + const preview = await admin.upload('/api/v1/imports/files', 'Discover-2026.csv', discoverCsv()); + await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: card }); + await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' }); +}); +afterEach(() => t.cleanup()); + +describe('restoring a backup', () => { + it('brings everything back, after saving a copy of how things were', async () => { + const before = await snapshot(); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + + // Things go wrong: the import is undone and a budget is wiped. + const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; + await admin.del(`/api/v1/imports/${imports[0]!.id}`); + await admin.put('/api/v1/budgets', { categoryId: before.budgets.items[0].categoryId, year: 2026, month: 1, amountCents: 0, scope: 'year' }); + expect((await snapshot()).total).toBe(0); + + const check = await admin.get(`/api/v1/backups/${backup}/check`); + expect(check.json.counts).toMatchObject({ transactions: before.total, accounts: 1, people: 1 }); + + const restored = await admin.post(`/api/v1/backups/${backup}/restore`); + expect(restored.status).toBe(200); + expect(restored.json.safetyCopy).toMatch(/^spendlight-pre-restore-/); + expect(await snapshot()).toEqual(before); + + // The app keeps working on the restored data, and the restore can be undone. + expect((await admin.post('/api/v1/subscriptions/scan')).status).toBe(200); + const undo = await admin.post(`/api/v1/backups/${restored.json.safetyCopy}/restore`); + expect(undo.status).toBe(200); + expect((await snapshot()).total).toBe(0); + expect(listBackups(t.ctx.config.backupDir).filter((b) => b.kind === 'pre-restore')).toHaveLength(2); + }); + + it('keeps whoever restored signed in, and leaves only whole files in the backup folder', async () => { + const backup = (await admin.post('/api/v1/backups')).json.name as string; + // A sign-in newer than the backup. + const { Client } = await import('./helpers'); + const fresh = new Client(t); + await fresh.post('/api/v1/auth/login', { username: 'Chris', password: 'correct-horse' }); + await fresh.get(`/api/v1/backups/${backup}/check`); + expect((await fresh.post(`/api/v1/backups/${backup}/restore`)).status).toBe(200); + expect((await fresh.get('/api/v1/me')).status).toBe(200); + const names = (await import('node:fs')).readdirSync(t.ctx.config.backupDir); + expect(names.every((n) => /^spendlight-[a-z-]+-\d{8}-\d{6}\.db$/.test(n))).toBe(true); + }); + + it('accepts an uploaded backup, such as one from another machine', async () => { + const before = await snapshot(); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; + await admin.del(`/api/v1/imports/${imports[0]!.id}`); + + const form = new FormData(); + const bytes = (await import('node:fs')).readFileSync(join(t.ctx.config.backupDir, backup)); + form.append('file', new File([bytes], backup)); + const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + expect(res.status).toBe(200); + expect(await snapshot()).toEqual(before); + }); + + it('refuses files that aren’t a Spendlight backup', async () => { + const dir = t.ctx.config.backupDir; + const upload = async (name: string, data: Buffer | string) => { + const form = new FormData(); + form.append('file', new File([data], name)); + const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); + return { status: res.status, json: (await res.json()) as { error: { code: string } } }; + }; + expect((await upload('notes.db', 'just some text')).json.error.code).toBe('not_sqlite'); + + // A SQLite file from some other app. + const other = join(dir, 'other.db'); + const db = new Database(other); + db.exec('CREATE TABLE things (id INTEGER)'); + db.close(); + expect((await upload('other.db', (await import('node:fs')).readFileSync(other))).json.error.code).toBe('not_spendlight'); + + // A Spendlight backup from a newer version, with a database upgrade this one doesn't know. + const backup = (await admin.post('/api/v1/backups')).json.name as string; + const newer = new Database(join(dir, backup)); + newer.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future', Date.now()); + newer.close(); + const tooNew = await admin.post(`/api/v1/backups/${backup}/restore`); + expect(tooNew.json.error.code).toBe('too_new'); + + // Nothing changed, and no safety copies were needed. + expect(listBackups(dir).filter((b) => b.kind === 'pre-restore')).toHaveLength(0); + expect((await admin.get('/api/v1/transactions?limit=1')).json.total).toBeGreaterThan(0); + }); + + it('is for admins only, and only for names in the backup folder', async () => { + await admin.post('/api/v1/people', { username: 'helper', displayName: 'Helper', access: 'full', isAdmin: false, password: 'helper-password-1' }); + const { Client } = await import('./helpers'); + const helper = new Client(t); + await helper.post('/api/v1/auth/login', { username: 'helper', password: 'helper-password-1' }); + const backup = (await admin.post('/api/v1/backups')).json.name as string; + expect((await helper.post(`/api/v1/backups/${backup}/restore`)).status).toBe(403); + expect((await admin.post('/api/v1/backups/..%2Fdata%2Fspendlight.db/restore')).status).toBe(404); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index 0d4668f..304a004 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -158,6 +158,7 @@ export interface Split { categoryId: number; amountCents: number; note: string | null; + tags: string[]; } export interface Transaction { diff --git a/web/package.json b/web/package.json index ae029b5..26699ef 100644 --- a/web/package.json +++ b/web/package.json @@ -4,12 +4,11 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.json && vite build", + "build": "tsc -p tsconfig.json && vite build && node scripts/compress.mjs", "typecheck": "tsc -p tsconfig.json", "icons": "node scripts/make-icons.mjs" }, "dependencies": { - "@fontsource-variable/inter": "^5.3.0", "@simplewebauthn/browser": "^14.0.0", "@spendlight/shared": "*", "@tanstack/react-query": "^5.104.1", @@ -22,7 +21,6 @@ "react-is": "^19.3.0", "react-router": "^8.4.0", "recharts": "^3.10.1", - "sonner": "^2.0.8", "tailwind-merge": "^3.7.0" }, "devDependencies": { diff --git a/web/scripts/compress.mjs b/web/scripts/compress.mjs new file mode 100644 index 0000000..fd5ffcc --- /dev/null +++ b/web/scripts/compress.mjs @@ -0,0 +1,25 @@ +// After the build: write brotli and gzip copies of every text file, so the +// server can send them compressed without compressing on each request. +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; + +const root = new URL('../dist/', import.meta.url).pathname; +const TEXT = new Set(['.js', '.mjs', '.css', '.html', '.svg', '.json', '.webmanifest', '.txt']); +let count = 0; + +function walk(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (TEXT.has(extname(name)) && statSync(path).size > 1024) { + const data = readFileSync(path); + writeFileSync(`${path}.br`, brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } })); + writeFileSync(`${path}.gz`, gzipSync(data, { level: 9 })); + count++; + } + } +} + +walk(root); +console.log(`Compressed ${count} files`); diff --git a/web/src/assets/fonts/LICENSE-Inter.txt b/web/src/assets/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/web/src/assets/fonts/LICENSE-Inter.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/inter-latin-wght.woff2 b/web/src/assets/fonts/inter-latin-wght.woff2 new file mode 100644 index 0000000..7cd6c64 Binary files /dev/null and b/web/src/assets/fonts/inter-latin-wght.woff2 differ diff --git a/web/src/components/AccountButton.tsx b/web/src/components/AccountButton.tsx new file mode 100644 index 0000000..607e342 --- /dev/null +++ b/web/src/components/AccountButton.tsx @@ -0,0 +1,29 @@ +import { lazy, Suspense, useState } from 'react'; +import { useCurrentUser } from '@/lib/session'; +import { Avatar } from './Avatar'; + +const UserMenu = lazy(() => import('./UserMenu').then((m) => ({ default: m.UserMenu }))); + +/** The avatar at the top right. Its menu loads on first use, keeping the first visit light. */ +export function AccountButton() { + const me = useCurrentUser(); + const [used, setUsed] = useState(false); + const button = ( + + ); + if (!used) return button; + return ( + + + + ); +} diff --git a/web/src/components/AlertCard.tsx b/web/src/components/AlertCard.tsx index 9b9e5d7..fb0951e 100644 --- a/web/src/components/AlertCard.tsx +++ b/web/src/components/AlertCard.tsx @@ -6,9 +6,9 @@ import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; const LOOK = { - bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad' }, - warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn' }, - good: { icon: PartyPopper, tone: 'bg-good/12 text-good' }, + bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad-fg' }, + warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn-fg' }, + good: { icon: PartyPopper, tone: 'bg-good/12 text-good-fg' }, info: { icon: Lightbulb, tone: 'bg-accent text-accent-foreground' }, } as const; diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 7af51a2..23c00f3 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,24 +1,25 @@ import { Ellipsis, Upload } from 'lucide-react'; import { Suspense, useEffect, useState } from 'react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { useAlertCounts, useReviewCount } from '@/lib/data'; import { queueFiles } from '@/lib/pendingFiles'; import { useConfig, useCurrentUser } from '@/lib/session'; import { cn } from '@/lib/utils'; import { APP_VERSION } from '@/lib/version'; import { LogoMark, VersionTag } from './brand'; -import { IdeaButton } from './IdeaDialog'; -import { NAV, SETTINGS_NAV, type NavItem } from './nav'; +import { IdeaButton } from './IdeaButton'; +import { HELP_NAV, NAV, SETTINGS_NAV, type NavItem } from './nav'; import { Button } from './ui/button'; -import { Skeleton, Tip } from './ui/misc'; -import { UserMenu } from './UserMenu'; +import { Skeleton } from './ui/misc'; +import { AccountButton } from './AccountButton'; function pageTitle(pathname: string): string { if (pathname === '/') return 'Home'; if (pathname.startsWith('/settings')) return 'Settings'; if (pathname.startsWith('/more')) return 'More'; if (pathname.startsWith('/import')) return 'Import'; + if (pathname.startsWith('/help')) return 'Help'; return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight'; } @@ -75,6 +76,7 @@ function Sidebar() { ))}
+
@@ -87,7 +89,7 @@ function Sidebar() { function PhoneTabBar() { const { pathname } = useLocation(); const badges = useBadges(); - const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to)); + const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || pathname.startsWith('/help') || NAV.some((n) => !n.phone && pathname.startsWith(n.to)); const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors'; return (
diff --git a/web/src/components/AuthLayout.tsx b/web/src/components/AuthLayout.tsx index b74587c..649d7ad 100644 --- a/web/src/components/AuthLayout.tsx +++ b/web/src/components/AuthLayout.tsx @@ -26,7 +26,7 @@ export function AuthLayout({ title, subtitle, children }: { title: string; subti export function FormError({ message }: { message: string | null }) { if (!message) return null; return ( -

+

{message}

); diff --git a/web/src/components/Avatar.tsx b/web/src/components/Avatar.tsx new file mode 100644 index 0000000..8306b6a --- /dev/null +++ b/web/src/components/Avatar.tsx @@ -0,0 +1,14 @@ +import { initials } from '@/lib/utils'; + +export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) { + return ( + + + {initials(name)} + + + ); +} diff --git a/web/src/components/BudgetEditor.tsx b/web/src/components/BudgetEditor.tsx index 8124282..04413ed 100644 --- a/web/src/components/BudgetEditor.tsx +++ b/web/src/components/BudgetEditor.tsx @@ -1,7 +1,7 @@ import { monthName, type BudgetScope } from '@spendlight/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { centsToInput, parseDollars } from '@/lib/money'; import { CategoryIcon } from './money'; diff --git a/web/src/components/IdeaButton.tsx b/web/src/components/IdeaButton.tsx new file mode 100644 index 0000000..e92e9b8 --- /dev/null +++ b/web/src/components/IdeaButton.tsx @@ -0,0 +1,34 @@ +import { Lightbulb } from 'lucide-react'; +import { lazy, Suspense, useState } from 'react'; +import { Button } from './ui/button'; + +const IdeaDialog = lazy(() => import('./IdeaDialog')); + +/** The lightbulb at the top right. Its pop-up loads on first use, keeping the first visit light. */ +export function IdeaButton() { + const [open, setOpen] = useState(false); + const [used, setUsed] = useState(false); + return ( + <> + + {used ? ( + + + + ) : null} + + ); +} diff --git a/web/src/components/IdeaDialog.tsx b/web/src/components/IdeaDialog.tsx index 571dc95..06e3875 100644 --- a/web/src/components/IdeaDialog.tsx +++ b/web/src/components/IdeaDialog.tsx @@ -1,18 +1,15 @@ import type { Idea } from '@spendlight/shared'; import { useMutation } from '@tanstack/react-query'; -import { Lightbulb } from 'lucide-react'; import { useState, type FormEvent } from 'react'; import { useLocation } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { Button } from './ui/button'; -import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'; +import { Dialog, DialogContent } from './ui/dialog'; import { Field, Input, Textarea } from './ui/form'; -import { Tip } from './ui/misc'; -/** The lightbulb at the top right: share an idea in a few seconds. */ -export function IdeaButton() { - const [open, setOpen] = useState(false); +/** The idea pop-up, loaded the first time someone taps the lightbulb. */ +export default function IdeaDialog({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const location = useLocation(); @@ -36,13 +33,6 @@ export function IdeaButton() { return ( - - - - -
- +

Something went wrong

diff --git a/web/src/components/TransactionSheet.tsx b/web/src/components/TransactionSheet.tsx index 8904a55..d9187ba 100644 --- a/web/src/components/TransactionSheet.tsx +++ b/web/src/components/TransactionSheet.tsx @@ -1,9 +1,9 @@ import { formatCents, type Transaction } from '@spendlight/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowLeftRight, ArrowRight, Plus, Repeat, Split as SplitIcon, Trash2, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from '@/lib/api'; import { MONEY_KEYS, useAccounts, useCategories, useSubscriptions, useTags } from '@/lib/data'; import { useCurrentUser } from '@/lib/session'; @@ -26,9 +26,10 @@ export function useRefreshMoney() { return () => MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] })); } -function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: string[]) => void }) { +function TagEditor({ value, onChange, placeholder = 'Add a tag, like Vacation 2026', label = 'Add a tag' }: { value: string[]; onChange: (tags: string[]) => void; placeholder?: string; label?: string }) { const tags = useTags(); const [draft, setDraft] = useState(''); + const listId = useId(); const add = (name: string) => { const clean = name.trim(); if (clean && !value.some((t) => t.toLowerCase() === clean.toLowerCase())) onChange([...value, clean]); @@ -51,7 +52,7 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri ) : null} setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { @@ -60,10 +61,10 @@ function TagEditor({ value, onChange }: { value: string[]; onChange: (tags: stri } }} onBlur={() => draft && add(draft)} - placeholder="Add a tag, like Vacation 2026" - aria-label="Add a tag" + placeholder={placeholder} + aria-label={label} /> - + {suggestions.map((s) => (
update(p.key, { note: e.target.value })} placeholder="Note (optional), like Paper towels" aria-label={`Note for part ${i + 1}`} /> + update(p.key, { tags })} placeholder="Tags for this part (optional)" label={`Tags for part ${i + 1}`} />
))}
- -

+

{left === 0 ? 'All assigned' : left > 0 ? `${formatCents(left)} left to assign` : `${formatCents(-left)} too much`}

@@ -276,6 +279,7 @@ export function TransactionSheet({ {byId.get(s.categoryId)?.name} {s.note ? · {s.note} : null} + {s.tags.length ? · {s.tags.join(', ')} : null} {formatCents(s.amountCents)} @@ -347,7 +351,7 @@ export function TransactionSheet({ )} {t.source === 'manual' ? ( - ) : null} diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 707d93a..56e3351 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -1,9 +1,9 @@ import type { Theme } from '@spendlight/shared'; -import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react'; +import { CircleHelp, LogOut, Monitor, Moon, Settings, Sun } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useSetTheme } from '@/lib/useSetTheme'; import { useCurrentUser, useSignOut } from '@/lib/session'; -import { initials } from '@/lib/utils'; +import { Avatar } from './Avatar'; import { DropdownMenu, DropdownMenuContent, @@ -13,33 +13,20 @@ import { DropdownMenuTrigger, } from './ui/menu'; -export function Avatar({ name, className = 'size-9 text-[13px]' }: { name: string; className?: string }) { - return ( - - - {initials(name)} - - - ); -} - const THEMES: { value: Theme; label: string; icon: typeof Sun }[] = [ { value: 'system', label: 'Match my device', icon: Monitor }, { value: 'light', label: 'Light', icon: Sun }, { value: 'dark', label: 'Dark', icon: Moon }, ]; -export function UserMenu() { +export function UserMenu({ defaultOpen }: { defaultOpen?: boolean }) { const me = useCurrentUser(); const navigate = useNavigate(); const signOut = useSignOut(); const setTheme = useSetTheme(); return ( - + @@ -61,6 +48,10 @@ export function UserMenu() { Settings + navigate('/help')}> + + How Spendlight works + signOut.mutate()}> Sign out diff --git a/web/src/components/brand.tsx b/web/src/components/brand.tsx index 0b3bb2d..18abc63 100644 --- a/web/src/components/brand.tsx +++ b/web/src/components/brand.tsx @@ -40,7 +40,7 @@ export function Wordmark({ className }: { className?: string }) { export function VersionTag({ className, version = APP_VERSION }: { className?: string; version?: string }) { return ( v{version} diff --git a/web/src/components/meter.tsx b/web/src/components/meter.tsx index 1bfdabd..dfd7b94 100644 --- a/web/src/components/meter.tsx +++ b/web/src/components/meter.tsx @@ -8,9 +8,9 @@ import { cn } from '@/lib/utils'; export const STATUS_TONE: Record = { none: { bar: 'bg-primary/70', text: 'text-muted-foreground', soft: 'bg-muted' }, - under: { bar: 'bg-good', text: 'text-good', soft: 'bg-good/12' }, - close: { bar: 'bg-warn', text: 'text-warn', soft: 'bg-warn/14' }, - over: { bar: 'bg-bad', text: 'text-bad', soft: 'bg-bad/12' }, + under: { bar: 'bg-good', text: 'text-good-fg', soft: 'bg-good/12' }, + close: { bar: 'bg-warn', text: 'text-warn-fg', soft: 'bg-warn/14' }, + over: { bar: 'bg-bad', text: 'text-bad-fg', soft: 'bg-bad/12' }, }; export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) { @@ -34,7 +34,7 @@ export function BudgetNote({ spent, budget, className, income }: { spent: number if (income) { const short = budget - spent; return ( - + {short <= 0 ? : null} {short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`} diff --git a/web/src/components/money.tsx b/web/src/components/money.tsx index 20190d4..5a6187c 100644 --- a/web/src/components/money.tsx +++ b/web/src/components/money.tsx @@ -199,7 +199,7 @@ export function CategoryIcon({ /** An amount with money in shown in green and a plus sign; money out plain. */ export function Amount({ cents, className, transfer }: { cents: number; className?: string; transfer?: boolean }) { return ( - 0 && !transfer && 'text-good', transfer && 'text-muted-foreground', className)}> + 0 && !transfer && 'text-good-fg', transfer && 'text-muted-foreground', className)}> {formatCents(cents, { sign: cents > 0 })} ); diff --git a/web/src/components/nav.ts b/web/src/components/nav.ts index e2c61a8..4819b1e 100644 --- a/web/src/components/nav.ts +++ b/web/src/components/nav.ts @@ -1,4 +1,4 @@ -import { ChartColumnBig, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react'; +import { ChartColumnBig, CircleHelp, House, Inbox, Landmark, ListOrdered, Repeat, Settings, Target, type LucideIcon } from 'lucide-react'; export interface NavItem { to: string; @@ -19,3 +19,4 @@ export const NAV: NavItem[] = [ ]; export const SETTINGS_NAV: NavItem = { to: '/settings', label: 'Settings', icon: Settings }; +export const HELP_NAV: NavItem = { to: '/help', label: 'How Spendlight works', icon: CircleHelp }; diff --git a/web/src/components/toast.tsx b/web/src/components/toast.tsx new file mode 100644 index 0000000..757267b --- /dev/null +++ b/web/src/components/toast.tsx @@ -0,0 +1,115 @@ +import { CircleAlert, CircleCheck, Info, X } from 'lucide-react'; +import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +// Small notices at the top of the screen: "Saved", "Filed 6 more", "Undo". +// A few lines of our own instead of a library, to keep a phone's first load light. + +type Kind = 'default' | 'success' | 'error' | 'info'; + +interface Options { + id?: string | number; + description?: ReactNode; + /** Milliseconds on screen; Infinity keeps it until dismissed. */ + duration?: number; + action?: { label: string; onClick: () => void }; +} + +interface Item extends Options { + id: string | number; + kind: Kind; + message: ReactNode; + duration: number; +} + +let items: Item[] = []; +let seq = 0; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((l) => l()); + +function show(kind: Kind, message: ReactNode, options: Options = {}) { + const id = options.id ?? ++seq; + const duration = options.duration ?? (kind === 'error' ? 7000 : options.action ? 8000 : 4000); + items = [{ ...options, id, kind, message, duration }, ...items.filter((t) => t.id !== id)].slice(0, 4); + emit(); + return id; +} + +function dismiss(id: string | number) { + items = items.filter((t) => t.id !== id); + emit(); +} + +export const toast = Object.assign((message: ReactNode, options?: Options) => show('default', message, options), { + success: (message: ReactNode, options?: Options) => show('success', message, options), + error: (message: ReactNode, options?: Options) => show('error', message, options), + info: (message: ReactNode, options?: Options) => show('info', message, options), + dismiss, +}); + +const ICON = { + success: , + error: , + info: , + default: null, +}; + +function Toast({ t }: { t: Item }) { + const [paused, setPaused] = useState(false); + const left = useRef(t.duration); + useEffect(() => { + if (paused || !Number.isFinite(left.current)) return; + const started = Date.now(); + const timer = setTimeout(() => dismiss(t.id), left.current); + return () => { + clearTimeout(timer); + left.current -= Date.now() - started; + }; + }, [paused, t.id, t]); + + return ( +
setPaused(true)} + onPointerLeave={() => setPaused(false)} + className="pointer-events-auto flex w-full max-w-md animate-fade-up items-start gap-3 rounded-2xl border bg-popover px-4 py-3 text-popover-foreground shadow-lift" + > + {ICON[t.kind] ? {ICON[t.kind]} : null} +
+

{t.message}

+ {t.description ?

{t.description}

: null} +
+ {t.action ? ( + + ) : null} + +
+ ); +} + +export function Toaster() { + const list = useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => items, + ); + return ( +
+ {list.map((t) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index c389ceb..bf51147 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -18,9 +18,10 @@ export const buttonVariants = cva( }, size: { default: 'h-11 px-4 sm:h-10', - sm: 'h-9 px-3 text-[13px]', + // 44 px on touch screens (sm:), a little tighter where a mouse does the pointing. + sm: 'h-11 px-3.5 text-[13px] sm:h-9 sm:px-3', lg: 'h-12 px-6 text-base', - icon: 'size-10', + icon: 'size-11 sm:size-10', }, }, defaultVariants: { variant: 'default', size: 'default' }, diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 0fddd15..68a6926 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -1,6 +1,6 @@ import { X } from 'lucide-react'; import { AlertDialog as AlertPrimitive, Dialog as DialogPrimitive } from 'radix-ui'; -import type { ComponentProps, ReactNode } from 'react'; +import { useState, type ComponentProps, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Button } from './button'; @@ -22,12 +22,26 @@ export function DialogContent({ children, title, description, + onCloseAutoFocus, ...props }: ComponentProps & { title: string; description?: ReactNode }) { + // Dialogs opened from a plain button (not a Dialog trigger) hand focus back + // to that button when they close, so keyboard users keep their place. + const [returnTo] = useState(() => (typeof document === 'undefined' ? null : document.activeElement)); return ( - + { + onCloseAutoFocus?.(e); + if (!e.defaultPrevented && returnTo instanceof HTMLElement && returnTo.isConnected && returnTo !== document.body) { + e.preventDefault(); + returnTo.focus(); + } + }} + {...props} + >
diff --git a/web/src/components/ui/menu.tsx b/web/src/components/ui/menu.tsx index f979110..6032c26 100644 --- a/web/src/components/ui/menu.tsx +++ b/web/src/components/ui/menu.tsx @@ -25,7 +25,7 @@ export function DropdownMenuItem({ className, destructive, ...props }: Component - {children} - - - {label} - - - - ); -} - export function Skeleton({ className, ...props }: ComponentProps<'div'>) { return
; } diff --git a/web/src/lib/useSetTheme.ts b/web/src/lib/useSetTheme.ts index 00725a2..3b73144 100644 --- a/web/src/lib/useSetTheme.ts +++ b/web/src/lib/useSetTheme.ts @@ -1,6 +1,6 @@ import type { Me, Theme } from '@spendlight/shared'; import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { api, errorMessage } from './api'; import { applyTheme } from './theme'; diff --git a/web/src/main.tsx b/web/src/main.tsx index c7e645a..b22ad7e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,12 +1,10 @@ -import '@fontsource-variable/inter'; import './styles.css'; import { QueryClientProvider } from '@tanstack/react-query'; -import { Tooltip } from 'radix-ui'; import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router/dom'; -import { Toaster } from 'sonner'; import { Splash } from './components/RequireAuth'; +import { Toaster } from './components/toast'; import { queryClient } from './lib/session'; import { watchSystemTheme } from './lib/theme'; import { router } from './router'; @@ -16,12 +14,10 @@ watchSystemTheme(); createRoot(document.getElementById('root')!).render( - - }> - - - - + }> + + + , ); diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx index 68adfdf..9dd264b 100644 --- a/web/src/pages/AccountsPage.tsx +++ b/web/src/pages/AccountsPage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Archive, ArchiveRestore, ChevronRight, CreditCard, Landmark, ListOrdered, Pencil, Plus, Scale, Trash2, Upload, Wallet } from 'lucide-react'; import { useState, type FormEvent } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AccountDot, PALETTE_CLASS } from '@/components/money'; import { EmptyState, PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; @@ -427,7 +427,7 @@ export default function AccountsPage() { )} {selected.transactionCount === 0 ? ( - ) : null} diff --git a/web/src/pages/ActivityPage.tsx b/web/src/pages/ActivityPage.tsx index 8d50816..621e0d1 100644 --- a/web/src/pages/ActivityPage.tsx +++ b/web/src/pages/ActivityPage.tsx @@ -3,7 +3,7 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'; import { ArrowLeftRight, Download, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react'; import { useMemo, useState, type FormEvent } from 'react'; import { useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryButton, CategoryPicker } from '@/components/CategoryPicker'; import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money'; import { EmptyState, PageIntro } from '@/components/page'; @@ -234,7 +234,7 @@ export default function ActivityPage() { <> - @@ -281,7 +281,7 @@ export default function ActivityPage() { type="search" /> - @@ -359,7 +359,7 @@ export default function ActivityPage() { {list.data.total} transaction{list.data.total === 1 ? '' : 's'} - In {formatCents(list.data.sums.inCents)} · Out{' '} + In {formatCents(list.data.sums.inCents)} · Out{' '} {formatCents(-list.data.sums.outCents)} · transfers not counted diff --git a/web/src/pages/BudgetPage.tsx b/web/src/pages/BudgetPage.tsx index 2a934a7..f3a2134 100644 --- a/web/src/pages/BudgetPage.tsx +++ b/web/src/pages/BudgetPage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { CalendarRange, ChevronLeft, ChevronRight, Copy, Inbox, LayoutList, Sparkles, Table2, Wand2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { BudgetEditor, type EditTarget } from '@/components/BudgetEditor'; import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter'; import { CategoryIcon } from '@/components/money'; @@ -227,14 +227,14 @@ function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: { {r.budget[i] ? whole(r.budget[i]!) : '–'} {own ? ● : null} - {past && r.actual[i] ? {whole(r.actual[i]!)} : null} + {past && r.actual[i] ? {whole(r.actual[i]!)} : null} ); })}
{yearBudget ? whole(yearBudget) : '–'}
- {yearActual ?
yearBudget ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(yearActual)}
: null} + {yearActual ?
yearBudget ? 'font-medium text-bad-fg' : 'text-muted-foreground')}>{whole(yearActual)}
: null} ); diff --git a/web/src/pages/HelpPage.tsx b/web/src/pages/HelpPage.tsx new file mode 100644 index 0000000..cc5a868 --- /dev/null +++ b/web/src/pages/HelpPage.tsx @@ -0,0 +1,174 @@ +import { ArrowRight, Download, Inbox, Upload } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; +import { PageIntro } from '@/components/page'; +import { Card } from '@/components/ui/card'; + +// A short guide inside the app: the monthly routine first, then a few +// sections to open when they're needed. Plain words, no accounting terms. + +function Step({ n, icon, title, children }: { n: number; icon: ReactNode; title: string; children: ReactNode }) { + return ( +
  • + {icon} +
    +

    + {n}. + {title} +

    +
    {children}
    +
    +
  • + ); +} + +function Section({ title, children, open }: { title: string; children: ReactNode; open?: boolean }) { + return ( +
    + + {title} + + +
    {children}
    +
    + ); +} + +const Key = ({ children }: { children: ReactNode }) => {children}; + +export default function HelpPage() { + return ( +
    + + + +

    The monthly routine

    +
      + } title="Download your statements"> + On each bank’s website, open the account’s activity and download it as a CSV file (sometimes + called “spreadsheet” or “Excel”). Include everything since your last import; overlapping dates are fine. + + } title="Drop them into Spendlight"> + Drag all the files onto any screen, or tap{' '} + + Import + {' '} + on a phone. Spendlight works out which account each file is for, skips anything already there, and matches card payments with your checking account. + + } title="Confirm what came in"> + In{' '} + + Review + + , most transactions already have a category. One tap confirms it; “Accept all” clears the lot. Then glance at Home to see where the month stands. + +
    +
    + +
    +
    +

    Banks move their buttons around from time to time, but the idea is always the same: find the account’s activity, then Download or Export, then CSV.

    +
      +
    • + Bank of America: open the checking or savings account, choose Download above the list of transactions, and pick the Microsoft + Excel format. The file is a CSV. +
    • +
    • + Chase, including the Amazon Prime Visa: open the card’s activity, choose the download icon, then CSV and the dates you want. +
    • +
    • + Discover: open Activity, choose Download, and pick the CSV or spreadsheet option. +
    • +
    • + Apple Card: in the Wallet app, open the card’s statements, choose a month, then Export Transactions as CSV. +
    • +
    • + Any other bank: Spendlight reads most CSV files. The first time it sees a new layout, it shows a few rows and asks which column is + which, then remembers. Quicken and Money files (OFX or QFX) work too. +
    • +
    +
    + +
    +
      +
    • The big button is Spendlight’s best guess. The smaller ones are the next most likely; “Something else” shows every category.
    • +
    • + After you file a purchase, Spendlight may offer to file the others from that shop the same way. Say yes and it remembers for future + imports too. Learned rules are listed in Settings → Rules. +
    • +
    • + A split divides one purchase between categories, such as groceries and household supplies from the same store. Tap a transaction in + Activity, then Split. +
    • +
    • + Tags (“Vacation 2026”, “Reimbursable”) and notes go on any transaction. Insights totals each tag. +
    • +
    • + Cash spending: in Activity, tap Cash entry. +
    • +
    • + On a computer, Review works from the keyboard: ↑ ↓ to move, Enter to accept the guess, 1–3 for a + suggestion, / for every category and S to split. +
    • +
    +
    + +
    +

    + Paying a card from checking, or moving money to savings, isn’t spending or income: the money just moves between your own accounts. Spendlight pairs the two + halves and shows them in italics, naming the other account, and leaves them out of every total. +

    +

    When only one half is in Spendlight, such as a payment to a card you don’t import, Review asks once whether it’s a transfer or spending.

    +
    + +
    +
      +
    • Each category gets one amount for every month. Any single month can be different, such as Gifts in December.
    • +
    • + Fill in on the Budget screen starts every category from your recent spending, rounded to friendly amounts. Adjust from there. +
    • +
    • + There’s no rollover: each month starts fresh. The year view keeps the running score, so a quiet month makes up for a busy one. +
    • +
    • + Green means on track, amber means close, and red means over. Each always comes with a word or an icon too. +
    • +
    • In December, the Budget screen offers to carry this year’s budgets into the next.
    • +
    +
    + +
    +
      +
    • + Spendlight spots charges that repeat on a steady rhythm, on any card or account, and asks once: “Is this a subscription?” Your answer is remembered. +
    • +
    • Bills that vary, like electric or insurance, have their own tab.
    • +
    • + It speaks up when a price changes, a charge doesn’t arrive, something is billed on two cards, a free trial is about to end, or a service you marked + cancelled charges again. +
    • +
    • Spendlight never cancels anything itself. Each subscription has a link to the place where you can manage or cancel it.
    • +
    +
    + +
    +
      +
    • + Add Spendlight to your home screen and it opens like an app. On iPhone: in Safari, tap Share, then “Add to Home Screen”. Sign in first, then add it. +
    • +
    • Turn on Face ID or fingerprint sign-in in Settings → Sign-in & devices, so a lapsed sign-in costs one glance, not a password.
    • +
    • Each device stays signed in for about a year, renewed every time you open the app.
    • +
    +
    + +
    +
      +
    • Everything stays on your own server. Spendlight makes no outside calls and never stores bank sign-ins.
    • +
    • A backup is made every night, and before every upgrade. An admin can restore any of them from Settings → Backups.
    • +
    • Export everything to Excel or CSV any time from Settings → Export.
    • +
    +
    +
    +
    + ); +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index a5bf87b..28c8a37 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -24,7 +24,7 @@ function Step({ done, icon, title, detail, action }: { done?: boolean; icon: Rea {done ? : icon} @@ -56,7 +56,12 @@ function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }:
    Getting started - A few steps now, and Spendlight does the bookkeeping from then on. + + A few steps now, and Spendlight does the bookkeeping from then on.{' '} + + How it works + +
    {onHide ? ( @@ -333,7 +333,7 @@ export default function ImportPage() { { diff --git a/web/src/pages/InsightsPage.tsx b/web/src/pages/InsightsPage.tsx index 3dd5350..9bde0bf 100644 --- a/web/src/pages/InsightsPage.tsx +++ b/web/src/pages/InsightsPage.tsx @@ -52,7 +52,7 @@ function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'war const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles; return (
    - +

    {children}

    @@ -174,7 +174,7 @@ function Overview({ report, year }: { report: MonthsReport; year: number }) {

    - What stands out + What stands out

    {alerts.data?.length ? (
    @@ -260,7 +260,7 @@ function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }
    - {pct !== null && budgetStatus(spent, budget) === 'over' ? +{pct}% : null} + {pct !== null && budgetStatus(spent, budget) === 'over' ? +{pct}% : null}
    @@ -370,7 +370,7 @@ function Categories({ report, year }: { report: MonthsReport; year: number }) { {r.actual.map((v, i) => { const over = r.budget[i]! > 0 && v > r.budget[i]!; return ( - + {v ? whole(v) : '–'} ); diff --git a/web/src/pages/MorePage.tsx b/web/src/pages/MorePage.tsx index 5b10799..a7e12a8 100644 --- a/web/src/pages/MorePage.tsx +++ b/web/src/pages/MorePage.tsx @@ -1,7 +1,7 @@ import { ChevronRight, LogOut } from 'lucide-react'; import { Link } from 'react-router'; import { Wordmark, VersionTag } from '@/components/brand'; -import { NAV, SETTINGS_NAV } from '@/components/nav'; +import { HELP_NAV, NAV, SETTINGS_NAV } from '@/components/nav'; import { useAlertCounts } from '@/lib/data'; import { useConfig, useSignOut } from '@/lib/session'; @@ -10,7 +10,7 @@ export default function MorePage() { const signOut = useSignOut(); const { data: config } = useConfig(); const { data: alerts } = useAlertCounts(); - const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV]; + const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV, HELP_NAV]; return (
    @@ -30,7 +30,7 @@ export default function MorePage() { diff --git a/web/src/pages/ReviewPage.tsx b/web/src/pages/ReviewPage.tsx index 1c7dbbf..6d3b63c 100644 --- a/web/src/pages/ReviewPage.tsx +++ b/web/src/pages/ReviewPage.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { ArrowLeftRight, Check, CheckCheck, ChevronRight, Inbox, PartyPopper, Search, SkipForward, Split as SplitIcon, Upload } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryPicker } from '@/components/CategoryPicker'; import { AccountDot, Amount, CategoryIcon, formatDay } from '@/components/money'; import { EmptyState } from '@/components/page'; diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index 51a3a49..b72bc6c 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -2,7 +2,7 @@ import type { Me } from '@spendlight/shared'; import { useQueryClient } from '@tanstack/react-query'; import { useState, type FormEvent } from 'react'; import { Navigate, useNavigate } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AuthLayout, FormError } from '@/components/AuthLayout'; import { Splash } from '@/components/RequireAuth'; import { Button } from '@/components/ui/button'; diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 4deafdf..e3acfec 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Bell, BellOff, CalendarClock, Check, ExternalLink, Merge, Plus, Repeat, Trash2, X } from 'lucide-react'; import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { AlertCard } from '@/components/AlertCard'; import { CategoryButton } from '@/components/CategoryPicker'; import { AccountDot, CategoryIcon, formatDay } from '@/components/money'; @@ -108,12 +108,15 @@ function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) { const category = s.categoryId ? byId.get(s.categoryId) : undefined; const live = LIVE.includes(s.status); return ( - {s.source === 'manual' ? ( - ) : ( diff --git a/web/src/pages/settings/AboutPage.tsx b/web/src/pages/settings/AboutPage.tsx index de0cb31..b9b1ec3 100644 --- a/web/src/pages/settings/AboutPage.tsx +++ b/web/src/pages/settings/AboutPage.tsx @@ -29,14 +29,14 @@ export default function AboutPage() { ) : (
    Development build
    )} - {version !== APP_VERSION ?
    These screens are {APP_VERSION}; reload to update.
    : null} + {version !== APP_VERSION ?
    These screens are {APP_VERSION}; reload to update.
    : null}

    - + Your data stays on your own server. Spendlight makes no calls to outside services.

    diff --git a/web/src/pages/settings/AiPage.tsx b/web/src/pages/settings/AiPage.tsx index 821f727..8c90bbe 100644 --- a/web/src/pages/settings/AiPage.tsx +++ b/web/src/pages/settings/AiPage.tsx @@ -1,7 +1,7 @@ import type { AiStatus } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ShieldCheck, Sparkles } from 'lucide-react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Card } from '@/components/ui/card'; import { Badge, Skeleton, Switch } from '@/components/ui/misc'; @@ -24,7 +24,7 @@ export default function AiPage() { ) : ( <>

    - +

    {s.providerConfigured ? ( <> diff --git a/web/src/pages/settings/AlertsPage.tsx b/web/src/pages/settings/AlertsPage.tsx index b41a960..475cd4a 100644 --- a/web/src/pages/settings/AlertsPage.tsx +++ b/web/src/pages/settings/AlertsPage.tsx @@ -1,7 +1,7 @@ import type { AlertSettings } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardFooter } from '@/components/ui/card'; diff --git a/web/src/pages/settings/BackupsPage.tsx b/web/src/pages/settings/BackupsPage.tsx index c8585c3..0ac8e03 100644 --- a/web/src/pages/settings/BackupsPage.tsx +++ b/web/src/pages/settings/BackupsPage.tsx @@ -1,12 +1,14 @@ import type { BackupInfo, BackupKind, BackupOverview } from '@spendlight/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Archive, Download, FolderSync } from 'lucide-react'; -import { toast } from 'sonner'; +import { Archive, Download, FolderSync, History, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { toast } from '@/components/toast'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ConfirmDialog } from '@/components/ui/dialog'; import { Badge, Skeleton } from '@/components/ui/misc'; -import { api, errorMessage } from '@/lib/api'; +import { api, ApiError, errorMessage } from '@/lib/api'; import { formatBytes, formatDateTime } from '@/lib/utils'; const KIND: Record = { @@ -16,14 +18,36 @@ const KIND: Record `${n.toLocaleString()} ${word}${n === 1 ? '' : 's'}`; + +/** After a restore everything on screen is stale, and the session may belong to the old data. */ +function afterRestore(safetyCopy: string | null) { + toast.success('Restored', { description: safetyCopy ? 'How things were before is saved as a “Before restore” backup.' : undefined }); + setTimeout(() => window.location.assign('/'), 1200); +} + export default function BackupsPage() { const client = useQueryClient(); const overview = useQuery({ queryKey: ['backups'], queryFn: () => api('/backups') }); + const [restoring, setRestoring] = useState(null); + const [pendingFile, setPendingFile] = useState(null); + const fileInput = useRef(null); + const check = useQuery({ + queryKey: ['backups', 'check', restoring?.name], + queryFn: () => api(`/backups/${encodeURIComponent(restoring!.name)}/check`), + enabled: !!restoring, + retry: false, + }); const backUp = useMutation({ mutationFn: () => api('/backups', { method: 'POST' }), onSuccess: () => { @@ -32,6 +56,34 @@ export default function BackupsPage() { }, onError: (error) => toast.error(errorMessage(error)), }); + const restore = useMutation({ + mutationFn: (name: string) => api<{ safetyCopy: string | null }>(`/backups/${encodeURIComponent(name)}/restore`, { method: 'POST' }), + onSuccess: (r) => { + setRestoring(null); + afterRestore(r.safetyCopy); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + const upload = useMutation({ + mutationFn: async (file: File) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch('/api/v1/backups/restore-upload', { method: 'POST', body: form, credentials: 'same-origin' }); + const body = await res.json().catch(() => null); + if (!res.ok) throw new ApiError(res.status, body?.error?.code ?? 'error', body?.error?.message ?? 'That backup couldn’t be restored.'); + return body as { safetyCopy: string | null }; + }, + onSuccess: (r) => { + setPendingFile(null); + afterRestore(r.safetyCopy); + }, + onError: (error) => { + setPendingFile(null); + toast.error(errorMessage(error)); + }, + }); + + const counts = check.data?.counts; return (

    @@ -39,11 +91,27 @@ export default function BackupsPage() { title="Backups" description="Spendlight copies everything to the backup folder every night. Point your backup tool at that folder to keep a copy off this server." actions={ - + <> + + + } /> + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) setPendingFile(file); + }} + /> {overview.data ? ( @@ -52,8 +120,8 @@ export default function BackupsPage() { Every night at {time12(overview.data.nightlyAt)} - Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are - also taken before every upgrade. + Keeps the last {overview.data.keepDaily} nights and one copy from each of the last {overview.data.keepMonthly} months. Copies are also taken before every + upgrade and every restore, so a restore can be undone. @@ -67,12 +135,16 @@ export default function BackupsPage() { ) : null}
      {overview.data?.backups.map((b) => ( -
    • +
    • {formatDateTime(b.createdAt)}

      -

      {formatBytes(b.size)}

      +

      + {formatBytes(b.size)} {KIND[b.kind].label} +

      - {KIND[b.kind].label} +
    + + !open && setRestoring(null)} + title={`Restore the backup from ${restoring ? formatDateTime(restoring.createdAt) : ''}?`} + description={ + check.isError ? ( + errorMessage(check.error) + ) : counts ? ( + <> + Everything in Spendlight goes back to how it was then: {plural(counts.transactions, 'transaction')}, {plural(counts.accounts, 'account')},{' '} + {plural(counts.budgets, 'budget')} and {plural(counts.subscriptions, 'subscription')}. A copy of how things are right now is saved first, so you can + undo this. You stay signed in; anyone else may need to sign in again, with the password they had then. + + ) : ( + 'Checking the backup…' + ) + } + confirmLabel="Restore" + destructive + loading={restore.isPending} + onConfirm={() => restoring && counts && restore.mutate(restoring.name)} + /> + !open && !upload.isPending && setPendingFile(null)} + title="Restore from this file?" + description={`Everything in Spendlight is replaced with “${pendingFile?.name}”, after Spendlight checks it’s a valid backup. A copy of how things are right now is saved first, so you can undo this.`} + confirmLabel="Restore" + destructive + loading={upload.isPending} + onConfirm={() => pendingFile && upload.mutate(pendingFile)} + />
    ); } diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx index 0f90c50..d4b1ab8 100644 --- a/web/src/pages/settings/CategoriesPage.tsx +++ b/web/src/pages/settings/CategoriesPage.tsx @@ -2,7 +2,7 @@ import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type Pal import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Archive, ArrowDown, ArrowUp, FolderPlus, Merge, Pencil, Plus, Trash2 } from 'lucide-react'; import { useState, type FormEvent } from 'react'; -import { toast } from 'sonner'; +import { toast } from '@/components/toast'; import { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money'; import { PageIntro } from '@/components/page'; import { Button } from '@/components/ui/button'; @@ -149,7 +149,7 @@ function CategoryForm({ category, group, groups, onDone }: { category?: Category ) : null}
    {category ? ( - ) : ( @@ -251,7 +251,7 @@ export default function CategoriesPage() { {!g.categories.length ? ( - ) : null} @@ -262,9 +262,9 @@ export default function CategoriesPage() { {visible.map((c) => { const ci = g.categories.indexOf(c); return ( -
    +