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: },