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 ``; + }) + .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} +
+ +
+ ); +} 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 (