From 4ecd64132b4e5d19261c9febb9fbf77037b3216f Mon Sep 17 00:00:00 2001
From: Chris Olson
Date: Thu, 8 Oct 2026 10:59:17 -0400
Subject: [PATCH] Milestone 2: the full picture (20261007.12)
Budgets: one amount per category for every month with any month set on its
own, filled from recent spending in one step and copied into the next year.
Home shows what's left to spend, year pacing, insights, balances and upcoming
charges. Insights covers year pacing, money in and out, budget against actual,
categories by month, merchants, balances over time and tags.
Subscriptions and bills are found across every account (rhythm and price
matching plus a built-in catalog), confirmed with one tap, and watched for
price changes, missed charges, charges after cancelling, double billing,
renewals and trials. Alerts are worked out again whenever the data changes.
Also: category, rule, tag and alert settings; Excel and CSV export from a
small built-in writer; the AI module designed in and switched off.
Co-Authored-By: Claude Opus 5.5
---
README.md | 48 +-
VERSION | 2 +-
package-lock.json | 390 ++++
server/drizzle/0002_full_picture.sql | 65 +
server/drizzle/meta/0002_snapshot.json | 2012 +++++++++++++++++++++
server/drizzle/meta/_journal.json | 7 +
server/src/ai/index.ts | 65 +
server/src/app.ts | 14 +
server/src/budgets/budgets.ts | 115 ++
server/src/config.ts | 3 +
server/src/context.ts | 14 +-
server/src/dates.ts | 39 +
server/src/db/schema.ts | 94 +
server/src/export/xlsx.ts | 215 +++
server/src/import/parse.ts | 2 +-
server/src/import/service.ts | 29 +-
server/src/insights/engine.ts | 427 +++++
server/src/reports/actuals.ts | 101 ++
server/src/routes/ai.ts | 55 +
server/src/routes/alerts.ts | 139 ++
server/src/routes/budgets.ts | 126 ++
server/src/routes/categories.ts | 397 ++++
server/src/routes/export.ts | 265 +++
server/src/routes/reports.ts | 243 +++
server/src/routes/subscriptions.ts | 321 ++++
server/src/routes/transactions.ts | 154 +-
server/src/schemas.ts | 1 +
server/src/settings.ts | 30 +
server/src/subscriptions/catalog.ts | 141 ++
server/src/subscriptions/engine.ts | 431 +++++
server/src/transactions/dto.ts | 1 +
server/src/transactions/filter.ts | 54 +
server/test/full-picture.test.ts | 415 +++++
server/test/helpers.ts | 1 +
server/test/synthetic.ts | 125 ++
shared/src/index.ts | 212 +++
web/package.json | 2 +
web/src/components/AlertCard.tsx | 46 +
web/src/components/AppShell.tsx | 21 +-
web/src/components/BudgetEditor.tsx | 108 ++
web/src/components/CategoryPicker.tsx | 4 +-
web/src/components/TransactionSheet.tsx | 17 +-
web/src/components/meter.tsx | 79 +
web/src/components/money.tsx | 67 +
web/src/lib/data.ts | 74 +-
web/src/lib/money.ts | 54 +
web/src/pages/AccountsPage.tsx | 15 +-
web/src/pages/ActivityPage.tsx | 76 +-
web/src/pages/BudgetPage.tsx | 466 +++++
web/src/pages/HomePage.tsx | 468 +++--
web/src/pages/InsightsPage.tsx | 613 +++++++
web/src/pages/MorePage.tsx | 5 +
web/src/pages/SubscriptionsPage.tsx | 673 +++++++
web/src/pages/Upcoming.tsx | 40 -
web/src/pages/settings/AiPage.tsx | 58 +
web/src/pages/settings/AlertsPage.tsx | 85 +
web/src/pages/settings/CategoriesPage.tsx | 333 ++++
web/src/pages/settings/ExportPage.tsx | 31 +
web/src/pages/settings/RulesPage.tsx | 144 ++
web/src/pages/settings/SettingsLayout.tsx | 8 +-
web/src/pages/settings/TagsPage.tsx | 93 +
web/src/router.tsx | 22 +-
62 files changed, 9977 insertions(+), 378 deletions(-)
create mode 100644 server/drizzle/0002_full_picture.sql
create mode 100644 server/drizzle/meta/0002_snapshot.json
create mode 100644 server/src/ai/index.ts
create mode 100644 server/src/budgets/budgets.ts
create mode 100644 server/src/dates.ts
create mode 100644 server/src/export/xlsx.ts
create mode 100644 server/src/insights/engine.ts
create mode 100644 server/src/reports/actuals.ts
create mode 100644 server/src/routes/ai.ts
create mode 100644 server/src/routes/alerts.ts
create mode 100644 server/src/routes/budgets.ts
create mode 100644 server/src/routes/categories.ts
create mode 100644 server/src/routes/export.ts
create mode 100644 server/src/routes/reports.ts
create mode 100644 server/src/routes/subscriptions.ts
create mode 100644 server/src/settings.ts
create mode 100644 server/src/subscriptions/catalog.ts
create mode 100644 server/src/subscriptions/engine.ts
create mode 100644 server/src/transactions/filter.ts
create mode 100644 server/test/full-picture.test.ts
create mode 100644 server/test/synthetic.ts
create mode 100644 web/src/components/AlertCard.tsx
create mode 100644 web/src/components/BudgetEditor.tsx
create mode 100644 web/src/components/meter.tsx
create mode 100644 web/src/lib/money.ts
create mode 100644 web/src/pages/BudgetPage.tsx
create mode 100644 web/src/pages/InsightsPage.tsx
create mode 100644 web/src/pages/SubscriptionsPage.tsx
delete mode 100644 web/src/pages/Upcoming.tsx
create mode 100644 web/src/pages/settings/AiPage.tsx
create mode 100644 web/src/pages/settings/AlertsPage.tsx
create mode 100644 web/src/pages/settings/CategoriesPage.tsx
create mode 100644 web/src/pages/settings/ExportPage.tsx
create mode 100644 web/src/pages/settings/RulesPage.tsx
create mode 100644 web/src/pages/settings/TagsPage.tsx
diff --git a/README.md b/README.md
index c14203a..debe7d7 100644
--- a/README.md
+++ b/README.md
@@ -4,4 +4,50 @@ 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: planning. The build starts once the plan is approved.
+Status: milestones 0 to 2 are built (foundation, money in, the full picture).
+Milestone 3, launch hardening, is next.
+
+## 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.
+ 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
+ from every choice. Splits, tags, notes and cash entries are built in.
+- **Budget** one amount per category for every month, with any month set on
+ its own. Fill a year from recent spending in one step; copy it into the next.
+- **Home** shows what's left to spend, how the year is pacing, insights, balances
+ and the charges coming up.
+- **Insights**: year pacing, money in and out, budget against actual,
+ categories by month, merchants, balances over time and tags.
+- **Subscriptions and bills** are found automatically across every account,
+ confirmed with one tap, and watched for price changes, missed charges, charges
+ after cancelling, double billing, renewals and trials ending.
+- **Export** everything to Excel or CSV.
+- **AI** features are designed in and switched off; no provider ships with the
+ app and it makes no outside calls.
+
+## Running it
+
+```bash
+cp .env.example .env # adjust the port, time zone and paths
+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.
+
+## Developing
+
+The host only needs Docker; Node 24 runs in a container.
+
+```bash
+scripts/dev.sh npm install
+scripts/dev.sh npm test
+scripts/dev.sh npm run typecheck
+```
+
+Statement files never belong in this repository: `samples/` is ignored and a
+pre-commit hook blocks statement and database files. Tests use made-up data in
+`server/test/fixtures/synthetic` and `server/test/synthetic.ts`.
diff --git a/VERSION b/VERSION
index 347ba35..f794101 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-20261007.10
+20261007.12
diff --git a/package-lock.json b/package-lock.json
index f24997a..b243ed1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3633,6 +3633,32 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT"
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
+ "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@remix-run/route-pattern": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
@@ -3965,6 +3991,18 @@
"resolved": "web",
"link": true
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -4296,6 +4334,69 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -4350,6 +4451,12 @@
"@types/react": "^19.3.0"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
@@ -4527,6 +4634,133 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5193,6 +5427,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/es-toolkit": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -5245,6 +5491,12 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -5326,6 +5578,25 @@
"node": ">=16.9.0"
}
},
+ "node_modules/immer": {
+ "version": "11.1.21",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
+ "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -5856,6 +6127,35 @@
"react": "^19.3.0"
}
},
+ "node_modules/react-is": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
+ "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
+ "license": "MIT"
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -5947,12 +6247,63 @@
}
}
},
+ "node_modules/recharts": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
+ "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^11.1.8",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.2.0",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
+ "node_modules/reselect": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
+ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
+ "license": "MIT"
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -6160,6 +6511,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
@@ -6304,6 +6661,37 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
@@ -6827,7 +7215,9 @@
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+ "react-is": "^19.3.0",
"react-router": "^8.4.0",
+ "recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0"
},
diff --git a/server/drizzle/0002_full_picture.sql b/server/drizzle/0002_full_picture.sql
new file mode 100644
index 0000000..9d66450
--- /dev/null
+++ b/server/drizzle/0002_full_picture.sql
@@ -0,0 +1,65 @@
+CREATE TABLE `alert` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `kind` text NOT NULL,
+ `subject_type` text NOT NULL,
+ `subject_id` integer DEFAULT 0 NOT NULL,
+ `period` text DEFAULT '' NOT NULL,
+ `area` text DEFAULT 'money' NOT NULL,
+ `severity` text DEFAULT 'info' NOT NULL,
+ `title` text NOT NULL,
+ `body` text,
+ `link` text,
+ `rank` integer DEFAULT 0 NOT NULL,
+ `created_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ `read_at` text,
+ `dismissed_at` text,
+ `resolved_at` text
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `alert_unique` ON `alert` (`kind`,`subject_type`,`subject_id`,`period`);--> statement-breakpoint
+CREATE INDEX `alert_open_idx` ON `alert` (`resolved_at`,`dismissed_at`);--> statement-breakpoint
+CREATE TABLE `budget` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `category_id` integer NOT NULL,
+ `year` integer NOT NULL,
+ `month` integer DEFAULT 0 NOT NULL,
+ `amount_cents` integer NOT NULL,
+ `updated_at` text NOT NULL,
+ FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `budget_unique` ON `budget` (`category_id`,`year`,`month`);--> statement-breakpoint
+CREATE INDEX `budget_year_idx` ON `budget` (`year`);--> statement-breakpoint
+CREATE TABLE `subscription` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `name` text NOT NULL,
+ `kind` text DEFAULT 'subscription' NOT NULL,
+ `merchant` text NOT NULL,
+ `aliases` text DEFAULT '[]' NOT NULL,
+ `catalog_key` text,
+ `cadence` text DEFAULT 'monthly' NOT NULL,
+ `expected_cents` integer NOT NULL,
+ `account_id` integer,
+ `category_id` integer,
+ `status` text DEFAULT 'suggested' NOT NULL,
+ `confidence` text DEFAULT 'likely' NOT NULL,
+ `source` text DEFAULT 'detected' NOT NULL,
+ `trial_ends_on` text,
+ `cancelled_on` text,
+ `first_charge_on` text,
+ `last_charge_on` text,
+ `next_expected_on` text,
+ `manage_url` text,
+ `note` text,
+ `alerts_on` integer DEFAULT true NOT NULL,
+ `created_at` text NOT NULL,
+ `updated_at` text NOT NULL,
+ FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null,
+ FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `subscription_merchant_idx` ON `subscription` (`merchant`);--> statement-breakpoint
+CREATE INDEX `subscription_status_idx` ON `subscription` (`status`);--> statement-breakpoint
+ALTER TABLE `txn` ADD `subscription_id` integer;--> statement-breakpoint
+CREATE INDEX `txn_subscription_idx` ON `txn` (`subscription_id`);
\ No newline at end of file
diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..5e073b3
--- /dev/null
+++ b/server/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,2012 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "12dd39b8-6a3f-405a-8ea8-7ae5a62c8b48",
+ "prevId": "9a9ab3f9-9b4e-490a-830c-02dacb25c682",
+ "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": {
+ "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 2c5e2e2..0c83e03 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1791404838766,
"tag": "0001_money_in",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1791417594406,
+ "tag": "0002_full_picture",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/src/ai/index.ts b/server/src/ai/index.ts
new file mode 100644
index 0000000..faa7c58
--- /dev/null
+++ b/server/src/ai/index.ts
@@ -0,0 +1,65 @@
+import type { AiFeature, AiStatus } from '@spendlight/shared';
+import type { AppContext } from '../context';
+import { getSetting, putSetting } from '../settings';
+
+// The AI module is built into the design and switched off. No provider ships
+// with Spendlight and nothing here makes an outside call, so the app stays
+// fully offline. Turning a feature on later needs two things: an admin who
+// switches it on in Settings, and a provider configured on the server
+// (AI_PROVIDER), which plugs in through the interface below.
+
+/** What a provider must offer. Each call receives only what that feature needs. */
+export interface AiProvider {
+ readonly name: string;
+ /** Suggest a category for a merchant no one has filed before; sends only the merchant text. */
+ suggestCategory(input: { merchant: string; categories: string[] }): Promise;
+ /** Write short insight cards from summary numbers (never raw transactions). */
+ summarize(input: { facts: string[] }): Promise;
+}
+
+export const AI_FEATURES: { key: AiFeature; label: string; description: string }[] = [
+ {
+ key: 'categorize',
+ label: 'Category suggestions for new merchants',
+ description: 'When no rule, history or built-in knowledge applies, ask the AI. Only the merchant’s name is sent.',
+ },
+ {
+ key: 'insights',
+ label: 'Written insights',
+ description: 'Plain-language notes about the month, written from totals by category. No transactions are sent.',
+ },
+ {
+ key: 'assistant',
+ label: 'Assistant',
+ description: 'Ask questions such as “what did we spend on the kids this year?” and get answers with the transactions behind them.',
+ },
+ {
+ key: 'agents',
+ label: 'Agents',
+ description: 'Let an assistant do routine work, such as filing a month, through the same API and roles as everyone else.',
+ },
+];
+
+type AiSettings = Record;
+const OFF: AiSettings = { categorize: false, insights: false, assistant: false, agents: false };
+
+export function aiStatus(ctx: AppContext): AiStatus {
+ const saved = getSetting(ctx, 'ai', OFF);
+ const configured = !!ctx.config.aiProvider;
+ return {
+ providerConfigured: configured,
+ provider: ctx.config.aiProvider,
+ features: AI_FEATURES.map((f) => ({ ...f, enabled: configured && saved[f.key] })),
+ };
+}
+
+export function setAiFeature(ctx: AppContext, feature: AiFeature, enabled: boolean): void {
+ putSetting(ctx, 'ai', { ...getSetting(ctx, 'ai', OFF), [feature]: enabled });
+}
+
+/** The provider for a feature, or null when it's off (always, until one is configured). */
+export function aiFor(ctx: AppContext, feature: AiFeature): AiProvider | null {
+ if (!aiStatus(ctx).features.find((f) => f.key === feature)?.enabled) return null;
+ // Providers register here when they are added. None ships today.
+ return null;
+}
diff --git a/server/src/app.ts b/server/src/app.ts
index 536ad6c..2f64f3f 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -7,13 +7,20 @@ import type { AppContext } from './context';
import { docsRoutes } from './docs';
import { fail, requestOrigin, type Env } from './http';
import { accountRoutes } from './routes/accounts';
+import { aiRoutes } from './routes/ai';
+import { alertRoutes } from './routes/alerts';
import { authRoutes } from './routes/auth';
import { backupRoutes } from './routes/backups';
+import { budgetRoutes } from './routes/budgets';
+import { categoryRoutes } from './routes/categories';
import { deviceRoutes } from './routes/devices';
+import { exportRoutes } from './routes/export';
import { ideaRoutes } from './routes/ideas';
import { importRoutes } from './routes/imports';
import { passkeyRoutes } from './routes/passkeys';
import { peopleRoutes } from './routes/people';
+import { reportRoutes } from './routes/reports';
+import { subscriptionRoutes } from './routes/subscriptions';
import { systemRoutes } from './routes/system';
import { transactionRoutes } from './routes/transactions';
import { validationHook } from './schemas';
@@ -87,6 +94,13 @@ export function createApp(ctx: AppContext) {
app.route('/api/v1', accountRoutes(ctx));
app.route('/api/v1', importRoutes(ctx));
app.route('/api/v1', transactionRoutes(ctx));
+ app.route('/api/v1', categoryRoutes(ctx));
+ app.route('/api/v1', budgetRoutes(ctx));
+ app.route('/api/v1', reportRoutes(ctx));
+ app.route('/api/v1', subscriptionRoutes(ctx));
+ app.route('/api/v1', alertRoutes(ctx));
+ app.route('/api/v1', exportRoutes(ctx));
+ app.route('/api/v1', aiRoutes(ctx));
if (ctx.config.ideasEnabled) app.route('/api/v1', ideaRoutes(ctx));
app.doc31('/api/v1/openapi.json', {
diff --git a/server/src/budgets/budgets.ts b/server/src/budgets/budgets.ts
new file mode 100644
index 0000000..aa9ead1
--- /dev/null
+++ b/server/src/budgets/budgets.ts
@@ -0,0 +1,115 @@
+import type { BudgetLine, BudgetScope, BudgetYear } from '@spendlight/shared';
+import { and, eq, gte, inArray, lte } from 'drizzle-orm';
+import type { AppContext } from '../context';
+import { budgets } from '../db/schema';
+
+// Each category has one amount for every month of a year (month 0), and any
+// month can have its own amount instead: Gifts at $100 a month, $800 in December.
+
+export function budgetYear(ctx: AppContext, year: number): BudgetYear {
+ const rows = ctx.db.select().from(budgets).where(eq(budgets.year, year)).all();
+ const lines = new Map();
+ for (const r of rows) {
+ const line = lines.get(r.categoryId) ?? { categoryId: r.categoryId, monthly: null, months: Array(12).fill(null) };
+ if (r.month === 0) line.monthly = r.amountCents;
+ else line.months[r.month - 1] = r.amountCents;
+ lines.set(r.categoryId, line);
+ }
+ return { year, items: [...lines.values()] };
+}
+
+export const effective = (line: BudgetLine | undefined, monthIndex: number): number =>
+ line ? (line.months[monthIndex] ?? line.monthly ?? 0) : 0;
+
+/** Budgets for each month in the list ("2026-01"...), by category. */
+export function budgetMatrix(ctx: AppContext, months: string[]): Map {
+ const years = [...new Set(months.map((m) => Number(m.slice(0, 4))))];
+ const byYear = new Map(years.map((y) => [y, new Map(budgetYear(ctx, y).items.map((l) => [l.categoryId, l]))]));
+ const out = new Map();
+ months.forEach((month, i) => {
+ const lines = byYear.get(Number(month.slice(0, 4)))!;
+ for (const [categoryId, line] of lines) {
+ const row = out.get(categoryId) ?? Array(months.length).fill(0);
+ row[i] = effective(line, Number(month.slice(5, 7)) - 1);
+ out.set(categoryId, row);
+ }
+ });
+ return out;
+}
+
+function put(ctx: AppContext, categoryId: number, year: number, month: number, amountCents: number, now: string) {
+ ctx.db
+ .insert(budgets)
+ .values({ categoryId, year, month, amountCents, updatedAt: now })
+ .onConflictDoUpdate({ target: [budgets.categoryId, budgets.year, budgets.month], set: { amountCents, updatedAt: now } })
+ .run();
+}
+
+/**
+ * Set a category's budget from one month:
+ * month: just that month
+ * rest: that month through December (earlier months keep what they had)
+ * year: every month of the year
+ */
+export function setBudget(ctx: AppContext, categoryId: number, year: number, month: number, amountCents: number, scope: BudgetScope): void {
+ const now = new Date().toISOString();
+ ctx.sqlite.transaction(() => {
+ const before = budgetYear(ctx, year).items.find((l) => l.categoryId === categoryId);
+ if (scope === 'month') {
+ put(ctx, categoryId, year, month, amountCents, now);
+ } else if (scope === 'year') {
+ ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year))).run();
+ if (amountCents > 0) put(ctx, categoryId, year, 0, amountCents, now);
+ } else {
+ // Earlier months that followed the yearly amount keep their old value.
+ for (let m = 1; m < month; m++) {
+ if (before?.months[m - 1] == null) {
+ const old = before?.monthly ?? 0;
+ if (old !== amountCents) put(ctx, categoryId, year, m, old, now);
+ }
+ }
+ ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year), gte(budgets.month, month), lte(budgets.month, 12))).run();
+ put(ctx, categoryId, year, 0, amountCents, now);
+ }
+ // Tidy up: overrides equal to the yearly amount add nothing.
+ const after = budgetYear(ctx, year).items.find((l) => l.categoryId === categoryId);
+ if (after && after.monthly !== null) {
+ const same = after.months.flatMap((v, i) => (v === after.monthly ? [i + 1] : []));
+ if (same.length) ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year), inArray(budgets.month, same))).run();
+ }
+ if (after && after.monthly === null && after.months.every((v) => v === null || v === 0)) {
+ ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year))).run();
+ }
+ })();
+}
+
+/** Fill in categories that have no budget for the year yet, as an amount for every month. */
+export function fillBudgets(ctx: AppContext, year: number, amounts: Map): number {
+ const now = new Date().toISOString();
+ const existing = new Set(budgetYear(ctx, year).items.map((l) => l.categoryId));
+ let filled = 0;
+ ctx.sqlite.transaction(() => {
+ for (const [categoryId, cents] of amounts) {
+ if (existing.has(categoryId) || cents <= 0) continue;
+ put(ctx, categoryId, year, 0, cents, now);
+ filled++;
+ }
+ })();
+ return filled;
+}
+
+/** Copy one year's budgets into another, for categories the new year doesn't have yet. */
+export function copyYear(ctx: AppContext, from: number, to: number): number {
+ const now = new Date().toISOString();
+ const target = new Set(budgetYear(ctx, to).items.map((l) => l.categoryId));
+ const rows = ctx.db.select().from(budgets).where(eq(budgets.year, from)).all();
+ const copied = new Set();
+ ctx.sqlite.transaction(() => {
+ for (const r of rows) {
+ if (target.has(r.categoryId)) continue;
+ put(ctx, r.categoryId, to, r.month, r.amountCents, now);
+ copied.add(r.categoryId);
+ }
+ })();
+ return copied.size;
+}
diff --git a/server/src/config.ts b/server/src/config.ts
index 595bf0c..750b693 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -17,6 +17,8 @@ export interface Config {
sessionDays: number;
/** Allow password sign-in over plain HTTP from a non-local address. */
allowInsecureSignin: boolean;
+ /** The AI provider configured on this server, if any. None ships with Spendlight. */
+ aiProvider: string | null;
version: string;
}
@@ -54,6 +56,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
keepMonthly: int(env.BACKUP_KEEP_MONTHLY, 12, 0, 120),
sessionDays: int(env.SESSION_DAYS, 400, 1, 400),
allowInsecureSignin: truthy(env.ALLOW_INSECURE_SIGNIN),
+ aiProvider: env.AI_PROVIDER?.trim() || null,
version: env.APP_VERSION?.trim() || 'dev',
};
}
diff --git a/server/src/context.ts b/server/src/context.ts
index 72b1697..becbb83 100644
--- a/server/src/context.ts
+++ b/server/src/context.ts
@@ -7,6 +7,7 @@ import type { Config } from './config';
import { migrateDatabase, openDatabase, type DB, type Sqlite } from './db/client';
import { consoleLogger, type Logger } from './log';
import { StagingStore } from './import/service';
+import { scanSubscriptions } from './subscriptions/engine';
import { MIGRATIONS_DIR } from './paths';
export interface AppContext {
@@ -33,7 +34,7 @@ export async function createContext(config: Config, options: { log?: Logger } =
if (pending > 0) log.info(`Database upgraded (${pending} step${pending === 1 ? '' : 's'})`);
seedCategories(db);
- return {
+ const ctx: AppContext = {
config,
db,
sqlite,
@@ -43,4 +44,15 @@ export async function createContext(config: Config, options: { log?: Logger } =
startedAt: new Date(),
log,
};
+ // Statements imported before subscriptions existed get looked through once.
+ const count = (table: string) => (sqlite.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
+ if (count('subscription') === 0 && count('txn') > 0) {
+ try {
+ const { found } = scanSubscriptions(ctx);
+ if (found) log.info(`Found ${found} possible subscription${found === 1 ? '' : 's'} in existing transactions`);
+ } catch (error) {
+ log.error('Could not look for subscriptions', error);
+ }
+ }
+ return ctx;
}
diff --git a/server/src/dates.ts b/server/src/dates.ts
new file mode 100644
index 0000000..2e1306b
--- /dev/null
+++ b/server/src/dates.ts
@@ -0,0 +1,39 @@
+// Calendar days are plain YYYY-MM-DD strings in the household's time zone.
+
+export { daysBetween, shiftDate as addDays } from './import/parse';
+
+/** Today's date where the household lives (the container's TZ). */
+export function today(now: Date = new Date()): string {
+ return now.toLocaleDateString('en-CA');
+}
+
+export const monthOf = (date: string) => date.slice(0, 7);
+
+/** Move a date by whole months, keeping the day where the month allows (Jan 31 → Feb 28). */
+export function addMonthsToDate(date: string, months: number): string {
+ const [y, m, d] = date.split('-').map(Number);
+ const first = new Date(Date.UTC(y!, m! - 1 + months, 1));
+ const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
+ first.setUTCDate(Math.min(d!, last));
+ return first.toISOString().slice(0, 10);
+}
+
+export function monthRange(from: string, to: string): string[] {
+ const out: string[] = [];
+ let [y, m] = from.split('-').map(Number) as [number, number];
+ const [ty, tm] = to.split('-').map(Number) as [number, number];
+ while (y < ty || (y === ty && m <= tm)) {
+ out.push(`${y}-${String(m).padStart(2, '0')}`);
+ m++;
+ if (m > 12) {
+ m = 1;
+ y++;
+ }
+ }
+ return out;
+}
+
+export function lastDayOf(month: string): string {
+ const [y, m] = month.split('-').map(Number);
+ return `${month}-${String(new Date(y!, m!, 0).getDate()).padStart(2, '0')}`;
+}
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index ca3960b..8d51b5e 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -212,6 +212,8 @@ export const transactions = sqliteTable(
bankType: text('bank_type'),
note: text('note'),
transferPeerId: integer('transfer_peer_id'),
+ /** The subscription or bill this charge belongs to, once one is recognized. */
+ subscriptionId: integer('subscription_id'),
source: text('source', { enum: ['import', 'manual'] }).notNull().default('import'),
/** account|date|amount|cleaned description: how duplicates are recognized. */
dedupeKey: text('dedupe_key').notNull(),
@@ -226,6 +228,7 @@ export const transactions = sqliteTable(
index('txn_dedupe_idx').on(t.dedupeKey),
index('txn_review_idx').on(t.needsReview),
index('txn_import_idx').on(t.importId),
+ index('txn_subscription_idx').on(t.subscriptionId),
],
);
@@ -285,7 +288,98 @@ export const rules = sqliteTable(
(t) => [index('rule_merchant_idx').on(t.merchant)],
);
+// ---------------------------------------------------------------------------
+// Milestone 2: the full picture.
+
+/**
+ * Monthly budget targets, always positive. Month 0 is the amount for every
+ * month of that year; months 1 to 12 override it for one month.
+ */
+export const budgets = sqliteTable(
+ 'budget',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ categoryId: integer('category_id')
+ .notNull()
+ .references(() => categories.id, { onDelete: 'cascade' }),
+ year: integer('year').notNull(),
+ month: integer('month').notNull().default(0),
+ amountCents: integer('amount_cents').notNull(),
+ updatedAt: text('updated_at').notNull(),
+ },
+ (t) => [uniqueIndex('budget_unique').on(t.categoryId, t.year, t.month), index('budget_year_idx').on(t.year)],
+);
+
+export const SUBSCRIPTION_STATUSES = ['suggested', 'active', 'trial', 'paused', 'cancelled', 'dismissed'] as const;
+export const CADENCES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const;
+
+/** A subscription or recurring bill: one entry per service, whichever card it bills to. */
+export const subscriptions = sqliteTable(
+ 'subscription',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ name: text('name').notNull(),
+ kind: text('kind', { enum: ['subscription', 'bill'] }).notNull().default('subscription'),
+ /** The merchant name its charges carry, plus any others merged into it. */
+ merchant: text('merchant').notNull(),
+ aliases: text('aliases').notNull().default('[]'),
+ catalogKey: text('catalog_key'),
+ cadence: text('cadence', { enum: CADENCES }).notNull().default('monthly'),
+ /** The usual charge, as a positive number of cents. */
+ expectedCents: integer('expected_cents').notNull(),
+ accountId: integer('account_id').references(() => accounts.id, { onDelete: 'set null' }),
+ categoryId: integer('category_id').references(() => categories.id, { onDelete: 'set null' }),
+ status: text('status', { enum: SUBSCRIPTION_STATUSES }).notNull().default('suggested'),
+ /** For suggestions: two matching charges are likely, three or more confident. */
+ confidence: text('confidence', { enum: ['likely', 'confident'] }).notNull().default('likely'),
+ source: text('source', { enum: ['detected', 'catalog', 'manual'] }).notNull().default('detected'),
+ trialEndsOn: text('trial_ends_on'),
+ cancelledOn: text('cancelled_on'),
+ firstChargeOn: text('first_charge_on'),
+ lastChargeOn: text('last_charge_on'),
+ nextExpectedOn: text('next_expected_on'),
+ manageUrl: text('manage_url'),
+ note: text('note'),
+ alertsOn: integer('alerts_on', { mode: 'boolean' }).notNull().default(true),
+ createdAt: text('created_at').notNull(),
+ updatedAt: text('updated_at').notNull(),
+ },
+ (t) => [index('subscription_merchant_idx').on(t.merchant), index('subscription_status_idx').on(t.status)],
+);
+
+/**
+ * Alerts and insights. Each is worked out again from the data whenever it
+ * changes; this table remembers which ones people have read or dismissed.
+ */
+export const alerts = sqliteTable(
+ 'alert',
+ {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ kind: text('kind').notNull(),
+ subjectType: text('subject_type').notNull(),
+ subjectId: integer('subject_id').notNull().default(0),
+ /** Tells one occurrence from the next, such as the month or the charge. */
+ period: text('period').notNull().default(''),
+ area: text('area', { enum: ['money', 'subscriptions'] }).notNull().default('money'),
+ severity: text('severity', { enum: ['info', 'good', 'warn', 'bad'] }).notNull().default('info'),
+ title: text('title').notNull(),
+ body: text('body'),
+ link: text('link'),
+ rank: integer('rank').notNull().default(0),
+ createdAt: text('created_at').notNull(),
+ updatedAt: text('updated_at').notNull(),
+ readAt: text('read_at'),
+ dismissedAt: text('dismissed_at'),
+ /** Set when the situation no longer holds, which hides the alert. */
+ resolvedAt: text('resolved_at'),
+ },
+ (t) => [uniqueIndex('alert_unique').on(t.kind, t.subjectType, t.subjectId, t.period), index('alert_open_idx').on(t.resolvedAt, t.dismissedAt)],
+);
+
export type AccountRow = typeof accounts.$inferSelect;
export type CategoryRow = typeof categories.$inferSelect;
export type TransactionRow = typeof transactions.$inferSelect;
export type RuleRow = typeof rules.$inferSelect;
+export type BudgetRow = typeof budgets.$inferSelect;
+export type SubscriptionRow = typeof subscriptions.$inferSelect;
+export type AlertRow = typeof alerts.$inferSelect;
diff --git a/server/src/export/xlsx.ts b/server/src/export/xlsx.ts
new file mode 100644
index 0000000..be9739f
--- /dev/null
+++ b/server/src/export/xlsx.ts
@@ -0,0 +1,215 @@
+import { crc32, deflateRawSync } from 'node:zlib';
+
+// A small, dependency-free writer for real Excel (.xlsx) files: bold frozen
+// headers, filters, column widths, dollar amounts and true dates. An .xlsx is
+// a zip of XML parts; this writes exactly the parts Excel needs.
+
+export type Cell = string | number | null | undefined | { date: string } | { money: number };
+
+export interface Sheet {
+ name: string;
+ columns: { header: string; width?: number }[];
+ rows: Cell[][];
+}
+
+const STYLE = { header: 1, money: 2, date: 3 } as const;
+
+// Characters XML 1.0 can't carry are dropped.
+// eslint-disable-next-line no-control-regex
+const INVALID = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
+const esc = (s: string) => s.replace(INVALID, '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+function columnName(index: number): string {
+ let n = index + 1;
+ let out = '';
+ while (n > 0) {
+ const r = (n - 1) % 26;
+ out = String.fromCharCode(65 + r) + out;
+ n = Math.floor((n - 1) / 26);
+ }
+ return out;
+}
+
+/** Excel counts days from 1899-12-30. */
+const serial = (date: string) => Math.round((Date.parse(`${date}T00:00:00Z`) - Date.UTC(1899, 11, 30)) / 86_400_000);
+
+function cellXml(value: Cell, ref: string, header = false): string {
+ if (value === null || value === undefined || value === '') return '';
+ if (header) return `${esc(String(value))} `;
+ if (typeof value === 'number') return `${value} `;
+ if (typeof value === 'object' && 'date' in value) return `${serial(value.date)} `;
+ if (typeof value === 'object' && 'money' in value) return `${(value.money / 100).toFixed(2)} `;
+ return `${esc(String(value))} `;
+}
+
+function sheetXml(sheet: Sheet): string {
+ const last = columnName(Math.max(0, sheet.columns.length - 1));
+ const height = sheet.rows.length + 1;
+ const cols = sheet.columns.map((c, i) => ` `).join('');
+ const header = `${sheet.columns.map((c, i) => cellXml(c.header, `${columnName(i)}1`, true)).join('')}
`;
+ const body = sheet.rows.map((row, r) => `${row.map((v, i) => cellXml(v, `${columnName(i)}${r + 2}`)).join('')}
`).join('');
+ return (
+ '' +
+ '' +
+ ` ` +
+ ' ' +
+ ' ' +
+ `${cols} ` +
+ `${header}${body} ` +
+ ` ` +
+ ' '
+ );
+}
+
+const STYLES =
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ';
+
+/** Sheet names: at most 31 characters, none of : \ / ? * [ ] */
+const sheetName = (name: string) => name.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31) || 'Sheet';
+
+export function buildXlsx(sheets: Sheet[]): Buffer {
+ const names = sheets.map((s) => sheetName(s.name));
+ const files: [string, string][] = [
+ [
+ '[Content_Types].xml',
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ sheets.map((_, i) => ` `).join('') +
+ ' ' +
+ ' ' +
+ ' ',
+ ],
+ [
+ '_rels/.rels',
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ',
+ ],
+ [
+ 'docProps/app.xml',
+ '' +
+ 'Spendlight ',
+ ],
+ [
+ 'docProps/core.xml',
+ '' +
+ '' +
+ `Spendlight ${new Date().toISOString().slice(0, 19)}Z ` +
+ ' ',
+ ],
+ [
+ 'xl/workbook.xml',
+ '' +
+ '' +
+ `${names.map((n, i) => ` `).join('')} ` +
+ `${sheets
+ .map((s, i) => {
+ const last = columnName(Math.max(0, s.columns.length - 1));
+ return `'${esc(names[i]!.replace(/'/g, "''"))}'!$A$1:$${last}$${s.rows.length + 1} `;
+ })
+ .join('')} ` +
+ ' ',
+ ],
+ [
+ 'xl/_rels/workbook.xml.rels',
+ '' +
+ '' +
+ sheets.map((_, i) => ` `).join('') +
+ ` ` +
+ ' ',
+ ],
+ ['xl/styles.xml', STYLES],
+ ...sheets.map((s, i): [string, string] => [`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s)]),
+ ];
+ return zip(files.map(([name, text]) => ({ name, data: Buffer.from(text, 'utf8') })));
+}
+
+/** A plain zip archive with deflated entries. */
+function zip(entries: { name: string; data: Buffer }[]): Buffer {
+ const locals: Buffer[] = [];
+ const centrals: Buffer[] = [];
+ let offset = 0;
+ // 1980-01-01 00:00, the zip format's epoch; the date inside an export doesn't matter.
+ const dosTime = 0;
+ const dosDate = (0 << 9) | (1 << 5) | 1;
+ for (const e of entries) {
+ const name = Buffer.from(e.name, 'utf8');
+ const packed = deflateRawSync(e.data, { level: 6 });
+ const crc = crc32(e.data);
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(0x04034b50, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(0x0800, 6); // names are UTF-8
+ local.writeUInt16LE(8, 8); // deflate
+ local.writeUInt16LE(dosTime, 10);
+ local.writeUInt16LE(dosDate, 12);
+ local.writeUInt32LE(crc, 14);
+ local.writeUInt32LE(packed.length, 18);
+ local.writeUInt32LE(e.data.length, 22);
+ local.writeUInt16LE(name.length, 26);
+ local.writeUInt16LE(0, 28);
+ locals.push(local, name, packed);
+
+ const central = Buffer.alloc(46);
+ central.writeUInt32LE(0x02014b50, 0);
+ central.writeUInt16LE(20, 4);
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(0x0800, 8);
+ central.writeUInt16LE(8, 10);
+ central.writeUInt16LE(dosTime, 12);
+ central.writeUInt16LE(dosDate, 14);
+ central.writeUInt32LE(crc, 16);
+ central.writeUInt32LE(packed.length, 20);
+ central.writeUInt32LE(e.data.length, 24);
+ central.writeUInt16LE(name.length, 28);
+ central.writeUInt32LE(offset, 42);
+ centrals.push(central, name);
+ offset += local.length + name.length + packed.length;
+ }
+ const centralSize = centrals.reduce((n, b) => n + b.length, 0);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(entries.length, 8);
+ end.writeUInt16LE(entries.length, 10);
+ end.writeUInt32LE(centralSize, 12);
+ end.writeUInt32LE(offset, 16);
+ return Buffer.concat([...locals, ...centrals, end]);
+}
+
+/** CSV that opens cleanly in Excel and Numbers; text that looks like a formula is defused. */
+export function buildCsv(columns: string[], rows: Cell[][]): string {
+ const field = (v: Cell): string => {
+ if (v === null || v === undefined) return '';
+ let s: string;
+ if (typeof v === 'number') s = String(v);
+ else if (typeof v === 'object' && 'date' in v) s = v.date;
+ else if (typeof v === 'object' && 'money' in v) s = (v.money / 100).toFixed(2);
+ else {
+ s = String(v);
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
+ }
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+ };
+ return '\uFEFF' + [columns.map(field), ...rows.map((r) => r.map(field))].map((r) => r.join(',')).join('\r\n') + '\r\n';
+}
diff --git a/server/src/import/parse.ts b/server/src/import/parse.ts
index 40f9051..99cbcc9 100644
--- a/server/src/import/parse.ts
+++ b/server/src/import/parse.ts
@@ -5,7 +5,7 @@ import Papa from 'papaparse';
/** Every non-blank row of a delimited file, as trimmed strings. */
export function readTable(text: string): string[][] {
- const clean = text.replace(/^/, '');
+ const clean = text.replace(/^\uFEFF/, '');
const result = Papa.parse(clean, { skipEmptyLines: 'greedy', delimitersToGuess: [',', '\t', ';', '|'] });
return result.data.map((row) => row.map((cell) => (cell ?? '').trim()));
}
diff --git a/server/src/import/service.ts b/server/src/import/service.ts
index 800fca5..a0438e9 100644
--- a/server/src/import/service.ts
+++ b/server/src/import/service.ts
@@ -1,13 +1,15 @@
import type { AccountKind, ImportColumnMap, ImportPreview, ImportSummary } from '@spendlight/shared';
-import { and, between, count, desc, eq, inArray } from 'drizzle-orm';
+import { and, between, count, desc, eq, inArray, sql } from 'drizzle-orm';
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, writeFileSync } from 'node:fs';
import { extname, join } from 'node:path';
import { logChange } from '../audit';
import { Suggester } from '../categorize/suggest';
import type { AppContext } from '../context';
-import { accounts, importLayouts, imports, transactions, type AccountRow } from '../db/schema';
+import { accounts, importLayouts, imports, rules, transactions, type AccountRow } from '../db/schema';
import { fail } from '../http';
+import { refreshAlerts } from '../insights/engine';
+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';
@@ -380,8 +382,10 @@ export function commitImport(
.get();
const ids: number[] = [];
+ const usedRules = new Set();
for (const { row, key } of planned) {
const rule = suggester.rule(row.merchant, account.id);
+ if (rule) usedRules.add(rule.id);
const merchant = rule?.renameTo || row.merchant;
const s = suggester.suggest({ ...row, merchant, accountId: account.id });
const inserted = ctx.db
@@ -408,6 +412,14 @@ export function commitImport(
ids.push(inserted.id);
}
+ if (usedRules.size) {
+ ctx.db
+ .update(rules)
+ .set({ hits: sql`${rules.hits} + 1`, lastUsedAt: now })
+ .where(inArray(rules.id, [...usedRules]))
+ .run();
+ }
+
const transferCount = pairTransfers(ctx, suggester, ids);
ctx.db.update(imports).set({ transferCount }).where(eq(imports.id, record.id)).run();
@@ -454,6 +466,7 @@ export function commitImport(
}
ctx.staging.delete(stagingId);
logChange(ctx, userId, 'import', 'import', result.record.id, { file: staged.fileName, added: planned.length });
+ afterMoneyChanged(ctx);
return importSummary(ctx, result.record.id);
}
@@ -528,7 +541,19 @@ export function undoImport(ctx: AppContext, id: number, userId: number): ImportS
ctx.db.update(imports).set({ status: 'undone', undoneAt: now }).where(eq(imports.id, id)).run();
})();
logChange(ctx, userId, 'undo_import', 'import', id);
+ afterMoneyChanged(ctx);
return importSummary(ctx, id);
}
+/** New or removed transactions: look for subscriptions, and work out the alerts again. */
+function afterMoneyChanged(ctx: AppContext) {
+ try {
+ scanSubscriptions(ctx);
+ refreshAlerts(ctx);
+ } catch (error) {
+ // The import itself is safe; subscriptions and alerts catch up next time.
+ ctx.log.error('Could not update subscriptions and alerts after an import', error);
+ }
+}
+
export { MAX_BYTES };
diff --git a/server/src/insights/engine.ts b/server/src/insights/engine.ts
new file mode 100644
index 0000000..73803e1
--- /dev/null
+++ b/server/src/insights/engine.ts
@@ -0,0 +1,427 @@
+import { addMonths, daysInMonth, formatCents, monthName, type AlertItem, type Overview, type Severity } from '@spendlight/shared';
+import { and, asc, desc, eq, isNull, ne, sql } from 'drizzle-orm';
+import type { AppContext } from '../context';
+import { accounts, alerts, categories, subscriptions, transactions } from '../db/schema';
+import { addDays, daysBetween, monthOf, monthRange, today } from '../dates';
+import { categoryKinds, monthsReport } from '../reports/actuals';
+import { alertSettings } from '../settings';
+import { CYCLE_DAYS, GRACE_DAYS } from '../subscriptions/engine';
+
+// Alerts and insights, worked out from the data each time it changes. Nothing
+// here is stored as a fact: if an import is undone or a budget changes, the
+// alerts that no longer hold simply disappear. The alert table only remembers
+// what people have read or dismissed.
+
+interface Draft {
+ kind: string;
+ subjectType: string;
+ subjectId: number;
+ period: string;
+ area: 'money' | 'subscriptions';
+ severity: Severity;
+ title: string;
+ body?: string | null;
+ link?: string | null;
+ /** Higher shows first. */
+ rank: number;
+}
+
+const money = (cents: number) => formatCents(cents, { whole: Math.abs(cents) >= 100_000 });
+const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
+const SEVERITY_RANK: Record = { bad: 300, warn: 200, good: 100, info: 50 };
+
+/** The month Home talks about: this one, or until statements arrive, the latest month with data. */
+export function focusMonth(ctx: AppContext): { month: string; isCurrent: boolean; dataThrough: string | null } {
+ const now = today();
+ const through = (ctx.sqlite.prepare('SELECT max(date) AS d FROM txn WHERE date <= ?').get(now) as { d: string | null }).d;
+ const month = through ? monthOf(through) : monthOf(now);
+ return { month, isCurrent: month === monthOf(now), dataThrough: through };
+}
+
+export function overview(ctx: AppContext, requested?: string): Overview {
+ const focus = focusMonth(ctx);
+ const month = requested ?? focus.month;
+ const isCurrent = month === monthOf(today());
+ const year = Number(month.slice(0, 4));
+ const report = monthsReport(ctx, monthRange(`${year}-01`, `${year}-12`));
+ const i = Number(month.slice(5, 7)) - 1;
+ const kinds = categoryKinds(ctx);
+
+ // How far into the month the statements reach, for pacing the budget.
+ const through = focus.dataThrough && monthOf(focus.dataThrough) === month ? Number(focus.dataThrough.slice(8, 10)) : daysInMonth(month);
+ const share = isCurrent ? Math.min(1, through / daysInMonth(month)) : 1;
+ const spentYtd = report.totals.spent.slice(0, i + 1).reduce((a, b) => a + b, 0);
+ const budgetSoFar = report.totals.budgetSpend.slice(0, i).reduce((a, b) => a + b, 0) + report.totals.budgetSpend[i]! * share;
+ const budgetYear = report.totals.budgetSpend.reduce((a, b) => a + b, 0);
+ const elapsedDays = daysBetween(`${year}-01-01`, `${month}-${String(Math.max(1, Math.round(share * daysInMonth(month)))).padStart(2, '0')}`) + 1;
+ const projected =
+ budgetSoFar > 0 ? Math.round((spentYtd * budgetYear) / budgetSoFar) : elapsedDays > 0 ? Math.round((spentYtd / elapsedDays) * (year % 4 === 0 ? 366 : 365)) : 0;
+
+ const toReview = (ctx.sqlite.prepare('SELECT count(*) AS n FROM txn WHERE needs_review = 1').get() as { n: number }).n;
+ const hasBudgets = (ctx.sqlite.prepare('SELECT count(*) AS n FROM budget WHERE year = ?').get(year) as { n: number }).n > 0;
+ const dayOfMonth = Number(today().slice(8, 10));
+
+ return {
+ month,
+ isCurrent,
+ daysLeft: isCurrent ? daysInMonth(month) - dayOfMonth : 0,
+ dataThrough: focus.dataThrough,
+ spent: report.totals.spent[i]!,
+ income: report.totals.income[i]!,
+ budgetSpend: report.totals.budgetSpend[i]!,
+ budgetIncome: report.totals.budgetIncome[i]!,
+ hasBudgets,
+ unbudgeted:
+ report.unfiled.out[i]! +
+ report.categories.filter((c) => kinds.get(c.categoryId) === 'expense' && c.budget[i] === 0 && c.actual[i]! > 0).reduce((s, c) => s + c.actual[i]!, 0),
+ categories: report.categories
+ .filter((c) => kinds.get(c.categoryId) === 'expense' && (c.actual[i] !== 0 || c.budget[i] !== 0))
+ .map((c) => ({ categoryId: c.categoryId, spent: c.actual[i]!, budget: c.budget[i]! }))
+ .sort((a, b) => b.spent - a.spent || b.budget - a.budget),
+ year: {
+ year,
+ spent: spentYtd,
+ budgetSoFar: Math.round(budgetSoFar),
+ budgetYear,
+ projected,
+ income: report.totals.income.slice(0, i + 1).reduce((a, b) => a + b, 0),
+ },
+ toReview,
+ };
+}
+
+function moneyAlerts(ctx: AppContext): Draft[] {
+ const drafts: Draft[] = [];
+ const settings = alertSettings(ctx);
+ const focus = focusMonth(ctx);
+ if (!focus.dataThrough) return drafts;
+ const names = new Map(ctx.db.select({ id: categories.id, name: categories.name }).from(categories).all().map((c) => [c.id, c.name]));
+ const kinds = categoryKinds(ctx);
+ const year = Number(focus.month.slice(0, 4));
+ const report = monthsReport(ctx, monthRange(`${year}-01`, `${year}-12`));
+ const at = (m: string) => Number(m.slice(5, 7)) - 1;
+ const expense = report.categories.filter((c) => kinds.get(c.categoryId) === 'expense');
+
+ // This month, while it's still going: categories close to or over budget.
+ if (focus.isCurrent) {
+ const i = at(focus.month);
+ const left = daysInMonth(focus.month) - Number(today().slice(8, 10));
+ for (const c of expense) {
+ const budget = c.budget[i]!;
+ const spent = c.actual[i]!;
+ if (budget <= 0 || spent <= 0) continue;
+ const name = names.get(c.categoryId)!;
+ const link = `/activity?categoryId=${c.categoryId}&period=month`;
+ if (spent > budget) {
+ drafts.push({
+ kind: 'budget_over', subjectType: 'category', subjectId: c.categoryId, period: focus.month, area: 'money', severity: 'bad',
+ title: `${name} is ${money(spent - budget)} over budget`,
+ body: `${money(spent)} spent of ${money(budget)} in ${monthName(focus.month)}${left > 0 ? `, with ${plural(left, 'day')} to go` : ''}.`,
+ link, rank: SEVERITY_RANK.bad + Math.min(99, Math.round(((spent - budget) / budget) * 50)),
+ });
+ } else if (spent >= (budget * settings.budgetWarnPct) / 100 && left > 0) {
+ const pct = Math.round((spent / budget) * 100);
+ drafts.push({
+ kind: 'budget_close', subjectType: 'category', subjectId: c.categoryId, period: focus.month, area: 'money', severity: 'warn',
+ title: `${name} is at ${pct}% of budget with ${plural(left, 'day')} left`,
+ body: `${money(budget - spent)} left of ${money(budget)} for ${monthName(focus.month)}.`,
+ link, rank: SEVERITY_RANK.warn + pct - 50,
+ });
+ }
+ }
+ }
+
+ // The last full month: where it ran over, set against the year so far.
+ const done = focus.isCurrent ? addMonths(focus.month, -1) : focus.month;
+ if (Number(done.slice(0, 4)) === year) {
+ const i = at(done);
+ const overs = expense
+ .map((c) => ({ c, over: c.actual[i]! - c.budget[i]! }))
+ .filter(({ c, over }) => c.budget[i]! > 0 && over >= Math.max(1000, c.budget[i]! * 0.1))
+ .sort((a, b) => b.over - a.over)
+ .slice(0, 3);
+ for (const { c, over } of overs) {
+ const ytd = c.actual.slice(0, i + 1).reduce((a, b) => a + b, 0) - c.budget.slice(0, i + 1).reduce((a, b) => a + b, 0);
+ drafts.push({
+ kind: 'month_category_over', subjectType: 'category', subjectId: c.categoryId, period: done, area: 'money', severity: 'warn',
+ title: `${names.get(c.categoryId)} ran ${money(over)} over in ${monthName(done)}`,
+ body: ytd <= 0 ? `You’re still ${money(-ytd)} under budget for the year.` : `That puts it ${money(ytd)} over budget for the year.`,
+ link: `/insights?view=categories&categoryId=${c.categoryId}`, rank: SEVERITY_RANK.warn - 20,
+ });
+ }
+ const spent = report.totals.spent[i]!;
+ const budget = report.totals.budgetSpend[i]!;
+ if (budget > 0 && spent > 0) {
+ const gap = budget - spent;
+ drafts.push({
+ kind: gap >= 0 ? 'month_under' : 'month_over', subjectType: 'month', subjectId: 0, period: done, area: 'money',
+ severity: gap >= 0 ? 'good' : 'bad',
+ title: gap >= 0 ? `${monthName(done)} came in ${money(gap)} under budget` : `${monthName(done)} ran ${money(-gap)} over budget`,
+ body: `${money(spent)} spent against a plan of ${money(budget)}.`,
+ link: `/insights?view=budget&month=${done}`, rank: (gap >= 0 ? SEVERITY_RANK.good : SEVERITY_RANK.bad) - 10,
+ });
+ }
+ }
+
+ // The year: on pace to finish over or under.
+ const o = overview(ctx);
+ if (o.year.budgetYear > 0 && o.year.budgetSoFar > 0) {
+ const gap = o.year.budgetYear - o.year.projected;
+ drafts.push({
+ kind: 'year_pace', subjectType: 'year', subjectId: year, period: focus.month, area: 'money',
+ severity: gap >= 0 ? 'good' : 'warn',
+ title: gap >= 0 ? `On pace to finish ${year} ${money(gap)} under budget` : `On pace to finish ${year} ${money(-gap)} over budget`,
+ body: `${money(o.year.spent)} spent so far against ${money(o.year.budgetSoFar)} planned to date.`,
+ link: '/insights?view=overview', rank: gap >= 0 ? SEVERITY_RANK.good - 5 : SEVERITY_RANK.warn - 5,
+ });
+ }
+
+ // Unusual purchases: several times what the category usually costs.
+ const recentFrom = addDays(focus.dataThrough, -45);
+ const recent = ctx.db
+ .select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, merchant: transactions.merchant, categoryId: transactions.categoryId })
+ .from(transactions)
+ .where(and(sql`${transactions.date} >= ${recentFrom}`, sql`${transactions.amountCents} <= -10000`, eq(transactions.isSplit, false)))
+ .orderBy(desc(transactions.date))
+ .limit(200)
+ .all()
+ .filter((t) => t.categoryId !== null && kinds.get(t.categoryId) === 'expense');
+ const typical = ctx.sqlite.prepare(
+ `SELECT amount_cents AS a FROM txn
+ WHERE category_id = ? AND amount_cents < 0 AND id != ? AND date >= ? AND date < ? AND is_split = 0
+ ORDER BY amount_cents`,
+ );
+ let unusual = 0;
+ for (const t of recent) {
+ if (unusual >= 3) break;
+ const prior = (typical.all(t.categoryId, t.id, addDays(t.date, -180), t.date) as { a: number }[]).map((r) => -r.a);
+ if (prior.length < 5) continue;
+ const med = prior[Math.floor(prior.length / 2)]!;
+ const amount = -t.amountCents;
+ if (med <= 0 || amount < med * settings.unusualMultiple) continue;
+ const times = Math.round(amount / med);
+ drafts.push({
+ kind: 'unusual', subjectType: 'transaction', subjectId: t.id, period: '', area: 'money', severity: 'info',
+ title: `Unusual: ${formatCents(amount)} at ${t.merchant}`,
+ body: `That’s ${times} times your typical ${names.get(t.categoryId!)} purchase.`,
+ link: `/activity?q=${encodeURIComponent(t.merchant)}`, rank: SEVERITY_RANK.info + 10,
+ });
+ unusual++;
+ }
+ return drafts;
+}
+
+function subscriptionAlerts(ctx: AppContext): Draft[] {
+ const drafts: Draft[] = [];
+ const settings = alertSettings(ctx);
+ const now = today();
+ const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
+ const coverage = new Map(
+ (ctx.sqlite.prepare('SELECT account_id AS a, max(date) AS d FROM txn GROUP BY account_id').all() as { a: number; d: string }[]).map((r) => [r.a, r.d]),
+ );
+ const newest = [...coverage.values()].sort().at(-1) ?? now;
+ const link = (id: number) => `/subscriptions?open=${id}`;
+ const per = (cadence: string) => (cadence === 'weekly' ? 'a week' : cadence === 'quarterly' ? 'every 3 months' : cadence === 'yearly' ? 'a year' : 'a month');
+
+ for (const s of ctx.db.select().from(subscriptions).where(ne(subscriptions.status, 'dismissed')).all()) {
+ const charges = ctx.db
+ .select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId })
+ .from(transactions)
+ .where(eq(transactions.subscriptionId, s.id))
+ .orderBy(asc(transactions.date), asc(transactions.id))
+ .all();
+
+ if (s.status === 'suggested') {
+ const since = s.firstChargeOn ? ` since ${monthName(monthOf(s.firstChargeOn))}` : '';
+ drafts.push({
+ kind: 'sub_found', subjectType: 'subscription', subjectId: s.id, period: '', area: 'subscriptions', severity: 'info',
+ title: `New recurring ${s.kind === 'bill' ? 'bill' : 'charge'}: ${s.name} at ${formatCents(s.expectedCents)} ${per(s.cadence)}${since}`,
+ body: s.kind === 'bill' ? 'Is it a regular bill? One tap tells Spendlight.' : 'Is it a subscription? One tap tells Spendlight.',
+ link: '/subscriptions', rank: SEVERITY_RANK.info + 20,
+ });
+ continue;
+ }
+ if (!s.alertsOn) continue;
+ const last = charges.at(-1);
+ const prev = charges.at(-2);
+ const fresh = (date: string) => daysBetween(date, newest) <= 45;
+
+ // Charged after it was cancelled: the alert most worth having.
+ if (s.status === 'cancelled' && s.cancelledOn) {
+ const after = charges.filter((c) => c.date > s.cancelledOn!).at(-1);
+ if (after && fresh(after.date)) {
+ drafts.push({
+ kind: 'sub_after_cancel', subjectType: 'subscription', subjectId: s.id, period: String(after.id), area: 'subscriptions', severity: 'bad',
+ title: `${s.name} charged ${accountNames.get(after.accountId) ?? 'a card'} after you marked it cancelled`,
+ body: `${formatCents(-after.amountCents)} on ${after.date}. It may still be running; check with ${s.name}.`,
+ link: link(s.id), rank: SEVERITY_RANK.bad + 50,
+ });
+ }
+ continue;
+ }
+
+ // Price changes.
+ if (last && prev && fresh(last.date) && s.status !== 'paused') {
+ const now$ = -last.amountCents;
+ if (s.kind === 'subscription') {
+ const was = -prev.amountCents;
+ const diff = now$ - was;
+ if (Math.abs(diff) > Math.max((was * settings.priceChangePct) / 100, settings.priceChangeMinCents)) {
+ drafts.push({
+ kind: 'sub_price', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions',
+ severity: diff > 0 ? 'warn' : 'good',
+ title: `${s.name} went ${diff > 0 ? 'up' : 'down'} ${formatCents(Math.abs(diff))}`,
+ body: `Now ${formatCents(now$)} ${per(s.cadence)}, ${diff > 0 ? 'up' : 'down'} from ${formatCents(was)}.`,
+ link: link(s.id), rank: SEVERITY_RANK.warn + 10,
+ });
+ }
+ } else {
+ const before = charges.slice(-4, -1).map((c) => -c.amountCents);
+ const usual = Math.round(before.reduce((a, b) => a + b, 0) / before.length);
+ const diff = now$ - usual;
+ if (diff > Math.max(usual * 0.25, 1000)) {
+ drafts.push({
+ kind: 'sub_price', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions', severity: 'warn',
+ title: `Your ${s.name} bill went up ${money(diff)} this ${s.cadence === 'monthly' ? 'month' : 'time'}`,
+ body: `${formatCents(now$)}, compared with about ${formatCents(usual)} usually.`,
+ link: link(s.id), rank: SEVERITY_RANK.warn,
+ });
+ }
+ }
+ }
+
+ // Billed on two cards in the same cycle.
+ if (last && fresh(last.date)) {
+ const twin = charges.find((c) => c.id !== last.id && c.accountId !== last.accountId && Math.abs(daysBetween(c.date, last.date)) < CYCLE_DAYS[s.cadence] * 0.5);
+ if (twin) {
+ drafts.push({
+ kind: 'sub_two_cards', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions', severity: 'warn',
+ title: `${s.name} was billed on two cards`,
+ body: `${formatCents(-twin.amountCents)} on ${accountNames.get(twin.accountId)} and ${formatCents(-last.amountCents)} on ${accountNames.get(last.accountId)}, within days of each other.`,
+ link: link(s.id), rank: SEVERITY_RANK.warn + 20,
+ });
+ }
+ }
+
+ if (s.status === 'active' && s.nextExpectedOn) {
+ // A charge that should have arrived, in statements that reach past it.
+ const reach = (s.accountId && coverage.get(s.accountId)) || newest;
+ if (daysBetween(s.nextExpectedOn, reach) > GRACE_DAYS[s.cadence]) {
+ drafts.push({
+ kind: 'sub_missing', subjectType: 'subscription', subjectId: s.id, period: s.nextExpectedOn, area: 'subscriptions', severity: 'warn',
+ title: s.cadence === 'yearly' ? `${s.name} didn’t renew` : `${s.name} didn’t charge in ${monthName(monthOf(s.nextExpectedOn))}`,
+ body: `It usually charges ${formatCents(s.expectedCents)} around ${s.nextExpectedOn}. If you cancelled it, mark it cancelled.`,
+ link: link(s.id), rank: SEVERITY_RANK.warn - 10,
+ });
+ }
+ // A yearly renewal coming up.
+ if (s.cadence === 'yearly' && s.nextExpectedOn >= now && daysBetween(now, s.nextExpectedOn) <= 14) {
+ drafts.push({
+ kind: 'sub_renewal', subjectType: 'subscription', subjectId: s.id, period: s.nextExpectedOn, area: 'subscriptions', severity: 'info',
+ title: `${s.name} renews on ${s.nextExpectedOn}`,
+ body: `${formatCents(s.expectedCents)} a year${s.accountId ? `, on ${accountNames.get(s.accountId)}` : ''}.`,
+ link: link(s.id), rank: SEVERITY_RANK.info + 30,
+ });
+ }
+ }
+
+ // Free trials about to turn into charges, and ones that just did.
+ if (s.status === 'trial' && s.trialEndsOn && s.trialEndsOn >= now && daysBetween(now, s.trialEndsOn) <= 3) {
+ const days = daysBetween(now, s.trialEndsOn);
+ drafts.push({
+ kind: 'sub_trial_ending', subjectType: 'subscription', subjectId: s.id, period: s.trialEndsOn, area: 'subscriptions', severity: 'warn',
+ title: `Your ${s.name} trial ends ${days === 0 ? 'today' : `in ${plural(days, 'day')}`}`,
+ body: `Then it’s ${formatCents(s.expectedCents)} ${per(s.cadence)}. Cancel before ${s.trialEndsOn} if you don’t want it.`,
+ link: link(s.id), rank: SEVERITY_RANK.warn + 30,
+ });
+ }
+ if (s.status === 'active' && s.trialEndsOn) {
+ const first = charges.find((c) => c.date >= s.trialEndsOn! && daysBetween(s.trialEndsOn!, c.date) <= 15);
+ if (first && fresh(first.date)) {
+ drafts.push({
+ kind: 'sub_trial_converted', subjectType: 'subscription', subjectId: s.id, period: s.trialEndsOn, area: 'subscriptions', severity: 'info',
+ title: `Your ${s.name} trial ended and it charged ${formatCents(-first.amountCents)}`,
+ body: 'It’s an active subscription now.',
+ link: link(s.id), rank: SEVERITY_RANK.info + 25,
+ });
+ }
+ }
+ }
+ return drafts;
+}
+
+/** Work out every alert again and remember the result. */
+export function refreshAlerts(ctx: AppContext): void {
+ const drafts = [...moneyAlerts(ctx), ...subscriptionAlerts(ctx)];
+ const now = new Date().toISOString();
+ const keyOf = (d: { kind: string; subjectType: string; subjectId: number; period: string }) => `${d.kind}|${d.subjectType}|${d.subjectId}|${d.period}`;
+ const live = new Set(drafts.map(keyOf));
+ ctx.sqlite.transaction(() => {
+ for (const d of drafts) {
+ ctx.db
+ .insert(alerts)
+ .values({ ...d, body: d.body ?? null, link: d.link ?? null, createdAt: now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: [alerts.kind, alerts.subjectType, alerts.subjectId, alerts.period],
+ set: { title: d.title, body: d.body ?? null, link: d.link ?? null, severity: d.severity, rank: d.rank, area: d.area, resolvedAt: null, updatedAt: now },
+ })
+ .run();
+ }
+ for (const a of ctx.db.select().from(alerts).where(isNull(alerts.resolvedAt)).all()) {
+ if (!live.has(keyOf(a))) ctx.db.update(alerts).set({ resolvedAt: now }).where(eq(alerts.id, a.id)).run();
+ }
+ })();
+ fresh.set(ctx, stamp(ctx));
+}
+
+// Alerts are refreshed lazily: when anything they depend on has changed since last time.
+const fresh = new WeakMap();
+function stamp(ctx: AppContext): string {
+ const q = (sqlText: string) => JSON.stringify(ctx.sqlite.prepare(sqlText).get());
+ return [
+ today(),
+ q('SELECT max(updated_at) AS u, count(*) AS n, sum(amount_cents) AS s FROM txn'),
+ q('SELECT max(updated_at) AS u, count(*) AS n, sum(amount_cents) AS s FROM budget'),
+ q('SELECT max(updated_at) AS u, count(*) AS n FROM subscription'),
+ q("SELECT value AS v FROM setting WHERE key = 'alerts'"),
+ q('SELECT count(*) AS n, max(id) AS m FROM category'),
+ ].join('|');
+}
+
+export function ensureFresh(ctx: AppContext): void {
+ if (fresh.get(ctx) !== stamp(ctx)) refreshAlerts(ctx);
+}
+
+export function listAlerts(ctx: AppContext, options: { area?: 'money' | 'subscriptions'; limit?: number } = {}): AlertItem[] {
+ ensureFresh(ctx);
+ return ctx.db
+ .select()
+ .from(alerts)
+ .where(and(isNull(alerts.resolvedAt), isNull(alerts.dismissedAt), options.area ? eq(alerts.area, options.area) : undefined))
+ .orderBy(desc(alerts.rank), desc(alerts.createdAt), desc(alerts.id))
+ .limit(options.limit ?? 100)
+ .all()
+ .map((a) => ({
+ id: a.id,
+ kind: a.kind,
+ area: a.area,
+ severity: a.severity,
+ title: a.title,
+ body: a.body,
+ link: a.link,
+ createdAt: a.createdAt,
+ read: !!a.readAt,
+ }));
+}
+
+export function alertCounts(ctx: AppContext): { money: number; subscriptions: number } {
+ ensureFresh(ctx);
+ const rows = ctx.db
+ .select({ area: alerts.area, n: sql`count(*)` })
+ .from(alerts)
+ .where(and(isNull(alerts.resolvedAt), isNull(alerts.dismissedAt), isNull(alerts.readAt)))
+ .groupBy(alerts.area)
+ .all();
+ return { money: rows.find((r) => r.area === 'money')?.n ?? 0, subscriptions: rows.find((r) => r.area === 'subscriptions')?.n ?? 0 };
+}
+
diff --git a/server/src/reports/actuals.ts b/server/src/reports/actuals.ts
new file mode 100644
index 0000000..4bc63c1
--- /dev/null
+++ b/server/src/reports/actuals.ts
@@ -0,0 +1,101 @@
+import type { CategoryKind, MonthsReport } from '@spendlight/shared';
+import { budgetMatrix } from '../budgets/budgets';
+import type { AppContext } from '../context';
+import { categories } from '../db/schema';
+import { lastDayOf, today } from '../dates';
+
+// Spending and income by category and month. Split purchases count each part
+// in its own category, refunds reduce the category they came back to, and
+// transfers between your own accounts never count.
+
+interface Totals {
+ /** Net cents as stored (money out negative), by category id and month index. */
+ net: Map;
+ unfiledOut: number[];
+ unfiledIn: number[];
+}
+
+export function categoryKinds(ctx: AppContext): Map {
+ return new Map(ctx.db.select({ id: categories.id, kind: categories.kind }).from(categories).all().map((c) => [c.id, c.kind]));
+}
+
+function netByMonth(ctx: AppContext, months: string[]): Totals {
+ const index = new Map(months.map((m, i) => [m, i]));
+ const from = `${months[0]}-01`;
+ const to = lastDayOf(months.at(-1)!);
+ const zero = () => Array(months.length).fill(0);
+ const out: Totals = { net: new Map(), unfiledOut: zero(), unfiledIn: zero() };
+
+ const whole = ctx.sqlite
+ .prepare(
+ `SELECT substr(date, 1, 7) AS month, category_id AS categoryId,
+ sum(amount_cents) AS net,
+ sum(CASE WHEN amount_cents < 0 THEN amount_cents ELSE 0 END) AS outCents,
+ sum(CASE WHEN amount_cents > 0 THEN amount_cents ELSE 0 END) AS inCents
+ FROM txn
+ WHERE is_split = 0 AND date >= ? AND date <= ?
+ AND NOT (category_id IS NULL AND transfer_peer_id IS NOT NULL)
+ GROUP BY month, category_id`,
+ )
+ .all(from, to) as { month: string; categoryId: number | null; net: number; outCents: number; inCents: number }[];
+ const parts = ctx.sqlite
+ .prepare(
+ `SELECT substr(t.date, 1, 7) AS month, s.category_id AS categoryId, sum(s.amount_cents) AS net
+ FROM split s JOIN txn t ON t.id = s.transaction_id
+ WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ?
+ GROUP BY month, s.category_id`,
+ )
+ .all(from, to) as { month: string; categoryId: number; net: number }[];
+
+ for (const r of [...whole, ...parts]) {
+ const i = index.get(r.month);
+ if (i === undefined) continue;
+ if (r.categoryId === null) {
+ const w = r as (typeof whole)[number];
+ out.unfiledOut[i]! += -w.outCents;
+ out.unfiledIn[i]! += w.inCents;
+ continue;
+ }
+ const row = out.net.get(r.categoryId) ?? zero();
+ row[i]! += r.net;
+ out.net.set(r.categoryId, row);
+ }
+ return out;
+}
+
+export function dataThrough(ctx: AppContext): string | null {
+ const row = ctx.sqlite.prepare(`SELECT max(date) AS d FROM txn WHERE date <= ?`).get(today()) as { d: string | null };
+ return row.d;
+}
+
+export function monthsReport(ctx: AppContext, months: string[]): MonthsReport {
+ const kinds = categoryKinds(ctx);
+ const totals = netByMonth(ctx, months);
+ const budgets = budgetMatrix(ctx, months);
+ const zero = () => Array(months.length).fill(0);
+ const ids = new Set([...totals.net.keys(), ...budgets.keys()]);
+ const report: MonthsReport = {
+ months,
+ categories: [],
+ unfiled: { out: totals.unfiledOut, in: totals.unfiledIn },
+ totals: { spent: [...totals.unfiledOut], income: [...totals.unfiledIn], budgetSpend: zero(), budgetIncome: zero() },
+ dataThrough: dataThrough(ctx),
+ };
+ for (const id of ids) {
+ const kind = kinds.get(id);
+ if (!kind || kind === 'transfer') continue;
+ const net = totals.net.get(id) ?? zero();
+ const actual = net.map((n) => (kind === 'expense' ? -n : n));
+ const budget = budgets.get(id) ?? zero();
+ report.categories.push({ categoryId: id, actual, budget });
+ actual.forEach((v, i) => {
+ if (kind === 'expense') report.totals.spent[i]! += v;
+ else report.totals.income[i]! += v;
+ });
+ budget.forEach((v, i) => {
+ if (kind === 'expense') report.totals.budgetSpend[i]! += v;
+ else report.totals.budgetIncome[i]! += v;
+ });
+ }
+ return report;
+}
diff --git a/server/src/routes/ai.ts b/server/src/routes/ai.ts
new file mode 100644
index 0000000..efb5c72
--- /dev/null
+++ b/server/src/routes/ai.ts
@@ -0,0 +1,55 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { aiStatus, setAiFeature } from '../ai';
+import { logChange } from '../audit';
+import { requireAdmin } from '../auth/guards';
+import type { AppContext } from '../context';
+import { fail, type Env } from '../http';
+import { jsonBody, jsonResponse, validationHook } from '../schemas';
+
+const Feature = z.enum(['categorize', 'insights', 'assistant', 'agents']);
+const AiStatusSchema = z
+ .object({
+ providerConfigured: z.boolean(),
+ provider: z.string().nullable(),
+ features: z.array(z.object({ key: Feature, label: z.string(), description: z.string(), enabled: z.boolean() })),
+ })
+ .openapi('AiStatus');
+
+export function aiRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/ai',
+ tags: ['AI'],
+ summary: 'AI features and whether each is on (admins only)',
+ responses: jsonResponse(AiStatusSchema, 'Status'),
+ }),
+ (c) => {
+ requireAdmin(c);
+ return c.json(aiStatus(ctx), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/ai',
+ tags: ['AI'],
+ summary: 'Switch an AI feature on or off (admins only; needs a provider on the server)',
+ request: jsonBody(z.object({ feature: Feature, enabled: z.boolean() })),
+ responses: jsonResponse(AiStatusSchema, 'Status'),
+ }),
+ (c) => {
+ const user = requireAdmin(c);
+ const { feature, enabled } = c.req.valid('json');
+ if (enabled && !ctx.config.aiProvider) fail(400, 'no_provider', 'No AI provider is set up on this server, so AI features stay off.');
+ setAiFeature(ctx, feature, enabled);
+ logChange(ctx, user.id, 'ai_feature', 'setting', 'ai', { feature, enabled });
+ return c.json(aiStatus(ctx), 200);
+ },
+ );
+
+ return r;
+}
diff --git a/server/src/routes/alerts.ts b/server/src/routes/alerts.ts
new file mode 100644
index 0000000..d79ee75
--- /dev/null
+++ b/server/src/routes/alerts.ts
@@ -0,0 +1,139 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { and, eq, isNull } from 'drizzle-orm';
+import { logChange } from '../audit';
+import { requireUser, requireWrite } from '../auth/guards';
+import type { AppContext } from '../context';
+import { alerts } from '../db/schema';
+import { fail, nowIso, type Env } from '../http';
+import { alertCounts, listAlerts, refreshAlerts } from '../insights/engine';
+import { IdParam, jsonBody, jsonResponse, validationHook } from '../schemas';
+import { alertSettings, putSetting } from '../settings';
+
+const Area = z.enum(['money', 'subscriptions']);
+
+const AlertSchema = z
+ .object({
+ id: z.number().int(),
+ kind: z.string(),
+ area: Area,
+ severity: z.enum(['info', 'good', 'warn', 'bad']),
+ title: z.string(),
+ body: z.string().nullable(),
+ link: z.string().nullable(),
+ createdAt: z.string(),
+ read: z.boolean(),
+ })
+ .openapi('Alert');
+
+const AlertSettingsSchema = z
+ .object({
+ priceChangePct: z.number().int().min(1).max(100),
+ priceChangeMinCents: z.number().int().min(0).max(100_000),
+ budgetWarnPct: z.number().int().min(50).max(100),
+ unusualMultiple: z.number().int().min(2).max(20),
+ })
+ .openapi('AlertSettings');
+
+export function alertRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/alerts',
+ tags: ['Alerts'],
+ summary: 'Alerts and insights that still hold, most important first',
+ request: { query: z.object({ area: Area.optional(), limit: z.coerce.number().int().min(1).max(100).default(50) }) },
+ responses: jsonResponse(z.array(AlertSchema), 'Alerts'),
+ }),
+ (c) => {
+ requireUser(c);
+ const q = c.req.valid('query');
+ return c.json(listAlerts(ctx, q), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/alerts/counts',
+ tags: ['Alerts'],
+ summary: 'How many unread alerts there are, for the badges on the menu',
+ responses: jsonResponse(z.object({ money: z.number().int(), subscriptions: z.number().int() }), 'Counts'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(alertCounts(ctx), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/alerts/read',
+ tags: ['Alerts'],
+ summary: 'Mark every alert in an area as read (they stay listed)',
+ request: jsonBody(z.object({ area: Area })),
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Read'),
+ }),
+ (c) => {
+ requireUser(c);
+ const { area } = c.req.valid('json');
+ ctx.db.update(alerts).set({ readAt: nowIso() }).where(and(eq(alerts.area, area), isNull(alerts.readAt))).run();
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/alerts/{id}/dismiss',
+ tags: ['Alerts'],
+ summary: 'Dismiss an alert; it won’t come back for the same occurrence',
+ request: { params: IdParam },
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Dismissed'),
+ }),
+ (c) => {
+ requireUser(c);
+ const { id } = c.req.valid('param');
+ const result = ctx.db.update(alerts).set({ dismissedAt: nowIso(), readAt: nowIso() }).where(eq(alerts.id, id)).run();
+ if (!result.changes) fail(404, 'not_found', 'That alert wasn’t found.');
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/settings/alerts',
+ tags: ['Alerts'],
+ summary: 'When alerts speak up',
+ responses: jsonResponse(AlertSettingsSchema, 'Settings'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(alertSettings(ctx), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'put',
+ path: '/settings/alerts',
+ tags: ['Alerts'],
+ summary: 'Change when alerts speak up',
+ request: jsonBody(AlertSettingsSchema),
+ responses: jsonResponse(AlertSettingsSchema, 'Saved'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const body = c.req.valid('json');
+ putSetting(ctx, 'alerts', body);
+ refreshAlerts(ctx);
+ logChange(ctx, user.id, 'alert_settings', 'setting', 'alerts', body);
+ return c.json(alertSettings(ctx), 200);
+ },
+ );
+
+ return r;
+}
diff --git a/server/src/routes/budgets.ts b/server/src/routes/budgets.ts
new file mode 100644
index 0000000..0a8045d
--- /dev/null
+++ b/server/src/routes/budgets.ts
@@ -0,0 +1,126 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { addMonths } from '@spendlight/shared';
+import { eq } from 'drizzle-orm';
+import { logChange } from '../audit';
+import { requireUser, requireWrite } from '../auth/guards';
+import { budgetMatrix, budgetYear, copyYear, fillBudgets, setBudget } from '../budgets/budgets';
+import type { AppContext } from '../context';
+import { categories } from '../db/schema';
+import { fail, type Env } from '../http';
+import { categoryKinds, monthsReport } from '../reports/actuals';
+import { jsonBody, jsonResponse, validationHook } from '../schemas';
+
+const Year = z.coerce.number().int().min(2000).max(2100);
+const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use a month like 2026-10.');
+
+const BudgetYearSchema = z
+ .object({
+ year: z.number().int(),
+ items: z.array(z.object({ categoryId: z.number().int(), monthly: z.number().int().nullable(), months: z.array(z.number().int().nullable()) })),
+ })
+ .openapi('BudgetYear');
+
+/** Budgets read best in round numbers: whole dollars, and steps of $5 from $20 up. */
+const roundBudget = (cents: number) => (cents >= 2000 ? Math.ceil(cents / 500) * 500 : Math.ceil(cents / 100) * 100);
+
+export function budgetRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/budgets',
+ tags: ['Budgets'],
+ summary: 'A year of budgets: an amount for every month, and any month set on its own',
+ request: { query: z.object({ year: Year }) },
+ responses: jsonResponse(BudgetYearSchema, 'Budgets'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(budgetYear(ctx, c.req.valid('query').year), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'put',
+ path: '/budgets',
+ tags: ['Budgets'],
+ summary: 'Set a category’s budget for one month, from a month through December, or for every month',
+ request: jsonBody(
+ z.object({
+ categoryId: z.number().int(),
+ year: Year,
+ month: z.number().int().min(1).max(12),
+ amountCents: z.number().int().min(0).max(100_000_000),
+ scope: z.enum(['month', 'rest', 'year']),
+ }),
+ ),
+ responses: jsonResponse(BudgetYearSchema, 'The year’s budgets'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const body = c.req.valid('json');
+ const kind = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, body.categoryId)).get()?.kind;
+ if (!kind) fail(400, 'no_category', 'That category doesn’t exist.');
+ if (kind === 'transfer') fail(400, 'transfer', 'Transfers between your accounts don’t need a budget.');
+ setBudget(ctx, body.categoryId, body.year, body.month, body.amountCents, body.scope);
+ logChange(ctx, user.id, 'set_budget', 'budget', body.categoryId, body);
+ return c.json(budgetYear(ctx, body.year), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/budgets/fill',
+ tags: ['Budgets'],
+ summary: 'Fill in categories with no budget yet, from last month or a 3-month average',
+ request: jsonBody(z.object({ month: Month, source: z.enum(['last_budget', 'last_actual', 'average3']) })),
+ responses: jsonResponse(z.object({ filled: z.number().int(), budgets: BudgetYearSchema }), 'Filled'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { month, source } = c.req.valid('json');
+ const year = Number(month.slice(0, 4));
+ const kinds = categoryKinds(ctx);
+ const amounts = new Map();
+ if (source === 'last_budget') {
+ const prev = addMonths(month, -1);
+ for (const [id, row] of budgetMatrix(ctx, [prev])) amounts.set(id, row[0]!);
+ } else {
+ const span = source === 'average3' ? 3 : 1;
+ const months = Array.from({ length: span }, (_, i) => addMonths(month, i - span));
+ for (const cat of monthsReport(ctx, months).categories) {
+ const avg = cat.actual.reduce((a, b) => a + b, 0) / span;
+ if (avg > 0) amounts.set(cat.categoryId, roundBudget(avg));
+ }
+ }
+ for (const id of amounts.keys()) if (kinds.get(id) === 'transfer' || !kinds.has(id)) amounts.delete(id);
+ const filled = fillBudgets(ctx, year, amounts);
+ logChange(ctx, user.id, 'fill_budgets', 'budget', null, { month, source, filled });
+ return c.json({ filled, budgets: budgetYear(ctx, year) }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/budgets/copy-year',
+ tags: ['Budgets'],
+ summary: 'Start a new year from last year’s budgets',
+ request: jsonBody(z.object({ from: Year, to: Year })),
+ responses: jsonResponse(z.object({ copied: z.number().int(), budgets: BudgetYearSchema }), 'Copied'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { from, to } = c.req.valid('json');
+ if (from === to) fail(400, 'same_year', 'Choose two different years.');
+ const copied = copyYear(ctx, from, to);
+ logChange(ctx, user.id, 'copy_budget_year', 'budget', null, { from, to, copied });
+ return c.json({ copied, budgets: budgetYear(ctx, to) }, 200);
+ },
+ );
+
+ return r;
+}
diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts
new file mode 100644
index 0000000..630b2b2
--- /dev/null
+++ b/server/src/routes/categories.ts
@@ -0,0 +1,397 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { and, asc, count, desc, eq, ne, sql } from 'drizzle-orm';
+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 { fail, nowIso, type Env } from '../http';
+import { CategoryGroupSchema, CategorySchema, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas';
+import { listCategoryGroups } from '../transactions/dto';
+
+// Categories live in one place and everything points at them by ID, so a
+// rename shows up everywhere at once. Deleting one means merging it into
+// another, which takes its transactions, budgets and rules along.
+
+const Name = z.string().trim().min(1, 'Please enter a name.').max(60);
+const Icon = z.string().regex(/^[a-z0-9-]{1,40}$/);
+const KindSchema = z.enum(['expense', 'income', 'transfer']);
+
+const RuleSchema = z
+ .object({
+ id: z.number().int(),
+ merchant: z.string(),
+ categoryId: z.number().int().nullable(),
+ renameTo: z.string().nullable(),
+ source: z.enum(['learned', 'manual']),
+ hits: z.number().int(),
+ createdAt: z.string(),
+ lastUsedAt: z.string().nullable(),
+ })
+ .openapi('Rule');
+
+export function categoryRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ const findCategory = (id: number): CategoryRow =>
+ ctx.db.select().from(categories).where(eq(categories.id, id)).get() ?? fail(404, 'not_found', 'That category wasn’t found.');
+ const findGroup = (id: number) =>
+ ctx.db.select().from(categoryGroups).where(eq(categoryGroups.id, id)).get() ?? fail(404, 'not_found', 'That group wasn’t found.');
+ const assertUniqueName = (name: string, except?: number) => {
+ const clash = ctx.db
+ .select({ id: categories.id })
+ .from(categories)
+ .where(and(sql`lower(${categories.name}) = ${name.toLowerCase()}`, except ? ne(categories.id, except) : undefined))
+ .get();
+ if (clash) fail(400, 'duplicate', `There’s already a category called “${name}”.`);
+ };
+ const asCategory = (id: number) => listCategoryGroups(ctx, true).flatMap((g) => g.categories).find((c) => c.id === id)!;
+
+ // --- Categories ----------------------------------------------------------------
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/categories',
+ tags: ['Categories'],
+ summary: 'Categories, in their groups',
+ request: { query: z.object({ includeArchived: z.enum(['true', 'false']).optional() }) },
+ responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(listCategoryGroups(ctx, c.req.valid('query').includeArchived === 'true'), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/categories',
+ tags: ['Categories'],
+ summary: 'Add a category',
+ request: jsonBody(z.object({ groupId: z.number().int(), name: Name, kind: KindSchema.optional(), icon: Icon.default('circle'), color: PaletteSchema.default('slate') })),
+ responses: jsonResponse(CategorySchema, 'Added'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const body = c.req.valid('json');
+ findGroup(body.groupId);
+ assertUniqueName(body.name);
+ // A new category takes the kind most common in its group.
+ const sibling = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.groupId, body.groupId)).get();
+ const last = ctx.db.select({ n: sql`coalesce(max(${categories.sortOrder}), -1)` }).from(categories).where(eq(categories.groupId, body.groupId)).get();
+ const row = ctx.db
+ .insert(categories)
+ .values({ groupId: body.groupId, name: body.name, kind: body.kind ?? sibling?.kind ?? 'expense', icon: body.icon, color: body.color, sortOrder: (last?.n ?? -1) + 1 })
+ .returning()
+ .get();
+ logChange(ctx, user.id, 'add_category', 'category', row.id, { name: body.name });
+ return c.json(asCategory(row.id), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/categories/{id}',
+ tags: ['Categories'],
+ summary: 'Rename, recolor, move or archive a category',
+ request: {
+ params: IdParam,
+ ...jsonBody(z.object({ name: Name.optional(), groupId: z.number().int().optional(), kind: KindSchema.optional(), icon: Icon.optional(), color: PaletteSchema.optional(), archived: z.boolean().optional() })),
+ },
+ responses: jsonResponse(CategorySchema, 'Updated'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const body = c.req.valid('json');
+ findCategory(id);
+ if (body.name) assertUniqueName(body.name, id);
+ if (body.groupId) findGroup(body.groupId);
+ ctx.db.update(categories).set(body).where(eq(categories.id, id)).run();
+ logChange(ctx, user.id, 'update_category', 'category', id, body);
+ return c.json(asCategory(id), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/categories/{id}/merge',
+ tags: ['Categories'],
+ summary: 'Delete a category by merging it into another',
+ description: 'Its transactions, split parts, budgets, rules and subscriptions all move to the other category.',
+ request: { params: IdParam, ...jsonBody(z.object({ intoId: z.number().int() })) },
+ responses: jsonResponse(z.object({ moved: z.number().int() }), 'Merged'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const { intoId } = c.req.valid('json');
+ if (id === intoId) fail(400, 'same', 'Choose a different category to merge into.');
+ const from = findCategory(id);
+ const into = findCategory(intoId);
+ if (from.kind !== into.kind) fail(400, 'kind', 'Merge into a category of the same kind: spending into spending, income into income.');
+ let moved = 0;
+ ctx.sqlite.transaction(() => {
+ moved = ctx.db.update(transactions).set({ categoryId: intoId, updatedAt: nowIso() }).where(eq(transactions.categoryId, id)).run().changes;
+ ctx.db.update(splits).set({ categoryId: intoId }).where(eq(splits.categoryId, id)).run();
+ ctx.db.update(rules).set({ categoryId: intoId }).where(eq(rules.categoryId, id)).run();
+ ctx.db.update(subscriptions).set({ categoryId: intoId }).where(eq(subscriptions.categoryId, id)).run();
+ // Budgets add together where both had one.
+ for (const b of ctx.db.select().from(budgets).where(eq(budgets.categoryId, id)).all()) {
+ const target = ctx.db.select().from(budgets).where(and(eq(budgets.categoryId, intoId), eq(budgets.year, b.year), eq(budgets.month, b.month))).get();
+ if (target) ctx.db.update(budgets).set({ amountCents: target.amountCents + b.amountCents, updatedAt: nowIso() }).where(eq(budgets.id, target.id)).run();
+ else ctx.db.insert(budgets).values({ categoryId: intoId, year: b.year, month: b.month, amountCents: b.amountCents, updatedAt: nowIso() }).run();
+ }
+ ctx.db.delete(budgets).where(eq(budgets.categoryId, id)).run();
+ ctx.db.delete(categories).where(eq(categories.id, id)).run();
+ })();
+ logChange(ctx, user.id, 'merge_category', 'category', intoId, { from: from.name, into: into.name, moved });
+ return c.json({ moved }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/categories/reorder',
+ tags: ['Categories'],
+ summary: 'Put groups, and the categories in them, in a new order',
+ request: jsonBody(z.object({ groups: z.array(z.object({ id: z.number().int(), categoryIds: z.array(z.number().int()) })).max(100) })),
+ responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { groups } = c.req.valid('json');
+ ctx.sqlite.transaction(() => {
+ groups.forEach((g, gi) => {
+ ctx.db.update(categoryGroups).set({ sortOrder: gi }).where(eq(categoryGroups.id, g.id)).run();
+ g.categoryIds.forEach((cid, ci) => ctx.db.update(categories).set({ groupId: g.id, sortOrder: ci }).where(eq(categories.id, cid)).run());
+ });
+ })();
+ logChange(ctx, user.id, 'reorder_categories', 'category', null);
+ return c.json(listCategoryGroups(ctx, true), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/category-groups',
+ tags: ['Categories'],
+ summary: 'Add a group',
+ request: jsonBody(z.object({ name: Name })),
+ responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Added'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { name } = c.req.valid('json');
+ const last = ctx.db.select({ n: sql`coalesce(max(${categoryGroups.sortOrder}), -1)` }).from(categoryGroups).get();
+ const row = ctx.db.insert(categoryGroups).values({ name, sortOrder: (last?.n ?? -1) + 1 }).returning().get();
+ logChange(ctx, user.id, 'add_category_group', 'category_group', row.id, { name });
+ return c.json({ id: row.id, name: row.name }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/category-groups/{id}',
+ tags: ['Categories'],
+ summary: 'Rename a group',
+ request: { params: IdParam, ...jsonBody(z.object({ name: Name })) },
+ responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Renamed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const { name } = c.req.valid('json');
+ findGroup(id);
+ ctx.db.update(categoryGroups).set({ name }).where(eq(categoryGroups.id, id)).run();
+ logChange(ctx, user.id, 'rename_category_group', 'category_group', id, { name });
+ return c.json({ id, name }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'delete',
+ path: '/category-groups/{id}',
+ tags: ['Categories'],
+ summary: 'Remove an empty group',
+ request: { params: IdParam },
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ findGroup(id);
+ const inside = ctx.db.select({ n: count() }).from(categories).where(eq(categories.groupId, id)).get()?.n ?? 0;
+ if (inside) fail(400, 'not_empty', 'Move or merge the categories in this group first.');
+ ctx.db.delete(categoryGroups).where(eq(categoryGroups.id, id)).run();
+ logChange(ctx, user.id, 'delete_category_group', 'category_group', id);
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ // --- Tags ------------------------------------------------------------------------
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/tags',
+ tags: ['Categories'],
+ summary: 'Tags, most used first',
+ responses: jsonResponse(z.array(z.object({ id: z.number().int(), name: z.string(), count: z.number().int() })), 'Tags'),
+ }),
+ (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();
+ return c.json(rows, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/tags/{id}',
+ tags: ['Categories'],
+ summary: 'Rename a tag (renaming it to an existing tag merges the two)',
+ request: { params: IdParam, ...jsonBody(z.object({ name: z.string().trim().min(1).max(40) })) },
+ responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Renamed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const { name } = c.req.valid('json');
+ if (!ctx.db.select().from(tags).where(eq(tags.id, id)).get()) fail(404, 'not_found', 'That tag wasn’t found.');
+ const other = ctx.db.select().from(tags).where(and(sql`lower(${tags.name}) = ${name.toLowerCase()}`, ne(tags.id, id))).get();
+ let result = { id, name };
+ ctx.sqlite.transaction(() => {
+ if (other) {
+ 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();
+ }
+ 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();
+ })();
+ logChange(ctx, user.id, 'rename_tag', 'tag', id, { name });
+ return c.json(result, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'delete',
+ path: '/tags/{id}',
+ tags: ['Categories'],
+ summary: 'Remove a tag from every transaction',
+ request: { params: IdParam },
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ ctx.db.delete(tags).where(eq(tags.id, id)).run();
+ logChange(ctx, user.id, 'delete_tag', 'tag', id);
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ // --- Rules -----------------------------------------------------------------------
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/rules',
+ tags: ['Categories'],
+ summary: 'Saved rules: merchants Spendlight always files or renames the same way',
+ responses: jsonResponse(z.array(RuleSchema), 'Rules'),
+ }),
+ (c) => {
+ requireUser(c);
+ const rows = ctx.db.select().from(rules).orderBy(asc(sql`lower(${rules.merchant})`)).all();
+ return c.json(
+ rows.map(({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt }) => ({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt })),
+ 200,
+ );
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/rules',
+ tags: ['Categories'],
+ summary: 'Always file a merchant in a category',
+ request: jsonBody(z.object({ merchant: z.string().trim().min(1).max(80), categoryId: z.number().int() })),
+ responses: jsonResponse(z.object({ id: z.number().int() }), 'Saved'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { merchant, categoryId } = c.req.valid('json');
+ findCategory(categoryId);
+ const id = rememberRule(ctx, merchant, categoryId, 'manual');
+ logChange(ctx, user.id, 'add_rule', 'rule', id, { merchant, categoryId });
+ return c.json({ id }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/rules/{id}',
+ tags: ['Categories'],
+ summary: 'Change what a rule does',
+ request: {
+ params: IdParam,
+ ...jsonBody(z.object({ categoryId: z.number().int().nullable().optional(), renameTo: z.string().trim().max(80).nullable().optional() })),
+ },
+ responses: jsonResponse(RuleSchema, 'Updated'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const body = c.req.valid('json');
+ const rule = ctx.db.select().from(rules).where(eq(rules.id, id)).get() ?? fail(404, 'not_found', 'That rule wasn’t found.');
+ if (body.categoryId) findCategory(body.categoryId);
+ const next = { categoryId: body.categoryId !== undefined ? body.categoryId : rule.categoryId, renameTo: body.renameTo !== undefined ? body.renameTo || null : rule.renameTo };
+ if (!next.categoryId && !next.renameTo) fail(400, 'empty', 'A rule needs a category or a new name. To stop using it, delete it instead.');
+ ctx.db.update(rules).set({ ...next, source: 'manual' }).where(eq(rules.id, id)).run();
+ logChange(ctx, user.id, 'update_rule', 'rule', id, body);
+ const row = ctx.db.select().from(rules).where(eq(rules.id, id)).get()!;
+ const { merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt } = row;
+ return c.json({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'delete',
+ path: '/rules/{id}',
+ tags: ['Categories'],
+ summary: 'Forget a rule',
+ request: { params: IdParam },
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ ctx.db.delete(rules).where(eq(rules.id, id)).run();
+ logChange(ctx, user.id, 'remove_rule', 'rule', id);
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ return r;
+}
diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts
new file mode 100644
index 0000000..9f62e85
--- /dev/null
+++ b/server/src/routes/export.ts
@@ -0,0 +1,265 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { ACCOUNT_KIND_LABEL, CADENCE_LABEL, monthName, SUBSCRIPTION_STATUS_LABEL } from '@spendlight/shared';
+import { asc, eq, inArray } from 'drizzle-orm';
+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 { today } from '../dates';
+import { buildCsv, buildXlsx, type Cell, type Sheet } from '../export/xlsx';
+import type { Env } from '../http';
+import { validationHook } from '../schemas';
+import { listCategoryGroups, newestFirst } from '../transactions/dto';
+import { TransactionQuery, transactionWhere, type TransactionFilter } from '../transactions/filter';
+
+// Everything can leave as CSV or as a real Excel workbook. Split purchases
+// export one row per part, so totals by category add up in a spreadsheet.
+
+const MAX_ROWS = 200_000;
+
+function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet {
+ const rows: TransactionRow[] = ctx.db.select().from(transactions).where(transactionWhere(ctx, filter)).orderBy(...newestFirst).limit(MAX_ROWS).all();
+ const ids = rows.map((r) => r.id);
+ const cats = new Map(listCategoryGroups(ctx, true).flatMap((g) => g.categories.map((c) => [c.id, c] as const)));
+ const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
+ const subNames = new Map(ctx.db.select({ id: subscriptions.id, name: subscriptions.name }).from(subscriptions).all().map((s) => [s.id, s.name]));
+ 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();
+ 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]);
+ }
+ for (const t of ctx.db
+ .select({ id: transactionTags.transactionId, name: tags.name })
+ .from(transactionTags)
+ .innerJoin(tags, eq(tags.id, transactionTags.tagId))
+ .where(inArray(transactionTags.transactionId, chunk))
+ .all()) {
+ tagNames.set(t.id, [...(tagNames.get(t.id) ?? []), t.name]);
+ }
+ }
+ const peers = new Map(
+ chunks(rows.map((r) => r.transferPeerId).filter((p): p is number => p !== null)).flatMap((chunk) =>
+ ctx.db.select({ id: transactions.id, accountId: transactions.accountId }).from(transactions).where(inArray(transactions.id, chunk)).all().map((p) => [p.id, p.accountId] as const),
+ ),
+ );
+
+ const out: Cell[][] = [];
+ for (const t of rows) {
+ const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null): Cell[] => {
+ const cat = categoryId ? cats.get(categoryId) : undefined;
+ return [
+ { date: t.date },
+ accountNames.get(t.accountId) ?? '',
+ t.merchant,
+ cat?.name ?? (t.transferPeerId ? 'Transfer' : ''),
+ cat?.groupName ?? '',
+ { money: cents },
+ splitOf === null ? '' : { money: splitOf },
+ t.transferPeerId ? (accountNames.get(peers.get(t.transferPeerId) ?? -1) ?? '') : '',
+ (tagNames.get(t.id) ?? []).join(', '),
+ note ?? '',
+ t.subscriptionId ? (subNames.get(t.subscriptionId) ?? '') : '',
+ t.needsReview ? 'To review' : 'Filed',
+ t.description,
+ ];
+ };
+ 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));
+ else out.push(base(t.categoryId, t.amountCents, t.note, null));
+ }
+ return {
+ name: 'Transactions',
+ columns: [
+ { header: 'Date', width: 12 },
+ { header: 'Account', width: 22 },
+ { header: 'Merchant', width: 28 },
+ { header: 'Category', width: 24 },
+ { header: 'Group', width: 18 },
+ { header: 'Amount', width: 13 },
+ { header: 'Split of', width: 13 },
+ { header: 'Transfer with', width: 22 },
+ { header: 'Tags', width: 18 },
+ { header: 'Note', width: 30 },
+ { header: 'Subscription', width: 20 },
+ { header: 'Status', width: 11 },
+ { header: 'Bank description', width: 44 },
+ ],
+ rows: out,
+ };
+}
+
+function everything(ctx: AppContext): Sheet[] {
+ const groups = listCategoryGroups(ctx, true);
+ const cats = groups.flatMap((g) => g.categories);
+ const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
+ const catName = (id: number | null) => (id ? (cats.find((c) => c.id === id)?.name ?? '') : '');
+ const kindLabel = { expense: 'Money out', income: 'Money in', transfer: 'Transfer (not counted)' } as const;
+
+ const years = [...new Set(ctx.db.select({ year: budgets.year }).from(budgets).all().map((b) => b.year))].sort();
+ const budgetRows: Cell[][] = [];
+ for (const year of years) {
+ const lines = new Map(budgetYear(ctx, year).items.map((l) => [l.categoryId, l]));
+ for (const c of cats) {
+ const line = lines.get(c.id);
+ if (!line) continue;
+ const months = Array.from({ length: 12 }, (_, i) => effective(line, i));
+ budgetRows.push([year, c.name, c.groupName, ...months.map((m) => ({ money: m })), { money: months.reduce((a, b) => a + b, 0) }]);
+ }
+ }
+
+ return [
+ transactionSheet(ctx, {}),
+ {
+ name: 'Budgets',
+ columns: [
+ { header: 'Year', width: 8 },
+ { header: 'Category', width: 24 },
+ { header: 'Group', width: 18 },
+ ...Array.from({ length: 12 }, (_, i) => ({ header: monthName(`2026-${String(i + 1).padStart(2, '0')}`, 'short'), width: 11 })),
+ { header: 'Year total', width: 13 },
+ ],
+ rows: budgetRows,
+ },
+ {
+ name: 'Categories',
+ columns: [{ header: 'Group', width: 20 }, { header: 'Category', width: 26 }, { header: 'Kind', width: 22 }, { header: 'Archived', width: 10 }],
+ rows: cats.map((c) => [c.groupName, c.name, kindLabel[c.kind], c.archived ? 'Yes' : '']),
+ },
+ {
+ name: 'Accounts',
+ columns: [
+ { header: 'Account', width: 24 },
+ { header: 'Kind', width: 16 },
+ { header: 'Bank', width: 20 },
+ { header: 'Last 4', width: 8 },
+ { header: 'Status', width: 10 },
+ { header: 'Balance', width: 14 },
+ { header: 'Balance confirmed', width: 18 },
+ ],
+ rows: ctx.db
+ .select()
+ .from(accounts)
+ .all()
+ .map((a) => [
+ a.name,
+ ACCOUNT_KIND_LABEL[a.kind],
+ a.institution ?? '',
+ a.last4 ?? '',
+ a.status === 'open' ? 'Open' : 'Retired',
+ a.balanceAsOf ? { money: currentBalance(ctx.db, a) } : '',
+ a.balanceAsOf ? { date: a.balanceAsOf } : '',
+ ]),
+ },
+ {
+ name: 'Subscriptions',
+ columns: [
+ { header: 'Name', width: 24 },
+ { header: 'Type', width: 13 },
+ { header: 'Status', width: 14 },
+ { header: 'Amount', width: 12 },
+ { header: 'How often', width: 16 },
+ { header: 'Card or account', width: 22 },
+ { header: 'Category', width: 22 },
+ { header: 'Last charge', width: 12 },
+ { header: 'Next expected', width: 14 },
+ { header: 'Manage at', width: 36 },
+ { header: 'Note', width: 30 },
+ ],
+ rows: ctx.db
+ .select()
+ .from(subscriptions)
+ .orderBy(asc(subscriptions.name))
+ .all()
+ .map((s) => [
+ s.name,
+ s.kind === 'bill' ? 'Bill' : 'Subscription',
+ SUBSCRIPTION_STATUS_LABEL[s.status],
+ { money: s.expectedCents },
+ CADENCE_LABEL[s.cadence],
+ s.accountId ? (accountNames.get(s.accountId) ?? '') : '',
+ catName(s.categoryId),
+ s.lastChargeOn ? { date: s.lastChargeOn } : '',
+ s.nextExpectedOn ? { date: s.nextExpectedOn } : '',
+ s.manageUrl ?? '',
+ s.note ?? '',
+ ]),
+ },
+ {
+ name: 'Rules',
+ columns: [{ header: 'Merchant', width: 28 }, { header: 'Files as', width: 24 }, { header: 'Renames to', width: 24 }, { header: 'Made', width: 10 }],
+ rows: ctx.db
+ .select()
+ .from(rules)
+ .all()
+ .map((r) => [r.merchant, catName(r.categoryId), r.renameTo ?? '', r.source === 'manual' ? 'By hand' : 'Learned']),
+ },
+ ];
+}
+
+const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export function exportRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+ const stamp = () => today();
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/export/transactions.csv',
+ tags: ['Export'],
+ summary: 'Transactions as CSV, with the same filters as the list',
+ request: { query: TransactionQuery },
+ responses: { 200: { description: 'CSV file', content: { 'text/csv': { schema: z.string() } } } },
+ }),
+ (c) => {
+ requireUser(c);
+ const sheet = transactionSheet(ctx, c.req.valid('query'));
+ return c.body(buildCsv(sheet.columns.map((col) => col.header), sheet.rows), 200, {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="spendlight-transactions-${stamp()}.csv"`,
+ });
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/export/transactions.xlsx',
+ tags: ['Export'],
+ summary: 'Transactions as an Excel workbook, with the same filters as the list',
+ request: { query: TransactionQuery },
+ responses: { 200: { description: 'Excel file', content: { [XLSX]: { schema: z.string() } } } },
+ }),
+ (c) => {
+ requireUser(c);
+ const file = buildXlsx([transactionSheet(ctx, c.req.valid('query'))]);
+ return c.body(new Uint8Array(file), 200, {
+ 'Content-Type': XLSX,
+ 'Content-Disposition': `attachment; filename="spendlight-transactions-${stamp()}.xlsx"`,
+ });
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/export/everything.xlsx',
+ tags: ['Export'],
+ summary: 'Everything in one Excel workbook: transactions, budgets, categories, accounts, subscriptions and rules',
+ responses: { 200: { description: 'Excel file', content: { [XLSX]: { schema: z.string() } } } },
+ }),
+ (c) => {
+ requireUser(c);
+ const file = buildXlsx(everything(ctx));
+ return c.body(new Uint8Array(file), 200, {
+ 'Content-Type': XLSX,
+ 'Content-Disposition': `attachment; filename="spendlight-${stamp()}.xlsx"`,
+ });
+ },
+ );
+
+ return r;
+}
diff --git a/server/src/routes/reports.ts b/server/src/routes/reports.ts
new file mode 100644
index 0000000..a7c1d96
--- /dev/null
+++ b/server/src/routes/reports.ts
@@ -0,0 +1,243 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { addMonths } from '@spendlight/shared';
+import { currentBalance } from '../accounts/balance';
+import { requireUser } from '../auth/guards';
+import type { AppContext } from '../context';
+import { accounts } from '../db/schema';
+import { lastDayOf, monthOf, monthRange, today } from '../dates';
+import { fail, type Env } from '../http';
+import { focusMonth, overview } from '../insights/engine';
+import { categoryKinds, monthsReport } from '../reports/actuals';
+import { DateString, jsonResponse, validationHook } from '../schemas';
+
+const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use a month like 2026-10.');
+const Cents = z.number().int();
+
+const MonthsReportSchema = z
+ .object({
+ months: z.array(z.string()),
+ categories: z.array(z.object({ categoryId: z.number().int(), actual: z.array(Cents), budget: z.array(Cents) })),
+ unfiled: z.object({ out: z.array(Cents), in: z.array(Cents) }),
+ totals: z.object({ spent: z.array(Cents), income: z.array(Cents), budgetSpend: z.array(Cents), budgetIncome: z.array(Cents) }),
+ dataThrough: z.string().nullable(),
+ })
+ .openapi('MonthsReport');
+
+const OverviewSchema = z
+ .object({
+ month: z.string(),
+ isCurrent: z.boolean(),
+ daysLeft: z.number().int(),
+ dataThrough: z.string().nullable(),
+ spent: Cents,
+ income: Cents,
+ budgetSpend: Cents,
+ budgetIncome: Cents,
+ hasBudgets: z.boolean(),
+ unbudgeted: Cents,
+ categories: z.array(z.object({ categoryId: z.number().int(), spent: Cents, budget: Cents })),
+ year: z.object({ year: z.number().int(), spent: Cents, budgetSoFar: Cents, budgetYear: Cents, projected: Cents, income: Cents }),
+ toReview: z.number().int(),
+ })
+ .openapi('Overview');
+
+export function reportRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/reports/overview',
+ tags: ['Reports'],
+ summary: 'Home’s numbers: the month against budget, and how the year is pacing',
+ description: 'Without a month, this is the current month, or the latest month that has transactions while statements catch up.',
+ request: { query: z.object({ month: Month.optional() }) },
+ responses: jsonResponse(OverviewSchema, 'Overview'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(overview(ctx, c.req.valid('query').month), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/reports/months',
+ tags: ['Reports'],
+ summary: 'Spending, income and budgets by category for a run of months',
+ description: 'Spending is positive and net of refunds; split purchases count each part in its own category; transfers never count.',
+ request: { query: z.object({ from: Month.optional(), to: Month.optional() }) },
+ responses: jsonResponse(MonthsReportSchema, 'Months'),
+ }),
+ (c) => {
+ requireUser(c);
+ const q = c.req.valid('query');
+ const to = q.to ?? focusMonth(ctx).month;
+ const from = q.from ?? `${to.slice(0, 4)}-01`;
+ if (from > to) fail(400, 'range', 'The first month needs to come before the last.');
+ const months = monthRange(from, to);
+ if (months.length > 60) fail(400, 'range', 'Choose up to 5 years at a time.');
+ return c.json(monthsReport(ctx, months), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/reports/merchants',
+ tags: ['Reports'],
+ summary: 'Who gets the money: merchants by total spent and by how often',
+ request: {
+ query: z.object({
+ from: DateString,
+ to: DateString,
+ categoryId: z.coerce.number().int().optional(),
+ limit: z.coerce.number().int().min(1).max(200).default(25),
+ }),
+ },
+ responses: jsonResponse(
+ z.array(z.object({ merchant: z.string(), spent: Cents, count: z.number().int(), lastDate: z.string(), categoryId: z.number().int().nullable() })),
+ 'Merchants',
+ ),
+ }),
+ (c) => {
+ requireUser(c);
+ const q = c.req.valid('query');
+ const kinds = categoryKinds(ctx);
+ const rows = ctx.sqlite
+ .prepare(
+ `SELECT merchant, categoryId, sum(net) AS net, count(*) AS n, max(date) AS last FROM (
+ SELECT merchant, category_id AS categoryId, amount_cents AS net, date FROM txn
+ WHERE is_split = 0 AND transfer_peer_id IS NULL AND date >= ? AND date <= ?
+ UNION ALL
+ SELECT t.merchant, s.category_id, s.amount_cents, t.date FROM split s JOIN txn t ON t.id = s.transaction_id
+ WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ?
+ ) GROUP BY merchant, categoryId`,
+ )
+ .all(q.from, q.to, q.from, q.to) as { merchant: string; categoryId: number | null; net: number; n: number; last: string }[];
+ const byMerchant = new Map();
+ for (const row of rows) {
+ const kind = row.categoryId === null ? (row.net < 0 ? 'expense' : 'income') : kinds.get(row.categoryId);
+ if (kind !== 'expense') continue;
+ if (q.categoryId !== undefined && row.categoryId !== q.categoryId) continue;
+ const m = byMerchant.get(row.merchant) ?? { merchant: row.merchant, spent: 0, count: 0, lastDate: row.last, categoryId: row.categoryId, top: 0 };
+ m.spent += -row.net;
+ m.count += row.n;
+ if (row.last > m.lastDate) m.lastDate = row.last;
+ if (-row.net > m.top) {
+ m.top = -row.net;
+ m.categoryId = row.categoryId;
+ }
+ byMerchant.set(row.merchant, m);
+ }
+ const list = [...byMerchant.values()]
+ .filter((m) => m.spent > 0)
+ .sort((a, b) => b.spent - a.spent)
+ .slice(0, q.limit)
+ .map(({ top: _top, ...m }) => m);
+ return c.json(list, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/reports/balances',
+ tags: ['Reports'],
+ summary: 'Each account’s balance at the end of each month',
+ description: 'Accounts without a known balance are left out (null). Money owed on cards is negative.',
+ request: { query: z.object({ months: z.coerce.number().int().min(2).max(36).default(12) }) },
+ responses: jsonResponse(
+ z.object({ months: z.array(z.string()), accounts: z.array(z.object({ accountId: z.number().int(), balances: z.array(Cents.nullable()) })) }),
+ 'Balances',
+ ),
+ }),
+ (c) => {
+ requireUser(c);
+ const { months: count } = c.req.valid('query');
+ const end = monthOf(today());
+ const months = monthRange(addMonths(end, -(count - 1)), end);
+ const later = ctx.sqlite.prepare('SELECT coalesce(sum(amount_cents), 0) AS s FROM txn WHERE account_id = ? AND date > ?');
+ const first = ctx.sqlite.prepare('SELECT min(date) AS d FROM txn WHERE account_id = ?');
+ const list = ctx.db
+ .select()
+ .from(accounts)
+ .all()
+ .map((a) => {
+ if (!a.balanceAsOf) return { accountId: a.id, balances: months.map(() => null) };
+ const now = currentBalance(ctx.db, a);
+ const start = (first.get(a.id) as { d: string | null }).d ?? a.balanceAsOf;
+ const opened = monthOf(start < a.balanceAsOf ? start : a.balanceAsOf);
+ return {
+ accountId: a.id,
+ balances: months.map((m) => {
+ if (m < opened) return null;
+ if (m === end) return now;
+ return now - (later.get(a.id, lastDayOf(m)) as { s: number }).s;
+ }),
+ };
+ });
+ return c.json({ months, accounts: list }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/reports/tags',
+ tags: ['Reports'],
+ summary: 'What each tag cost over a period, by category',
+ request: { query: z.object({ from: DateString.optional(), to: DateString.optional() }) },
+ responses: jsonResponse(
+ z.array(
+ z.object({
+ tag: z.string(),
+ spent: Cents,
+ income: Cents,
+ count: z.number().int(),
+ byCategory: z.array(z.object({ categoryId: z.number().int().nullable(), cents: Cents })),
+ }),
+ ),
+ 'Tags',
+ ),
+ }),
+ (c) => {
+ requireUser(c);
+ const q = c.req.valid('query');
+ const from = q.from ?? '0000-01-01';
+ const to = q.to ?? '9999-12-31';
+ const kinds = categoryKinds(ctx);
+ 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
+ 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
+ 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 <= ?
+ ) GROUP BY tag, categoryId`,
+ )
+ .all(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);
+ if (kind === 'transfer' || !kind) continue;
+ const t = byTag.get(row.tag) ?? { tag: row.tag, spent: 0, income: 0, count: 0, byCategory: [] };
+ if (kind === 'expense') {
+ t.spent += -row.net;
+ t.byCategory.push({ categoryId: row.categoryId, cents: -row.net });
+ } else t.income += row.net;
+ t.count += row.n;
+ byTag.set(row.tag, t);
+ }
+ const list = [...byTag.values()].map((t) => ({ ...t, byCategory: t.byCategory.sort((a, b) => b.cents - a.cents) })).sort((a, b) => b.spent - a.spent);
+ return c.json(list, 200);
+ },
+ );
+
+ return r;
+}
+
diff --git a/server/src/routes/subscriptions.ts b/server/src/routes/subscriptions.ts
new file mode 100644
index 0000000..d12153c
--- /dev/null
+++ b/server/src/routes/subscriptions.ts
@@ -0,0 +1,321 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { and, eq, isNull } from 'drizzle-orm';
+import { logChange } from '../audit';
+import { requireUser, requireWrite } from '../auth/guards';
+import type { AppContext } from '../context';
+import { accounts, categories, subscriptions, transactions, type SubscriptionRow } from '../db/schema';
+import { today } from '../dates';
+import { fail, nowIso, type Env } from '../http';
+import { refreshAlerts } from '../insights/engine';
+import { DateString, IdParam, jsonBody, jsonResponse, validationHook } from '../schemas';
+import { chargeCounts, listSubscriptions, scanSubscriptions, subscriptionDetail, toSubscription, upcomingCharges } from '../subscriptions/engine';
+
+const CadenceSchema = z.enum(['weekly', 'monthly', 'quarterly', 'yearly']).openapi('Cadence');
+const StatusSchema = z.enum(['suggested', 'active', 'trial', 'paused', 'cancelled', 'dismissed']).openapi('SubscriptionStatus');
+const Url = z
+ .string()
+ .trim()
+ .max(300)
+ .refine((u) => /^https?:\/\//i.test(u), 'Use a web address starting with https://');
+
+const SubscriptionSchema = z
+ .object({
+ id: z.number().int(),
+ name: z.string(),
+ kind: z.enum(['subscription', 'bill']),
+ merchant: z.string(),
+ aliases: z.array(z.string()),
+ cadence: CadenceSchema,
+ expectedCents: z.number().int(),
+ monthlyCents: z.number().int(),
+ accountId: z.number().int().nullable(),
+ categoryId: z.number().int().nullable(),
+ status: StatusSchema,
+ confidence: z.enum(['likely', 'confident']),
+ source: z.enum(['detected', 'catalog', 'manual']),
+ trialEndsOn: z.string().nullable(),
+ cancelledOn: z.string().nullable(),
+ firstChargeOn: z.string().nullable(),
+ lastChargeOn: z.string().nullable(),
+ nextExpectedOn: z.string().nullable(),
+ manageUrl: z.string().nullable(),
+ note: z.string().nullable(),
+ alertsOn: z.boolean(),
+ chargeCount: z.number().int(),
+ })
+ .openapi('Subscription');
+
+const DetailSchema = SubscriptionSchema.extend({
+ charges: z.array(z.object({ id: z.number().int(), date: z.string(), amountCents: z.number().int(), accountId: z.number().int() })),
+ priceHistory: z.array(z.object({ from: z.string(), amountCents: z.number().int() })),
+}).openapi('SubscriptionDetail');
+
+export function subscriptionRoutes(ctx: AppContext) {
+ const r = new OpenAPIHono({ defaultHook: validationHook });
+
+ const find = (id: number): SubscriptionRow =>
+ ctx.db.select().from(subscriptions).where(eq(subscriptions.id, id)).get() ?? fail(404, 'not_found', 'That subscription wasn’t found.');
+ const one = (id: number) => toSubscription(find(id), chargeCounts(ctx).get(id) ?? 0);
+ const assertRefs = (accountId?: number | null, categoryId?: number | null) => {
+ if (accountId && !ctx.db.select().from(accounts).where(eq(accounts.id, accountId)).get()) fail(400, 'no_account', 'That account doesn’t exist.');
+ if (categoryId) {
+ const kind = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, categoryId)).get()?.kind;
+ if (kind !== 'expense') fail(400, 'no_category', 'Choose a spending category.');
+ }
+ };
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/subscriptions',
+ tags: ['Subscriptions'],
+ summary: 'Every subscription and recurring bill across all accounts',
+ request: { query: z.object({ includeDismissed: z.enum(['true', 'false']).optional() }) },
+ responses: jsonResponse(z.array(SubscriptionSchema), 'Subscriptions'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(listSubscriptions(ctx, c.req.valid('query').includeDismissed === 'true'), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/subscriptions/upcoming',
+ tags: ['Subscriptions'],
+ summary: 'Charges expected in the coming days',
+ request: { query: z.object({ days: z.coerce.number().int().min(1).max(120).default(30) }) },
+ responses: jsonResponse(
+ z.array(
+ z.object({
+ subscriptionId: z.number().int(),
+ name: z.string(),
+ kind: z.enum(['subscription', 'bill']),
+ date: z.string(),
+ amountCents: z.number().int(),
+ accountId: z.number().int().nullable(),
+ trial: z.boolean(),
+ }),
+ ),
+ 'Upcoming',
+ ),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(upcomingCharges(ctx, c.req.valid('query').days), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'get',
+ path: '/subscriptions/{id}',
+ tags: ['Subscriptions'],
+ summary: 'One subscription with its charges and price history',
+ request: { params: IdParam },
+ responses: jsonResponse(DetailSchema, 'Subscription'),
+ }),
+ (c) => {
+ requireUser(c);
+ return c.json(subscriptionDetail(ctx, find(c.req.valid('param').id)), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/subscriptions',
+ tags: ['Subscriptions'],
+ summary: 'Add a subscription by hand, before it has charged (useful for yearly plans)',
+ request: jsonBody(
+ z.object({
+ name: z.string().trim().min(1, 'Please give it a name.').max(80),
+ kind: z.enum(['subscription', 'bill']).default('subscription'),
+ merchant: z.string().trim().max(80).optional(),
+ cadence: CadenceSchema,
+ expectedCents: z.number().int().min(1, 'Please enter the amount.').max(100_000_000),
+ nextExpectedOn: DateString.nullable().optional(),
+ accountId: z.number().int().nullable().optional(),
+ categoryId: z.number().int().nullable().optional(),
+ status: z.enum(['active', 'trial']).default('active'),
+ trialEndsOn: DateString.nullable().optional(),
+ manageUrl: Url.nullable().optional(),
+ note: z.string().trim().max(500).nullable().optional(),
+ }),
+ ),
+ responses: jsonResponse(SubscriptionSchema, 'Added'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const body = c.req.valid('json');
+ assertRefs(body.accountId, body.categoryId);
+ if (body.status === 'trial' && !body.trialEndsOn) fail(400, 'trial_end', 'When does the free trial end?');
+ const now = nowIso();
+ const row = ctx.db
+ .insert(subscriptions)
+ .values({
+ name: body.name,
+ kind: body.kind,
+ merchant: body.merchant || body.name,
+ cadence: body.cadence,
+ expectedCents: body.expectedCents,
+ nextExpectedOn: body.nextExpectedOn ?? null,
+ accountId: body.accountId ?? null,
+ categoryId: body.categoryId ?? null,
+ status: body.status,
+ confidence: 'confident',
+ source: 'manual',
+ trialEndsOn: body.trialEndsOn ?? null,
+ manageUrl: body.manageUrl ?? null,
+ note: body.note ?? null,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning()
+ .get();
+ scanSubscriptions(ctx);
+ refreshAlerts(ctx);
+ logChange(ctx, user.id, 'add_subscription', 'subscription', row.id, { name: body.name });
+ return c.json(one(row.id), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'patch',
+ path: '/subscriptions/{id}',
+ tags: ['Subscriptions'],
+ summary: 'Confirm, cancel, pause or edit a subscription',
+ description:
+ 'status "active" answers "yes, it’s a subscription"; "dismissed" answers "no" and is remembered. Changing the category also files its charges there.',
+ request: {
+ params: IdParam,
+ ...jsonBody(
+ z.object({
+ name: z.string().trim().min(1).max(80).optional(),
+ kind: z.enum(['subscription', 'bill']).optional(),
+ cadence: CadenceSchema.optional(),
+ expectedCents: z.number().int().min(1).max(100_000_000).optional(),
+ nextExpectedOn: DateString.nullable().optional(),
+ accountId: z.number().int().nullable().optional(),
+ categoryId: z.number().int().nullable().optional(),
+ status: StatusSchema.exclude(['suggested']).optional(),
+ trialEndsOn: DateString.nullable().optional(),
+ cancelledOn: DateString.nullable().optional(),
+ manageUrl: Url.nullable().optional(),
+ note: z.string().trim().max(500).nullable().optional(),
+ alertsOn: z.boolean().optional(),
+ }),
+ ),
+ },
+ responses: jsonResponse(SubscriptionSchema, 'Updated'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const body = c.req.valid('json');
+ const s = find(id);
+ assertRefs(body.accountId, body.categoryId);
+ const set: Partial = { updatedAt: nowIso() };
+ for (const key of ['name', 'kind', 'cadence', 'expectedCents', 'nextExpectedOn', 'accountId', 'categoryId', 'trialEndsOn', 'manageUrl', 'note', 'alertsOn'] as const) {
+ if (body[key] !== undefined) (set as Record)[key] = body[key];
+ }
+ if (body.status) {
+ set.status = body.status;
+ if (body.status === 'cancelled') set.cancelledOn = body.cancelledOn ?? s.cancelledOn ?? today();
+ else if (body.status !== 'dismissed') set.cancelledOn = null;
+ if (body.status === 'trial' && !(body.trialEndsOn ?? s.trialEndsOn)) fail(400, 'trial_end', 'When does the free trial end?');
+ } else if (body.cancelledOn !== undefined) set.cancelledOn = body.cancelledOn;
+ ctx.sqlite.transaction(() => {
+ ctx.db.update(subscriptions).set(set).where(eq(subscriptions.id, id)).run();
+ // Its charges follow its category, except purchases split across several.
+ if (body.categoryId) {
+ ctx.db
+ .update(transactions)
+ .set({ categoryId: body.categoryId, needsReview: false, updatedAt: nowIso() })
+ .where(and(eq(transactions.subscriptionId, id), eq(transactions.isSplit, false), isNull(transactions.transferPeerId)))
+ .run();
+ }
+ })();
+ if (body.cadence || body.kind) scanSubscriptions(ctx);
+ refreshAlerts(ctx);
+ logChange(ctx, user.id, 'update_subscription', 'subscription', id, body);
+ return c.json(one(id), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/subscriptions/{id}/merge',
+ tags: ['Subscriptions'],
+ summary: 'Merge this entry into another that is really the same service',
+ request: { params: IdParam, ...jsonBody(z.object({ intoId: z.number().int() })) },
+ responses: jsonResponse(SubscriptionSchema, 'The combined subscription'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const { intoId } = c.req.valid('json');
+ if (id === intoId) fail(400, 'same', 'Choose a different subscription to merge into.');
+ const from = find(id);
+ const into = find(intoId);
+ const names = new Set([...(JSON.parse(into.aliases) as string[]), from.merchant, ...(JSON.parse(from.aliases) as string[])]);
+ names.delete(into.merchant);
+ ctx.sqlite.transaction(() => {
+ ctx.db.update(transactions).set({ subscriptionId: intoId }).where(eq(transactions.subscriptionId, id)).run();
+ ctx.db.update(subscriptions).set({ aliases: JSON.stringify([...names]), updatedAt: nowIso() }).where(eq(subscriptions.id, intoId)).run();
+ ctx.db.delete(subscriptions).where(eq(subscriptions.id, id)).run();
+ })();
+ scanSubscriptions(ctx);
+ refreshAlerts(ctx);
+ logChange(ctx, user.id, 'merge_subscription', 'subscription', intoId, { from: id });
+ return c.json(one(intoId), 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'delete',
+ path: '/subscriptions/{id}',
+ tags: ['Subscriptions'],
+ summary: 'Remove a subscription you added by hand',
+ description: 'Ones Spendlight found are answered with "Not a subscription" instead, so they aren’t found again.',
+ request: { params: IdParam },
+ responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
+ }),
+ (c) => {
+ const user = requireWrite(c);
+ const { id } = c.req.valid('param');
+ const s = find(id);
+ if (s.source !== 'manual') fail(400, 'found', 'Spendlight found this one. Mark it “Not a subscription” instead, so it isn’t found again.');
+ ctx.sqlite.transaction(() => {
+ ctx.db.update(transactions).set({ subscriptionId: null }).where(eq(transactions.subscriptionId, id)).run();
+ ctx.db.delete(subscriptions).where(eq(subscriptions.id, id)).run();
+ })();
+ refreshAlerts(ctx);
+ logChange(ctx, user.id, 'delete_subscription', 'subscription', id, { name: s.name });
+ return c.json({ ok: true as const }, 200);
+ },
+ );
+
+ r.openapi(
+ createRoute({
+ method: 'post',
+ path: '/subscriptions/scan',
+ tags: ['Subscriptions'],
+ summary: 'Look through every transaction for subscriptions again',
+ responses: jsonResponse(z.object({ linked: z.number().int(), found: z.number().int() }), 'Scanned'),
+ }),
+ (c) => {
+ requireWrite(c);
+ const result = scanSubscriptions(ctx);
+ refreshAlerts(ctx);
+ return c.json(result, 200);
+ },
+ );
+
+ return r;
+}
+
diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts
index 7f1a890..bcce07f 100644
--- a/server/src/routes/transactions.ts
+++ b/server/src/routes/transactions.ts
@@ -1,16 +1,15 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import type { ReviewItem } from '@spendlight/shared';
-import { and, count, desc, eq, exists, gte, inArray, isNull, like, lte, ne, or, sql, type SQL } from 'drizzle-orm';
+import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
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, tags, transactions, transactionTags, type TransactionRow } from '../db/schema';
+import { accounts, categories, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema';
import { fail, nowIso, type Env } from '../http';
import { dedupeKey } from '../import/merchant';
import { unpair } from '../import/transfers';
import {
- CategoryGroupSchema,
Cents,
DateString,
IdParam,
@@ -20,7 +19,8 @@ import {
TransactionSchema,
validationHook,
} from '../schemas';
-import { listCategoryGroups, newestFirst, toTransaction, toTransactions } from '../transactions/dto';
+import { newestFirst, toTransaction, toTransactions } from '../transactions/dto';
+import { TransactionQuery, transactionWhere } from '../transactions/filter';
const SimilarSchema = z
.object({ merchant: z.string(), count: z.number().int(), categoryId: z.number().int() })
@@ -84,14 +84,7 @@ export function transactionRoutes(ctx: AppContext) {
tags: ['Transactions'],
summary: 'Search and filter transactions across every account',
request: {
- query: z.object({
- q: z.string().max(100).optional(),
- accountId: z.coerce.number().int().optional(),
- categoryId: z.union([z.coerce.number().int(), z.literal('none')]).optional(),
- tag: z.string().max(40).optional(),
- from: DateString.optional(),
- to: DateString.optional(),
- review: z.enum(['true', 'false']).optional(),
+ query: TransactionQuery.extend({
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
}),
@@ -108,37 +101,7 @@ export function transactionRoutes(ctx: AppContext) {
(c) => {
requireUser(c);
const q = c.req.valid('query');
- const where: SQL[] = [];
- if (q.q) {
- const term = `%${q.q.replace(/[%_]/g, '')}%`;
- where.push(or(like(transactions.merchant, term), like(transactions.description, term), like(transactions.note, term))!);
- }
- if (q.accountId) where.push(eq(transactions.accountId, q.accountId));
- if (q.categoryId === 'none') where.push(and(isNull(transactions.categoryId), eq(transactions.isSplit, false))!);
- else if (q.categoryId !== undefined) {
- const cid = q.categoryId;
- where.push(
- or(
- eq(transactions.categoryId, cid),
- exists(ctx.db.select().from(splits).where(and(eq(splits.transactionId, transactions.id), eq(splits.categoryId, cid)))),
- )!,
- );
- }
- if (q.tag) {
- 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()}`)),
- ),
- );
- }
- if (q.from) where.push(gte(transactions.date, q.from));
- if (q.to) where.push(lte(transactions.date, q.to));
- if (q.review) where.push(eq(transactions.needsReview, q.review === 'true'));
- const condition = where.length ? and(...where) : undefined;
+ const condition = transactionWhere(ctx, q);
const rows = ctx.db.select().from(transactions).where(condition).orderBy(...newestFirst).limit(q.limit).offset(q.offset).all();
const total = ctx.db.select({ n: count() }).from(transactions).where(condition).get()?.n ?? 0;
@@ -228,6 +191,7 @@ export function transactionRoutes(ctx: AppContext) {
else ctx.db.insert(rules).values({ merchant: t.merchant, renameTo: body.merchant, source: 'manual', createdAt: now }).run();
// Rules keyed on the old name now apply to the new one too.
ctx.db.update(rules).set({ merchant: body.merchant }).where(and(eq(rules.merchant, t.merchant), isNull(rules.renameTo))).run();
+ ctx.db.update(subscriptions).set({ merchant: body.merchant, updatedAt: now }).where(eq(subscriptions.merchant, t.merchant)).run();
}
}
ctx.sqlite.transaction(() => {
@@ -495,109 +459,5 @@ export function transactionRoutes(ctx: AppContext) {
},
);
- // --- Categories, tags, rules -------------------------------------------------------
-
- r.openapi(
- createRoute({
- method: 'get',
- path: '/categories',
- tags: ['Categories'],
- summary: 'Categories, in their groups',
- responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
- }),
- (c) => {
- requireUser(c);
- return c.json(listCategoryGroups(ctx), 200);
- },
- );
-
- r.openapi(
- createRoute({
- method: 'get',
- path: '/tags',
- tags: ['Categories'],
- summary: 'Tags in use, most used first',
- responses: jsonResponse(z.array(z.object({ name: z.string(), count: z.number().int() })), 'Tags'),
- }),
- (c) => {
- requireUser(c);
- const rows = ctx.db
- .select({ 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();
- return c.json(rows, 200);
- },
- );
-
- r.openapi(
- createRoute({
- method: 'get',
- path: '/rules',
- tags: ['Categories'],
- summary: 'Saved rules: merchants Spendlight always files or renames the same way',
- responses: jsonResponse(
- z.array(
- z.object({
- id: z.number().int(),
- merchant: z.string(),
- categoryId: z.number().int().nullable(),
- renameTo: z.string().nullable(),
- source: z.enum(['learned', 'manual']),
- createdAt: z.string(),
- }),
- ),
- 'Rules',
- ),
- }),
- (c) => {
- requireUser(c);
- const rows = ctx.db.select().from(rules).orderBy(rules.merchant).all();
- return c.json(
- rows.map(({ id, merchant, categoryId, renameTo, source, createdAt }) => ({ id, merchant, categoryId, renameTo, source, createdAt })),
- 200,
- );
- },
- );
-
- r.openapi(
- createRoute({
- method: 'post',
- path: '/rules',
- tags: ['Categories'],
- summary: 'Always file a merchant in a category',
- request: jsonBody(z.object({ merchant: z.string().trim().min(1).max(80), categoryId: z.number().int() })),
- responses: jsonResponse(z.object({ id: z.number().int() }), 'Saved'),
- }),
- (c) => {
- const user = requireWrite(c);
- const { merchant, categoryId } = c.req.valid('json');
- assertCategory(categoryId);
- const id = rememberRule(ctx, merchant, categoryId, 'manual');
- logChange(ctx, user.id, 'add_rule', 'rule', id, { merchant, categoryId });
- return c.json({ id }, 200);
- },
- );
-
- r.openapi(
- createRoute({
- method: 'delete',
- path: '/rules/{id}',
- tags: ['Categories'],
- summary: 'Forget a rule',
- request: { params: IdParam },
- responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
- }),
- (c) => {
- const user = requireWrite(c);
- const { id } = c.req.valid('param');
- ctx.db.delete(rules).where(eq(rules.id, id)).run();
- logChange(ctx, user.id, 'remove_rule', 'rule', id);
- return c.json({ ok: true as const }, 200);
- },
- );
-
return r;
}
diff --git a/server/src/schemas.ts b/server/src/schemas.ts
index b519886..5a75fd2 100644
--- a/server/src/schemas.ts
+++ b/server/src/schemas.ts
@@ -189,6 +189,7 @@ export const TransactionSchema = z
source: z.enum(['import', 'manual']),
bankCategory: z.string().nullable(),
importId: z.number().int().nullable(),
+ subscriptionId: z.number().int().nullable(),
})
.openapi('Transaction');
diff --git a/server/src/settings.ts b/server/src/settings.ts
new file mode 100644
index 0000000..c392d19
--- /dev/null
+++ b/server/src/settings.ts
@@ -0,0 +1,30 @@
+import type { AlertSettings } from '@spendlight/shared';
+import { eq } from 'drizzle-orm';
+import type { AppContext } from './context';
+import { settings } from './db/schema';
+
+// Household-wide settings, stored as JSON in the setting table.
+
+export function getSetting(ctx: AppContext, key: string, fallback: T): T {
+ const row = ctx.db.select().from(settings).where(eq(settings.key, key)).get();
+ if (!row) return fallback;
+ try {
+ return { ...fallback, ...(JSON.parse(row.value) as T) };
+ } catch {
+ return fallback;
+ }
+}
+
+export function putSetting(ctx: AppContext, key: string, value: unknown): void {
+ const json = JSON.stringify(value);
+ ctx.db.insert(settings).values({ key, value: json }).onConflictDoUpdate({ target: settings.key, set: { value: json } }).run();
+}
+
+export const DEFAULT_ALERT_SETTINGS: AlertSettings = {
+ priceChangePct: 10,
+ priceChangeMinCents: 200,
+ budgetWarnPct: 80,
+ unusualMultiple: 4,
+};
+
+export const alertSettings = (ctx: AppContext) => getSetting(ctx, 'alerts', DEFAULT_ALERT_SETTINGS);
diff --git a/server/src/subscriptions/catalog.ts b/server/src/subscriptions/catalog.ts
new file mode 100644
index 0000000..996e0fd
--- /dev/null
+++ b/server/src/subscriptions/catalog.ts
@@ -0,0 +1,141 @@
+import type { Cadence, SubscriptionKind } from '@spendlight/shared';
+
+// Services Spendlight recognizes from their very first charge. Ships with the
+// app, so it works offline. The links open each service's own account page,
+// where it can be managed or cancelled; Spendlight never cancels anything itself.
+
+export interface CatalogEntry {
+ key: string;
+ name: string;
+ pattern: RegExp;
+ cadence: Cadence;
+ kind: SubscriptionKind;
+ manageUrl: string | null;
+ /** Some services sell both monthly and yearly plans: a single charge above this many cents is read as yearly. */
+ yearlyFrom?: number;
+ /** False where the same merchant also sells one-off purchases, so a single charge proves nothing. */
+ single?: boolean;
+}
+
+const sub = (key: string, name: string, pattern: RegExp, manageUrl: string | null, extra: Partial = {}): CatalogEntry => ({
+ key,
+ name,
+ pattern,
+ cadence: 'monthly',
+ kind: 'subscription',
+ manageUrl,
+ ...extra,
+});
+
+const bill = (key: string, name: string, pattern: RegExp, manageUrl: string | null, extra: Partial = {}): CatalogEntry => ({
+ key,
+ name,
+ pattern,
+ cadence: 'monthly',
+ kind: 'bill',
+ manageUrl,
+ ...extra,
+});
+
+export const CATALOG: CatalogEntry[] = [
+ // Streaming video
+ sub('netflix', 'Netflix', /NETFLIX/i, 'https://www.netflix.com/account'),
+ sub('hulu', 'Hulu', /HULU/i, 'https://secure.hulu.com/account'),
+ sub('disney-plus', 'Disney+', /DISNEY ?\+|DISNEYPLUS|DISNEY PLUS/i, 'https://www.disneyplus.com/account', { yearlyFrom: 6000 }),
+ sub('hbo-max', 'HBO Max', /HBO ?MAX|\bMAX\.COM/i, 'https://www.hbomax.com'),
+ sub('peacock', 'Peacock', /PEACOCK/i, 'https://www.peacocktv.com', { yearlyFrom: 5000 }),
+ sub('paramount-plus', 'Paramount+', /PARAMOUNT/i, 'https://www.paramountplus.com/account/', { yearlyFrom: 5000 }),
+ sub('youtube-premium', 'YouTube Premium', /YOUTUBE ?PREMIUM|YOUTUBEPREMIUM|GOOGLE \*?YOUTUBE(?! ?TV)/i, 'https://www.youtube.com/paid_memberships'),
+ sub('youtube-tv', 'YouTube TV', /YOUTUBE ?TV/i, 'https://tv.youtube.com'),
+ sub('prime-video', 'Prime Video channels', /PRIME ?VIDEO/i, 'https://www.amazon.com/gp/video/settings', { single: false }),
+ sub('espn-plus', 'ESPN+', /ESPN/i, 'https://plus.espn.com', { yearlyFrom: 6000 }),
+ sub('crunchyroll', 'Crunchyroll', /CRUNCHYROLL/i, 'https://www.crunchyroll.com/account/membership'),
+ sub('sling', 'Sling TV', /SLING ?TV|SLING\.COM/i, 'https://www.sling.com/account'),
+ sub('fubo', 'Fubo', /FUBO/i, 'https://www.fubo.tv'),
+ sub('philo', 'Philo', /PHILO/i, 'https://www.philo.com'),
+ // Music and audio
+ sub('spotify', 'Spotify', /SPOTIFY/i, 'https://www.spotify.com/account/'),
+ sub('siriusxm', 'SiriusXM', /SIRIUS ?XM/i, 'https://www.siriusxm.com'),
+ sub('pandora', 'Pandora', /PANDORA(\.COM|\*| ?MEDIA| ?PLUS| ?PREMIUM)/i, 'https://www.pandora.com/settings/subscription'),
+ sub('tidal', 'Tidal', /TIDAL\.COM|TIDAL MUSIC/i, 'https://account.tidal.com'),
+ sub('audible', 'Audible', /AUDIBLE/i, 'https://www.audible.com/account/overview'),
+ // Big-tech bundles and storage
+ sub('apple', 'Apple subscription', /APPLE\.COM\/BILL|APPLE\.COM BILL|ITUNES/i, 'https://apps.apple.com/account/subscriptions', { single: false }),
+ sub('google-one', 'Google One', /GOOGLE \*?(STORAGE|ONE)|GOOGLE ONE/i, 'https://play.google.com/store/account/subscriptions', { single: false }),
+ sub('amazon-prime', 'Amazon Prime', /AMAZON PRIME|PRIME MEMBERSHIP|AMZN PRIME/i, 'https://www.amazon.com/gp/primecentral', { yearlyFrom: 6000 }),
+ sub('kindle-unlimited', 'Kindle Unlimited', /KINDLE ?UNLTD|KINDLE UNLIMITED/i, 'https://www.amazon.com/kindle-dbs/ku/ku-central'),
+ sub('microsoft-365', 'Microsoft 365', /MICROSOFT ?\*?(365|OFFICE)|MSFT \*(365|OFFICE)/i, 'https://account.microsoft.com/services', { yearlyFrom: 5000 }),
+ sub('xbox', 'Xbox Game Pass', /XBOX.*(PASS|ULTIMATE|LIVE)|GAME ?PASS/i, 'https://account.microsoft.com/services'),
+ sub('playstation', 'PlayStation Plus', /PLAYSTATION ?(PLUS|\+)|PS ?PLUS/i, 'https://www.playstation.com', { yearlyFrom: 4000 }),
+ sub('nintendo', 'Nintendo Switch Online', /NINTENDO.*(ONLINE|MEMBERSHIP)/i, 'https://accounts.nintendo.com', { yearlyFrom: 1500 }),
+ sub('dropbox', 'Dropbox', /DROPBOX/i, 'https://www.dropbox.com/account/plan', { yearlyFrom: 6000 }),
+ sub('adobe', 'Adobe', /ADOBE/i, 'https://account.adobe.com/plans'),
+ sub('chatgpt', 'ChatGPT', /OPENAI|CHATGPT/i, 'https://chatgpt.com'),
+ sub('claude', 'Claude', /ANTHROPIC|CLAUDE\.AI/i, 'https://claude.ai/settings/billing'),
+ sub('github', 'GitHub', /GITHUB/i, 'https://github.com/settings/billing'),
+ sub('1password', '1Password', /1PASSWORD/i, 'https://my.1password.com'),
+ sub('norton', 'Norton', /NORTON ?(360|LIFELOCK|\*)|NORTONLIFELOCK|GEN DIGITAL/i, 'https://my.norton.com', { yearlyFrom: 3000 }),
+ sub('mcafee', 'McAfee', /MCAFEE/i, 'https://home.mcafee.com', { yearlyFrom: 3000 }),
+ sub('grammarly', 'Grammarly', /GRAMMARLY/i, 'https://account.grammarly.com/subscription'),
+ sub('canva', 'Canva', /\bCANVA\b/i, 'https://www.canva.com/settings/billing-and-teams'),
+ sub('zoom', 'Zoom', /ZOOM\.US|ZOOM VIDEO/i, 'https://zoom.us/billing'),
+ sub('linkedin', 'LinkedIn Premium', /LINKEDIN/i, 'https://www.linkedin.com/premium/manage'),
+ sub('duolingo', 'Duolingo', /DUOLINGO/i, 'https://www.duolingo.com/settings/subscription', { yearlyFrom: 3000 }),
+ sub('patreon', 'Patreon', /PATREON/i, 'https://www.patreon.com/settings/memberships'),
+ // News
+ sub('nytimes', 'New York Times', /NYTIMES|NY TIMES|NEW YORK TIMES/i, 'https://myaccount.nytimes.com'),
+ sub('wsj', 'Wall Street Journal', /\bWSJ\b|WALL STREET JOURNAL|DOW JONES/i, 'https://customercenter.wsj.com'),
+ sub('washington-post', 'Washington Post', /WASHINGTON ?POST|WASHPOST/i, 'https://www.washingtonpost.com'),
+ // Health and fitness
+ sub('peloton', 'Peloton', /PELOTON/i, 'https://www.onepeloton.com'),
+ sub('planet-fitness', 'Planet Fitness', /PLANET ?FITNESS|PLNT ?FITNESS/i, 'https://www.planetfitness.com'),
+ sub('headspace', 'Headspace', /HEADSPACE/i, 'https://www.headspace.com', { yearlyFrom: 4000 }),
+ sub('calm', 'Calm', /\bCALM\.COM|CALM APP/i, 'https://www.calm.com', { yearlyFrom: 4000 }),
+ sub('strava', 'Strava', /STRAVA/i, 'https://www.strava.com/account', { yearlyFrom: 4000 }),
+ // Food and delivery memberships
+ sub('hellofresh', 'HelloFresh', /HELLO ?FRESH/i, 'https://www.hellofresh.com'),
+ sub('factor', 'Factor', /FACTOR ?75|FACTOR MEALS/i, 'https://www.factor75.com'),
+ sub('dashpass', 'DashPass', /DASHPASS/i, 'https://www.doordash.com'),
+ sub('uber-one', 'Uber One', /UBER ?ONE/i, 'https://www.uber.com'),
+ sub('instacart-plus', 'Instacart+', /INSTACART ?\+|INSTACART PLUS|INSTACART MEMBERSHIP/i, 'https://www.instacart.com', { yearlyFrom: 5000 }),
+ sub('walmart-plus', 'Walmart+', /WALMART ?\+|WALMART PLUS|WMT PLUS/i, 'https://www.walmart.com/plus/account', { yearlyFrom: 5000 }),
+ sub('costco-membership', 'Costco membership', /COSTCO.*(MEMBER|MBRSHP|RENEW)/i, 'https://www.costco.com', { cadence: 'yearly' }),
+ sub('sams-membership', "Sam's Club membership", /SAMS? ?CLUB.*(MEMBER|MBRSHP|RENEW)/i, 'https://www.samsclub.com', { cadence: 'yearly' }),
+ // Home security
+ sub('ring', 'Ring Protect', /\bRING\b.*(PROTECT|PLAN)|RING\.COM/i, 'https://account.ring.com', { yearlyFrom: 5000 }),
+ sub('simplisafe', 'SimpliSafe', /SIMPLISAFE/i, 'https://simplisafe.com'),
+ bill('adt', 'ADT', /\bADT\b/i, 'https://www.adt.com'),
+ // Phone, internet and TV
+ bill('xfinity', 'Xfinity', /COMCAST|XFINITY/i, 'https://customer.xfinity.com'),
+ bill('spectrum', 'Spectrum', /SPECTRUM|CHARTER COMM/i, 'https://www.spectrum.net'),
+ bill('verizon', 'Verizon', /VERIZON|VZWRLSS|VZ WIRELESS/i, 'https://www.verizon.com/myaccount'),
+ bill('att', 'AT&T', /\bAT ?& ?T\b|\bATT\b\*?/i, 'https://www.att.com/my'),
+ bill('t-mobile', 'T-Mobile', /T-?MOBILE/i, 'https://www.t-mobile.com/account'),
+ bill('cox', 'Cox', /\bCOX\b.*COMM|COX CABLE/i, 'https://www.cox.com/myaccount'),
+ bill('optimum', 'Optimum', /OPTIMUM|ALTICE/i, 'https://www.optimum.net'),
+ bill('google-fi', 'Google Fi', /GOOGLE ?\*?FI\b/i, 'https://fi.google.com/account'),
+ // Utilities
+ bill('duke-energy', 'Duke Energy', /DUKE ?ENERGY|DUKE-ENERGY/i, 'https://www.duke-energy.com'),
+ bill('dominion-energy', 'Dominion Energy', /DOMINION ?ENERGY/i, 'https://www.dominionenergy.com'),
+ bill('pge', 'PG&E', /\bPG ?& ?E\b|PACIFIC GAS/i, 'https://www.pge.com'),
+ bill('con-edison', 'Con Edison', /CON ?ED/i, 'https://www.coned.com'),
+ bill('georgia-power', 'Georgia Power', /GEORGIA POWER/i, 'https://www.georgiapower.com'),
+ bill('fpl', 'FPL', /\bFPL\b|FLORIDA POWER/i, 'https://www.fpl.com'),
+ bill('xcel', 'Xcel Energy', /XCEL ?ENERGY/i, 'https://www.xcelenergy.com'),
+ // Insurance
+ bill('geico', 'GEICO', /GEICO/i, 'https://www.geico.com'),
+ bill('progressive', 'Progressive', /PROGRESSIVE/i, 'https://www.progressive.com'),
+ bill('state-farm', 'State Farm', /STATE ?FARM/i, 'https://www.statefarm.com'),
+ bill('allstate', 'Allstate', /ALLSTATE/i, 'https://www.allstate.com'),
+ bill('usaa', 'USAA', /\bUSAA\b/i, 'https://www.usaa.com'),
+ bill('liberty-mutual', 'Liberty Mutual', /LIBERTY ?MUTUAL/i, 'https://www.libertymutual.com'),
+];
+
+export function catalogMatch(merchant: string, description: string): CatalogEntry | undefined {
+ const text = `${merchant} ${description}`;
+ return CATALOG.find((e) => e.pattern.test(text));
+}
+
+export function catalogEntry(key: string | null): CatalogEntry | undefined {
+ return key ? CATALOG.find((e) => e.key === key) : undefined;
+}
diff --git a/server/src/subscriptions/engine.ts b/server/src/subscriptions/engine.ts
new file mode 100644
index 0000000..b15d44c
--- /dev/null
+++ b/server/src/subscriptions/engine.ts
@@ -0,0 +1,431 @@
+import { monthlyCost, type Cadence, type Subscription, type SubscriptionDetail, type SubscriptionKind } from '@spendlight/shared';
+import { asc, count, eq, gte, inArray, isNotNull, ne } from 'drizzle-orm';
+import { TRANSFER_PATTERN } from '../categorize/known';
+import type { AppContext } from '../context';
+import { categories, categoryGroups, subscriptions, transactions, type SubscriptionRow } from '../db/schema';
+import { addDays, addMonthsToDate, daysBetween, today } from '../dates';
+import { catalogEntry, catalogMatch, type CatalogEntry } from './catalog';
+
+// Finding subscriptions and bills. After every import, new charges are first
+// matched to the subscriptions already known; whatever is left is searched for
+// a merchant charging a similar amount on a steady rhythm. Every find waits for
+// a person to say "yes, that's a subscription" before it counts.
+
+export const CYCLE_DAYS: Record = { weekly: 7, monthly: 30.44, quarterly: 91.3, yearly: 365.25 };
+const BAND: Record = { weekly: [5, 9], monthly: [25, 36], quarterly: [80, 100], yearly: [340, 390] };
+
+/** How long after an expected charge before it counts as missing. */
+export const GRACE_DAYS: Record = { weekly: 3, monthly: 7, quarterly: 12, yearly: 21 };
+
+const BILL_CATEGORIES = new Set([
+ 'mortgage or rent',
+ 'home insurance',
+ 'property tax',
+ 'electric and gas',
+ 'water and trash',
+ 'internet',
+ 'mobile phone',
+ 'car payment',
+ 'car insurance',
+]);
+
+/** Wording that marks a payment as a bill rather than a subscription. */
+const BILL_WORDS = /MORTGAGE|\bMTG\b|\bLOAN\b|INSURANCE|\bINS\b|ENERGY|ELECTRIC|\bPOWER\b|WATER|SEWER|UTILIT|NATURAL GAS|\bGAS CO|TRASH|WASTE|PROPANE|\bHOA\b|\bRENT\b|TUITION|DAYCARE|CHILD ?CARE/i;
+
+export function nextCharge(date: string, cadence: Cadence): string {
+ if (cadence === 'weekly') return addDays(date, 7);
+ return addMonthsToDate(date, cadence === 'monthly' ? 1 : cadence === 'quarterly' ? 3 : 12);
+}
+
+/** The rhythm a series of charge dates follows, allowing for one skipped cycle here and there. */
+export function fitCadence(dates: string[]): Cadence | null {
+ if (dates.length < 2) return null;
+ const gaps = dates.slice(1).map((d, i) => daysBetween(dates[i]!, d));
+ for (const cadence of ['monthly', 'yearly', 'quarterly', 'weekly'] as Cadence[]) {
+ const [lo, hi] = BAND[cadence];
+ const exact = gaps.filter((g) => g >= lo && g <= hi).length;
+ const skipped = gaps.filter((g) => g >= lo * 2 && g <= hi * 2).length;
+ if (exact >= Math.ceil(gaps.length / 2) && (exact + skipped) / gaps.length >= 0.75) return cadence;
+ }
+ return null;
+}
+
+interface Charge {
+ id: number;
+ accountId: number;
+ date: string;
+ /** Positive cents. */
+ amount: number;
+ merchant: string;
+ description: string;
+ categoryId: number | null;
+}
+
+interface Candidate {
+ charges: Charge[];
+ cadence: Cadence;
+ kind: SubscriptionKind;
+}
+
+const median = (values: number[]) => {
+ const s = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(s.length / 2);
+ return s.length % 2 ? s[mid]! : Math.round((s[mid - 1]! + s[mid]!) / 2);
+};
+
+const namesOf = (s: SubscriptionRow) => new Set([s.merchant, ...(JSON.parse(s.aliases) as string[])].map((n) => n.toLowerCase()));
+
+/** Charges of about the same amount, so a $2.99 and a $9.99 plan from Apple stay apart. */
+function clusterByAmount(charges: Charge[]): Charge[][] {
+ const sorted = [...charges].sort((a, b) => a.amount - b.amount);
+ const clusters: Charge[][] = [];
+ for (const c of sorted) {
+ const current = clusters.at(-1);
+ if (current && c.amount - current[0]!.amount <= Math.max(current[0]!.amount * 0.03, 50)) current.push(c);
+ else clusters.push([c]);
+ }
+ return clusters.map((cl) => cl.sort((a, b) => a.date.localeCompare(b.date) || a.id - b.id));
+}
+
+function detectGroup(group: Charge[], catalog: CatalogEntry | undefined, billLike: (c: Charge) => boolean): Candidate[] {
+ const found: Candidate[] = [];
+ const latest = group.at(-1)!;
+ const isBill = catalog ? catalog.kind === 'bill' : billLike(latest) || BILL_WORDS.test(latest.description);
+
+ // Bills (electric, insurance, the mortgage) go by rhythm alone, since their
+ // amounts can change every month. Anything else needs a steady price too.
+ if (isBill) {
+ if (group.length < 2) return found;
+ const cadence = fitCadence(group.map((c) => c.date));
+ const mid = median(group.map((c) => c.amount));
+ if (cadence && cadence !== 'weekly' && group.every((c) => c.amount >= mid / 2.5 && c.amount <= mid * 2.5)) {
+ found.push({ charges: group, cadence, kind: 'bill' });
+ }
+ return found;
+ }
+
+ const used = new Set();
+ // A large fixed monthly payment is a bill too (a car loan, rent), not something you'd cancel.
+ const kindFor = (charges: Charge[]): SubscriptionKind => (!catalog && charges.at(-1)!.amount >= 30_000 ? 'bill' : 'subscription');
+
+ for (const cluster of clusterByAmount(group)) {
+ if (cluster.length < 2) continue;
+ const cadence = fitCadence(cluster.map((c) => c.date));
+ if (!cadence) continue;
+ if (cadence === 'weekly' && cluster.length < 4) continue;
+ // Two charges of the same amount a month apart can be chance; ask only when they match to the cent.
+ if (cluster.length === 2 && !catalog && cluster[0]!.amount !== cluster[1]!.amount) continue;
+ found.push({ charges: cluster, cadence, kind: kindFor(cluster) });
+ cluster.forEach((c) => used.add(c.id));
+ }
+
+ // A price change splits one service into two runs of charges; join them back up.
+ found.sort((a, b) => a.charges[0]!.date.localeCompare(b.charges[0]!.date));
+ for (let i = 0; i < found.length - 1; i++) {
+ const a = found[i]!;
+ const b = found[i + 1]!;
+ const gap = daysBetween(a.charges.at(-1)!.date, b.charges[0]!.date);
+ const ratio = b.charges[0]!.amount / a.charges.at(-1)!.amount;
+ if (a.cadence === b.cadence && gap > 0 && gap <= CYCLE_DAYS[a.cadence] * 1.6 && ratio > 0.6 && ratio < 1.7) {
+ found.splice(i, 2, { ...b, charges: [...a.charges, ...b.charges] });
+ i--;
+ }
+ }
+
+ // Known services count from their very first charge.
+ if (!found.length && catalog && catalog.kind === 'subscription' && catalog.single !== false) {
+ const cadence = catalog.yearlyFrom && latest.amount >= catalog.yearlyFrom ? 'yearly' : catalog.cadence;
+ found.push({ charges: [latest], cadence, kind: 'subscription' });
+ }
+ return found;
+}
+
+/** Match new charges to known subscriptions, look for new ones, and bring every entry up to date. */
+export function scanSubscriptions(ctx: AppContext): { linked: number; found: number } {
+ const now = new Date().toISOString();
+ const subs = ctx.db.select().from(subscriptions).all();
+ const catRows = ctx.db
+ .select({ id: categories.id, name: categories.name, kind: categories.kind, group: categoryGroups.name })
+ .from(categories)
+ .innerJoin(categoryGroups, eq(categoryGroups.id, categories.groupId))
+ .all();
+ const cats = new Map(catRows.map((c) => [c.id, c]));
+ const billLike = (c: Charge) => {
+ const cat = c.categoryId ? cats.get(c.categoryId) : undefined;
+ return !!cat && (BILL_CATEGORIES.has(cat.name.toLowerCase()) || cat.group.toLowerCase() === 'utilities');
+ };
+
+ const coverage = new Map(
+ (ctx.sqlite.prepare('SELECT account_id AS a, max(date) AS d FROM txn GROUP BY account_id').all() as { a: number; d: string }[]).map((r) => [r.a, r.d]),
+ );
+ const newest = [...coverage.values()].sort().at(-1) ?? today();
+ const since = addDays(newest, -550);
+
+ const rows = ctx.db
+ .select({
+ id: transactions.id,
+ accountId: transactions.accountId,
+ date: transactions.date,
+ amountCents: transactions.amountCents,
+ merchant: transactions.merchant,
+ description: transactions.description,
+ categoryId: transactions.categoryId,
+ subscriptionId: transactions.subscriptionId,
+ transferPeerId: transactions.transferPeerId,
+ isSplit: transactions.isSplit,
+ })
+ .from(transactions)
+ .where(gte(transactions.date, since))
+ .orderBy(asc(transactions.date), asc(transactions.id))
+ .all();
+
+ const linkedBySub = new Map();
+ for (const r of rows) if (r.subscriptionId) linkedBySub.set(r.subscriptionId, [...(linkedBySub.get(r.subscriptionId) ?? []), r]);
+
+ const candidates: Charge[] = rows
+ .filter(
+ (r) =>
+ !r.subscriptionId &&
+ r.amountCents <= -100 &&
+ !r.isSplit &&
+ !r.transferPeerId &&
+ // Card payments and transfers never count. Bills paid by "autopay" from
+ // checking read like transfers, so wording alone rules out only unfiled ones.
+ (r.categoryId === null
+ ? !TRANSFER_PATTERN.test(r.description) || !!catalogMatch(r.merchant, r.description)
+ : cats.get(r.categoryId)?.kind === 'expense'),
+ )
+ .map((r) => ({ id: r.id, accountId: r.accountId, date: r.date, amount: -r.amountCents, merchant: r.merchant, description: r.description, categoryId: r.categoryId }));
+
+ const link = (subscriptionId: number, charges: Charge[]) => {
+ if (!charges.length) return;
+ ctx.db.update(transactions).set({ subscriptionId }).where(inArray(transactions.id, charges.map((c) => c.id))).run();
+ linkedBySub.set(subscriptionId, [...(linkedBySub.get(subscriptionId) ?? []), ...charges]);
+ };
+
+ let linked = 0;
+ let found = 0;
+ ctx.sqlite.transaction(() => {
+ // 1. Charges from subscriptions already known.
+ const byName = new Map();
+ for (const s of subs) for (const n of namesOf(s)) byName.set(n, [...(byName.get(n) ?? []), s]);
+ const remaining: Charge[] = [];
+ for (const c of candidates) {
+ let best: { s: SubscriptionRow; diff: number } | null = null;
+ const known = catalogMatch(c.merchant, c.description)?.key;
+ for (const s of byName.get(c.merchant.toLowerCase()) ?? []) {
+ // A membership from the catalog takes only charges that read like it:
+ // Costco's yearly renewal, not the weekly shop at Costco.
+ if (s.catalogKey && known !== s.catalogKey) continue;
+ const diff = Math.abs(c.amount - s.expectedCents);
+ const fits =
+ s.status === 'dismissed'
+ ? diff <= Math.max(s.expectedCents * 0.05, 50)
+ : s.kind === 'bill'
+ ? c.amount >= s.expectedCents * 0.25 && c.amount <= s.expectedCents * 4
+ : diff <= Math.max(s.expectedCents * 0.25, 100);
+ if (!fits) continue;
+ // One charge per cycle on each card; a second one is a separate purchase.
+ const sameCycle = (linkedBySub.get(s.id) ?? []).some(
+ (x) => x.accountId === c.accountId && Math.abs(daysBetween(x.date, c.date)) < CYCLE_DAYS[s.cadence] * 0.5,
+ );
+ if (sameCycle) continue;
+ if (!best || diff < best.diff) best = { s, diff };
+ }
+ if (best) {
+ link(best.s.id, [c]);
+ linked++;
+ } else remaining.push(c);
+ }
+
+ // 2. New subscriptions among the rest, one group per merchant (charges
+ // that match a catalog service form their own group).
+ const groups = new Map();
+ for (const c of remaining) {
+ const catalog = catalogMatch(c.merchant, c.description);
+ const id = `${c.merchant.toLowerCase()}|${catalog?.key ?? ''}`;
+ const g = groups.get(id) ?? { key: c.merchant.toLowerCase(), catalog, charges: [] };
+ g.charges.push(c);
+ groups.set(id, g);
+ }
+ for (const { key, catalog, charges: group } of groups.values()) {
+ for (const cand of detectGroup(group, catalog, billLike)) {
+ const first = cand.charges[0]!;
+ const last = cand.charges.at(-1)!;
+ // A new price for a subscription already known continues it.
+ const continued = subs.find(
+ (s) =>
+ namesOf(s).has(key) &&
+ s.catalogKey === (catalog?.key ?? null) &&
+ s.status !== 'dismissed' &&
+ s.cadence === cand.cadence &&
+ s.lastChargeOn &&
+ first.date > s.lastChargeOn &&
+ daysBetween(s.lastChargeOn, first.date) <= CYCLE_DAYS[s.cadence] * 1.6,
+ );
+ if (continued) {
+ link(continued.id, cand.charges);
+ linked += cand.charges.length;
+ continue;
+ }
+ // Charges that stopped long ago belong to something already ended.
+ const reach = coverage.get(last.accountId) ?? newest;
+ if (daysBetween(last.date, reach) > CYCLE_DAYS[cand.cadence] * 1.5 + 7) continue;
+ const category = last.categoryId && cats.get(last.categoryId)?.kind === 'expense' ? last.categoryId : null;
+ const row = ctx.db
+ .insert(subscriptions)
+ .values({
+ name: catalog?.name ?? last.merchant,
+ kind: cand.kind,
+ merchant: last.merchant,
+ catalogKey: catalog?.key ?? null,
+ cadence: cand.cadence,
+ expectedCents: last.amount,
+ accountId: last.accountId,
+ categoryId: category,
+ status: 'suggested',
+ confidence: cand.charges.length >= 3 ? 'confident' : 'likely',
+ source: catalog ? 'catalog' : 'detected',
+ manageUrl: catalog?.manageUrl ?? null,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning()
+ .get();
+ subs.push(row);
+ link(row.id, cand.charges);
+ found++;
+ }
+ }
+
+ // 3. Bring every entry up to date with its charges.
+ for (const s of ctx.db.select().from(subscriptions).all()) recompute(ctx, s, now);
+ })();
+ return { linked, found };
+}
+
+function recompute(ctx: AppContext, s: SubscriptionRow, now: string) {
+ const charges = ctx.db
+ .select({ date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId, categoryId: transactions.categoryId })
+ .from(transactions)
+ .where(eq(transactions.subscriptionId, s.id))
+ .orderBy(asc(transactions.date), asc(transactions.id))
+ .all();
+ if (!charges.length || (s.status === 'suggested' && s.source === 'detected' && charges.length < 2)) {
+ if (s.status === 'suggested' && s.source !== 'manual') {
+ ctx.db.update(transactions).set({ subscriptionId: null }).where(eq(transactions.subscriptionId, s.id)).run();
+ ctx.db.delete(subscriptions).where(eq(subscriptions.id, s.id)).run();
+ } else if (s.lastChargeOn && !charges.length) {
+ ctx.db.update(subscriptions).set({ firstChargeOn: null, lastChargeOn: null, updatedAt: now }).where(eq(subscriptions.id, s.id)).run();
+ }
+ return;
+ }
+ const last = charges.at(-1)!;
+ const recent = charges.slice(-3).map((c) => -c.amountCents);
+ const expected = s.kind === 'bill' ? Math.round(recent.reduce((a, b) => a + b, 0) / recent.length) : -last.amountCents;
+ const status = s.status === 'trial' && s.trialEndsOn && last.date > s.trialEndsOn ? 'active' : s.status;
+ const set: Partial = {
+ firstChargeOn: charges[0]!.date,
+ lastChargeOn: last.date,
+ nextExpectedOn: nextCharge(last.date, s.cadence),
+ accountId: last.accountId,
+ expectedCents: expected,
+ status,
+ confidence: charges.length >= 3 ? 'confident' : s.confidence,
+ };
+ const changed = (Object.keys(set) as (keyof SubscriptionRow)[]).some((k) => set[k] !== s[k]);
+ if (changed) ctx.db.update(subscriptions).set({ ...set, updatedAt: now }).where(eq(subscriptions.id, s.id)).run();
+}
+
+// --- Reading -------------------------------------------------------------------
+
+export function toSubscription(s: SubscriptionRow, chargeCount: number): Subscription {
+ return {
+ id: s.id,
+ name: s.name,
+ kind: s.kind,
+ merchant: s.merchant,
+ aliases: JSON.parse(s.aliases) as string[],
+ cadence: s.cadence,
+ expectedCents: s.expectedCents,
+ monthlyCents: monthlyCost(s.expectedCents, s.cadence),
+ accountId: s.accountId,
+ categoryId: s.categoryId,
+ status: s.status,
+ confidence: s.confidence,
+ source: s.source,
+ trialEndsOn: s.trialEndsOn,
+ cancelledOn: s.cancelledOn,
+ firstChargeOn: s.firstChargeOn,
+ lastChargeOn: s.lastChargeOn,
+ nextExpectedOn: s.nextExpectedOn,
+ manageUrl: s.manageUrl ?? catalogEntry(s.catalogKey)?.manageUrl ?? null,
+ note: s.note,
+ alertsOn: s.alertsOn,
+ chargeCount,
+ };
+}
+
+export function chargeCounts(ctx: AppContext): Map {
+ return new Map(
+ ctx.db
+ .select({ id: transactions.subscriptionId, n: count() })
+ .from(transactions)
+ .where(isNotNull(transactions.subscriptionId))
+ .groupBy(transactions.subscriptionId)
+ .all()
+ .map((r) => [r.id!, r.n]),
+ );
+}
+
+export function listSubscriptions(ctx: AppContext, includeDismissed = false): Subscription[] {
+ const counts = chargeCounts(ctx);
+ return ctx.db
+ .select()
+ .from(subscriptions)
+ .where(includeDismissed ? undefined : ne(subscriptions.status, 'dismissed'))
+ .orderBy(asc(subscriptions.name))
+ .all()
+ .map((s) => toSubscription(s, counts.get(s.id) ?? 0));
+}
+
+export function subscriptionDetail(ctx: AppContext, s: SubscriptionRow): SubscriptionDetail {
+ const charges = ctx.db
+ .select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId })
+ .from(transactions)
+ .where(eq(transactions.subscriptionId, s.id))
+ .orderBy(asc(transactions.date), asc(transactions.id))
+ .all();
+ const priceHistory: SubscriptionDetail['priceHistory'] = [];
+ if (s.kind === 'subscription') {
+ for (const c of charges) {
+ const amount = -c.amountCents;
+ const step = priceHistory.at(-1);
+ if (!step || Math.abs(amount - step.amountCents) > Math.max(step.amountCents * 0.01, 5)) priceHistory.push({ from: c.date, amountCents: amount });
+ }
+ }
+ return { ...toSubscription(s, charges.length), charges: charges.reverse(), priceHistory };
+}
+
+/** Charges expected in the next few days, for "Coming up". */
+export function upcomingCharges(ctx: AppContext, days = 30) {
+ const start = today();
+ const end = addDays(start, days);
+ const out: { subscriptionId: number; name: string; kind: SubscriptionKind; date: string; amountCents: number; accountId: number | null; trial: boolean }[] = [];
+ for (const s of ctx.db.select().from(subscriptions).where(inArray(subscriptions.status, ['active', 'trial'])).all()) {
+ if (s.status === 'trial') {
+ if (s.trialEndsOn && s.trialEndsOn >= start && s.trialEndsOn <= end) {
+ out.push({ subscriptionId: s.id, name: s.name, kind: s.kind, date: s.trialEndsOn, amountCents: s.expectedCents, accountId: s.accountId, trial: true });
+ }
+ continue;
+ }
+ let d = s.nextExpectedOn;
+ if (!d) continue;
+ for (let guard = 0; d < start && guard < 600; guard++) d = nextCharge(d, s.cadence);
+ for (let guard = 0; d <= end && guard < 10; guard++) {
+ out.push({ subscriptionId: s.id, name: s.name, kind: s.kind, date: d, amountCents: s.expectedCents, accountId: s.accountId, trial: false });
+ d = nextCharge(d, s.cadence);
+ }
+ }
+ return out.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
+}
diff --git a/server/src/transactions/dto.ts b/server/src/transactions/dto.ts
index 8fac557..4af87b0 100644
--- a/server/src/transactions/dto.ts
+++ b/server/src/transactions/dto.ts
@@ -45,6 +45,7 @@ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transac
source: r.source,
bankCategory: r.bankCategory,
importId: r.importId,
+ subscriptionId: r.subscriptionId,
}));
}
diff --git a/server/src/transactions/filter.ts b/server/src/transactions/filter.ts
new file mode 100644
index 0000000..8085182
--- /dev/null
+++ b/server/src/transactions/filter.ts
@@ -0,0 +1,54 @@
+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 { DateString } from '../schemas';
+
+/** The search and filters shared by the transaction list and its exports. */
+export const TransactionQuery = z.object({
+ q: z.string().max(100).optional(),
+ accountId: z.coerce.number().int().optional(),
+ categoryId: z.union([z.coerce.number().int(), z.literal('none')]).optional(),
+ tag: z.string().max(40).optional(),
+ from: DateString.optional(),
+ to: DateString.optional(),
+ review: z.enum(['true', 'false']).optional(),
+ subscriptionId: z.coerce.number().int().optional(),
+});
+
+export type TransactionFilter = z.infer;
+
+export function transactionWhere(ctx: AppContext, q: TransactionFilter): SQL | undefined {
+ const where: SQL[] = [];
+ if (q.q) {
+ const term = `%${q.q.replace(/[%_]/g, '')}%`;
+ where.push(or(like(transactions.merchant, term), like(transactions.description, term), like(transactions.note, term))!);
+ }
+ if (q.accountId) where.push(eq(transactions.accountId, q.accountId));
+ if (q.categoryId === 'none') where.push(and(isNull(transactions.categoryId), eq(transactions.isSplit, false))!);
+ else if (q.categoryId !== undefined) {
+ const cid = q.categoryId;
+ where.push(
+ or(
+ eq(transactions.categoryId, cid),
+ exists(ctx.db.select().from(splits).where(and(eq(splits.transactionId, transactions.id), eq(splits.categoryId, cid)))),
+ )!,
+ );
+ }
+ if (q.tag) {
+ 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()}`)),
+ ),
+ );
+ }
+ if (q.from) where.push(gte(transactions.date, q.from));
+ if (q.to) where.push(lte(transactions.date, q.to));
+ if (q.review) where.push(eq(transactions.needsReview, q.review === 'true'));
+ if (q.subscriptionId) where.push(eq(transactions.subscriptionId, q.subscriptionId));
+ return where.length ? and(...where) : undefined;
+}
diff --git a/server/test/full-picture.test.ts b/server/test/full-picture.test.ts
new file mode 100644
index 0000000..591c9f1
--- /dev/null
+++ b/server/test/full-picture.test.ts
@@ -0,0 +1,415 @@
+import { inflateRawSync } from 'node:zlib';
+import type { AlertItem, BudgetYear, MonthsReport, Overview, Subscription, SubscriptionDetail } from '@spendlight/shared';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { today } from '../src/dates';
+import { buildCsv } from '../src/export/xlsx';
+import { fitCadence } from '../src/subscriptions/engine';
+import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers';
+import { chaseCsv, checkingCsv, discoverCsv } from './synthetic';
+
+// Milestone 2: budgets, reports, subscriptions, alerts, management and export,
+// all on made-up statements (see synthetic.ts).
+
+let t: TestApp;
+let admin: Client;
+let ids: { checking: number; discover: number; chase: number };
+let imports: { discover: number; chase: number; checking: number };
+
+const groups = async () => (await admin.get('/api/v1/categories?includeArchived=true')).json as { id: number; name: string; categories: { id: number; name: string; archived: boolean }[] }[];
+const category = async (name: string) => (await groups()).flatMap((g) => g.categories).find((c) => c.name === name)!.id;
+const subs = async () => (await admin.get('/api/v1/subscriptions')).json as Subscription[];
+const sub = async (name: string) => (await subs()).find((s) => s.name === name)!;
+const alerts = async (area?: string) => (await admin.get(`/api/v1/alerts${area ? `?area=${area}` : ''}`)).json as AlertItem[];
+
+async function importText(name: string, text: string, accountId: number) {
+ const preview = await admin.upload('/api/v1/imports/files', name, text);
+ expect(preview.status).toBe(200);
+ const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId });
+ expect(done.status).toBe(200);
+ return done.json.id as number;
+}
+
+beforeEach(async () => {
+ t = await makeApp();
+ admin = await setUpAdmin(t);
+ const add = async (body: Record) => (await admin.post('/api/v1/accounts', body)).json.id as number;
+ ids = {
+ checking: await add({ name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' }),
+ discover: await add({ name: 'Discover', kind: 'credit_card', institution: 'Discover' }),
+ chase: await add({ name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }),
+ };
+ // Filed by hand once, so the big Best Buy purchase has a typical Electronics purchase to compare with.
+ await admin.post('/api/v1/rules', { merchant: 'Micro Center', categoryId: await category('Electronics') });
+ imports = {
+ discover: await importText('Discover-2026.csv', discoverCsv(), ids.discover),
+ chase: await importText('Chase9876_Activity.csv', chaseCsv(), ids.chase),
+ checking: await importText('stmt.csv', checkingCsv(), ids.checking),
+ };
+});
+afterEach(() => t.cleanup());
+
+describe('budgets', () => {
+ it('sets a monthly amount, a one-month exception, and "from this month on"', async () => {
+ const gifts = await category('Gifts');
+ let year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 10000, scope: 'year' })).json as BudgetYear;
+ expect(year.items).toEqual([{ categoryId: gifts, monthly: 10000, months: Array(12).fill(null) }]);
+
+ year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 12, amountCents: 80000, scope: 'month' })).json;
+ expect(year.items[0]!.months[11]).toBe(80000);
+
+ // October onward becomes $150; January to September keep $100. December's own amount gives way.
+ year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 10, amountCents: 15000, scope: 'rest' })).json;
+ const line = year.items[0]!;
+ const effective = line.months.map((m) => m ?? line.monthly);
+ expect(effective).toEqual([...Array(9).fill(10000), 15000, 15000, 15000]);
+
+ // Setting a month to the yearly amount leaves no needless exception behind.
+ year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 3, amountCents: 15000, scope: 'month' })).json;
+ expect(year.items[0]!.months[2]).toBeNull();
+
+ // Zero for every month removes the budget.
+ year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 0, scope: 'year' })).json;
+ expect(year.items).toEqual([]);
+ });
+
+ it('fills empty categories from a 3-month average and copies a year forward', async () => {
+ const groceries = await category('Groceries');
+ const mine = await category('Dining Out');
+ await admin.put('/api/v1/budgets', { categoryId: mine, year: 2026, month: 1, amountCents: 12345, scope: 'year' });
+ const filled = await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' });
+ expect(filled.status).toBe(200);
+ expect(filled.json.filled).toBeGreaterThan(3);
+ const lines = new Map((filled.json.budgets as BudgetYear).items.map((l) => [l.categoryId, l]));
+ // Categories already budgeted are left alone; the rest round to $5 steps.
+ expect(lines.get(mine)!.monthly).toBe(12345);
+ const report = (await admin.get('/api/v1/reports/months?from=2026-07&to=2026-09')).json as MonthsReport;
+ const actual = report.categories.find((c) => c.categoryId === groceries)!.actual;
+ const average = actual.reduce((a, b) => a + b, 0) / 3;
+ expect(lines.get(groceries)!.monthly).toBe(Math.ceil(average / 500) * 500);
+ // Transfers never get a budget.
+ expect(lines.has(await category('Credit Card Payment'))).toBe(false);
+
+ const copied = await admin.post('/api/v1/budgets/copy-year', { from: 2026, to: 2027 });
+ expect(copied.json.copied).toBe(filled.json.filled + 1);
+ expect((await admin.put('/api/v1/budgets', { categoryId: await category('Credit Card Payment'), year: 2026, month: 1, amountCents: 100, scope: 'year' })).status).toBe(400);
+ });
+});
+
+describe('reports', () => {
+ it('totals each month by category: refunds reduce spending, transfers never count, splits count by part', async () => {
+ const report = (await admin.get('/api/v1/reports/months?from=2026-01&to=2026-09')).json as MonthsReport;
+ expect(report.months).toHaveLength(9);
+ expect(report.dataThrough).toBe('2026-09-29');
+ // Income: 19 or 20 paychecks of $2,450 across nine months.
+ const income = report.totals.income.reduce((a, b) => a + b, 0);
+ expect(income % 245000).toBe(0);
+ // Card payments are paired transfers, so no month counts them as spending.
+ const spentSep = report.totals.spent[8]!;
+ const page = (await admin.get('/api/v1/transactions?from=2026-09-01&to=2026-09-30&limit=500')).json;
+ expect(spentSep).toBe(-page.sums.outCents);
+
+ // Split a September Kroger trip: $20 of it was Household Supplies.
+ const kroger = page.items.find((x: { merchant: string }) => x.merchant === 'Kroger');
+ const groceries = await category('Groceries');
+ const household = await category('Household Supplies');
+ const before = report.categories.find((c) => c.categoryId === groceries)!.actual[8]!;
+ await admin.call('PUT', `/api/v1/transactions/${kroger.id}/split`, {
+ parts: [
+ { categoryId: groceries, amountCents: kroger.amountCents + 2000 },
+ { categoryId: household, amountCents: -2000 },
+ ],
+ });
+ const after = (await admin.get('/api/v1/reports/months?from=2026-09&to=2026-09')).json as MonthsReport;
+ expect(after.categories.find((c) => c.categoryId === groceries)!.actual[0]).toBe(before - 2000);
+ expect(after.categories.find((c) => c.categoryId === household)!.actual[0]).toBe(2000);
+ expect(after.totals.spent[0]).toBe(spentSep);
+ });
+
+ it('shows the latest month with data on Home while statements catch up, and paces the year', async () => {
+ const gifts = await category('Groceries');
+ await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 30000, scope: 'year' });
+ const o = (await admin.get('/api/v1/reports/overview')).json as Overview;
+ if (today() > '2026-09-30') {
+ expect(o.month).toBe('2026-09');
+ expect(o.isCurrent).toBe(false);
+ }
+ expect(o.hasBudgets).toBe(true);
+ expect(o.budgetSpend).toBe(30000);
+ expect(o.year.budgetYear).toBe(360000);
+ expect(o.year.budgetSoFar).toBe(270000);
+ // Spending is far above this one small budget, so the projection runs over it.
+ expect(o.year.projected).toBeGreaterThan(o.year.budgetYear);
+ expect(o.categories[0]!.spent).toBeGreaterThanOrEqual(o.categories.at(-1)!.spent);
+ });
+
+ it('lists merchants, balances and tags', async () => {
+ const merchants = (await admin.get('/api/v1/reports/merchants?from=2026-01-01&to=2026-09-30')).json as { merchant: string; spent: number; count: number }[];
+ expect(merchants[0]!.merchant).toBe('Rocket Mortgage');
+ expect(merchants.find((m) => m.merchant === 'Starbucks')!.count).toBeGreaterThan(60);
+ expect(merchants.some((m) => /payment/i.test(m.merchant))).toBe(false);
+
+ const balances = (await admin.get('/api/v1/reports/balances?months=12')).json as { months: string[]; accounts: { accountId: number; balances: (number | null)[] }[] };
+ const checking = balances.accounts.find((a) => a.accountId === ids.checking)!;
+ const sep = balances.months.indexOf('2026-09');
+ expect(sep).toBeGreaterThan(-1);
+ // The statement's running balance anchors checking; the cards have no known balance yet.
+ expect(checking.balances[sep]).toBeTypeOf('number');
+ expect(balances.accounts.find((a) => a.accountId === ids.discover)!.balances.every((b) => b === null)).toBe(true);
+
+ const page = (await admin.get('/api/v1/transactions?q=Joe&limit=3')).json;
+ for (const x of page.items) await admin.patch(`/api/v1/transactions/${x.id}`, { tags: ['Birthday'] });
+ const tags = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number; count: number }[];
+ expect(tags[0]).toMatchObject({ tag: 'Birthday', count: 3 });
+ expect(tags[0]!.spent).toBe(-page.items.reduce((a: number, x: { amountCents: number }) => a + x.amountCents, 0));
+ });
+});
+
+describe('subscriptions', () => {
+ it('finds subscriptions and bills, one entry per service even across cards', async () => {
+ const list = await subs();
+ const byName = new Map(list.map((s) => [s.name, s]));
+ expect([...byName.keys()].sort()).toEqual(['Costco membership', 'Disney+', 'Duke Energy', 'Hulu', 'Netflix', 'Rocket Mortgage', 'Spotify']);
+ expect(byName.get('Netflix')).toMatchObject({ kind: 'subscription', cadence: 'monthly', expectedCents: 1799, chargeCount: 10, status: 'suggested', confidence: 'confident' });
+ expect(byName.get('Costco membership')).toMatchObject({ cadence: 'yearly', expectedCents: 6500, chargeCount: 1, monthlyCents: 542 });
+ expect(byName.get('Duke Energy')).toMatchObject({ kind: 'bill', chargeCount: 9 });
+ expect(byName.get('Rocket Mortgage')).toMatchObject({ kind: 'bill', expectedCents: 185000 });
+ expect(byName.get('Disney+')).toMatchObject({ confidence: 'likely', chargeCount: 2, manageUrl: 'https://www.disneyplus.com/account' });
+
+ const netflix = (await admin.get(`/api/v1/subscriptions/${byName.get('Netflix')!.id}`)).json as SubscriptionDetail;
+ expect(netflix.priceHistory).toEqual([
+ { from: '2026-01-12', amountCents: 1549 },
+ { from: '2026-07-12', amountCents: 1799 },
+ ]);
+ expect(new Set(netflix.charges.map((c) => c.accountId))).toEqual(new Set([ids.discover, ids.chase]));
+ // Each find asks once.
+ expect((await alerts('subscriptions')).filter((a) => a.kind === 'sub_found')).toHaveLength(7);
+ });
+
+ it('raises alerts once confirmed: price change, two cards, charged after cancelling', async () => {
+ const netflix = await sub('Netflix');
+ await admin.patch(`/api/v1/subscriptions/${netflix.id}`, { status: 'active' });
+ const hulu = await sub('Hulu');
+ await admin.patch(`/api/v1/subscriptions/${hulu.id}`, { status: 'cancelled', cancelledOn: '2026-08-25' });
+
+ const list = await alerts('subscriptions');
+ const kinds = list.map((a) => a.kind);
+ expect(kinds).toContain('sub_two_cards');
+ expect(kinds).toContain('sub_after_cancel');
+ expect(list.find((a) => a.kind === 'sub_after_cancel')!.title).toBe('Hulu charged Discover after you marked it cancelled');
+ // The most urgent comes first.
+ expect(list[0]!.kind).toBe('sub_after_cancel');
+ // The July price rise is old news by September; recent rises raise an alert.
+ expect(kinds).not.toContain('sub_price');
+
+ const counts = (await admin.get('/api/v1/alerts/counts')).json;
+ expect(counts.subscriptions).toBe(list.length);
+ await admin.post('/api/v1/alerts/read', { area: 'subscriptions' });
+ expect((await admin.get('/api/v1/alerts/counts')).json.subscriptions).toBe(0);
+
+ const dismissed = list.find((a) => a.kind === 'sub_two_cards')!;
+ await admin.post(`/api/v1/alerts/${dismissed.id}/dismiss`);
+ expect((await alerts('subscriptions')).some((a) => a.id === dismissed.id)).toBe(false);
+ });
+
+ it('remembers "not a subscription", adds by hand, merges, and follows an undone import', async () => {
+ const disney = await sub('Disney+');
+ await admin.patch(`/api/v1/subscriptions/${disney.id}`, { status: 'dismissed' });
+ await admin.post('/api/v1/subscriptions/scan');
+ expect((await subs()).some((s) => s.name === 'Disney+')).toBe(false);
+
+ const added = await admin.post('/api/v1/subscriptions', { name: 'Car wash club', cadence: 'monthly', expectedCents: 2999, nextExpectedOn: '2026-10-15' });
+ expect(added.status).toBe(200);
+ expect(added.json).toMatchObject({ source: 'manual', status: 'active', chargeCount: 0 });
+ const upcoming = (await admin.get('/api/v1/subscriptions/upcoming?days=120')).json as { name: string }[];
+ expect(upcoming.some((u) => u.name === 'Car wash club')).toBe(true);
+
+ const spotify = await sub('Spotify');
+ const merged = await admin.post(`/api/v1/subscriptions/${added.json.id}/merge`, { intoId: spotify.id });
+ expect(merged.json.aliases).toEqual(['Car wash club']);
+ expect((await subs()).some((s) => s.name === 'Car wash club')).toBe(false);
+
+ // Undo the Chase import: Disney+'s charges go with it and the second Netflix card disappears.
+ await admin.del(`/api/v1/imports/${imports.chase}`);
+ const netflix = (await admin.get(`/api/v1/subscriptions/${(await sub('Netflix')).id}`)).json as SubscriptionDetail;
+ expect(netflix.charges.every((c) => c.accountId === ids.discover)).toBe(true);
+ expect(netflix.chargeCount).toBe(9);
+ });
+
+ it('files every charge when a subscription’s category changes', async () => {
+ const spotify = await sub('Spotify');
+ const streaming = await category('Streaming and Subscriptions');
+ await admin.patch(`/api/v1/subscriptions/${spotify.id}`, { status: 'active', categoryId: streaming });
+ const charges = (await admin.get(`/api/v1/transactions?subscriptionId=${spotify.id}&limit=50`)).json;
+ expect(charges.total).toBe(9);
+ expect(charges.items.every((x: { categoryId: number; needsReview: boolean }) => x.categoryId === streaming && !x.needsReview)).toBe(true);
+ });
+
+ it('reads rhythms, allowing a skipped month', () => {
+ expect(fitCadence(['2026-01-05', '2026-02-05', '2026-03-06', '2026-05-05'])).toBe('monthly');
+ expect(fitCadence(['2025-03-10', '2026-03-09'])).toBe('yearly');
+ expect(fitCadence(['2026-01-05', '2026-01-09', '2026-01-20'])).toBeNull();
+ });
+});
+
+describe('money alerts', () => {
+ it('flags an unusual purchase and a month over budget, set against the year', async () => {
+ const list = await alerts('money');
+ const unusual = list.find((a) => a.kind === 'unusual')!;
+ expect(unusual.title).toBe('Unusual: $480.00 at Best Buy');
+ expect(unusual.body).toMatch(/times your typical Electronics purchase/);
+
+ const dining = await category('Dining Out');
+ await admin.put('/api/v1/budgets', { categoryId: dining, year: 2026, month: 1, amountCents: 1000, scope: 'year' });
+ const after = await alerts('money');
+ const over = after.find((a) => a.kind === 'month_category_over');
+ if (today() > '2026-09-30') {
+ expect(over!.title).toMatch(/^Dining Out ran \$\d+(\.\d\d)? over in September$/);
+ expect(over!.body).toMatch(/over budget for the year/);
+ expect(after.find((a) => a.kind === 'year_pace')!.title).toMatch(/^On pace to finish 2026 .* over budget$/);
+ }
+ });
+
+ it('warns while a month is running when a category nears its budget', async () => {
+ const now = today();
+ const coffee = await category('Coffee and Snacks');
+ const add = await admin.post('/api/v1/transactions', { accountId: ids.checking, date: now, amountCents: -9000, merchant: 'Corner Cafe', categoryId: coffee });
+ expect(add.status).toBe(200);
+ await admin.put('/api/v1/budgets', { categoryId: coffee, year: Number(now.slice(0, 4)), month: Number(now.slice(5, 7)), amountCents: 10000, scope: 'month' });
+ const list = await alerts('money');
+ const left = new Date(Number(now.slice(0, 4)), Number(now.slice(5, 7)), 0).getDate() - Number(now.slice(8, 10));
+ const close = list.find((a) => a.kind === 'budget_close' || a.kind === 'budget_over');
+ if (left > 0) expect(close?.title).toMatch(/^Coffee and Snacks is at \d+% of budget with \d+ days? left$/);
+ });
+});
+
+describe('managing categories, rules and tags', () => {
+ it('adds, renames, archives and merges categories, carrying budgets along', async () => {
+ const home = (await groups()).find((g) => g.name === 'Home')!;
+ const added = await admin.post('/api/v1/categories', { groupId: home.id, name: 'Lawn Care', icon: 'wrench', color: 'emerald' });
+ expect(added.json).toMatchObject({ name: 'Lawn Care', kind: 'expense', groupName: 'Home' });
+ expect((await admin.post('/api/v1/categories', { groupId: home.id, name: 'lawn care' })).status).toBe(400);
+
+ const repairs = await category('Repairs and Maintenance');
+ await admin.put('/api/v1/budgets', { categoryId: added.json.id, year: 2026, month: 1, amountCents: 5000, scope: 'year' });
+ await admin.put('/api/v1/budgets', { categoryId: repairs, year: 2026, month: 1, amountCents: 10000, scope: 'year' });
+ const page = (await admin.get('/api/v1/transactions?q=Joe&limit=1')).json;
+ await admin.patch(`/api/v1/transactions/${page.items[0].id}`, { categoryId: added.json.id });
+
+ const merged = await admin.post(`/api/v1/categories/${added.json.id}/merge`, { intoId: repairs });
+ expect(merged.json.moved).toBe(1);
+ const budgets = (await admin.get('/api/v1/budgets?year=2026')).json as BudgetYear;
+ expect(budgets.items.find((l) => l.categoryId === repairs)!.monthly).toBe(15000);
+ expect((await admin.get(`/api/v1/transactions/${page.items[0].id}`)).json.categoryId).toBe(repairs);
+ // Merging spending into income makes no sense.
+ expect((await admin.post(`/api/v1/categories/${repairs}/merge`, { intoId: await category('Paycheck') })).status).toBe(400);
+
+ await admin.patch(`/api/v1/categories/${repairs}`, { name: 'Home Repairs', archived: true });
+ const active = (await admin.get('/api/v1/categories')).json as { categories: { name: string }[] }[];
+ expect(active.flatMap((g) => g.categories).some((c) => c.name === 'Home Repairs')).toBe(false);
+ expect((await groups()).flatMap((g) => g.categories).find((c) => c.id === repairs)).toMatchObject({ name: 'Home Repairs', archived: true });
+
+ expect((await admin.del(`/api/v1/category-groups/${home.id}`)).status).toBe(400);
+ const empty = await admin.post('/api/v1/category-groups', { name: 'Side business' });
+ expect((await admin.del(`/api/v1/category-groups/${empty.json.id}`)).status).toBe(200);
+ });
+
+ it('counts rule use on import, edits rules, and merges tags on rename', async () => {
+ const rules = (await admin.get('/api/v1/rules')).json as { id: number; merchant: string; hits: number; lastUsedAt: string | null }[];
+ const micro = rules.find((r) => r.merchant === 'Micro Center')!;
+ expect(micro.hits).toBe(1);
+ expect(micro.lastUsedAt).not.toBeNull();
+ const edited = await admin.patch(`/api/v1/rules/${micro.id}`, { renameTo: 'Micro Center Electronics' });
+ expect(edited.json.renameTo).toBe('Micro Center Electronics');
+ expect((await admin.patch(`/api/v1/rules/${micro.id}`, { categoryId: null, renameTo: null })).status).toBe(400);
+
+ const page = (await admin.get('/api/v1/transactions?q=Kroger&limit=2')).json;
+ await admin.patch(`/api/v1/transactions/${page.items[0].id}`, { tags: ['Party'] });
+ await admin.patch(`/api/v1/transactions/${page.items[1].id}`, { tags: ['Parties'] });
+ let tags = (await admin.get('/api/v1/tags')).json as { id: number; name: string; count: number }[];
+ const party = tags.find((x) => x.name === 'Parties')!;
+ await admin.patch(`/api/v1/tags/${party.id}`, { name: 'party' });
+ tags = (await admin.get('/api/v1/tags')).json;
+ expect(tags).toEqual([{ id: expect.any(Number), name: 'Party', count: 2 }]);
+ });
+});
+
+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');
+ const res = await t.app.request('http://localhost/api/v1/export/transactions.csv?q=Netflix', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-disposition')).toMatch(/attachment; filename="spendlight-transactions-\d{4}-\d\d-\d\d\.csv"/);
+ const bytes = Buffer.from(await res.arrayBuffer());
+ // Excel needs the byte-order mark to read accents and symbols correctly.
+ expect([...bytes.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf]);
+ const lines = bytes.subarray(3).toString('utf8').trim().split('\r\n');
+ expect(lines[0]).toBe('Date,Account,Merchant,Category,Group,Amount,Split of,Transfer with,Tags,Note,Subscription,Status,Bank description');
+ expect(lines).toHaveLength(11);
+ });
+
+ it('writes a real Excel workbook with every part Excel needs', async () => {
+ const res = await t.app.request('http://localhost/api/v1/export/everything.xlsx', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
+ expect(res.status).toBe(200);
+ const zip = Buffer.from(await res.arrayBuffer());
+ const files = unzip(zip);
+ expect([...files.keys()].sort()).toEqual([
+ '[Content_Types].xml',
+ '_rels/.rels',
+ 'docProps/app.xml',
+ 'docProps/core.xml',
+ 'xl/_rels/workbook.xml.rels',
+ 'xl/styles.xml',
+ 'xl/workbook.xml',
+ 'xl/worksheets/sheet1.xml',
+ 'xl/worksheets/sheet2.xml',
+ 'xl/worksheets/sheet3.xml',
+ 'xl/worksheets/sheet4.xml',
+ 'xl/worksheets/sheet5.xml',
+ 'xl/worksheets/sheet6.xml',
+ ]);
+ expect(files.get('xl/workbook.xml')).toContain(' ');
+ const sheet = files.get('xl/worksheets/sheet1.xml')!;
+ expect(sheet).toContain(' ');
+ expect(sheet).toMatch(/-?\d+\.\d\d<\/v><\/c>/);
+ expect(sheet).toMatch(/46\d\d\d<\/v><\/c>/);
+ });
+});
+
+describe('roles and AI', () => {
+ it('keeps budgets read-only for read-only people and AI off without a provider', async () => {
+ await admin.post('/api/v1/people', { username: 'reader', displayName: 'Reader', access: 'read', isAdmin: false, password: 'reader-password-1' });
+ const { Client } = await import('./helpers');
+ const reader = new Client(t);
+ await reader.post('/api/v1/auth/login', { username: 'reader', password: 'reader-password-1' });
+ expect((await reader.get('/api/v1/reports/overview')).status).toBe(200);
+ expect((await reader.put('/api/v1/budgets', { categoryId: 1, year: 2026, month: 1, amountCents: 100, scope: 'year' })).status).toBe(403);
+ expect((await reader.get('/api/v1/ai')).status).toBe(403);
+
+ const status = (await admin.get('/api/v1/ai')).json;
+ expect(status.providerConfigured).toBe(false);
+ expect(status.features.every((f: { enabled: boolean }) => !f.enabled)).toBe(true);
+ const refused = await admin.patch('/api/v1/ai', { feature: 'categorize', enabled: true });
+ expect(refused.status).toBe(400);
+ expect(refused.json.error.code).toBe('no_provider');
+ });
+});
+
+/** Read a zip's entries, enough to check what buildXlsx wrote. */
+function unzip(buf: Buffer): Map {
+ const out = new Map();
+ const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
+ const entries = buf.readUInt16LE(end + 10);
+ let p = buf.readUInt32LE(end + 16);
+ for (let i = 0; i < entries; i++) {
+ const nameLength = buf.readUInt16LE(p + 28);
+ const extra = buf.readUInt16LE(p + 30);
+ const comment = buf.readUInt16LE(p + 32);
+ const offset = buf.readUInt32LE(p + 42);
+ const name = buf.subarray(p + 46, p + 46 + nameLength).toString('utf8');
+ const size = buf.readUInt32LE(p + 20);
+ const local = offset + 30 + buf.readUInt16LE(offset + 26) + buf.readUInt16LE(offset + 28);
+ out.set(name, inflateRawSync(buf.subarray(local, local + size)).toString('utf8'));
+ p += 46 + nameLength + extra + comment;
+ }
+ return out;
+}
diff --git a/server/test/helpers.ts b/server/test/helpers.ts
index 2edc9db..3bb6135 100644
--- a/server/test/helpers.ts
+++ b/server/test/helpers.ts
@@ -89,6 +89,7 @@ export class Client {
get = (path: string, headers?: Record) => this.call('GET', path, undefined, headers);
post = (path: string, body: unknown = {}, headers?: Record) => this.call('POST', path, body, headers);
patch = (path: string, body: unknown, headers?: Record) => this.call('PATCH', path, body, headers);
+ put = (path: string, body: unknown, headers?: Record) => this.call('PUT', path, body, headers);
del = (path: string, headers?: Record) => this.call('DELETE', path, undefined, headers);
cookie(name: string) {
diff --git a/server/test/synthetic.ts b/server/test/synthetic.ts
new file mode 100644
index 0000000..c22b796
--- /dev/null
+++ b/server/test/synthetic.ts
@@ -0,0 +1,125 @@
+// Made-up statements for nine months of 2026, shaped like the real exports
+// (Discover, Chase and Bank of America checking). Nothing here is real data.
+// They carry the patterns milestone 2 has to find: subscriptions with a price
+// change, a cancelled service that charges again, one billed on two cards, a
+// yearly membership, variable bills, and an unusually large purchase.
+
+function random(seed: number) {
+ return () => {
+ seed |= 0;
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const pad = (n: number) => String(n).padStart(2, '0');
+const us = (iso: string) => `${iso.slice(5, 7)}/${iso.slice(8, 10)}/${iso.slice(0, 4)}`;
+const day = (month: number, d: number) => `2026-${pad(month)}-${pad(d)}`;
+const money = (cents: number) => (cents / 100).toFixed(2);
+const quoted = (cents: number) => `"${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}"`;
+
+interface Row {
+ date: string;
+ description: string;
+ /** Money out negative. */
+ cents: number;
+ category?: string;
+}
+
+function everyDay(months: number[], fn: (date: string, weekday: number) => void) {
+ for (const m of months) {
+ const last = new Date(2026, m, 0).getDate();
+ for (let d = 1; d <= last; d++) fn(day(m, d), new Date(2026, m - 1, d).getDay());
+ }
+}
+
+const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+
+export function discoverRows(): Row[] {
+ const r = random(7);
+ const rows: Row[] = [];
+ for (const m of MONTHS) {
+ rows.push({ date: day(m, 12), description: 'NETFLIX.COM 866-579-7172 CA', cents: m <= 6 ? -1549 : -1799, category: 'Services' });
+ rows.push({ date: day(m, 3), description: 'SPOTIFY USA 877-778-1161 NY', cents: -1199, category: 'Services' });
+ rows.push({ date: day(m, 20), description: 'HULU 877-8244858 CA', cents: -799, category: 'Services' });
+ rows.push({ date: day(m, 25), description: 'MICRO CENTER #045 CHARLOTTE NC', cents: -Math.round(4000 + r() * 4000), category: 'Merchandise' });
+ rows.push({ date: day(m, 8), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
+ rows.push({ date: day(m, 22), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
+ rows.push({ date: day(m, 28), description: 'INTERNET PAYMENT - THANK YOU', cents: 60000, category: 'Payments and Credits' });
+ }
+ everyDay(MONTHS, (date, weekday) => {
+ if (weekday === 6) rows.push({ date, description: 'KROGER #123 CHARLOTTE NC', cents: -Math.round(4000 + r() * 10000), category: 'Supermarkets' });
+ if (weekday === 2 || weekday === 4) rows.push({ date, description: 'STARBUCKS #4567 CHARLOTTE NC', cents: -545, category: 'Restaurants' });
+ });
+ rows.push({ date: day(3, 10), description: 'COSTCO *ANNUAL RENEWAL 800-774-2678 WA', cents: -6500, category: 'Warehouse Clubs' });
+ rows.push({ date: day(9, 15), description: 'BEST BUY #321 CHARLOTTE NC', cents: -48000, category: 'Merchandise' });
+ return rows.sort((a, b) => a.date.localeCompare(b.date));
+}
+
+export function chaseRows(): Row[] {
+ const r = random(11);
+ const rows: Row[] = [];
+ everyDay(MONTHS, (date, weekday) => {
+ if (weekday === 1) rows.push({ date, description: 'SHELL OIL 57444 CHARLOTTE NC', cents: -Math.round(3000 + r() * 2500), category: 'Gas' });
+ if (weekday === 5 && r() < 0.6) rows.push({ date, description: `AMAZON MKTPL*${Math.floor(r() * 1e9).toString(36).toUpperCase()}`, cents: -Math.round(1500 + r() * 6000), category: 'Shopping' });
+ });
+ for (const m of MONTHS) rows.push({ date: day(m, 27), description: 'AUTOMATIC PAYMENT - THANK', cents: 40000, category: '' });
+ rows.push({ date: day(8, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
+ rows.push({ date: day(9, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
+ // The same Netflix plan, accidentally on a second card.
+ rows.push({ date: day(9, 14), description: 'NETFLIX.COM 866-579-7172 CA', cents: -1799, category: 'Entertainment' });
+ return rows.sort((a, b) => a.date.localeCompare(b.date));
+}
+
+export function checkingRows(): Row[] {
+ const r = random(23);
+ const rows: Row[] = [];
+ for (let d = new Date(2026, 0, 9); d.getMonth() < 9; d.setDate(d.getDate() + 14)) {
+ rows.push({ date: d.toLocaleDateString('en-CA'), description: 'ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD', cents: 245000 });
+ }
+ for (const m of MONTHS) {
+ rows.push({ date: day(m, 1), description: 'ROCKET MORTGAGE DES:MTG PYMT ID:XXXXX77777 INDN:JANE DOE CO ID:XXXXX77777 PPD', cents: -185000 });
+ rows.push({ date: day(m, 22), description: 'DUKE ENERGY DES:DUKEENERGY ID:XXXXX33333 INDN:JANE DOE CO ID:XXXXX33333 WEB', cents: -Math.round(9500 + r() * 8500) });
+ rows.push({ date: day(m, 28), description: 'DISCOVER DES:E-PAYMENT ID:XXXXX99999 INDN:JANE DOE CO ID:XXXXX99999 WEB', cents: -60000 });
+ rows.push({ date: day(m, 27), description: 'CHASE CREDIT CRD DES:EPAY ID:XXXXX55555 INDN:JANE DOE CO ID:XXXXX55555 WEB', cents: -40000 });
+ }
+ return rows.sort((a, b) => a.date.localeCompare(b.date));
+}
+
+export function discoverCsv(rows = discoverRows()): string {
+ return ['Trans. Date,Post Date,Description,Amount,Category', ...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${money(-x.cents)},${x.category ?? ''}`)].join('\n') + '\n';
+}
+
+export function chaseCsv(rows = chaseRows()): string {
+ return (
+ [
+ 'Transaction Date,Post Date,Description,Category,Type,Amount,Memo',
+ ...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${x.category ?? ''},${x.cents > 0 ? 'Payment' : 'Sale'},${money(x.cents)},`),
+ ].join('\n') + '\n'
+ );
+}
+
+export function checkingCsv(rows = checkingRows(), opening = 500000): string {
+ let balance = opening;
+ const lines = rows.map((x) => {
+ balance += x.cents;
+ return `${us(x.date)},"${x.description}",${quoted(x.cents)},${quoted(balance)}`;
+ });
+ const credits = rows.filter((x) => x.cents > 0).reduce((a, x) => a + x.cents, 0);
+ const debits = rows.filter((x) => x.cents < 0).reduce((a, x) => a + x.cents, 0);
+ return (
+ [
+ 'Description,,Summary Amt.',
+ `Beginning balance as of 01/01/2026,,${quoted(opening)}`,
+ `Total credits,,${quoted(credits)}`,
+ `Total debits,,${quoted(debits)}`,
+ `Ending balance as of 09/30/2026,,${quoted(balance)}`,
+ '',
+ 'Date,Description,Amount,Running Bal.',
+ `01/01/2026,Beginning balance as of 01/01/2026,,${quoted(opening)}`,
+ ...lines,
+ ].join('\n') + '\n'
+ );
+}
diff --git a/shared/src/index.ts b/shared/src/index.ts
index 8cf0129..0d4668f 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -178,6 +178,8 @@ export interface Transaction {
source: 'import' | 'manual';
bankCategory: string | null;
importId: number | null;
+ /** The subscription or bill this charge belongs to, if any. */
+ subscriptionId: number | null;
}
export interface ReviewItem extends Transaction {
@@ -253,3 +255,213 @@ export function formatCents(cents: number, options: { sign?: boolean; whole?: bo
if (cents < 0) return `−${text}`;
return options.sign && cents > 0 ? `+${text}` : text;
}
+
+// ---------------------------------------------------------------------------
+// Milestone 2: the full picture. Spending and budgets are positive numbers of
+// cents; refunds reduce spending in the category they belong to.
+
+export type Cadence = 'weekly' | 'monthly' | 'quarterly' | 'yearly';
+export type SubscriptionKind = 'subscription' | 'bill';
+export type SubscriptionStatus = 'suggested' | 'active' | 'trial' | 'paused' | 'cancelled' | 'dismissed';
+export type Severity = 'info' | 'good' | 'warn' | 'bad';
+export type BudgetScope = 'month' | 'rest' | 'year';
+
+export const CADENCE_LABEL: Record = {
+ weekly: 'Every week',
+ monthly: 'Every month',
+ quarterly: 'Every 3 months',
+ yearly: 'Every year',
+};
+
+export const CADENCE_SHORT: Record = { weekly: '/wk', monthly: '/mo', quarterly: '/qtr', yearly: '/yr' };
+
+export const SUBSCRIPTION_STATUS_LABEL: Record = {
+ suggested: 'Found',
+ active: 'Active',
+ trial: 'Free trial',
+ paused: 'Paused',
+ cancelled: 'Cancelled',
+ dismissed: 'Not a subscription',
+};
+
+/** What a charge costs per month on average: a yearly plan counts as one twelfth. */
+export function monthlyCost(cents: number, cadence: Cadence): number {
+ if (cadence === 'weekly') return Math.round((cents * 52) / 12);
+ if (cadence === 'quarterly') return Math.round(cents / 3);
+ if (cadence === 'yearly') return Math.round(cents / 12);
+ return cents;
+}
+
+/** "2026-10" plus n months. */
+export function addMonths(month: string, n: number): string {
+ const [y, m] = month.split('-').map(Number);
+ const d = new Date(y!, m! - 1 + n, 1);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+export function daysInMonth(month: string): number {
+ const [y, m] = month.split('-').map(Number);
+ return new Date(y!, m!, 0).getDate();
+}
+
+export function monthName(month: string, style: 'long' | 'short' = 'long', withYear = false): string {
+ const [y, m] = month.split('-').map(Number);
+ return new Date(y!, m! - 1, 1).toLocaleDateString('en-US', { month: style, ...(withYear ? { year: 'numeric' } : {}) });
+}
+
+export interface BudgetLine {
+ categoryId: number;
+ /** The amount for every month of the year, if one is set. */
+ monthly: number | null;
+ /** Amounts set for one particular month, January first; null where none is set. */
+ months: (number | null)[];
+}
+
+export interface BudgetYear {
+ year: number;
+ items: BudgetLine[];
+}
+
+export interface CategoryMonths {
+ categoryId: number;
+ /** Money spent (or, for income categories, received) each month. */
+ actual: number[];
+ budget: number[];
+}
+
+export interface MonthsReport {
+ months: string[];
+ categories: CategoryMonths[];
+ /** Transactions without a category yet. */
+ unfiled: { out: number[]; in: number[] };
+ totals: { spent: number[]; income: number[]; budgetSpend: number[]; budgetIncome: number[] };
+ /** The newest transaction date, which is how far the statements reach. */
+ dataThrough: string | null;
+}
+
+export interface Overview {
+ month: string;
+ /** True when this is the calendar month we're in; false when Home shows the latest month with data. */
+ isCurrent: boolean;
+ daysLeft: number;
+ dataThrough: string | null;
+ spent: number;
+ income: number;
+ budgetSpend: number;
+ budgetIncome: number;
+ hasBudgets: boolean;
+ /** Spending this month in categories without a budget, including what isn't filed yet. */
+ unbudgeted: number;
+ categories: { categoryId: number; spent: number; budget: number }[];
+ year: {
+ year: number;
+ spent: number;
+ budgetSoFar: number;
+ budgetYear: number;
+ /** Spending projected to December 31 at the current pace. */
+ projected: number;
+ income: number;
+ };
+ toReview: number;
+}
+
+export interface Subscription {
+ id: number;
+ name: string;
+ kind: SubscriptionKind;
+ merchant: string;
+ aliases: string[];
+ cadence: Cadence;
+ expectedCents: number;
+ monthlyCents: number;
+ accountId: number | null;
+ categoryId: number | null;
+ status: SubscriptionStatus;
+ confidence: 'likely' | 'confident';
+ source: 'detected' | 'catalog' | 'manual';
+ trialEndsOn: string | null;
+ cancelledOn: string | null;
+ firstChargeOn: string | null;
+ lastChargeOn: string | null;
+ nextExpectedOn: string | null;
+ manageUrl: string | null;
+ note: string | null;
+ alertsOn: boolean;
+ chargeCount: number;
+}
+
+export interface SubscriptionDetail extends Subscription {
+ charges: { id: number; date: string; amountCents: number; accountId: number }[];
+ /** Price steps, oldest first: "$10.99, then $11.99 from March". */
+ priceHistory: { from: string; amountCents: number }[];
+}
+
+export interface AlertItem {
+ id: number;
+ kind: string;
+ area: 'money' | 'subscriptions';
+ severity: Severity;
+ title: string;
+ body: string | null;
+ link: string | null;
+ createdAt: string;
+ read: boolean;
+}
+
+export interface AlertSettings {
+ /** A subscription's charge counts as a price change past this percentage... */
+ priceChangePct: number;
+ /** ...and this many cents, whichever is larger. */
+ priceChangeMinCents: number;
+ /** A category gets a heads-up at this share of its budget. */
+ budgetWarnPct: number;
+ /** A purchase is unusual at this many times the category's typical purchase. */
+ unusualMultiple: number;
+}
+
+export interface MerchantStat {
+ merchant: string;
+ spent: number;
+ count: number;
+ lastDate: string;
+ categoryId: number | null;
+}
+
+export interface BalanceSeries {
+ months: string[];
+ accounts: { accountId: number; balances: (number | null)[] }[];
+}
+
+export interface TagReport {
+ tag: string;
+ spent: number;
+ income: number;
+ count: number;
+ byCategory: { categoryId: number | null; cents: number }[];
+}
+
+export interface Rule {
+ id: number;
+ merchant: string;
+ categoryId: number | null;
+ renameTo: string | null;
+ source: 'learned' | 'manual';
+ hits: number;
+ createdAt: string;
+ lastUsedAt: string | null;
+}
+
+export interface TagInfo {
+ id: number;
+ name: string;
+ count: number;
+}
+
+export type AiFeature = 'categorize' | 'insights' | 'assistant' | 'agents';
+
+export interface AiStatus {
+ /** Whether this server has an AI provider configured at all. */
+ providerConfigured: boolean;
+ provider: string | null;
+ features: { key: AiFeature; label: string; description: string; enabled: boolean }[];
+}
diff --git a/web/package.json b/web/package.json
index 1e4a11e..ae029b5 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,7 +19,9 @@
"radix-ui": "^1.7.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+ "react-is": "^19.3.0",
"react-router": "^8.4.0",
+ "recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0"
},
diff --git a/web/src/components/AlertCard.tsx b/web/src/components/AlertCard.tsx
new file mode 100644
index 0000000..9b9e5d7
--- /dev/null
+++ b/web/src/components/AlertCard.tsx
@@ -0,0 +1,46 @@
+import type { AlertItem } from '@spendlight/shared';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { ArrowRight, CircleAlert, Lightbulb, PartyPopper, TriangleAlert, X } from 'lucide-react';
+import { Link } from 'react-router';
+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' },
+ info: { icon: Lightbulb, tone: 'bg-accent text-accent-foreground' },
+} as const;
+
+/** One insight or alert: what happened, why it matters, and a way to see the transactions. */
+export function AlertCard({ alert, compact }: { alert: AlertItem; compact?: boolean }) {
+ const client = useQueryClient();
+ const dismiss = useMutation({
+ mutationFn: () => api(`/alerts/${alert.id}/dismiss`, { method: 'POST' }),
+ onSuccess: () => client.invalidateQueries({ queryKey: ['alerts'] }),
+ });
+ const { icon: Icon, tone } = LOOK[alert.severity];
+ return (
+
+
+
+
+
+
{alert.title}
+ {alert.body ?
{alert.body}
: null}
+ {alert.link ? (
+
+ See {alert.link.startsWith('/activity') ? 'transactions' : 'details'}
+
+ ) : null}
+
+
dismiss.mutate()}
+ className="absolute right-2.5 top-2.5 flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition hover:bg-muted hover:opacity-100"
+ aria-label="Dismiss"
+ >
+
+
+
+ );
+}
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
index c24a9c6..7af51a2 100644
--- a/web/src/components/AppShell.tsx
+++ b/web/src/components/AppShell.tsx
@@ -2,7 +2,7 @@ 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 { useReviewCount } from '@/lib/data';
+import { useAlertCounts, useReviewCount } from '@/lib/data';
import { queueFiles } from '@/lib/pendingFiles';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
@@ -55,8 +55,14 @@ function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
);
}
-function Sidebar() {
+function useBadges(): Record {
const { data: toReview } = useReviewCount();
+ const { data: alerts } = useAlertCounts();
+ return { '/review': toReview, '/subscriptions': alerts?.subscriptions, '/more': alerts?.subscriptions };
+}
+
+function Sidebar() {
+ const badges = useBadges();
return (
@@ -65,7 +71,7 @@ function Sidebar() {
{NAV.map((item) => (
-
+
))}
@@ -80,7 +86,7 @@ function Sidebar() {
function PhoneTabBar() {
const { pathname } = useLocation();
- const { data: toReview } = useReviewCount();
+ const badges = useBadges();
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || 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 (
@@ -90,13 +96,16 @@ function PhoneTabBar() {
cn(tab, isActive && 'text-primary')}>
- {to === '/review' ? : null}
+
{label}
))}
-
+
+
+
+
More
diff --git a/web/src/components/BudgetEditor.tsx b/web/src/components/BudgetEditor.tsx
new file mode 100644
index 0000000..8124282
--- /dev/null
+++ b/web/src/components/BudgetEditor.tsx
@@ -0,0 +1,108 @@
+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 { api, errorMessage } from '@/lib/api';
+import { centsToInput, parseDollars } from '@/lib/money';
+import { CategoryIcon } from './money';
+import { Button } from './ui/button';
+import { Dialog, DialogContent } from './ui/dialog';
+import { Input } from './ui/form';
+import { Segmented } from './ui/misc';
+import type { Category } from '@spendlight/shared';
+
+export interface EditTarget {
+ category: Category;
+ month: string;
+ /** The amount in effect for that month now. */
+ current: number;
+ /** Whether that category already has any budget this year. */
+ hasBudget: boolean;
+}
+
+/** Set one category's budget: just this month, from this month on, or every month. */
+export function BudgetEditor({ target, onClose }: { target: EditTarget | null; onClose: () => void }) {
+ return (
+ !open && onClose()}>
+ {target ? : null}
+
+ );
+}
+
+function EditorBody({ target, onClose }: { target: EditTarget; onClose: () => void }) {
+ const client = useQueryClient();
+ const year = Number(target.month.slice(0, 4));
+ const month = Number(target.month.slice(5, 7));
+ const [amount, setAmount] = useState(target.current ? centsToInput(target.current) : '');
+ const [scope, setScope] = useState(target.hasBudget ? (month === 12 ? 'month' : 'rest') : 'year');
+ const income = target.category.kind === 'income';
+ const save = useMutation({
+ mutationFn: () => {
+ const cents = parseDollars(amount);
+ if (cents === null) throw new Error('Please enter an amount like 250 or 249.99');
+ return api('/budgets', { method: 'PUT', body: { categoryId: target.category.id, year, month, amountCents: cents, scope } });
+ },
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ['budgets'] });
+ void client.invalidateQueries({ queryKey: ['reports'] });
+ void client.invalidateQueries({ queryKey: ['alerts'] });
+ onClose();
+ },
+ onError: (error) => toast.error(errorMessage(error)),
+ });
+ const name = monthName(target.month);
+ const options: { value: BudgetScope; label: string }[] = [
+ { value: 'month', label: `Just ${monthName(target.month, 'short')}` },
+ ...(month < 12 ? [{ value: 'rest' as const, label: `${monthName(target.month, 'short')} – Dec` }] : []),
+ { value: 'year', label: `All of ${year}` },
+ ];
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/CategoryPicker.tsx b/web/src/components/CategoryPicker.tsx
index c4cd2f2..e23bc59 100644
--- a/web/src/components/CategoryPicker.tsx
+++ b/web/src/components/CategoryPicker.tsx
@@ -175,17 +175,19 @@ export function CategoryButton({
onPick,
className,
placeholder = 'Choose category',
+ noTransfers,
}: {
categoryId: number | null;
suggestions?: number[];
onPick: (category: Category) => void;
className?: string;
placeholder?: string;
+ noTransfers?: boolean;
}) {
const { byId } = useCategories();
const c = categoryId ? byId.get(categoryId) : undefined;
return (
-
+
accounts.data?.find((a) => a.id === t?.accountId), [accounts.data, t]);
+ const subscriptions = useSubscriptions();
const save = useMutation({
mutationFn: (body: Record) => api<{ transaction: Transaction }>(`/transactions/${t!.id}`, { method: 'PATCH', body }),
@@ -219,6 +221,7 @@ export function TransactionSheet({
if (!t) return null;
const transfer = t.transferPeer;
+ const subscription = t.subscriptionId ? subscriptions.data?.find((x) => x.id === t.subscriptionId && x.status !== 'suggested') : undefined;
return (
<>
@@ -255,6 +258,16 @@ export function TransactionSheet({
) : null}
+ {subscription ? (
+
+
+
+ Part of your {subscription.name} {subscription.kind === 'bill' ? 'bill' : 'subscription'}
+
+
+
+ ) : null}
+
{t.isSplit ? (
Split between
diff --git a/web/src/components/meter.tsx b/web/src/components/meter.tsx
new file mode 100644
index 0000000..1bfdabd
--- /dev/null
+++ b/web/src/components/meter.tsx
@@ -0,0 +1,79 @@
+import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { budgetStatus, whole, type BudgetStatus } from '@/lib/money';
+import { cn } from '@/lib/utils';
+
+// Progress bars and rings for "spent against budget". Status always comes
+// with an icon and a word, never color alone.
+
+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' },
+};
+
+export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) {
+ const status = budgetStatus(spent, budget);
+ // Without a budget, the bar compares categories with each other (scale is the largest).
+ const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : scale ? Math.max(0, Math.min(100, (spent / scale) * 100)) : 0;
+ return (
+
+
+ {pace !== undefined && budget > 0 && pace > 0 && pace < 1 ? (
+ // Where spending "should" be by today, if it were spread evenly over the month.
+
+ ) : null}
+
+ );
+}
+
+/** "$42 left", "$18 over" or "no budget", with an icon. */
+export function BudgetNote({ spent, budget, className, income }: { spent: number; budget: number; className?: string; income?: boolean }) {
+ if (budget <= 0) return No budget ;
+ if (income) {
+ const short = budget - spent;
+ return (
+
+ {short <= 0 ? : null}
+ {short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`}
+
+ );
+ }
+ const status = budgetStatus(spent, budget);
+ const Icon = status === 'over' ? CircleAlert : status === 'close' ? TriangleAlert : CircleCheck;
+ return (
+
+
+ {status === 'over' ? `${whole(spent - budget)} over` : `${whole(budget - spent)} left`}
+
+ );
+}
+
+/** A ring that fills as the month's budget is spent. */
+export function Ring({ spent, budget, size = 168, stroke = 14, children, className }: { spent: number; budget: number; size?: number; stroke?: number; children?: ReactNode; className?: string }) {
+ const status = budgetStatus(spent, budget);
+ const r = (size - stroke) / 2;
+ const circumference = 2 * Math.PI * r;
+ const share = budget > 0 ? Math.min(1, spent / budget) : 0;
+ const color = { none: 'stroke-primary', under: 'stroke-good', close: 'stroke-warn', over: 'stroke-bad' }[status];
+ return (
+
+ );
+}
diff --git a/web/src/components/money.tsx b/web/src/components/money.tsx
index 6f2d794..20190d4 100644
--- a/web/src/components/money.tsx
+++ b/web/src/components/money.tsx
@@ -42,6 +42,38 @@ import {
Wifi,
Wrench,
Zap,
+ Apple,
+ Beer,
+ Bike,
+ BookOpen,
+ Briefcase,
+ Building2,
+ Bus,
+ Cake,
+ Camera,
+ Cat,
+ Church,
+ Clapperboard,
+ Dog,
+ Film,
+ Flower2,
+ Gamepad2,
+ GraduationCap,
+ Hammer,
+ HandCoins,
+ HeartPulse,
+ Hotel,
+ Music,
+ Package,
+ PiggyBank,
+ Pizza,
+ School,
+ Train,
+ TreePine,
+ Trophy,
+ Umbrella,
+ Wallet,
+ Wine,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -106,8 +138,43 @@ const ICONS: Record = {
scale: Scale,
'credit-card': CreditCard,
'arrow-left-right': ArrowLeftRight,
+ 'apple': Apple,
+ 'beer': Beer,
+ 'bike': Bike,
+ 'book-open': BookOpen,
+ 'briefcase': Briefcase,
+ 'building-2': Building2,
+ 'bus': Bus,
+ 'cake': Cake,
+ 'camera': Camera,
+ 'cat': Cat,
+ 'church': Church,
+ 'clapperboard': Clapperboard,
+ 'dog': Dog,
+ 'film': Film,
+ 'flower-2': Flower2,
+ 'gamepad-2': Gamepad2,
+ 'graduation-cap': GraduationCap,
+ 'hammer': Hammer,
+ 'hand-coins': HandCoins,
+ 'heart-pulse': HeartPulse,
+ 'hotel': Hotel,
+ 'music': Music,
+ 'package': Package,
+ 'piggy-bank': PiggyBank,
+ 'pizza': Pizza,
+ 'school': School,
+ 'train': Train,
+ 'tree-pine': TreePine,
+ 'trophy': Trophy,
+ 'umbrella': Umbrella,
+ 'wallet': Wallet,
+ 'wine': Wine,
};
+/** Every icon a category can use, for the icon picker. */
+export const ICON_NAMES = Object.keys(ICONS);
+
export function CategoryIcon({
icon,
color,
diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts
index 8cf52a4..58eb56d 100644
--- a/web/src/lib/data.ts
+++ b/web/src/lib/data.ts
@@ -1,4 +1,15 @@
-import type { Account, Category, CategoryGroup } from '@spendlight/shared';
+import type {
+ Account,
+ AlertItem,
+ BudgetYear,
+ Category,
+ CategoryGroup,
+ MonthsReport,
+ Overview,
+ Rule,
+ Subscription,
+ TagInfo,
+} from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from './api';
@@ -8,7 +19,8 @@ export function useAccounts() {
}
export function useCategories() {
- const query = useQuery({ queryKey: ['categories'], queryFn: () => api('/categories'), staleTime: 5 * 60_000 });
+ // Archived categories are included so old transactions still show theirs; pickers leave them out.
+ const query = useQuery({ queryKey: ['categories'], queryFn: () => api('/categories?includeArchived=true'), staleTime: 5 * 60_000 });
const lookup = useMemo(() => {
const all: Category[] = query.data?.flatMap((g) => g.categories) ?? [];
return { all, byId: new Map(all.map((c) => [c.id, c])) };
@@ -26,11 +38,65 @@ export function useReviewCount() {
}
export function useTags() {
- return useQuery({ queryKey: ['tags'], queryFn: () => api<{ name: string; count: number }[]>('/tags') });
+ return useQuery({ queryKey: ['tags'], queryFn: () => api('/tags') });
+}
+
+export function useOverview(month?: string) {
+ return useQuery({ queryKey: ['reports', 'overview', month ?? 'auto'], queryFn: () => api(`/reports/overview${month ? `?month=${month}` : ''}`) });
+}
+
+export function useMonthsReport(from: string, to: string, enabled = true) {
+ return useQuery({ queryKey: ['reports', 'months', from, to], queryFn: () => api(`/reports/months?from=${from}&to=${to}`), enabled });
+}
+
+export function useBudgets(year: number) {
+ return useQuery({ queryKey: ['budgets', year], queryFn: () => api(`/budgets?year=${year}`) });
+}
+
+export function useSubscriptions() {
+ return useQuery({ queryKey: ['subscriptions'], queryFn: () => api('/subscriptions') });
+}
+
+export interface Upcoming {
+ subscriptionId: number;
+ name: string;
+ kind: 'subscription' | 'bill';
+ date: string;
+ amountCents: number;
+ accountId: number | null;
+ trial: boolean;
+}
+
+export function useUpcoming(days = 30) {
+ return useQuery({ queryKey: ['subscriptions', 'upcoming', days], queryFn: () => api(`/subscriptions/upcoming?days=${days}`) });
+}
+
+export function useAlerts(area?: 'money' | 'subscriptions', limit = 50) {
+ return useQuery({ queryKey: ['alerts', area ?? 'all', limit], queryFn: () => api(`/alerts?limit=${limit}${area ? `&area=${area}` : ''}`) });
+}
+
+export function useAlertCounts() {
+ return useQuery({ queryKey: ['alerts', 'counts'], queryFn: () => api<{ money: number; subscriptions: number }>('/alerts/counts'), refetchOnWindowFocus: true });
+}
+
+export function useRules() {
+ return useQuery({ queryKey: ['rules'], queryFn: () => api('/rules') });
}
/** Everything that changes when transactions change. */
-export const MONEY_KEYS = [['transactions'], ['review'], ['review-count'], ['accounts'], ['imports'], ['tags']] as const;
+export const MONEY_KEYS = [
+ ['transactions'],
+ ['review'],
+ ['review-count'],
+ ['accounts'],
+ ['imports'],
+ ['tags'],
+ ['reports'],
+ ['budgets'],
+ ['subscriptions'],
+ ['alerts'],
+ ['rules'],
+] as const;
/** Remember recent category picks on this device, for the top of the picker. */
const RECENT_KEY = 'sl-recent-categories';
diff --git a/web/src/lib/money.ts b/web/src/lib/money.ts
new file mode 100644
index 0000000..a1937f3
--- /dev/null
+++ b/web/src/lib/money.ts
@@ -0,0 +1,54 @@
+import { formatCents } from '@spendlight/shared';
+
+/** "1,234.50", "$12", "12.5" → cents; null when it isn't a number. */
+export function parseDollars(text: string): number | null {
+ const clean = text.replace(/[$,\s]/g, '');
+ if (!clean) return 0;
+ if (!/^\d*(\.\d{0,2})?$/.test(clean)) return null;
+ return Math.round(Number(clean) * 100);
+}
+
+/** Cents as a plain number for an input box: 12345 → "123.45", 10000 → "100". */
+export const centsToInput = (cents: number) => (cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2));
+
+export const whole = (cents: number) => formatCents(cents, { whole: true });
+
+export const thisMonth = () => new Date().toLocaleDateString('en-CA').slice(0, 7);
+
+export const isoDay = (d: Date) => d.toLocaleDateString('en-CA');
+
+export function monthBounds(month: string): { from: string; to: string } {
+ const [y, m] = month.split('-').map(Number);
+ return { from: `${month}-01`, to: isoDay(new Date(y!, m!, 0)) };
+}
+
+export type BudgetStatus = 'none' | 'under' | 'close' | 'over';
+
+export function budgetStatus(spent: number, budget: number, warnPct = 80): BudgetStatus {
+ if (budget <= 0) return 'none';
+ if (spent > budget) return 'over';
+ if (spent >= (budget * warnPct) / 100) return 'close';
+ return 'under';
+}
+
+/** The next date a charge is due on or after today, stepping forward from the last expected one. */
+export function nextDue(date: string | null, cadence: 'weekly' | 'monthly' | 'quarterly' | 'yearly'): string | null {
+ if (!date) return null;
+ const today = isoDay(new Date());
+ const [y, m, d] = date.split('-').map(Number);
+ let step = 0;
+ let next = date;
+ while (next < today && step < 600) {
+ step++;
+ const dt =
+ cadence === 'weekly'
+ ? new Date(y!, m! - 1, d! + 7 * step)
+ : (() => {
+ const months = (cadence === 'monthly' ? 1 : cadence === 'quarterly' ? 3 : 12) * step;
+ const last = new Date(y!, m! - 1 + months + 1, 0).getDate();
+ return new Date(y!, m! - 1 + months, Math.min(d!, last));
+ })();
+ next = isoDay(dt);
+ }
+ return next;
+}
diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx
index ce30de5..68adfdf 100644
--- a/web/src/pages/AccountsPage.tsx
+++ b/web/src/pages/AccountsPage.tsx
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
-import { Skeleton } from '@/components/ui/misc';
+import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
@@ -155,16 +155,27 @@ function BalanceForm({ account, onDone }: { account: Account; onDone: () => void
/** A card's balance means nothing until someone has entered it once. */
const needsBalance = (a: Account) => isLiability(a.kind) && !a.balanceConfirmedOn;
+/** Statements arrive monthly; after about 40 days without one, a nudge. */
+function statementDue(a: Account): boolean {
+ if (a.status !== 'open' || !a.lastTransactionDate || a.kind === 'cash') return false;
+ const [y, m, d] = a.lastTransactionDate.split('-').map(Number);
+ return (Date.now() - new Date(y!, m! - 1, d!).getTime()) / 86_400_000 > 40;
+}
+
function AccountRow({ account, onOpen }: { account: Account; onOpen: () => void }) {
const liability = isLiability(account.kind);
const missing = needsBalance(account);
+ const due = statementDue(account);
return (
{account.kind === 'credit_card' ? : account.kind === 'cash' ? : }
-
{account.name}
+
+ {account.name}
+ {due ? Statement due : null}
+
{[account.institution, account.last4 ? `••${account.last4}` : null].filter(Boolean).join(' ') || ACCOUNT_KIND_LABEL[account.kind]}
{account.lastImportAt ? ` · imported ${timeAgo(account.lastImportAt)}` : account.transactionCount === 0 ? ' · no statements yet' : ''}
diff --git a/web/src/pages/ActivityPage.tsx b/web/src/pages/ActivityPage.tsx
index b9cbaba..8d50816 100644
--- a/web/src/pages/ActivityPage.tsx
+++ b/web/src/pages/ActivityPage.tsx
@@ -1,6 +1,6 @@
-import { formatCents, type Transaction, type TransactionPage } from '@spendlight/shared';
+import { formatCents, monthName, type Transaction, type TransactionPage } from '@spendlight/shared';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
-import { ArrowLeftRight, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
+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';
@@ -12,9 +12,11 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
import { Badge, Segmented, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
-import { useAccounts, useCategories } from '@/lib/data';
+import { useAccounts, useCategories, useSubscriptions } from '@/lib/data';
+import { monthBounds } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
@@ -164,29 +166,44 @@ export default function ActivityPage() {
const accountId = params.get('accountId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const tag = params.get('tag') ?? '';
- const period = params.get('period') ?? 'all';
+ const subscriptionId = params.get('subscriptionId') ?? '';
+ const month = params.get('month') ?? '';
+ const customFrom = params.get('from') ?? '';
+ const customTo = params.get('to') ?? '';
+ // A month or date range from a link (Home, Budget, Insights) replaces the quick periods.
+ const custom = month ? monthBounds(month) : customFrom || customTo ? { from: customFrom, to: customTo } : null;
+ const customLabel = month
+ ? monthName(month, 'long', true)
+ : custom
+ ? `${custom.from ? formatDay(custom.from, true) : 'Start'} – ${custom.to ? formatDay(custom.to, true) : 'today'}${custom.from && custom.to && custom.from.slice(0, 4) === custom.to.slice(0, 4) ? ` ${custom.from.slice(0, 4)}` : ''}`
+ : '';
+ const period = custom ? 'custom' : (params.get('period') ?? 'all');
const q = params.get('q') ?? '';
+ const subscriptions = useSubscriptions();
+ const subscription = subscriptionId ? subscriptions.data?.find((x) => String(x.id) === subscriptionId) : undefined;
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value && value !== 'all') next.set(key, value);
else next.delete(key);
+ if (key === 'period') ['month', 'from', 'to'].forEach((k) => next.delete(k));
setParams(next, { replace: true });
setLimit(PAGE);
};
- const query = useMemo(() => {
+ const filters = useMemo(() => {
const s = new URLSearchParams();
if (q) s.set('q', q);
if (accountId) s.set('accountId', accountId);
if (categoryId) s.set('categoryId', categoryId);
if (tag) s.set('tag', tag);
- const r = periodRange(period);
+ if (subscriptionId) s.set('subscriptionId', subscriptionId);
+ const r = custom ?? periodRange(period);
if (r.from) s.set('from', r.from);
if (r.to) s.set('to', r.to);
- s.set('limit', String(limit));
return s.toString();
- }, [q, accountId, categoryId, tag, period, limit]);
+ }, [q, accountId, categoryId, tag, subscriptionId, period, custom?.from, custom?.to]);
+ const query = `${filters}${filters ? '&' : ''}limit=${limit}`;
const list = useQuery({
queryKey: ['transactions', query],
@@ -204,7 +221,7 @@ export default function ActivityPage() {
return out;
}, [list.data]);
- const filterCount = [accountId, categoryId, tag].filter(Boolean).length;
+ const filterCount = [accountId, categoryId, tag, subscriptionId].filter(Boolean).length;
const category = categoryId && categoryId !== 'none' ? byId.get(Number(categoryId)) : undefined;
return (
@@ -214,11 +231,33 @@ export default function ActivityPage() {
titleOnPhone={false}
description="Every transaction from every account. Tap one to change its category, split it, tag it or add a note."
actions={
- me.access === 'full' ? (
- setAdding(true)}>
- Cash entry
-
- ) : undefined
+ <>
+
+
+
+ Export
+
+
+
+
+
+ Excel workbook
+
+
+
+
+ CSV file
+
+
+ Exactly what’s listed, with the same search and filters.
+
+
+ {me.access === 'full' ? (
+ setAdding(true)}>
+ Cash entry
+
+ ) : null}
+ >
}
/>
@@ -253,18 +292,25 @@ export default function ActivityPage() {
['month', 'This month'],
['last', 'Last month'],
['year', 'This year'],
+ ...(custom ? [['custom', customLabel]] : []),
].map(([value, label]) => (
setParam('period', value!)}
+ onClick={() => setParam('period', value === 'custom' ? 'all' : value!)}
className={cn(
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
period === value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{label}
+ {value === 'custom' ? : null}
))}
+ {subscriptionId ? (
+ setParam('subscriptionId', '')} className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-transparent bg-accent px-3.5 text-[13px] font-medium text-accent-foreground">
+ {subscription ? `${subscription.name} charges` : 'Subscription charges'}
+
+ ) : null}
{showFilters ? (
diff --git a/web/src/pages/BudgetPage.tsx b/web/src/pages/BudgetPage.tsx
new file mode 100644
index 0000000..2a934a7
--- /dev/null
+++ b/web/src/pages/BudgetPage.tsx
@@ -0,0 +1,466 @@
+import { addMonths, monthName, type BudgetLine, type Category, type CategoryGroup } from '@spendlight/shared';
+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 { BudgetEditor, type EditTarget } from '@/components/BudgetEditor';
+import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter';
+import { CategoryIcon } from '@/components/money';
+import { PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
+import { Segmented, Skeleton } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { useBudgets, useCategories, useMonthsReport, useOverview } from '@/lib/data';
+import { budgetStatus, thisMonth, whole } from '@/lib/money';
+import { useMediaQuery } from '@/lib/useMediaQuery';
+import { useCurrentUser } from '@/lib/session';
+import { cn } from '@/lib/utils';
+
+const effective = (line: BudgetLine | undefined, i: number) => (line ? (line.months[i] ?? line.monthly ?? 0) : 0);
+const VIEW_KEY = 'sl-budget-view';
+
+interface Row {
+ category: Category;
+ budget: number[];
+ actual: number[];
+ line: BudgetLine | undefined;
+}
+
+function useBudgetRows(year: number) {
+ const budgets = useBudgets(year);
+ const report = useMonthsReport(`${year}-01`, `${year}-12`);
+ const { data: groups } = useCategories();
+ const sections = useMemo(() => {
+ if (!groups || !budgets.data || !report.data) return null;
+ const lines = new Map(budgets.data.items.map((l) => [l.categoryId, l]));
+ const actuals = new Map(report.data.categories.map((c) => [c.categoryId, c.actual]));
+ const zero = Array(12).fill(0) as number[];
+ const build = (g: CategoryGroup) => {
+ const rows: Row[] = g.categories
+ .filter((c) => c.kind !== 'transfer')
+ .map((c) => ({ category: c, line: lines.get(c.id), budget: Array.from({ length: 12 }, (_, i) => effective(lines.get(c.id), i)), actual: actuals.get(c.id) ?? zero }))
+ .filter((r) => !r.category.archived || r.budget.some(Boolean) || r.actual.some(Boolean));
+ return { group: g, rows };
+ };
+ const all = groups.map(build).filter((s) => s.rows.length);
+ const kindOf = (s: (typeof all)[number]) => (s.rows.every((r) => r.category.kind === 'income') ? 'income' : 'expense');
+ return { expense: all.filter((s) => kindOf(s) === 'expense'), income: all.filter((s) => kindOf(s) === 'income') };
+ }, [groups, budgets.data, report.data]);
+ return { sections, report: report.data, budgets: budgets.data, loading: budgets.isPending || report.isPending };
+}
+
+const sum = (rows: Row[], key: 'budget' | 'actual', i: number) => rows.reduce((s, r) => s + r[key][i]!, 0);
+
+function MonthSwitcher({ month, onChange }: { month: string; onChange: (m: string) => void }) {
+ return (
+
+ onChange(addMonths(month, -1))} aria-label="Previous month">
+
+
+ {monthName(month, 'long', true)}
+ onChange(addMonths(month, 1))} aria-label="Next month">
+
+
+
+ );
+}
+
+function Summary({ spend, spent, income, received }: { spend: number; spent: number; income: number; received: number }) {
+ const items = [
+ { label: 'Planned spending', value: whole(spend), sub: spend ? `${whole(spent)} spent` : 'Not set yet' },
+ { label: 'Planned income', value: whole(income), sub: income ? `${whole(received)} received` : 'Optional' },
+ { label: 'Planned savings', value: whole(income - spend), sub: income ? `${Math.round(((income - spend) / income) * 100)}% of income` : 'Add income to see it' },
+ ];
+ return (
+
+ {items.map((it) => (
+
+ {it.label}
+ {it.value}
+ {it.sub}
+
+ ))}
+
+ );
+}
+
+function MonthList({ month, sections, canEdit, onEdit, unfiled, pace }: {
+ month: string;
+ sections: NonNullable['sections']>;
+ canEdit: boolean;
+ onEdit: (row: Row) => void;
+ unfiled: number;
+ pace?: number;
+}) {
+ const i = Number(month.slice(5, 7)) - 1;
+ // Categories with neither a budget nor spending this month wait behind "show more",
+ // unless nothing is budgeted yet (then every category is there to set).
+ const [showAll, setShowAll] = useState(false);
+ const all = [...sections.expense, ...sections.income].flatMap((s) => s.rows);
+ const active = (r: Row) => r.budget[i]! > 0 || r.actual[i]! !== 0;
+ const hiddenCount = all.filter((r) => !active(r)).length;
+ const filtering = !showAll && all.some((r) => r.budget[i]! > 0) && hiddenCount > 0;
+ const block = (whole_: (typeof sections.expense)[number], income: boolean) => {
+ const s = { ...whole_, rows: filtering ? whole_.rows.filter(active) : whole_.rows };
+ if (!s.rows.length) return null;
+ const budget = sum(s.rows, 'budget', i);
+ const actual = sum(s.rows, 'actual', i);
+ return (
+
+
+
{s.group.name}
+
+ {whole(actual)} {budget ? `of ${whole(budget)}` : ''}
+
+
+
+ {s.rows.map((r) => (
+
+
+
+
+
+ {r.category.name}
+ {whole(r.actual[i]!)}
+
+
+
+
+
+
onEdit(r)}
+ className={cn(
+ 'tabular h-10 min-w-20 shrink-0 rounded-xl border px-3 text-right text-[14px] font-medium transition-colors',
+ canEdit ? 'bg-card hover:border-primary/40 hover:bg-accent' : 'cursor-default',
+ !r.budget[i] && 'border-dashed text-muted-foreground',
+ )}
+ aria-label={`Budget for ${r.category.name}`}
+ >
+ {r.budget[i] ? whole(r.budget[i]!) : canEdit ? 'Set' : '—'}
+
+
+ ))}
+
+
+ );
+ };
+ return (
+
+ {unfiled > 0 ? (
+
+
+
+ {whole(unfiled)} spent in {monthName(month)} isn’t filed in a category yet.
+
+ Review
+
+ ) : null}
+ {sections.expense.map((s) => block(s, false))}
+ {sections.income.length && sections.income.some((s) => !filtering || s.rows.some(active)) ?
Income : null}
+ {sections.income.map((s) => block(s, true))}
+ {hiddenCount && all.some((r) => r.budget[i]! > 0) ? (
+ setShowAll((v) => !v)}>
+ {showAll ? 'Hide categories without a budget' : `Show ${hiddenCount} more categor${hiddenCount === 1 ? 'y' : 'ies'} without a budget`}
+
+ ) : null}
+
+ );
+}
+
+function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: {
+ year: number;
+ /** The last month the statements reach; later months have no actuals to show yet. */
+ through: string;
+ sections: NonNullable['sections']>;
+ canEdit: boolean;
+ onEdit: (row: Row, monthIndex: number) => void;
+ onMonth: (month: string) => void;
+}) {
+ const months = Array.from({ length: 12 }, (_, i) => `${year}-${String(i + 1).padStart(2, '0')}`);
+ const now = thisMonth();
+ const cell = 'tabular px-2 py-2 text-right text-[13px]';
+ const groupYear = (rows: Row[]) => rows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0);
+ const groupRows = (s: (typeof sections.expense)[number], income: boolean) => [
+
+
+ {s.group.name}
+
+ {months.map((_, i) => (
+
+ {sum(s.rows, 'budget', i) ? whole(sum(s.rows, 'budget', i)) : ''}
+
+ ))}
+ {groupYear(s.rows) ? whole(groupYear(s.rows)) : ''}
+ ,
+ ...s.rows.map((r) => {
+ const yearBudget = r.budget.reduce((a, b) => a + b, 0);
+ const yearActual = r.actual.reduce((a, b) => a + b, 0);
+ return (
+
+
+
+
+ {r.category.name}
+
+
+ {months.map((m, i) => {
+ const status = income ? 'none' : budgetStatus(r.actual[i]!, r.budget[i]!);
+ const past = m <= through;
+ const own = r.line?.months[i] != null;
+ return (
+
+ onEdit(r, i)}
+ className={cn(
+ 'flex w-full flex-col items-end rounded-lg px-2 py-1.5 transition-colors',
+ canEdit && 'hover:bg-accent',
+ past && status === 'over' && 'bg-bad/8',
+ )}
+ title={own ? 'Set for this month only' : undefined}
+ >
+
+ {r.budget[i] ? whole(r.budget[i]!) : '–'}
+ {own ? ● : 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}
+
+
+ );
+ }),
+ ];
+ const expenseRows = sections.expense.flatMap((s) => s.rows);
+ return (
+
+
+
+
+
+ Category
+ {months.map((m) => (
+
+ onMonth(m)} className={cn('rounded-md px-1.5 py-0.5 hover:bg-muted', m === now && 'bg-accent text-accent-foreground')}>
+ {monthName(m, 'short')}
+
+
+ ))}
+ Year
+
+
+
+ {sections.expense.flatMap((s) => groupRows(s, false))}
+
+
+ Total spending
+
+ {months.map((m, i) => (
+
+ {whole(sum(expenseRows, 'budget', i))}
+ {m <= through ? {whole(sum(expenseRows, 'actual', i))}
: null}
+
+ ))}
+ {whole(expenseRows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0))}
+
+ {sections.income.flatMap((s) => groupRows(s, true))}
+
+
+
+
+ Each cell shows the budget, with what was actually spent beneath it. ● marks a month set on its own. Tap any amount to change it.
+
+
+ );
+}
+
+export default function BudgetPage() {
+ const me = useCurrentUser();
+ const canEdit = me.access === 'full';
+ const client = useQueryClient();
+ const wide = useMediaQuery('(min-width: 1024px)');
+ const overview = useOverview();
+ const [params, setParams] = useSearchParams();
+ const month = params.get('month') ?? overview.data?.month ?? thisMonth();
+ const setMonth = (m: string) => setParams((p) => (p.set('month', m), p), { replace: true });
+ const year = Number(month.slice(0, 4));
+ const i = Number(month.slice(5, 7)) - 1;
+ const [view, setViewState] = useState<'month' | 'year'>(() => {
+ try {
+ return (localStorage.getItem(VIEW_KEY) as 'month' | 'year' | null) ?? 'year';
+ } catch {
+ return 'year';
+ }
+ });
+ const setView = (v: 'month' | 'year') => {
+ setViewState(v);
+ try {
+ localStorage.setItem(VIEW_KEY, v);
+ } catch {
+ /* fine */
+ }
+ };
+ const showYear = wide && view === 'year';
+ const { sections, report, budgets, loading } = useBudgetRows(year);
+ const previous = useBudgets(year - 1);
+ const [editing, setEditing] = useState(null);
+
+ useEffect(() => {
+ if (!params.get('month') && overview.data) setParams((p) => (p.set('month', overview.data!.month), p), { replace: true });
+ }, [overview.data, params, setParams]);
+
+ const refresh = () => {
+ void client.invalidateQueries({ queryKey: ['budgets'] });
+ void client.invalidateQueries({ queryKey: ['reports'] });
+ void client.invalidateQueries({ queryKey: ['alerts'] });
+ };
+ const fill = useMutation({
+ mutationFn: (source: 'last_budget' | 'last_actual' | 'average3') => api<{ filled: number }>('/budgets/fill', { method: 'POST', body: { month, source } }),
+ onSuccess: ({ filled }) => {
+ refresh();
+ toast.success(filled ? `Filled in ${filled} categor${filled === 1 ? 'y' : 'ies'}` : 'Nothing to fill in', {
+ description: filled ? `As an amount for every month of ${year}. Categories you’d already set kept their amounts.` : 'Every category with recent spending already has a budget.',
+ });
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const copy = useMutation({
+ mutationFn: () => api<{ copied: number }>('/budgets/copy-year', { method: 'POST', body: { from: year - 1, to: year } }),
+ onSuccess: ({ copied }) => {
+ refresh();
+ toast.success(`Copied ${copied} budget${copied === 1 ? '' : 's'} from ${year - 1}`);
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+
+ const edit = (row: Row, monthIndex = i) =>
+ setEditing({
+ category: row.category,
+ month: `${year}-${String(monthIndex + 1).padStart(2, '0')}`,
+ current: row.budget[monthIndex]!,
+ hasBudget: !!row.line,
+ });
+
+ const empty = budgets && budgets.items.length === 0;
+ const canCopy = empty && (previous.data?.items.length ?? 0) > 0;
+ const offerNextYear = month.slice(5, 7) === '12' && !empty;
+ const expenseRows = sections?.expense.flatMap((s) => s.rows) ?? [];
+ const incomeRows = sections?.income.flatMap((s) => s.rows) ?? [];
+ const pace = month === thisMonth() ? new Date().getDate() / new Date(year, i + 1, 0).getDate() : undefined;
+
+ return (
+
+
+
+
+ Fill in
+
+
+
+ fill.mutate('average3')}>
+ From my 3-month average
+
+ fill.mutate('last_actual')}>
+ From last month’s spending
+
+ fill.mutate('last_budget')}>
+ From last month’s budget
+
+ Fills only categories without a budget, rounded to friendly amounts.
+
+
+ ) : undefined
+ }
+ />
+
+
+ {showYear ? (
+
+ setMonth(`${year - 1}-${month.slice(5)}`)} aria-label="Previous year">
+
+
+ {year}
+ setMonth(`${year + 1}-${month.slice(5)}`)} aria-label="Next year">
+
+
+
+ ) : (
+
+ )}
+ {wide ? (
+
},
+ { value: 'year', label: 'Year', icon:
},
+ ]}
+ />
+ ) : null}
+
+
+ {canEdit && canCopy ? (
+
+
+
Start {year} from {year - 1}’s budgets?
+
Every category and month comes across; change anything afterwards.
+
+ copy.mutate()} loading={copy.isPending}>
+ Copy {year - 1}
+
+
+ ) : null}
+ {canEdit && offerNextYear ? (
+
+
+
Plan {year + 1}
+
Bring this year’s budgets into next year, then adjust.
+
+ setMonth(`${year + 1}-01`)}>
+ Go to {year + 1}
+
+
+ ) : null}
+
+ {loading || !sections ? (
+
+ ) : (
+ <>
+ {!showYear ? (
+
+ ) : null}
+ {empty && canEdit && !canCopy ? (
+
+
+
+ No budgets for {year} yet. Use Fill in to start every category from your recent spending, or tap any amount to set it.
+
+
+ ) : null}
+ {showYear ? (
+ (setMonth(m), setView('month'))} through={report?.dataThrough?.slice(0, 7) ?? thisMonth()} />
+ ) : (
+ edit(r)} unfiled={report?.unfiled.out[i] ?? 0} pace={pace} />
+ )}
+ >
+ )}
+ setEditing(null)} />
+
+ );
+}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 5a4b548..a5bf87b 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -1,12 +1,15 @@
-import { formatCents, type TransactionPage } from '@spendlight/shared';
-import { useQuery } from '@tanstack/react-query';
-import { ArrowRight, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Target, Upload } from 'lucide-react';
-import type { ReactNode } from 'react';
+import { daysInMonth, formatCents, LIABILITY_KINDS, monthName } from '@spendlight/shared';
+import { ArrowRight, CalendarClock, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Sparkles, Target, TrendingDown, TrendingUp, Upload } from 'lucide-react';
+import { useState, type ReactNode } from 'react';
import { Link } from 'react-router';
+import { AlertCard } from '@/components/AlertCard';
+import { BudgetNote, ProgressBar, Ring } from '@/components/meter';
+import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
+import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge, Skeleton } from '@/components/ui/misc';
-import { api } from '@/lib/api';
-import { useAccounts, useReviewCount } from '@/lib/data';
+import { useAccounts, useAlerts, useCategories, useOverview, useUpcoming } from '@/lib/data';
+import { whole } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
import { cn, isIos, isStandalone } from '@/lib/utils';
@@ -15,36 +18,19 @@ function greeting(): string {
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}
-function Step({
- done,
- icon,
- title,
- detail,
- action,
- later,
-}: {
- done?: boolean;
- icon: ReactNode;
- title: string;
- detail: ReactNode;
- action?: ReactNode;
- later?: string;
-}) {
+function Step({ done, icon, title, detail, action }: { done?: boolean; icon: ReactNode; title: string; detail: ReactNode; action?: ReactNode }) {
return (
{done ? : icon}
-
-
{title}
- {later ?
{later} : null}
-
+
{title}
{!done ?
{detail}
: null}
{!done && action ? {action}
: null}
@@ -52,145 +38,327 @@ function Step({
);
}
+const go = (to: string, label: string) => (
+
+ {label}
+
+);
+
+const HIDE_KEY = 'sl-hide-getting-started';
+const touch = () => window.matchMedia('(pointer: coarse)').matches;
+
+function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }: { hasAccounts: boolean; hasMoney: boolean; toReview: number; hasBudgets: boolean; onHide?: () => void }) {
+ const me = useCurrentUser();
+ // Adding to the home screen is for phones and tablets.
+ const installed = isStandalone() || !touch();
+ return (
+
+
+
+ Getting started
+ A few steps now, and Spendlight does the bookkeeping from then on.
+
+ {onHide ? (
+
+ Hide
+
+ ) : null}
+
+
+
+ } title="Turn on Face ID or fingerprint sign-in" detail="One glance instead of a password, if this device is ever signed out." action={go('/settings/security', 'Set up')} />
+ : }
+ title="Add Spendlight to your home screen"
+ detail={isIos() ? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.' : 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'}
+ />
+ } title="Add your accounts" detail="Checking, savings and every card. Or skip ahead: importing a statement offers to create its account." action={go('/accounts', 'Add')} />
+ } title="Drop in your statements" detail="Download a CSV from each bank and drop them in together. Spendlight recognizes the bank and skips duplicates." action={go('/import', 'Import')} />
+ } title="Review what came in" detail="Confirm Spendlight’s guesses. It learns from every choice." action={toReview ? go('/review', 'Review') : undefined} />
+ } title="Set your monthly budgets" detail="Start from your recent spending in one tap, then adjust." action={go('/budget', 'Budget')} />
+
+
+
+ );
+}
+
+function Tile({ label, value, sub, to }: { label: string; value: string | null; sub?: ReactNode; to?: string }) {
+ const body = (
+ <>
+ {label}
+ {value !== null ? {value}
: }
+ {sub ? {sub}
: null}
+ >
+ );
+ const cls = 'min-w-0 rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur';
+ return to ? (
+
+ {body}
+
+ ) : (
+ {body}
+ );
+}
+
export default function HomePage() {
const me = useCurrentUser();
const firstName = me.displayName.split(' ')[0];
- const installed = isStandalone();
- const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
- const now = new Date();
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toLocaleDateString('en-CA');
- const monthName = now.toLocaleDateString(undefined, { month: 'long' });
+ const overview = useOverview();
const accounts = useAccounts();
- const { data: toReview } = useReviewCount();
- const everything = useQuery({ queryKey: ['transactions', 'home-any'], queryFn: () => api('/transactions?limit=1') });
- // Statements usually arrive after a month ends, so until this month has
- // transactions, Home shows the most recent month that does.
- const latest = everything.data?.items[0]?.date;
- const showStart = latest && latest < monthStart ? `${latest.slice(0, 7)}-01` : monthStart;
- const showEnd = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)), 0).toLocaleDateString('en-CA');
- const shownName = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)) - 1, 1).toLocaleDateString(undefined, { month: 'long' });
- const month = useQuery({
- queryKey: ['transactions', 'home-month', showStart],
- queryFn: () => api(`/transactions?from=${showStart}&to=${showEnd}&limit=1`),
- enabled: everything.isSuccess,
- });
+ const alerts = useAlerts(undefined, 3);
+ const upcoming = useUpcoming(30);
+ const { byId } = useCategories();
+ const o = overview.data;
const hasAccounts = (accounts.data?.length ?? 0) > 0;
- const hasMoney = (everything.data?.total ?? 0) > 0;
+ const hasMoney = !!o?.dataThrough;
+ const left = o ? o.budgetSpend - o.spent : 0;
+ const month = o ? monthName(o.month) : '';
+ const saved = o ? o.income - o.spent : 0;
+ const pace = o && o.isCurrent ? 1 - o.daysLeft / daysInMonth(o.month) : undefined;
+
+ const open = accounts.data?.filter((a) => a.status === 'open' && a.balanceConfirmedOn) ?? [];
+ const cash = open.filter((a) => !LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
+ const owed = open.filter((a) => LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
+ const yearGap = o ? o.year.budgetYear - o.year.projected : 0;
+ const [hidden, setHidden] = useState(() => {
+ try {
+ return localStorage.getItem(HIDE_KEY) === '1';
+ } catch {
+ return false;
+ }
+ });
+ const hide = () => {
+ setHidden(true);
+ try {
+ localStorage.setItem(HIDE_KEY, '1');
+ } catch {
+ /* fine */
+ }
+ };
+ const setupDone = hidden || (me.hasPasskey && (isStandalone() || !touch()) && hasAccounts && hasMoney && o?.toReview === 0 && o.hasBudgets);
+
+ let headline: ReactNode = null;
+ if (o && hasMoney) {
+ headline = o.hasBudgets ? (
+ <>
+ {left >= 0 ? `Left to spend in ${month}` : `Over budget in ${month}`}
+ {whole(Math.abs(left))}
+
+ {whole(o.spent)} spent of {whole(o.budgetSpend)}
+ {o.isCurrent ? ` · ${o.daysLeft} day${o.daysLeft === 1 ? '' : 's'} to go` : ''}
+
+ {o.unbudgeted > 0 ? (
+
+ Includes {whole(o.unbudgeted)} in categories without a budget
+
+ ) : null}
+ >
+ ) : (
+ <>
+ Spent in {month}
+ {whole(o.spent)}
+ Set budgets and this becomes what’s left to spend, with the year’s pace below.
+ >
+ );
+ }
return (
-
+
-
{today}
-
- {greeting()}, {firstName}.
-
-
- {hasMoney
- ? showStart === monthStart
- ? `Here’s ${monthName} so far. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
- : `Here’s ${shownName}, your latest month in Spendlight. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
- : 'Spendlight is ready for your accounts. Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.'}
+
+ {greeting()}, {firstName}
+ {o && hasMoney && !o.isCurrent ? ` · Showing ${month}, your latest statements` : ''}
-
- {[
- { label: 'Spent', value: month.data ? formatCents(-month.data.sums.outCents, { whole: true }) : null },
- { label: 'Money in', value: month.data ? formatCents(month.data.sums.inCents, { whole: true }) : null },
- { label: 'To review', value: toReview !== undefined ? String(toReview) : null, to: '/review' },
- ].map(({ label, value, to }) => {
- const body = (
- <>
-
{label}
- {value !== null && hasMoney ? (
-
{value}
- ) : (
-
- )}
- >
- );
- return to && hasMoney ? (
-
- {body}
-
- ) : (
-
- {body}
-
- );
- })}
-
+ {overview.isPending ? (
+
+ ) : headline ? (
+
+
{headline}
+ {o!.hasBudgets ? (
+
+ {o!.budgetSpend ? Math.round((o!.spent / o!.budgetSpend) * 100) : 0}%
+ of budget
+
+ ) : null}
+
+ ) : (
+ <>
+
Welcome to Spendlight.
+
+ Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.
+
+ >
+ )}
+ {o && hasMoney && o.hasBudgets && o.year.budgetSoFar > 0 ? (
+
+ {yearGap >= 0 ?
:
}
+ On pace to finish {o.year.year} {whole(Math.abs(yearGap))} {yearGap >= 0 ? 'under' : 'over'} budget
+
+ ) : null}
+ {hasMoney && o ? (
+
+
+
+ = 0 ? 'Saved' : 'Overspent'}
+ value={whole(Math.abs(saved))}
+ sub={
+ o.income > 0 ? (
+ <>
+ {Math.round((saved / o.income) * 100)}% of income
+ >
+ ) : undefined
+ }
+ />
+
+ ) : null}
-
-
- Getting started
- A few steps now, and Spendlight does the bookkeeping from then on.
-
-
-
- } title="Create your sign-in" detail="" />
- }
- title="Turn on Face ID or fingerprint sign-in"
- detail="One glance instead of a password, if this device is ever signed out."
- action={
-
- Set up
-
- }
- />
- : }
- title="Add Spendlight to your home screen"
- detail={
- isIos()
- ? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.'
- : 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'
- }
- />
- }
- title="Add your accounts"
- detail="Checking, savings and every card, each with its own color. Or skip ahead: importing a statement offers to create its account."
- action={
-
- Add
-
- }
- />
- }
- title="Drop in your statements"
- detail="Download a CSV from each bank’s website and drop them in together. Spendlight recognizes the bank, skips duplicates and fills in categories."
- action={
-
- Import
-
- }
- />
- }
- title="Review what came in"
- detail={toReview ? `${toReview} transaction${toReview === 1 ? '' : 's'} waiting, most already filled in. Usually a few taps.` : 'Confirm Spendlight’s guesses. It learns from every choice.'}
- action={
- toReview ? (
-
- Review
+ {!hasMoney && o ? : null}
+
+ {o && o.toReview > 0 ? (
+
+
+
+
+
+
+ {o.toReview} transaction{o.toReview === 1 ? '' : 's'} need{o.toReview === 1 ? 's' : ''} a category
+
+
Most are already filled in. A few taps and the numbers here are exact.
+
+
+
+ ) : null}
+
+ {hasMoney && o ? (
+
+
+
+
+
+ Where it went in {month}
+ {o.hasBudgets ? 'Each category against its budget.' : 'Your biggest categories this month.'}
+
+
+
+ Budget
- ) : undefined
- }
- />
- } later="Coming in milestone 2" title="Set your monthly budgets" detail="Start from last month’s spending and adjust from there." />
-
-
-
+
+
+
+ {o.categories.length ? (
+
+ {o.categories.slice(0, 7).map((c) => {
+ const cat = byId.get(c.categoryId);
+ return (
+
+
+
+
+
+ {cat?.name}
+
+ {whole(c.spent)}
+ {c.budget > 0 ? / {whole(c.budget)} : null}
+
+
+
+ {c.budget > 0 ?
: null}
+
+
+
+ );
+ })}
+
+ ) : (
+ Nothing spent yet this month.
+ )}
+
+
+
+
+
+
+
+
+ Insights
+
+
+ See all
+
+
+
+ {alerts.data?.length ? (
+ alerts.data.map((a) =>
)
+ ) : (
+
+ Nothing needs your attention. Insights appear here as the months fill in.
+
+ )}
+
+
+
+
+
+ Balances
+
+ Accounts
+
+
+
+
+
Cash
+
{formatCents(cash, { whole: true })}
+
+
+
Owed on cards
+
{formatCents(Math.abs(owed), { whole: true })}
+
+
+
+
+
+
+
+ Coming up
+
+
+ Subscriptions
+
+
+
+ {upcoming.data?.length ? (
+
+ {upcoming.data.slice(0, 5).map((u) => {
+ const account = accounts.data?.find((a) => a.id === u.accountId);
+ return (
+
+ {formatDay(u.date, true)}
+
+ {u.name}
+ {u.trial ? Trial ends : null}
+
+ {account ? : null}
+ {formatCents(u.amountCents)}
+
+ );
+ })}
+
+ ) : (
+ Confirm your subscriptions and their next charges show up here.
+ )}
+
+
+
+
+ ) : null}
+
+ {hasMoney && o && !setupDone ? : null}
);
}
diff --git a/web/src/pages/InsightsPage.tsx b/web/src/pages/InsightsPage.tsx
new file mode 100644
index 0000000..3dd5350
--- /dev/null
+++ b/web/src/pages/InsightsPage.tsx
@@ -0,0 +1,613 @@
+import { addMonths, formatCents, LIABILITY_KINDS, monthName, type BalanceSeries, type MerchantStat, type MonthsReport, type TagReport } from '@spendlight/shared';
+import { useQuery } from '@tanstack/react-query';
+import { ArrowRight, ChevronLeft, ChevronRight, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
+import { useMemo, useState, type ReactNode } from 'react';
+import { Link, useSearchParams } from 'react-router';
+import { Area, Bar, CartesianGrid, ComposedChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+import { AlertCard } from '@/components/AlertCard';
+import { BudgetNote, ProgressBar } from '@/components/meter';
+import { AccountDot, CategoryIcon } from '@/components/money';
+import { EmptyState, PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Segmented, Skeleton } from '@/components/ui/misc';
+import { api } from '@/lib/api';
+import { useAccounts, useAlerts, useCategories, useMonthsReport, useOverview } from '@/lib/data';
+import { budgetStatus, monthBounds, whole } from '@/lib/money';
+import { cn } from '@/lib/utils';
+
+type View = 'overview' | 'budget' | 'categories' | 'merchants' | 'balances' | 'tags';
+const VIEWS: { value: View; label: string }[] = [
+ { value: 'overview', label: 'Overview' },
+ { value: 'budget', label: 'Budget vs actual' },
+ { value: 'categories', label: 'Categories' },
+ { value: 'merchants', label: 'Merchants' },
+ { value: 'balances', label: 'Balances' },
+ { value: 'tags', label: 'Tags' },
+];
+
+const compact = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });
+const axisMoney = (v: number) => compact.format(v / 100);
+const tick = { fontSize: 12, fill: 'var(--muted-foreground)' };
+
+function ChartTip({ active, payload, label, labeler }: { active?: boolean; payload?: { name: string; value: number; color: string; dataKey: string }[]; label?: string; labeler?: (l: string) => string }) {
+ if (!active || !payload?.length) return null;
+ return (
+
+
{labeler ? labeler(String(label)) : label}
+ {payload
+ .filter((p) => p.value !== null && p.value !== undefined)
+ .map((p) => (
+
+
+ {p.name}
+ {formatCents(p.value, { whole: true })}
+
+ ))}
+
+ );
+}
+
+function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'warn' | 'neutral' }) {
+ const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles;
+ return (
+
+ );
+}
+
+function YearSwitcher({ year, onChange }: { year: number; onChange: (y: number) => void }) {
+ return (
+
+ onChange(year - 1)} aria-label="Previous year">
+
+
+ {year}
+ onChange(year + 1)} aria-label="Next year">
+
+
+
+ );
+}
+
+/** Months of the year that have happened (by the statements' reach). */
+const monthsDone = (report: MonthsReport) => {
+ const through = report.dataThrough?.slice(0, 7);
+ return report.months.filter((m) => through && m <= through).length;
+};
+
+function Overview({ report, year }: { report: MonthsReport; year: number }) {
+ const overview = useOverview();
+ const alerts = useAlerts('money');
+ const done = monthsDone(report);
+ const o = overview.data?.year.year === year ? overview.data : undefined;
+ const budgetYear = report.totals.budgetSpend.reduce((a, b) => a + b, 0);
+
+ const pacing = useMemo(() => {
+ let actual = 0;
+ let budget = 0;
+ const projectedEnd = o?.year.projected ?? 0;
+ return report.months.map((m, i) => {
+ budget += report.totals.budgetSpend[i]!;
+ if (i < done) actual += report.totals.spent[i]!;
+ const row: { month: string; actual: number | null; budget: number; projection: number | null } = { month: m, actual: i < done ? actual : null, budget, projection: null };
+ if (o && i >= done - 1 && done > 0) {
+ const start = report.totals.spent.slice(0, done).reduce((a, b) => a + b, 0);
+ row.projection = done >= 12 ? null : Math.round(start + ((projectedEnd - start) * (i - (done - 1))) / (12 - done));
+ }
+ return row;
+ });
+ }, [report, done, o]);
+
+ const flow = report.months.slice(0, Math.max(done, 1)).map((m, i) => ({ month: m, in: report.totals.income[i]!, out: report.totals.spent[i]!, net: report.totals.income[i]! - report.totals.spent[i]! }));
+ const totalIn = flow.reduce((s, f) => s + f.in, 0);
+ const totalOut = flow.reduce((s, f) => s + f.out, 0);
+ const gap = o ? o.year.budgetYear - o.year.projected : 0;
+
+ return (
+
+ {o && budgetYear > 0 ? (
+
= 0 ? 'good' : 'warn'}>
+ On pace to finish {year} {whole(Math.abs(gap))} {gap >= 0 ? 'under' : 'over'} budget
+
+ ) : totalIn || totalOut ? (
+
= totalOut ? 'good' : 'warn'}>
+ {totalIn >= totalOut ? `Saved ${whole(totalIn - totalOut)} so far in ${year}` : `Spent ${whole(totalOut - totalIn)} more than came in so far in ${year}`}
+
+ ) : null}
+
+
+
+
+ Year pacing
+ Spending so far against the budget so far{o ? ', with where the year is heading' : ''}.
+
+
+ {budgetYear > 0 ? (
+
+
+
+ monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
+
+ monthName(m, 'long', true)} />} />
+
+
+
+
+
+ ) : (
+ Set budgets to see how the year is pacing.
+ )}
+
+
+
+
+
+ Money in and out
+
+ {totalIn > 0 ? `Saved ${Math.round(((totalIn - totalOut) / totalIn) * 100)}% of income so far in ${year}.` : 'Income and spending by month.'} Transfers between your accounts don’t count.
+
+
+
+ {done ? (
+
+
+
+ monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
+
+ monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
+
+
+
+
+
+ ) : (
+ Import statements to see money in and out.
+ )}
+
+
+
+
+
+
+ What stands out
+
+ {alerts.data?.length ? (
+
+ {alerts.data.map((a) => (
+
+ ))}
+
+ ) : (
+ Nothing unusual right now.
+ )}
+
+
+ );
+}
+
+function EmptyChart({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }) {
+ const { byId } = useCategories();
+ const [params, setParams] = useSearchParams();
+ const done = Math.max(1, monthsDone(report));
+ const month = params.get('month')?.startsWith(String(year)) ? params.get('month')! : report.months[done - 1]!;
+ const i = report.months.indexOf(month);
+ const [span, setSpan] = useState<'month' | 'ytd'>('month');
+ const rows = report.categories
+ .filter((c) => byId.get(c.categoryId)?.kind === 'expense')
+ .map((c) => {
+ const spent = span === 'month' ? c.actual[i]! : c.actual.slice(0, i + 1).reduce((a, b) => a + b, 0);
+ const budget = span === 'month' ? c.budget[i]! : c.budget.slice(0, i + 1).reduce((a, b) => a + b, 0);
+ return { c, spent, budget, gap: spent - budget };
+ })
+ .filter((r) => r.spent || r.budget)
+ .sort((a, b) => b.gap - a.gap);
+ const totalSpent = rows.reduce((s, r) => s + r.spent, 0);
+ const totalBudget = rows.reduce((s, r) => s + r.budget, 0);
+ const label = span === 'month' ? monthName(month) : `${monthName(`${year}-01`, 'short')}–${monthName(month, 'short')} ${year}`;
+ const { from, to } = span === 'month' ? monthBounds(month) : { from: `${year}-01-01`, to: monthBounds(month).to };
+
+ return (
+
+
+
+ setParams((p) => (p.set('month', addMonths(month, -1)), p), { replace: true })} disabled={i <= 0} aria-label="Previous month">
+
+
+ {monthName(month, 'long', true)}
+ setParams((p) => (p.set('month', addMonths(month, 1)), p), { replace: true })} disabled={i >= 11} aria-label="Next month">
+
+
+
+
+
+ {totalBudget > 0 ? (
+
+ {label}: {whole(Math.abs(totalBudget - totalSpent))} {totalSpent <= totalBudget ? 'under' : 'over'} budget ({Math.round((totalSpent / totalBudget) * 100)}% used)
+
+ ) : null}
+ {rows.length ? (
+
+ {rows.map(({ c, spent, budget, gap }) => {
+ const cat = byId.get(c.categoryId);
+ const pct = budget ? Math.round((gap / budget) * 100) : null;
+ return (
+
+
+
+
+ {cat?.name}
+
+ {whole(spent)} / {budget ? whole(budget) : 'no budget'}
+
+
+
+
+
+ {pct !== null && budgetStatus(spent, budget) === 'over' ? +{pct}% : null}
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function Categories({ report, year }: { report: MonthsReport; year: number }) {
+ const { byId } = useCategories();
+ const [params, setParams] = useSearchParams();
+ const done = Math.max(1, monthsDone(report));
+ const rows = report.categories
+ .filter((c) => byId.get(c.categoryId)?.kind === 'expense' && c.actual.some(Boolean))
+ .sort((a, b) => b.actual.reduce((x, y) => x + y, 0) - a.actual.reduce((x, y) => x + y, 0));
+ const selected = Number(params.get('categoryId')) || rows[0]?.categoryId;
+ const current = rows.find((r) => r.categoryId === selected) ?? rows[0];
+ const cat = current ? byId.get(current.categoryId) : undefined;
+ const merchants = useQuery({
+ queryKey: ['reports', 'merchants', year, current?.categoryId],
+ queryFn: () => api(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&categoryId=${current!.categoryId}&limit=6`),
+ enabled: !!current,
+ });
+ if (!rows.length) return ;
+ const total = current!.actual.reduce((a, b) => a + b, 0);
+ const average = Math.round(current!.actual.slice(0, done).reduce((a, b) => a + b, 0) / done);
+ const chart = report.months.map((m, i) => ({ month: m, spent: current!.actual[i]!, budget: current!.budget[i]! || null }));
+
+ return (
+
+
+
+
+
+ {cat?.name}
+
+ {whole(total)} in {year} · about {whole(average)} a month
+
+
+
+
+ Transactions
+
+
+
+
+
+
+
+
+ monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
+
+ monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
+
+
+
+
+
+
+
Top merchants
+
+ {merchants.data?.map((m) => (
+
+
+ {m.merchant}
+
+
+ {m.count}× · {whole(m.spent)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Category
+ {report.months.map((m) => (
+
+ {monthName(m, 'short')}
+
+ ))}
+ Total
+
+
+
+ {rows.map((r) => {
+ const c = byId.get(r.categoryId);
+ return (
+
+
+ setParams((p) => (p.set('categoryId', String(r.categoryId)), p), { replace: true })} className="flex items-center gap-2 text-left">
+
+ {c?.name}
+
+
+ {r.actual.map((v, i) => {
+ const over = r.budget[i]! > 0 && v > r.budget[i]!;
+ return (
+
+ {v ? whole(v) : '–'}
+
+ );
+ })}
+ {whole(r.actual.reduce((a, b) => a + b, 0))}
+
+ );
+ })}
+
+
+
+ Red cells ran over that month’s budget. Choose a category to see its trend and merchants.
+
+
+ );
+}
+
+function Merchants({ year }: { year: number }) {
+ const [sort, setSort] = useState<'spent' | 'count'>('spent');
+ const { byId } = useCategories();
+ const list = useQuery({ queryKey: ['reports', 'merchants', year, 'all'], queryFn: () => api(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&limit=100`) });
+ const rows = [...(list.data ?? [])].sort((a, b) => (sort === 'spent' ? b.spent - a.spent : b.count - a.count)).slice(0, 30);
+ const top = rows[0] ? (sort === 'spent' ? rows[0].spent : rows[0].count) : 1;
+ return (
+
+
+
+
+ {list.isPending ?
: null}
+ {rows.length ? (
+
+ {rows.map((m, i) => {
+ const c = m.categoryId ? byId.get(m.categoryId) : undefined;
+ const share = (sort === 'spent' ? m.spent : m.count) / top;
+ return (
+
+ {i + 1}
+
+
+
+ {m.merchant}
+
+ {m.count}× ·
+ {whole(m.spent)}
+
+
+
+
+
+ );
+ })}
+
+ ) : list.data ? (
+
+ ) : null}
+
+ );
+}
+
+function Balances() {
+ const accounts = useAccounts();
+ const series = useQuery({ queryKey: ['reports', 'balances'], queryFn: () => api('/reports/balances?months=12') });
+ if (!series.data || !accounts.data) return ;
+ const kindOf = new Map(accounts.data.map((a) => [a.id, a.kind]));
+ const data = series.data.months.map((m, i) => {
+ let cash = 0;
+ let owed = 0;
+ let any = false;
+ for (const a of series.data.accounts) {
+ const v = a.balances[i];
+ if (v === null || v === undefined) continue;
+ any = true;
+ if (LIABILITY_KINDS.includes(kindOf.get(a.accountId)!)) owed += -v;
+ else cash += v;
+ }
+ return { month: m, cash: any ? cash : null, owed: any ? owed : null };
+ });
+ const known = accounts.data.filter((a) => a.balanceConfirmedOn);
+ const unknown = accounts.data.filter((a) => !a.balanceConfirmedOn && a.status === 'open');
+ return (
+
+
+
+ Cash and cards over 12 months
+ Month-end balances: cash in checking and savings, and what’s owed on cards.
+
+
+ {known.length ? (
+
+
+
+ monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
+
+ `End of ${monthName(m, 'long', true)}`} />} />
+
+
+
+
+ ) : (
+ Add a balance to an account and its history appears here.
+ )}
+
+
+
+ {known.map((a) => (
+
+
+
{a.name}
+
+ {formatCents(LIABILITY_KINDS.includes(a.kind) ? -a.balanceCents : a.balanceCents)}
+ {LIABILITY_KINDS.includes(a.kind) ? owed : null}
+
+
+ ))}
+ {unknown.length ? (
+
+ {unknown.map((a) => a.name).join(', ')} {unknown.length === 1 ? 'has' : 'have'} no balance yet. Add one in Accounts
+
+ ) : null}
+
+
+ );
+}
+
+function Tags({ year }: { year: number }) {
+ const { byId } = useCategories();
+ const [span, setSpan] = useState<'year' | 'all'>('all');
+ const q = span === 'year' ? `?from=${year}-01-01&to=${year}-12-31` : '';
+ const list = useQuery({ queryKey: ['reports', 'tags', span, year], queryFn: () => api(`/reports/tags${q}`) });
+ return (
+
+
+
+
+ {list.data?.length ? (
+
+ {list.data.map((t) => (
+
+
+
+ {t.tag}
+
+ {t.count} transaction{t.count === 1 ? '' : 's'}
+ {t.income ? ` · ${whole(t.income)} came back` : ''}
+
+
+ {whole(t.spent)}
+
+
+ {t.byCategory.slice(0, 5).map((b) => {
+ const c = b.categoryId ? byId.get(b.categoryId) : undefined;
+ return (
+
+
+ {c?.name ?? 'Not filed yet'}
+ {whole(b.cents)}
+
+ );
+ })}
+
+ See transactions
+
+
+
+ ))}
+
+ ) : list.data ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default function InsightsPage() {
+ const [params, setParams] = useSearchParams();
+ const overview = useOverview();
+ const view = (params.get('view') as View) || 'overview';
+ const focusYear = overview.data ? Number(overview.data.month.slice(0, 4)) : new Date().getFullYear();
+ const year = Number(params.get('year')) || focusYear;
+ const report = useMonthsReport(`${year}-01`, `${year}-12`, !!overview.data);
+ const set = (key: string, value: string) => setParams((p) => (p.set(key, value), p), { replace: true });
+
+ return (
+
+
+
+
+ {VIEWS.map((v) => (
+ set('view', v.value)}
+ className={cn(
+ 'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
+ view === v.value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {v.label}
+
+ ))}
+
+ {view !== 'balances' ?
set('year', String(y))} /> : null}
+
+ {view === 'balances' ? (
+
+ ) : view === 'merchants' ? (
+
+ ) : view === 'tags' ? (
+
+ ) : !report.data ? (
+
+ ) : view === 'budget' ? (
+
+ ) : view === 'categories' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/MorePage.tsx b/web/src/pages/MorePage.tsx
index 12c41a9..5b10799 100644
--- a/web/src/pages/MorePage.tsx
+++ b/web/src/pages/MorePage.tsx
@@ -2,12 +2,14 @@ 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 { useAlertCounts } from '@/lib/data';
import { useConfig, useSignOut } from '@/lib/session';
/** The phone's "More" tab: everything that doesn't fit on the bottom bar. */
export default function MorePage() {
const signOut = useSignOut();
const { data: config } = useConfig();
+ const { data: alerts } = useAlertCounts();
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
return (
@@ -19,6 +21,9 @@ export default function MorePage() {
{label}
+ {to === '/subscriptions' && alerts?.subscriptions ? (
+ {alerts.subscriptions}
+ ) : null}
))}
diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx
new file mode 100644
index 0000000..4deafdf
--- /dev/null
+++ b/web/src/pages/SubscriptionsPage.tsx
@@ -0,0 +1,673 @@
+import {
+ CADENCE_LABEL,
+ CADENCE_SHORT,
+ formatCents,
+ monthName,
+ SUBSCRIPTION_STATUS_LABEL,
+ type Cadence,
+ type Subscription,
+ type SubscriptionDetail,
+ type SubscriptionKind,
+ type SubscriptionStatus,
+} from '@spendlight/shared';
+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 { AlertCard } from '@/components/AlertCard';
+import { CategoryButton } from '@/components/CategoryPicker';
+import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
+import { EmptyState, PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
+import { Field, Input, Select, Textarea } from '@/components/ui/form';
+import { Badge, Segmented, Skeleton, Switch } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { useAccounts, useAlerts, useCategories, useSubscriptions, useUpcoming } from '@/lib/data';
+import { centsToInput, nextDue, parseDollars } from '@/lib/money';
+import { useCurrentUser } from '@/lib/session';
+import { cn } from '@/lib/utils';
+
+const LIVE: SubscriptionStatus[] = ['active', 'trial'];
+const STATUS_TONE: Partial> = { trial: 'warn', paused: 'neutral', cancelled: 'neutral', suggested: 'primary' };
+
+function useRefresh() {
+ const client = useQueryClient();
+ return () => {
+ for (const key of ['subscriptions', 'alerts', 'transactions', 'reports', 'review', 'review-count']) void client.invalidateQueries({ queryKey: [key] });
+ };
+}
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, '');
+ } catch {
+ return url;
+ }
+}
+
+function Found({ items }: { items: Subscription[] }) {
+ const refresh = useRefresh();
+ const accounts = useAccounts();
+ const me = useCurrentUser();
+ const answer = useMutation({
+ mutationFn: ({ id, status }: { id: number; status: 'active' | 'dismissed' }) => api(`/subscriptions/${id}`, { method: 'PATCH', body: { status } }),
+ onSuccess: (_, v) => {
+ refresh();
+ if (v.status === 'dismissed') toast('Got it, not a subscription', { description: 'Spendlight won’t ask about it again.' });
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ if (!items.length) return null;
+ return (
+
+
+ Found in your statements
+ Is each of these a regular charge? Your answer is remembered.
+
+
+ {items.map((s) => {
+ const account = accounts.data?.find((a) => a.id === s.accountId);
+ return (
+
+
+
+ {s.name} · {formatCents(s.expectedCents)} {CADENCE_LABEL[s.cadence].toLowerCase()}
+
+
+ {account ? : null}
+ {account?.name ?? 'Several accounts'} · {s.chargeCount} charge{s.chargeCount === 1 ? '' : 's'}
+ {s.firstChargeOn ? ` since ${monthName(s.firstChargeOn.slice(0, 7), 'short', true)}` : ''}
+ {s.kind === 'bill' ? ' · looks like a bill' : ''}
+
+
+ {me.access === 'full' ? (
+
+ answer.mutate({ id: s.id, status: 'active' })}>
+ Yes{s.kind === 'bill' ? ', a bill' : ''}
+
+ answer.mutate({ id: s.id, status: 'dismissed' })}>
+ No
+
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
+
+function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) {
+ const accounts = useAccounts();
+ const { byId } = useCategories();
+ const account = accounts.data?.find((a) => a.id === s.accountId);
+ const category = s.categoryId ? byId.get(s.categoryId) : undefined;
+ const live = LIVE.includes(s.status);
+ return (
+
+ {category ? (
+
+ ) : (
+ {s.name[0]}
+ )}
+
+
+ {s.name}
+ {s.status !== 'active' ? {SUBSCRIPTION_STATUS_LABEL[s.status]} : null}
+ {!s.alertsOn ? : null}
+
+
+ {account ? : null}
+
+ {account?.name ?? 'No card yet'}
+ · {CADENCE_LABEL[s.cadence]}
+ {s.status === 'active' && s.nextExpectedOn ? ` · next ${formatDay(nextDue(s.nextExpectedOn, s.cadence)!, true)}` : ''}
+ {s.status === 'trial' && s.trialEndsOn ? ` · trial ends ${formatDay(s.trialEndsOn, true)}` : ''}
+
+
+
+
+
+ {formatCents(s.expectedCents)}
+ {CADENCE_SHORT[s.cadence]}
+
+ {s.cadence !== 'monthly' ?
{formatCents(s.monthlyCents)}/mo
: null}
+
+
+ );
+}
+
+function SubscriptionForm({ initial, onDone }: { initial?: SubscriptionDetail; onDone: (s: Subscription) => void }) {
+ const accounts = useAccounts();
+ const [name, setName] = useState(initial?.name ?? '');
+ const [amount, setAmount] = useState(initial ? centsToInput(initial.expectedCents) : '');
+ const [cadence, setCadence] = useState(initial?.cadence ?? 'monthly');
+ const [kind, setKind] = useState(initial?.kind ?? 'subscription');
+ const [accountId, setAccountId] = useState(initial?.accountId ?? '');
+ const [next, setNext] = useState(initial?.nextExpectedOn ?? '');
+ const [categoryId, setCategoryId] = useState(initial?.categoryId ?? null);
+ const [manageUrl, setManageUrl] = useState(initial?.manageUrl ?? '');
+ const save = useMutation({
+ mutationFn: () => {
+ const cents = parseDollars(amount);
+ if (!cents) throw new Error('Please enter the amount, like 15.99');
+ const body = {
+ name,
+ kind,
+ cadence,
+ expectedCents: cents,
+ accountId: accountId || null,
+ nextExpectedOn: next || null,
+ manageUrl: manageUrl.trim() ? (/^https?:\/\//i.test(manageUrl.trim()) ? manageUrl.trim() : `https://${manageUrl.trim()}`) : null,
+ ...(categoryId !== (initial?.categoryId ?? null) ? { categoryId } : {}),
+ };
+ return initial ? api(`/subscriptions/${initial.id}`, { method: 'PATCH', body }) : api('/subscriptions', { method: 'POST', body });
+ },
+ onSuccess: onDone,
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ return (
+
+ );
+}
+
+function DetailSheet({ id, onClose, all }: { id: number | null; onClose: () => void; all: Subscription[] }) {
+ const me = useCurrentUser();
+ const canEdit = me.access === 'full';
+ const refresh = useRefresh();
+ const accounts = useAccounts();
+ const { byId } = useCategories();
+ const detail = useQuery({ queryKey: ['subscriptions', 'detail', id], queryFn: () => api(`/subscriptions/${id}`), enabled: id !== null });
+ const [editing, setEditing] = useState(false);
+ const [merging, setMerging] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [trialEnd, setTrialEnd] = useState('');
+ const [note, setNote] = useState(null);
+ const s = detail.data;
+ useEffect(() => {
+ setEditing(false);
+ setMerging(false);
+ setNote(null);
+ }, [id]);
+
+ const patch = useMutation({
+ mutationFn: (body: Record) => api(`/subscriptions/${id}`, { method: 'PATCH', body }),
+ onSuccess: () => refresh(),
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const merge = useMutation({
+ mutationFn: (intoId: number) => api(`/subscriptions/${id}/merge`, { method: 'POST', body: { intoId } }),
+ onSuccess: (into) => {
+ refresh();
+ toast.success(`Merged into ${into.name}`);
+ onClose();
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const remove = useMutation({
+ mutationFn: () => api(`/subscriptions/${id}`, { method: 'DELETE' }),
+ onSuccess: () => {
+ refresh();
+ onClose();
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+
+ const setStatus = (status: SubscriptionStatus) => {
+ if (status === 'trial') {
+ if (!trialEnd) return toast.error('Choose the date the free trial ends');
+ patch.mutate({ status, trialEndsOn: trialEnd });
+ } else patch.mutate({ status });
+ };
+
+ return (
+ !open && onClose()}>
+
+ {!s ? (
+
+ ) : editing ? (
+ {
+ refresh();
+ setEditing(false);
+ }}
+ />
+ ) : (
+
+
+
+
{s.kind === 'bill' ? 'Usually' : 'Costs'}
+
+ {formatCents(s.expectedCents)}
+ {CADENCE_SHORT[s.cadence]}
+
+
{formatCents(s.monthlyCents * 12)} a year
+
+
+
Next charge
+
{s.nextExpectedOn && s.status === 'active' ? formatDay(nextDue(s.nextExpectedOn, s.cadence)!) : s.status === 'trial' && s.trialEndsOn ? `${formatDay(s.trialEndsOn)} (trial ends)` : '—'}
+
+ {(() => {
+ const a = accounts.data?.find((x) => x.id === s.accountId);
+ return a ? (
+ <>
+ {a.name}
+ >
+ ) : (
+ 'Card not known yet'
+ );
+ })()}
+
+
+
+
+ {s.manageUrl ? (
+
+
+ Manage or cancel at {hostOf(s.manageUrl)}
+
+
+ ) : null}
+
+ {canEdit ? (
+
+
Status
+
+ {(['active', 'trial', 'paused', 'cancelled'] as SubscriptionStatus[]).map((st) => (
+ setStatus(st)}
+ className={cn(
+ 'h-9 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
+ s.status === st ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {SUBSCRIPTION_STATUS_LABEL[st]}
+
+ ))}
+
+ {s.status !== 'trial' ? (
+
+ For a free trial, choose when it ends:
+ setTrialEnd(e.target.value)} className="h-9 w-40 sm:h-9" aria-label="Trial ends" />
+
+ ) : (
+
Trial ends {s.trialEndsOn ? formatDay(s.trialEndsOn) : 'soon'}; you’ll get a heads-up 3 days before.
+ )}
+ {s.status === 'cancelled' && s.cancelledOn ? (
+
Marked cancelled on {formatDay(s.cancelledOn)}. Spendlight will speak up if it charges again.
+ ) : null}
+
+ ) : null}
+
+ {s.priceHistory.length > 1 ? (
+
+
Price history
+
+ {s.priceHistory.map((p, i) => (
+
+ {i === 0 ? '' : ', then '}
+ {formatCents(p.amountCents)}
+ {i === 0 ? '' : ` from ${monthName(p.from.slice(0, 7))}`}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
Charges
+ {s.chargeCount ? (
+
+ See in Activity
+
+ ) : null}
+
+ {s.charges.length ? (
+
+ {s.charges.slice(0, 24).map((c) => {
+ const a = accounts.data?.find((x) => x.id === c.accountId);
+ return (
+
+ {formatDay(c.date, true)}
+
+ {a ? : null}
+ {a?.name}
+
+ {formatCents(-c.amountCents)}
+
+ );
+ })}
+
+ ) : (
+
No charges yet. They’ll attach themselves as statements come in.
+ )}
+
+
+ {canEdit ? (
+ <>
+
+ {(fid) => (
+
+
+
+ Alerts for this {s.kind}
+
+ patch.mutate({ alertsOn })} />
+
+ {merging ? (
+
+ {(fid) => (
+ e.target.value && merge.mutate(Number(e.target.value))}>
+ Choose…
+ {all
+ .filter((o) => o.id !== s.id)
+ .map((o) => (
+
+ {o.name} · {formatCents(o.expectedCents)}
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ setEditing(true)}>
+ Edit details
+
+ setMerging((m) => !m)}>
+ Merge
+
+ {s.source === 'manual' ? (
+ setConfirmDelete(true)}>
+ Remove
+
+ ) : (
+ {
+ patch.mutate({ status: 'dismissed' });
+ onClose();
+ }}
+ >
+ Not a subscription
+
+ )}
+
+ >
+ ) : null}
+ {s.note && !canEdit ?
{s.note}
: null}
+ {s.categoryId && byId.get(s.categoryId) ?
Filed under {byId.get(s.categoryId)!.name}.
: null}
+
+ )}
+ remove.mutate()}
+ />
+
+
+ );
+}
+
+export default function SubscriptionsPage() {
+ const me = useCurrentUser();
+ const client = useQueryClient();
+ const list = useSubscriptions();
+ const upcoming = useUpcoming(30);
+ const alerts = useAlerts('subscriptions');
+ const accounts = useAccounts();
+ const [params, setParams] = useSearchParams();
+ const [tab, setTab] = useState((params.get('tab') as SubscriptionKind) || 'subscription');
+ const [adding, setAdding] = useState(false);
+ const openId = params.get('open') ? Number(params.get('open')) : null;
+ const setOpen = (id: number | null) =>
+ setParams(
+ (p) => {
+ if (id === null) p.delete('open');
+ else p.set('open', String(id));
+ return p;
+ },
+ { replace: true },
+ );
+
+ // Opening this screen counts as having seen its alerts, which clears the menu badge.
+ const read = useMutation({ mutationFn: () => api('/alerts/read', { method: 'POST', body: { area: 'subscriptions' } }), onSuccess: () => client.invalidateQueries({ queryKey: ['alerts', 'counts'] }) });
+ const readMutate = read.mutate;
+ useEffect(() => {
+ if (alerts.data?.some((a) => !a.read)) readMutate();
+ }, [alerts.data, readMutate]);
+
+ const all = list.data ?? [];
+ const found = all.filter((s) => s.status === 'suggested');
+ const mine = all.filter((s) => s.status !== 'suggested' && s.kind === tab);
+ const live = mine.filter((s) => LIVE.includes(s.status));
+ const stopped = mine.filter((s) => !LIVE.includes(s.status));
+ const monthly = live.filter((s) => s.status === 'active').reduce((sum, s) => sum + s.monthlyCents, 0);
+ const counts = useMemo(
+ () => ({ subscription: all.filter((s) => s.status !== 'suggested' && s.kind === 'subscription').length, bill: all.filter((s) => s.status !== 'suggested' && s.kind === 'bill').length }),
+ [all],
+ );
+ const shownAlerts = alerts.data?.filter((a) => a.kind !== 'sub_found') ?? [];
+
+ return (
+
+
setAdding(true)}>
+ Add by hand
+
+ ) : undefined
+ }
+ />
+
+ {shownAlerts.length ? (
+
+ {shownAlerts.map((a) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {
+ setTab(v);
+ setParams((p) => (p.set('tab', v), p), { replace: true });
+ }}
+ options={[
+ { value: 'subscription', label: `Subscriptions${counts.subscription ? ` · ${counts.subscription}` : ''}` },
+ { value: 'bill', label: `Bills${counts.bill ? ` · ${counts.bill}` : ''}` },
+ ]}
+ />
+
+
+
+ {[
+ { label: 'Each month', value: formatCents(monthly, { whole: monthly >= 100_000 }) },
+ { label: 'Each year', value: formatCents(monthly * 12, { whole: true }) },
+ { label: 'Active', value: String(live.filter((s) => s.status === 'active').length) },
+ ].map((x) => (
+
+ {x.label}
+ {x.value}
+
+ ))}
+
+
+
+
+ {list.isPending ? : null}
+ {list.data && !live.length && !stopped.length ? (
+
+ ) : null}
+ {live.length ? (
+
+ {live.map((s) => (
+ setOpen(s.id)} />
+ ))}
+
+ ) : null}
+ {stopped.length ? (
+
+ Paused or cancelled
+
+ {stopped.map((s) => (
+ setOpen(s.id)} />
+ ))}
+
+
+ ) : null}
+
+
+
+
+ Coming up in the next 30 days
+
+
+
+ {upcoming.data?.length ? (
+
+ {upcoming.data.map((u) => {
+ const a = accounts.data?.find((x) => x.id === u.accountId);
+ return (
+
+ setOpen(u.subscriptionId)} className="flex w-full items-center gap-3 text-left text-[14px]">
+ {formatDay(u.date, true)}
+
+ {u.name}
+ {u.trial ? Trial ends : null}
+
+ {a ? : null}
+ {formatCents(u.amountCents)}
+
+
+ );
+ })}
+
+ Total
+ {formatCents(upcoming.data.reduce((s, u) => s + u.amountCents, 0))}
+
+
+ ) : (
+ Nothing expected in the next 30 days.
+ )}
+
+
+
+
+ setOpen(null)} all={all.filter((s) => s.status !== 'suggested')} />
+
+
+ {
+ for (const key of ['subscriptions', 'alerts']) void client.invalidateQueries({ queryKey: [key] });
+ setAdding(false);
+ toast.success('Added');
+ }}
+ />
+
+
+
+ );
+}
diff --git a/web/src/pages/Upcoming.tsx b/web/src/pages/Upcoming.tsx
deleted file mode 100644
index 40c88be..0000000
--- a/web/src/pages/Upcoming.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { ChartColumnBig, Repeat, Target, type LucideIcon } from 'lucide-react';
-import { EmptyState } from '@/components/page';
-
-// Screens that arrive in later milestones, each explaining what it will do.
-
-interface Upcoming {
- icon: LucideIcon;
- title: string;
- description: string;
- milestone: string;
-}
-
-const SCREENS: Record = {
- budget: {
- icon: Target,
- title: 'Monthly budgets',
- description:
- 'Set a target for each category, copy last month in one tap, and see over or under for the month and the year at a glance.',
- milestone: 'Milestone 2 · The full picture',
- },
- subscriptions: {
- icon: Repeat,
- title: 'Every subscription in one place',
- description:
- 'Spendlight spots recurring charges across all your cards, totals them by month and year, and speaks up when a price changes or a cancelled service charges again.',
- milestone: 'Milestone 2 · The full picture',
- },
- insights: {
- icon: ChartColumnBig,
- title: 'Where the money goes',
- description:
- 'Month-by-month spending by category, budget against actual, year pacing, and money in against money out, each in plain words first.',
- milestone: 'Milestone 2 · The full picture',
- },
-};
-
-export default function UpcomingPage({ screen }: { screen: keyof typeof SCREENS }) {
- const s = SCREENS[screen]!;
- return ;
-}
diff --git a/web/src/pages/settings/AiPage.tsx b/web/src/pages/settings/AiPage.tsx
new file mode 100644
index 0000000..821f727
--- /dev/null
+++ b/web/src/pages/settings/AiPage.tsx
@@ -0,0 +1,58 @@
+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 { PageIntro } from '@/components/page';
+import { Card } from '@/components/ui/card';
+import { Badge, Skeleton, Switch } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+
+export default function AiPage() {
+ const client = useQueryClient();
+ const status = useQuery({ queryKey: ['ai'], queryFn: () => api('/ai') });
+ const toggle = useMutation({
+ mutationFn: (body: { feature: string; enabled: boolean }) => api('/ai', { method: 'PATCH', body }),
+ onSuccess: (next) => client.setQueryData(['ai'], next),
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const s = status.data;
+ return (
+
+
+ {!s ? (
+
+ ) : (
+ <>
+
+
+
+ {s.providerConfigured ? (
+ <>
+ Provider set up on this server: {s.provider} . Each feature below sends only what it says, and only when it’s on.
+ >
+ ) : (
+ <>
+ No AI provider is set up on this server, so Spendlight makes no outside calls at all. Your financial data stays on this machine.
+ >
+ )}
+
+
+
+ {s.features.map((f) => (
+
+
+
+
+ {f.label} {!s.providerConfigured ? Off : null}
+
+ {f.description}
+
+ toggle.mutate({ feature: f.key, enabled })} aria-label={f.label} />
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/web/src/pages/settings/AlertsPage.tsx b/web/src/pages/settings/AlertsPage.tsx
new file mode 100644
index 0000000..b41a960
--- /dev/null
+++ b/web/src/pages/settings/AlertsPage.tsx
@@ -0,0 +1,85 @@
+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 { PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardFooter } from '@/components/ui/card';
+import { Field, Input } from '@/components/ui/form';
+import { Skeleton } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { centsToInput, parseDollars } from '@/lib/money';
+import { useCurrentUser } from '@/lib/session';
+
+export default function AlertsPage() {
+ const me = useCurrentUser();
+ const client = useQueryClient();
+ const current = useQuery({ queryKey: ['settings', 'alerts'], queryFn: () => api('/settings/alerts') });
+ const [form, setForm] = useState({ pct: '', min: '', warn: '', unusual: '' });
+ useEffect(() => {
+ if (current.data) {
+ setForm({ pct: String(current.data.priceChangePct), min: centsToInput(current.data.priceChangeMinCents), warn: String(current.data.budgetWarnPct), unusual: String(current.data.unusualMultiple) });
+ }
+ }, [current.data]);
+ const save = useMutation({
+ mutationFn: () => {
+ const min = parseDollars(form.min);
+ if (min === null) throw new Error('Enter the price change in dollars, like 2');
+ return api('/settings/alerts', {
+ method: 'PUT',
+ body: { priceChangePct: Number(form.pct), priceChangeMinCents: min, budgetWarnPct: Number(form.warn), unusualMultiple: Number(form.unusual) },
+ });
+ },
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ['settings', 'alerts'] });
+ void client.invalidateQueries({ queryKey: ['alerts'] });
+ toast.success('Saved');
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const canEdit = me.access === 'full';
+ const set = (key: keyof typeof form) => (e: ChangeEvent) => setForm({ ...form, [key]: e.target.value });
+
+ return (
+
+
+ {!current.data ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/settings/CategoriesPage.tsx b/web/src/pages/settings/CategoriesPage.tsx
new file mode 100644
index 0000000..0f90c50
--- /dev/null
+++ b/web/src/pages/settings/CategoriesPage.tsx
@@ -0,0 +1,333 @@
+import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type PaletteColor } from '@spendlight/shared';
+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 { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money';
+import { PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
+import { Field, Input, Select } from '@/components/ui/form';
+import { Badge, Skeleton, Switch } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { MONEY_KEYS, useCategories } from '@/lib/data';
+import { useCurrentUser } from '@/lib/session';
+import { cn } from '@/lib/utils';
+
+const KIND_LABEL: Record = { expense: 'Money out', income: 'Money in', transfer: 'Transfer' };
+
+function useRefresh() {
+ const client = useQueryClient();
+ return () => {
+ void client.invalidateQueries({ queryKey: ['categories'] });
+ MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
+ };
+}
+
+function CategoryForm({ category, group, groups, onDone }: { category?: Category; group: CategoryGroup; groups: CategoryGroup[]; onDone: () => void }) {
+ const refresh = useRefresh();
+ const [name, setName] = useState(category?.name ?? '');
+ const [icon, setIcon] = useState(category?.icon ?? 'circle');
+ const [color, setColor] = useState(category?.color ?? 'indigo');
+ const [groupId, setGroupId] = useState(category?.groupId ?? group.id);
+ const [kind, setKind] = useState(category?.kind ?? group.categories[0]?.kind ?? 'expense');
+ const [archived, setArchived] = useState(category?.archived ?? false);
+ const [merging, setMerging] = useState(false);
+ const [into, setInto] = useState('');
+ const save = useMutation({
+ mutationFn: () =>
+ category
+ ? api(`/categories/${category.id}`, { method: 'PATCH', body: { name, icon, color, groupId, kind, archived } })
+ : api('/categories', { method: 'POST', body: { name, icon, color, groupId, kind } }),
+ onSuccess: () => {
+ refresh();
+ toast.success(category ? 'Saved' : 'Category added');
+ onDone();
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const merge = useMutation({
+ mutationFn: () => api<{ moved: number }>(`/categories/${category!.id}/merge`, { method: 'POST', body: { intoId: into } }),
+ onSuccess: ({ moved }) => {
+ refresh();
+ toast.success(`Merged ${category!.name}`, { description: `${moved} transaction${moved === 1 ? '' : 's'}, its budgets and rules moved across.` });
+ onDone();
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const sameKind = groups.flatMap((g) => g.categories).filter((c) => c.kind === (category?.kind ?? kind) && c.id !== category?.id && !c.archived);
+
+ return (
+
+ );
+}
+
+export default function CategoriesPage() {
+ const me = useCurrentUser();
+ const canEdit = me.access === 'full';
+ const refresh = useRefresh();
+ const { data: groups } = useCategories();
+ const [editing, setEditing] = useState<{ category?: Category; group: CategoryGroup } | null>(null);
+ const [groupEdit, setGroupEdit] = useState<{ group?: CategoryGroup; name: string } | null>(null);
+ const [deleteGroup, setDeleteGroup] = useState(null);
+ const [showArchived, setShowArchived] = useState(false);
+
+ const reorder = useMutation({
+ mutationFn: (next: CategoryGroup[]) => api('/categories/reorder', { method: 'POST', body: { groups: next.map((g) => ({ id: g.id, categoryIds: g.categories.map((c) => c.id) })) } }),
+ onSuccess: refresh,
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const saveGroup = useMutation({
+ mutationFn: () => (groupEdit?.group ? api(`/category-groups/${groupEdit.group.id}`, { method: 'PATCH', body: { name: groupEdit.name } }) : api('/category-groups', { method: 'POST', body: { name: groupEdit!.name } })),
+ onSuccess: () => {
+ refresh();
+ setGroupEdit(null);
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const removeGroup = useMutation({
+ mutationFn: (id: number) => api(`/category-groups/${id}`, { method: 'DELETE' }),
+ onSuccess: () => {
+ refresh();
+ setDeleteGroup(null);
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+
+ const move = (gi: number, ci: number | null, delta: number) => {
+ if (!groups) return;
+ const next = groups.map((g) => ({ ...g, categories: [...g.categories] }));
+ if (ci === null) {
+ const j = gi + delta;
+ if (j < 0 || j >= next.length) return;
+ [next[gi], next[j]] = [next[j]!, next[gi]!];
+ } else {
+ const list = next[gi]!.categories;
+ const j = ci + delta;
+ if (j < 0 || j >= list.length) return;
+ [list[ci], list[j]] = [list[j]!, list[ci]!];
+ }
+ reorder.mutate(next);
+ };
+
+ return (
+
+
setGroupEdit({ name: '' })}>
+ Add group
+
+ ) : undefined
+ }
+ />
+
+ Show archived categories
+
+ {!groups ? : null}
+
+ {groups?.map((g, gi) => {
+ const visible = g.categories.filter((c) => showArchived || !c.archived);
+ const kinds = new Set(g.categories.map((c) => c.kind));
+ return (
+
+
+
+ {g.name}
+ {kinds.size === 1 && [...kinds][0] !== 'expense' ? · {KIND_LABEL[[...kinds][0]!]} : null}
+
+ {canEdit ? (
+ <>
+
move(gi, null, -1)} disabled={gi === 0} aria-label={`Move ${g.name} up`}>
+
+
+
move(gi, null, 1)} disabled={gi === groups.length - 1} aria-label={`Move ${g.name} down`}>
+
+
+
setGroupEdit({ group: g, name: g.name })} aria-label={`Rename ${g.name}`}>
+
+
+ {!g.categories.length ? (
+
setDeleteGroup(g)} aria-label={`Remove ${g.name}`}>
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ {visible.map((c) => {
+ const ci = g.categories.indexOf(c);
+ return (
+
+
setEditing({ category: c, group: g })} className="flex min-w-0 flex-1 items-center gap-3 text-left">
+
+ {c.name}
+ {kinds.size > 1 ? {KIND_LABEL[c.kind]} : null}
+ {c.archived ? Archived : null}
+
+ {canEdit ? (
+
+ move(gi, ci, -1)} disabled={ci === 0} aria-label={`Move ${c.name} up`}>
+
+
+ move(gi, ci, 1)} disabled={ci === g.categories.length - 1} aria-label={`Move ${c.name} down`}>
+
+
+
+ ) : null}
+
+ );
+ })}
+ {canEdit ? (
+ setEditing({ group: g })} className="flex w-full items-center gap-3 px-4 py-2.5 text-[14px] font-medium text-primary hover:bg-muted/60 sm:px-5">
+ Add to {g.name}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ !o && setEditing(null)}>
+ {editing && groups ? (
+
+ setEditing(null)} />
+
+ ) : null}
+
+ !o && setGroupEdit(null)}>
+ {groupEdit ? (
+
+
+
+ ) : null}
+
+ !o && setDeleteGroup(null)}
+ title={`Remove ${deleteGroup?.name}?`}
+ description="The group is empty, so nothing else changes."
+ confirmLabel="Remove"
+ destructive
+ loading={removeGroup.isPending}
+ onConfirm={() => deleteGroup && removeGroup.mutate(deleteGroup.id)}
+ />
+
+ );
+}
diff --git a/web/src/pages/settings/ExportPage.tsx b/web/src/pages/settings/ExportPage.tsx
new file mode 100644
index 0000000..fd842af
--- /dev/null
+++ b/web/src/pages/settings/ExportPage.tsx
@@ -0,0 +1,31 @@
+import { FileSpreadsheet, FileText, Sheet } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { PageIntro } from '@/components/page';
+import { Card } from '@/components/ui/card';
+
+function Download({ href, icon, title, detail }: { href: string; icon: ReactNode; title: string; detail: string }) {
+ return (
+
+ {icon}
+
+ {title}
+ {detail}
+
+
+ );
+}
+
+export default function ExportPage() {
+ const year = new Date().getFullYear();
+ return (
+
+
+
+ } title="Everything, as Excel" detail="Transactions, budgets, categories, accounts, subscriptions and rules, one sheet each." />
+ } title={`${year}’s transactions, as Excel`} detail="Handy for taxes. Split purchases come out one row per part." />
+ } title="All transactions, as CSV" detail="Opens in Excel, Numbers, Google Sheets or anything else." />
+
+
To export just part of your history, search or filter in Activity and use its Export button.
+
+ );
+}
diff --git a/web/src/pages/settings/RulesPage.tsx b/web/src/pages/settings/RulesPage.tsx
new file mode 100644
index 0000000..d579fa9
--- /dev/null
+++ b/web/src/pages/settings/RulesPage.tsx
@@ -0,0 +1,144 @@
+import type { Rule } from '@spendlight/shared';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Plus, Search, Trash2, Wand2 } from 'lucide-react';
+import { useState, type FormEvent } from 'react';
+import { toast } from 'sonner';
+import { CategoryButton } from '@/components/CategoryPicker';
+import { EmptyState, PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { Field, Input } from '@/components/ui/form';
+import { Badge, Skeleton } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { useRules } from '@/lib/data';
+import { useCurrentUser } from '@/lib/session';
+import { timeAgo } from '@/lib/utils';
+
+function RuleRow({ rule, canEdit }: { rule: Rule; canEdit: boolean }) {
+ const client = useQueryClient();
+ const [rename, setRename] = useState(rule.renameTo ?? '');
+ const refresh = () => client.invalidateQueries({ queryKey: ['rules'] });
+ const patch = useMutation({
+ mutationFn: (body: Record) => api(`/rules/${rule.id}`, { method: 'PATCH', body }),
+ onSuccess: refresh,
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const remove = useMutation({
+ mutationFn: () => api(`/rules/${rule.id}`, { method: 'DELETE' }),
+ onSuccess: () => {
+ void refresh();
+ toast(`Forgot the rule for ${rule.merchant}`);
+ },
+ });
+ return (
+
+
+
+ {rule.merchant}
+ {rule.source === 'manual' ? 'Set by hand' : 'Learned'}
+
+
+ Used {rule.hits} time{rule.hits === 1 ? '' : 's'} on import{rule.lastUsedAt ? `, last ${timeAgo(rule.lastUsedAt)}` : ''}
+
+
+
+ canEdit && patch.mutate({ categoryId: c.id })} placeholder="No category" className="w-48" />
+ setRename(e.target.value)}
+ onBlur={() => rename !== (rule.renameTo ?? '') && patch.mutate({ renameTo: rename.trim() || null })}
+ placeholder="Keep its name"
+ aria-label={`New name for ${rule.merchant}`}
+ className="h-10 w-44 sm:h-10"
+ />
+ {canEdit ? (
+ remove.mutate()} aria-label={`Forget the rule for ${rule.merchant}`}>
+
+
+ ) : null}
+
+
+ );
+}
+
+export default function RulesPage() {
+ const me = useCurrentUser();
+ const canEdit = me.access === 'full';
+ const client = useQueryClient();
+ const rules = useRules();
+ const [q, setQ] = useState('');
+ const [adding, setAdding] = useState(false);
+ const [merchant, setMerchant] = useState('');
+ const [categoryId, setCategoryId] = useState(null);
+ const add = useMutation({
+ mutationFn: () => {
+ if (!categoryId) throw new Error('Choose a category for it');
+ return api('/rules', { method: 'POST', body: { merchant, categoryId } });
+ },
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ['rules'] });
+ setAdding(false);
+ setMerchant('');
+ setCategoryId(null);
+ },
+ onError: (e) => toast.error(errorMessage(e)),
+ });
+ const shown = rules.data?.filter((r) => r.merchant.toLowerCase().includes(q.toLowerCase()) || r.renameTo?.toLowerCase().includes(q.toLowerCase())) ?? [];
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/settings/SettingsLayout.tsx b/web/src/pages/settings/SettingsLayout.tsx
index 88e8a81..200b1c7 100644
--- a/web/src/pages/settings/SettingsLayout.tsx
+++ b/web/src/pages/settings/SettingsLayout.tsx
@@ -1,4 +1,4 @@
-import { Archive, Info, Lightbulb, ShieldCheck, UserRound, Users } from 'lucide-react';
+import { Archive, Bell, Download, Info, Lightbulb, Shapes, ShieldCheck, Sparkles, Tag, UserRound, Users, Wand2 } from 'lucide-react';
import { NavLink, Outlet } from 'react-router';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
@@ -9,8 +9,14 @@ export default function SettingsLayout() {
const sections = [
{ to: 'profile', label: 'Profile', icon: UserRound, show: true },
{ to: 'security', label: 'Sign-in & devices', icon: ShieldCheck, show: true },
+ { to: 'categories', label: 'Categories', icon: Shapes, show: true },
+ { to: 'rules', label: 'Rules', icon: Wand2, show: true },
+ { to: 'tags', label: 'Tags', icon: Tag, show: true },
+ { to: 'alerts', label: 'Alerts', icon: Bell, show: true },
+ { to: 'export', label: 'Export', icon: Download, show: true },
{ to: 'people', label: 'People', icon: Users, show: me.isAdmin },
{ to: 'backups', label: 'Backups', icon: Archive, show: me.isAdmin },
+ { to: 'ai', label: 'AI features', icon: Sparkles, show: me.isAdmin },
{ to: 'ideas', label: 'Ideas', icon: Lightbulb, show: me.isAdmin && !!config?.ideasEnabled },
{ to: 'about', label: 'About', icon: Info, show: true },
].filter((s) => s.show);
diff --git a/web/src/pages/settings/TagsPage.tsx b/web/src/pages/settings/TagsPage.tsx
new file mode 100644
index 0000000..34991b6
--- /dev/null
+++ b/web/src/pages/settings/TagsPage.tsx
@@ -0,0 +1,93 @@
+import type { TagInfo } from '@spendlight/shared';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { ArrowRight, Tag, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { Link } from 'react-router';
+import { toast } from 'sonner';
+import { EmptyState, PageIntro } from '@/components/page';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { ConfirmDialog } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/form';
+import { Skeleton } from '@/components/ui/misc';
+import { api, errorMessage } from '@/lib/api';
+import { useTags } from '@/lib/data';
+import { useCurrentUser } from '@/lib/session';
+
+function TagRow({ tag, canEdit, onDelete }: { tag: TagInfo; canEdit: boolean; onDelete: () => void }) {
+ const client = useQueryClient();
+ const [name, setName] = useState(tag.name);
+ const rename = useMutation({
+ mutationFn: () => api(`/tags/${tag.id}`, { method: 'PATCH', body: { name: name.trim() } }),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ['tags'] });
+ void client.invalidateQueries({ queryKey: ['transactions'] });
+ void client.invalidateQueries({ queryKey: ['reports'] });
+ },
+ onError: (e) => {
+ toast.error(errorMessage(e));
+ setName(tag.name);
+ },
+ });
+ return (
+
+
+
setName(e.target.value)}
+ onBlur={() => name.trim() && name.trim() !== tag.name && rename.mutate()}
+ onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
+ aria-label={`Rename ${tag.name}`}
+ className="h-9 flex-1 border-transparent bg-transparent px-2 shadow-none hover:border-input sm:h-9"
+ />
+
+ {tag.count}
+
+ {canEdit ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export default function TagsPage() {
+ const me = useCurrentUser();
+ const client = useQueryClient();
+ const tags = useTags();
+ const [removing, setRemoving] = useState(null);
+ const remove = useMutation({
+ mutationFn: (id: number) => api(`/tags/${id}`, { method: 'DELETE' }),
+ onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ['tags'] });
+ void client.invalidateQueries({ queryKey: ['transactions'] });
+ setRemoving(null);
+ },
+ });
+ return (
+
+
+ {tags.isPending ?
: null}
+ {tags.data && !tags.data.length ?
: null}
+ {tags.data?.length ? (
+
+ {tags.data.map((t) => (
+ setRemoving(t)} />
+ ))}
+
+ ) : null}
+
!o && setRemoving(null)}
+ title={`Remove the tag “${removing?.name}”?`}
+ description={`It comes off ${removing?.count ?? 0} transaction${removing?.count === 1 ? '' : 's'}. The transactions themselves stay.`}
+ confirmLabel="Remove tag"
+ destructive
+ loading={remove.isPending}
+ onConfirm={() => removing && remove.mutate(removing.id)}
+ />
+
+ );
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 177ce3f..d3a13d4 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -9,11 +9,13 @@ import SignInPage from './pages/SignInPage';
// Each screen loads its own code, so a phone's first visit stays small.
const HomePage = lazy(() => import('./pages/HomePage'));
-const UpcomingPage = lazy(() => import('./pages/Upcoming'));
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
const ImportPage = lazy(() => import('./pages/ImportPage'));
+const BudgetPage = lazy(() => import('./pages/BudgetPage'));
+const SubscriptionsPage = lazy(() => import('./pages/SubscriptionsPage'));
+const InsightsPage = lazy(() => import('./pages/InsightsPage'));
const MorePage = lazy(() => import('./pages/MorePage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
@@ -22,6 +24,12 @@ const SecurityPage = lazy(() => import('./pages/settings/SecurityPage'));
const PeoplePage = lazy(() => import('./pages/settings/PeoplePage'));
const BackupsPage = lazy(() => import('./pages/settings/BackupsPage'));
const IdeasPage = lazy(() => import('./pages/settings/IdeasPage'));
+const CategoriesPage = lazy(() => import('./pages/settings/CategoriesPage'));
+const RulesPage = lazy(() => import('./pages/settings/RulesPage'));
+const TagsPage = lazy(() => import('./pages/settings/TagsPage'));
+const AlertsPage = lazy(() => import('./pages/settings/AlertsPage'));
+const ExportPage = lazy(() => import('./pages/settings/ExportPage'));
+const AiPage = lazy(() => import('./pages/settings/AiPage'));
const AboutPage = lazy(() => import('./pages/settings/AboutPage'));
function AdminOnly({ children }: { children: ReactNode }) {
@@ -48,9 +56,9 @@ export const router = createBrowserRouter([
{ path: 'review', element: },
{ path: 'activity', element: },
{ path: 'import', element: },
- { path: 'budget', element: },
- { path: 'subscriptions', element: },
- { path: 'insights', element: },
+ { path: 'budget', element: },
+ { path: 'subscriptions', element: },
+ { path: 'insights', element: },
{ path: 'accounts', element: },
{ path: 'more', element: },
{
@@ -60,6 +68,12 @@ export const router = createBrowserRouter([
{ index: true, element: },
{ path: 'profile', element: },
{ path: 'security', element: },
+ { path: 'categories', element: },
+ { path: 'rules', element: },
+ { path: 'tags', element: },
+ { path: 'alerts', element: },
+ { path: 'export', element: },
+ { path: 'ai', element: },
{ path: 'people', element: },
{ path: 'backups', element: },
{ path: 'ideas', element: },