Milestone 2: the full picture (20261007.12)

Budgets: one amount per category for every month with any month set on its
own, filled from recent spending in one step and copied into the next year.
Home shows what's left to spend, year pacing, insights, balances and upcoming
charges. Insights covers year pacing, money in and out, budget against actual,
categories by month, merchants, balances over time and tags.

Subscriptions and bills are found across every account (rhythm and price
matching plus a built-in catalog), confirmed with one tap, and watched for
price changes, missed charges, charges after cancelling, double billing,
renewals and trials. Alerts are worked out again whenever the data changes.

Also: category, rule, tag and alert settings; Excel and CSV export from a
small built-in writer; the AI module designed in and switched off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:59:17 -04:00
parent c3cf0f3eb8
commit 4ecd64132b
62 changed files with 9977 additions and 378 deletions

View File

@@ -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`.

View File

@@ -1 +1 @@
20261007.10
20261007.12

390
package-lock.json generated
View File

@@ -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"
},

View File

@@ -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`);

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1791404838766,
"tag": "0001_money_in",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1791417594406,
"tag": "0002_full_picture",
"breakpoints": true
}
]
}

65
server/src/ai/index.ts Normal file
View File

@@ -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<string | null>;
/** Write short insight cards from summary numbers (never raw transactions). */
summarize(input: { facts: string[] }): Promise<string[]>;
}
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<AiFeature, boolean>;
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;
}

View File

@@ -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', {

View File

@@ -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<number, BudgetLine>();
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<number, number[]> {
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<number, number[]>();
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, number>): 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<number>();
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;
}

View File

@@ -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',
};
}

View File

@@ -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;
}

39
server/src/dates.ts Normal file
View File

@@ -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')}`;
}

View File

@@ -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;

215
server/src/export/xlsx.ts Normal file
View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 `<c r="${ref}" s="${STYLE.header}" t="inlineStr"><is><t xml:space="preserve">${esc(String(value))}</t></is></c>`;
if (typeof value === 'number') return `<c r="${ref}"><v>${value}</v></c>`;
if (typeof value === 'object' && 'date' in value) return `<c r="${ref}" s="${STYLE.date}"><v>${serial(value.date)}</v></c>`;
if (typeof value === 'object' && 'money' in value) return `<c r="${ref}" s="${STYLE.money}"><v>${(value.money / 100).toFixed(2)}</v></c>`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${esc(String(value))}</t></is></c>`;
}
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) => `<col min="${i + 1}" max="${i + 1}" width="${c.width ?? 14}" customWidth="1"/>`).join('');
const header = `<row r="1">${sheet.columns.map((c, i) => cellXml(c.header, `${columnName(i)}1`, true)).join('')}</row>`;
const body = sheet.rows.map((row, r) => `<row r="${r + 2}">${row.map((v, i) => cellXml(v, `${columnName(i)}${r + 2}`)).join('')}</row>`).join('');
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
`<dimension ref="A1:${last}${height}"/>` +
'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' +
'<sheetFormatPr defaultRowHeight="15"/>' +
`<cols>${cols}</cols>` +
`<sheetData>${header}${body}</sheetData>` +
`<autoFilter ref="A1:${last}${height}"/>` +
'</worksheet>'
);
}
const STYLES =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<numFmts count="2"><numFmt numFmtId="164" formatCode="&quot;$&quot;#,##0.00;[Red]\\-&quot;$&quot;#,##0.00"/><numFmt numFmtId="165" formatCode="yyyy\\-mm\\-dd"/></numFmts>' +
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font><font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts>' +
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="4">' +
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
'<xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
'</cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
'</styleSheet>';
/** 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',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('') +
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
'</Types>',
],
[
'_rels/.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' +
'</Relationships>',
],
[
'docProps/app.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Spendlight</Application></Properties>',
],
[
'docProps/core.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
`<dc:creator>Spendlight</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString().slice(0, 19)}Z</dcterms:created>` +
'</cp:coreProperties>',
],
[
'xl/workbook.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
`<sheets>${names.map((n, i) => `<sheet name="${esc(n)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('')}</sheets>` +
`<definedNames>${sheets
.map((s, i) => {
const last = columnName(Math.max(0, s.columns.length - 1));
return `<definedName name="_xlnm._FilterDatabase" localSheetId="${i}" hidden="1">'${esc(names[i]!.replace(/'/g, "''"))}'!$A$1:$${last}$${s.rows.length + 1}</definedName>`;
})
.join('')}</definedNames>` +
'</workbook>',
],
[
'xl/_rels/workbook.xml.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('') +
`<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
'</Relationships>',
],
['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';
}

View File

@@ -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<string[]>(clean, { skipEmptyLines: 'greedy', delimitersToGuess: [',', '\t', ';', '|'] });
return result.data.map((row) => row.map((cell) => (cell ?? '').trim()));
}

View File

@@ -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<number>();
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 };

View File

@@ -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<Severity, number> = { 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<AppContext, string>();
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<number>`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 };
}

View File

@@ -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<number, number[]>;
unfiledOut: number[];
unfiledIn: number[];
}
export function categoryKinds(ctx: AppContext): Map<number, CategoryKind> {
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<number>(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<number>(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;
}

55
server/src/routes/ai.ts Normal file
View File

@@ -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<Env>({ 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;
}

139
server/src/routes/alerts.ts Normal file
View File

@@ -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<Env>({ 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;
}

View File

@@ -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<Env>({ 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<number, number>();
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;
}

View File

@@ -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<Env>({ 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<number>`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<number>`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;
}

265
server/src/routes/export.ts Normal file
View File

@@ -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 = <T,>(list: T[], size = 900) => Array.from({ length: Math.ceil(list.length / size) }, (_, i) => list.slice(i * size, i * size + size));
const parts = new Map<number, (typeof splits.$inferSelect)[]>();
const tagNames = new Map<number, string[]>();
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<Env>({ 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;
}

View File

@@ -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<Env>({ 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<string, { merchant: string; spent: number; count: number; lastDate: string; categoryId: number | null; top: number }>();
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<string, { tag: string; spent: number; income: number; count: number; byCategory: { categoryId: number | null; cents: number }[] }>();
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;
}

View File

@@ -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<Env>({ 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<SubscriptionRow> = { 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<string, unknown>)[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;
}

View File

@@ -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;
}

View File

@@ -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');

30
server/src/settings.ts Normal file
View File

@@ -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<T>(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);

View File

@@ -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> = {}): CatalogEntry => ({
key,
name,
pattern,
cadence: 'monthly',
kind: 'subscription',
manageUrl,
...extra,
});
const bill = (key: string, name: string, pattern: RegExp, manageUrl: string | null, extra: Partial<CatalogEntry> = {}): 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;
}

View File

@@ -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<Cadence, number> = { weekly: 7, monthly: 30.44, quarterly: 91.3, yearly: 365.25 };
const BAND: Record<Cadence, [number, number]> = { 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<Cadence, number> = { 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<number>();
// 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<number, { date: string; accountId: number }[]>();
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<string, SubscriptionRow[]>();
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<string, { key: string; catalog: CatalogEntry | undefined; charges: Charge[] }>();
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<SubscriptionRow> = {
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<number, number> {
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));
}

View File

@@ -45,6 +45,7 @@ export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transac
source: r.source,
bankCategory: r.bankCategory,
importId: r.importId,
subscriptionId: r.subscriptionId,
}));
}

View File

@@ -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<typeof TransactionQuery>;
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;
}

View File

@@ -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<string, unknown>) => (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('<sheet name="Subscriptions" sheetId="5" r:id="rId5"/>');
const sheet = files.get('xl/worksheets/sheet1.xml')!;
expect(sheet).toContain('<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>');
expect(sheet).toMatch(/<c r="F2" s="2"><v>-?\d+\.\d\d<\/v><\/c>/);
expect(sheet).toMatch(/<c r="A2" s="3"><v>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<string, string> {
const out = new Map<string, string>();
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;
}

View File

@@ -89,6 +89,7 @@ export class Client {
get = (path: string, headers?: Record<string, string>) => this.call('GET', path, undefined, headers);
post = (path: string, body: unknown = {}, headers?: Record<string, string>) => this.call('POST', path, body, headers);
patch = (path: string, body: unknown, headers?: Record<string, string>) => this.call('PATCH', path, body, headers);
put = (path: string, body: unknown, headers?: Record<string, string>) => this.call('PUT', path, body, headers);
del = (path: string, headers?: Record<string, string>) => this.call('DELETE', path, undefined, headers);
cookie(name: string) {

125
server/test/synthetic.ts Normal file
View File

@@ -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'
);
}

View File

@@ -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<Cadence, string> = {
weekly: 'Every week',
monthly: 'Every month',
quarterly: 'Every 3 months',
yearly: 'Every year',
};
export const CADENCE_SHORT: Record<Cadence, string> = { weekly: '/wk', monthly: '/mo', quarterly: '/qtr', yearly: '/yr' };
export const SUBSCRIPTION_STATUS_LABEL: Record<SubscriptionStatus, string> = {
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 }[];
}

View File

@@ -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"
},

View File

@@ -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 (
<div className={cn('group relative flex gap-3.5 rounded-2xl border bg-card p-4 shadow-soft', compact && 'p-3.5')}>
<span className={cn('flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', tone)}>
<Icon strokeWidth={2} />
</span>
<div className="min-w-0 flex-1 pr-6">
<p className="text-[14.5px] font-medium leading-snug">{alert.title}</p>
{alert.body ? <p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{alert.body}</p> : null}
{alert.link ? (
<Link to={alert.link} className="mt-1.5 inline-flex items-center gap-1 text-[13px] font-medium text-primary">
See {alert.link.startsWith('/activity') ? 'transactions' : 'details'} <ArrowRight className="size-3.5" />
</Link>
) : null}
</div>
<button
onClick={() => dismiss.mutate()}
className="absolute right-2.5 top-2.5 flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition hover:bg-muted hover:opacity-100"
aria-label="Dismiss"
>
<X className="size-4" />
</button>
</div>
);
}

View File

@@ -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<string, number | undefined> {
const { data: toReview } = useReviewCount();
const { data: alerts } = useAlertCounts();
return { '/review': toReview, '/subscriptions': alerts?.subscriptions, '/more': alerts?.subscriptions };
}
function Sidebar() {
const badges = useBadges();
return (
<aside className="fixed inset-y-0 left-0 z-40 hidden flex-col border-r bg-card/70 backdrop-blur-xl md:flex md:w-[84px] lg:w-64">
<Link to="/" className="flex h-16 items-center gap-2.5 px-6 md:justify-center lg:justify-start" aria-label="Spendlight home">
@@ -65,7 +71,7 @@ function Sidebar() {
</Link>
<nav className="flex flex-1 flex-col gap-1 px-3 py-2" aria-label="Main">
{NAV.map((item) => (
<SideLink key={item.to} item={item} badge={item.to === '/review' ? toReview : undefined} />
<SideLink key={item.to} item={item} badge={badges[item.to]} />
))}
</nav>
<div className="px-3 pb-4">
@@ -80,7 +86,7 @@ function Sidebar() {
function PhoneTabBar() {
const { pathname } = useLocation();
const { data: toReview } = useReviewCount();
const badges = useBadges();
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors';
return (
@@ -90,13 +96,16 @@ function PhoneTabBar() {
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => cn(tab, isActive && 'text-primary')}>
<span className="relative">
<Icon className="size-[22px]" strokeWidth={1.9} />
{to === '/review' ? <CountBadge count={toReview} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" /> : null}
<CountBadge count={badges[to]} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" />
</span>
{label}
</NavLink>
))}
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
<span className="relative">
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
<CountBadge count={badges['/more']} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" />
</span>
More
</NavLink>
</div>

View File

@@ -0,0 +1,108 @@
import { monthName, type BudgetScope } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { api, errorMessage } from '@/lib/api';
import { centsToInput, parseDollars } from '@/lib/money';
import { CategoryIcon } from './money';
import { Button } from './ui/button';
import { Dialog, DialogContent } from './ui/dialog';
import { Input } from './ui/form';
import { Segmented } from './ui/misc';
import type { Category } from '@spendlight/shared';
export interface EditTarget {
category: Category;
month: string;
/** The amount in effect for that month now. */
current: number;
/** Whether that category already has any budget this year. */
hasBudget: boolean;
}
/** Set one category's budget: just this month, from this month on, or every month. */
export function BudgetEditor({ target, onClose }: { target: EditTarget | null; onClose: () => void }) {
return (
<Dialog open={!!target} onOpenChange={(open) => !open && onClose()}>
{target ? <EditorBody key={`${target.category.id}-${target.month}`} target={target} onClose={onClose} /> : null}
</Dialog>
);
}
function EditorBody({ target, onClose }: { target: EditTarget; onClose: () => void }) {
const client = useQueryClient();
const year = Number(target.month.slice(0, 4));
const month = Number(target.month.slice(5, 7));
const [amount, setAmount] = useState(target.current ? centsToInput(target.current) : '');
const [scope, setScope] = useState<BudgetScope>(target.hasBudget ? (month === 12 ? 'month' : 'rest') : 'year');
const income = target.category.kind === 'income';
const save = useMutation({
mutationFn: () => {
const cents = parseDollars(amount);
if (cents === null) throw new Error('Please enter an amount like 250 or 249.99');
return api('/budgets', { method: 'PUT', body: { categoryId: target.category.id, year, month, amountCents: cents, scope } });
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['budgets'] });
void client.invalidateQueries({ queryKey: ['reports'] });
void client.invalidateQueries({ queryKey: ['alerts'] });
onClose();
},
onError: (error) => toast.error(errorMessage(error)),
});
const name = monthName(target.month);
const options: { value: BudgetScope; label: string }[] = [
{ value: 'month', label: `Just ${monthName(target.month, 'short')}` },
...(month < 12 ? [{ value: 'rest' as const, label: `${monthName(target.month, 'short')} – Dec` }] : []),
{ value: 'year', label: `All of ${year}` },
];
return (
<DialogContent title={`${target.category.name}`} description={income ? `How much you expect to receive in ${name}.` : `How much you plan to spend in ${name}.`}>
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
className="grid gap-5"
>
<div className="flex items-center gap-3">
<CategoryIcon icon={target.category.icon} color={target.category.color} size="lg" />
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-[18px] text-muted-foreground">$</span>
<Input
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="decimal"
placeholder="0"
autoFocus
aria-label="Monthly amount"
className="tabular h-14 pl-8 text-[24px] font-semibold sm:h-14 sm:text-[24px]"
/>
</div>
</div>
<div className="grid gap-2">
<span className="text-[13px] font-medium">Applies to</span>
<div className="overflow-x-auto">
<Segmented label="Which months" value={scope} onChange={setScope} options={options} />
</div>
<p className="text-[12.5px] text-muted-foreground">
{scope === 'month'
? `Only ${name} changes; every other month keeps its amount.`
: scope === 'rest'
? `${name} through December. Earlier months keep what they had.`
: `The same amount every month of ${year}. Set any single month differently later.`}
</p>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={save.isPending}>
Save budget
</Button>
</div>
</form>
</DialogContent>
);
}

View File

@@ -175,17 +175,19 @@ export function CategoryButton({
onPick,
className,
placeholder = 'Choose category',
noTransfers,
}: {
categoryId: number | null;
suggestions?: number[];
onPick: (category: Category) => void;
className?: string;
placeholder?: string;
noTransfers?: boolean;
}) {
const { byId } = useCategories();
const c = categoryId ? byId.get(categoryId) : undefined;
return (
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick}>
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick} noTransfers={noTransfers}>
<button
type="button"
className={cn(

View File

@@ -1,10 +1,11 @@
import { formatCents, type Transaction } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftRight, Plus, Split as SplitIcon, Trash2, X } from 'lucide-react';
import { ArrowLeftRight, ArrowRight, Plus, Repeat, Split as SplitIcon, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { api, errorMessage } from '@/lib/api';
import { MONEY_KEYS, useAccounts, useCategories, useTags } from '@/lib/data';
import { MONEY_KEYS, useAccounts, useCategories, useSubscriptions, useTags } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
import { CategoryButton } from './CategoryPicker';
@@ -191,6 +192,7 @@ export function TransactionSheet({
}, [transaction, startWithSplit]);
const account = useMemo(() => accounts.data?.find((a) => a.id === t?.accountId), [accounts.data, t]);
const subscriptions = useSubscriptions();
const save = useMutation({
mutationFn: (body: Record<string, unknown>) => api<{ transaction: Transaction }>(`/transactions/${t!.id}`, { method: 'PATCH', body }),
@@ -219,6 +221,7 @@ export function TransactionSheet({
if (!t) return null;
const transfer = t.transferPeer;
const subscription = t.subscriptionId ? subscriptions.data?.find((x) => x.id === t.subscriptionId && x.status !== 'suggested') : undefined;
return (
<>
@@ -255,6 +258,16 @@ export function TransactionSheet({
</p>
) : null}
{subscription ? (
<Link to={`/subscriptions?open=${subscription.id}`} onClick={onClose} className="flex items-center gap-2 rounded-xl border px-3.5 py-3 text-[14px] hover:bg-muted/60">
<Repeat className="size-4 text-muted-foreground" />
<span className="flex-1">
Part of your <span className="font-medium">{subscription.name}</span> {subscription.kind === 'bill' ? 'bill' : 'subscription'}
</span>
<ArrowRight className="size-4 text-muted-foreground" />
</Link>
) : null}
{t.isSplit ? (
<div className="grid gap-2">
<p className="text-[13px] font-medium">Split between</p>

View File

@@ -0,0 +1,79 @@
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
import type { ReactNode } from 'react';
import { budgetStatus, whole, type BudgetStatus } from '@/lib/money';
import { cn } from '@/lib/utils';
// Progress bars and rings for "spent against budget". Status always comes
// with an icon and a word, never color alone.
export const STATUS_TONE: Record<BudgetStatus, { bar: string; text: string; soft: string }> = {
none: { bar: 'bg-primary/70', text: 'text-muted-foreground', soft: 'bg-muted' },
under: { bar: 'bg-good', text: 'text-good', soft: 'bg-good/12' },
close: { bar: 'bg-warn', text: 'text-warn', soft: 'bg-warn/14' },
over: { bar: 'bg-bad', text: 'text-bad', soft: 'bg-bad/12' },
};
export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) {
const status = budgetStatus(spent, budget);
// Without a budget, the bar compares categories with each other (scale is the largest).
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : scale ? Math.max(0, Math.min(100, (spent / scale) * 100)) : 0;
return (
<div className={cn('relative h-2 overflow-hidden rounded-full bg-muted', className)} role="presentation">
<div className={cn('h-full rounded-full transition-[width] duration-500 ease-out', STATUS_TONE[status].bar)} style={{ width: `${pct}%` }} />
{pace !== undefined && budget > 0 && pace > 0 && pace < 1 ? (
// Where spending "should" be by today, if it were spread evenly over the month.
<div className="absolute inset-y-0 w-0.5 bg-foreground/35" style={{ left: `${pace * 100}%` }} aria-hidden />
) : null}
</div>
);
}
/** "$42 left", "$18 over" or "no budget", with an icon. */
export function BudgetNote({ spent, budget, className, income }: { spent: number; budget: number; className?: string; income?: boolean }) {
if (budget <= 0) return <span className={cn('text-[12.5px] text-muted-foreground', className)}>No budget</span>;
if (income) {
const short = budget - spent;
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', short <= 0 ? 'text-good' : 'text-muted-foreground', className)}>
{short <= 0 ? <CircleCheck className="size-3.5" /> : null}
{short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`}
</span>
);
}
const status = budgetStatus(spent, budget);
const Icon = status === 'over' ? CircleAlert : status === 'close' ? TriangleAlert : CircleCheck;
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', STATUS_TONE[status].text, className)}>
<Icon className="size-3.5" />
{status === 'over' ? `${whole(spent - budget)} over` : `${whole(budget - spent)} left`}
</span>
);
}
/** A ring that fills as the month's budget is spent. */
export function Ring({ spent, budget, size = 168, stroke = 14, children, className }: { spent: number; budget: number; size?: number; stroke?: number; children?: ReactNode; className?: string }) {
const status = budgetStatus(spent, budget);
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const share = budget > 0 ? Math.min(1, spent / budget) : 0;
const color = { none: 'stroke-primary', under: 'stroke-good', close: 'stroke-warn', over: 'stroke-bad' }[status];
return (
<div className={cn('relative shrink-0', className)} style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-current opacity-15" />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - share)}
className={cn('transition-[stroke-dashoffset] duration-700 ease-out', color)}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">{children}</div>
</div>
);
}

View File

@@ -42,6 +42,38 @@ import {
Wifi,
Wrench,
Zap,
Apple,
Beer,
Bike,
BookOpen,
Briefcase,
Building2,
Bus,
Cake,
Camera,
Cat,
Church,
Clapperboard,
Dog,
Film,
Flower2,
Gamepad2,
GraduationCap,
Hammer,
HandCoins,
HeartPulse,
Hotel,
Music,
Package,
PiggyBank,
Pizza,
School,
Train,
TreePine,
Trophy,
Umbrella,
Wallet,
Wine,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -106,8 +138,43 @@ const ICONS: Record<string, LucideIcon> = {
scale: Scale,
'credit-card': CreditCard,
'arrow-left-right': ArrowLeftRight,
'apple': Apple,
'beer': Beer,
'bike': Bike,
'book-open': BookOpen,
'briefcase': Briefcase,
'building-2': Building2,
'bus': Bus,
'cake': Cake,
'camera': Camera,
'cat': Cat,
'church': Church,
'clapperboard': Clapperboard,
'dog': Dog,
'film': Film,
'flower-2': Flower2,
'gamepad-2': Gamepad2,
'graduation-cap': GraduationCap,
'hammer': Hammer,
'hand-coins': HandCoins,
'heart-pulse': HeartPulse,
'hotel': Hotel,
'music': Music,
'package': Package,
'piggy-bank': PiggyBank,
'pizza': Pizza,
'school': School,
'train': Train,
'tree-pine': TreePine,
'trophy': Trophy,
'umbrella': Umbrella,
'wallet': Wallet,
'wine': Wine,
};
/** Every icon a category can use, for the icon picker. */
export const ICON_NAMES = Object.keys(ICONS);
export function CategoryIcon({
icon,
color,

View File

@@ -1,4 +1,15 @@
import type { Account, Category, CategoryGroup } from '@spendlight/shared';
import type {
Account,
AlertItem,
BudgetYear,
Category,
CategoryGroup,
MonthsReport,
Overview,
Rule,
Subscription,
TagInfo,
} from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { api } from './api';
@@ -8,7 +19,8 @@ export function useAccounts() {
}
export function useCategories() {
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories'), staleTime: 5 * 60_000 });
// Archived categories are included so old transactions still show theirs; pickers leave them out.
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories?includeArchived=true'), staleTime: 5 * 60_000 });
const lookup = useMemo(() => {
const all: Category[] = query.data?.flatMap((g) => g.categories) ?? [];
return { all, byId: new Map(all.map((c) => [c.id, c])) };
@@ -26,11 +38,65 @@ export function useReviewCount() {
}
export function useTags() {
return useQuery({ queryKey: ['tags'], queryFn: () => api<{ name: string; count: number }[]>('/tags') });
return useQuery({ queryKey: ['tags'], queryFn: () => api<TagInfo[]>('/tags') });
}
export function useOverview(month?: string) {
return useQuery({ queryKey: ['reports', 'overview', month ?? 'auto'], queryFn: () => api<Overview>(`/reports/overview${month ? `?month=${month}` : ''}`) });
}
export function useMonthsReport(from: string, to: string, enabled = true) {
return useQuery({ queryKey: ['reports', 'months', from, to], queryFn: () => api<MonthsReport>(`/reports/months?from=${from}&to=${to}`), enabled });
}
export function useBudgets(year: number) {
return useQuery({ queryKey: ['budgets', year], queryFn: () => api<BudgetYear>(`/budgets?year=${year}`) });
}
export function useSubscriptions() {
return useQuery({ queryKey: ['subscriptions'], queryFn: () => api<Subscription[]>('/subscriptions') });
}
export interface Upcoming {
subscriptionId: number;
name: string;
kind: 'subscription' | 'bill';
date: string;
amountCents: number;
accountId: number | null;
trial: boolean;
}
export function useUpcoming(days = 30) {
return useQuery({ queryKey: ['subscriptions', 'upcoming', days], queryFn: () => api<Upcoming[]>(`/subscriptions/upcoming?days=${days}`) });
}
export function useAlerts(area?: 'money' | 'subscriptions', limit = 50) {
return useQuery({ queryKey: ['alerts', area ?? 'all', limit], queryFn: () => api<AlertItem[]>(`/alerts?limit=${limit}${area ? `&area=${area}` : ''}`) });
}
export function useAlertCounts() {
return useQuery({ queryKey: ['alerts', 'counts'], queryFn: () => api<{ money: number; subscriptions: number }>('/alerts/counts'), refetchOnWindowFocus: true });
}
export function useRules() {
return useQuery({ queryKey: ['rules'], queryFn: () => api<Rule[]>('/rules') });
}
/** Everything that changes when transactions change. */
export const MONEY_KEYS = [['transactions'], ['review'], ['review-count'], ['accounts'], ['imports'], ['tags']] as const;
export const MONEY_KEYS = [
['transactions'],
['review'],
['review-count'],
['accounts'],
['imports'],
['tags'],
['reports'],
['budgets'],
['subscriptions'],
['alerts'],
['rules'],
] as const;
/** Remember recent category picks on this device, for the top of the picker. */
const RECENT_KEY = 'sl-recent-categories';

54
web/src/lib/money.ts Normal file
View File

@@ -0,0 +1,54 @@
import { formatCents } from '@spendlight/shared';
/** "1,234.50", "$12", "12.5" → cents; null when it isn't a number. */
export function parseDollars(text: string): number | null {
const clean = text.replace(/[$,\s]/g, '');
if (!clean) return 0;
if (!/^\d*(\.\d{0,2})?$/.test(clean)) return null;
return Math.round(Number(clean) * 100);
}
/** Cents as a plain number for an input box: 12345 → "123.45", 10000 → "100". */
export const centsToInput = (cents: number) => (cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2));
export const whole = (cents: number) => formatCents(cents, { whole: true });
export const thisMonth = () => new Date().toLocaleDateString('en-CA').slice(0, 7);
export const isoDay = (d: Date) => d.toLocaleDateString('en-CA');
export function monthBounds(month: string): { from: string; to: string } {
const [y, m] = month.split('-').map(Number);
return { from: `${month}-01`, to: isoDay(new Date(y!, m!, 0)) };
}
export type BudgetStatus = 'none' | 'under' | 'close' | 'over';
export function budgetStatus(spent: number, budget: number, warnPct = 80): BudgetStatus {
if (budget <= 0) return 'none';
if (spent > budget) return 'over';
if (spent >= (budget * warnPct) / 100) return 'close';
return 'under';
}
/** The next date a charge is due on or after today, stepping forward from the last expected one. */
export function nextDue(date: string | null, cadence: 'weekly' | 'monthly' | 'quarterly' | 'yearly'): string | null {
if (!date) return null;
const today = isoDay(new Date());
const [y, m, d] = date.split('-').map(Number);
let step = 0;
let next = date;
while (next < today && step < 600) {
step++;
const dt =
cadence === 'weekly'
? new Date(y!, m! - 1, d! + 7 * step)
: (() => {
const months = (cadence === 'monthly' ? 1 : cadence === 'quarterly' ? 3 : 12) * step;
const last = new Date(y!, m! - 1 + months + 1, 0).getDate();
return new Date(y!, m! - 1 + months, Math.min(d!, last));
})();
next = isoDay(dt);
}
return next;
}

View File

@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/misc';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
@@ -155,16 +155,27 @@ function BalanceForm({ account, onDone }: { account: Account; onDone: () => void
/** A card's balance means nothing until someone has entered it once. */
const needsBalance = (a: Account) => isLiability(a.kind) && !a.balanceConfirmedOn;
/** Statements arrive monthly; after about 40 days without one, a nudge. */
function statementDue(a: Account): boolean {
if (a.status !== 'open' || !a.lastTransactionDate || a.kind === 'cash') return false;
const [y, m, d] = a.lastTransactionDate.split('-').map(Number);
return (Date.now() - new Date(y!, m! - 1, d!).getTime()) / 86_400_000 > 40;
}
function AccountRow({ account, onOpen }: { account: Account; onOpen: () => void }) {
const liability = isLiability(account.kind);
const missing = needsBalance(account);
const due = statementDue(account);
return (
<button onClick={onOpen} className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5">
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', PALETTE_CLASS[account.color].soft, PALETTE_CLASS[account.color].text)}>
{account.kind === 'credit_card' ? <CreditCard /> : account.kind === 'cash' ? <Wallet /> : <Landmark />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-medium">{account.name}</p>
<p className="flex items-center gap-2 truncate text-[15px] font-medium">
<span className="truncate">{account.name}</span>
{due ? <Badge tone="warn">Statement due</Badge> : null}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">
{[account.institution, account.last4 ? `••${account.last4}` : null].filter(Boolean).join(' ') || ACCOUNT_KIND_LABEL[account.kind]}
{account.lastImportAt ? ` · imported ${timeAgo(account.lastImportAt)}` : account.transactionCount === 0 ? ' · no statements yet' : ''}

View File

@@ -1,6 +1,6 @@
import { formatCents, type Transaction, type TransactionPage } from '@spendlight/shared';
import { formatCents, monthName, type Transaction, type TransactionPage } from '@spendlight/shared';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import { ArrowLeftRight, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
import { ArrowLeftRight, Download, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
import { useMemo, useState, type FormEvent } from 'react';
import { useSearchParams } from 'react-router';
import { toast } from 'sonner';
@@ -12,9 +12,11 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
import { Badge, Segmented, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts, useCategories } from '@/lib/data';
import { useAccounts, useCategories, useSubscriptions } from '@/lib/data';
import { monthBounds } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
@@ -164,29 +166,44 @@ export default function ActivityPage() {
const accountId = params.get('accountId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const tag = params.get('tag') ?? '';
const period = params.get('period') ?? 'all';
const subscriptionId = params.get('subscriptionId') ?? '';
const month = params.get('month') ?? '';
const customFrom = params.get('from') ?? '';
const customTo = params.get('to') ?? '';
// A month or date range from a link (Home, Budget, Insights) replaces the quick periods.
const custom = month ? monthBounds(month) : customFrom || customTo ? { from: customFrom, to: customTo } : null;
const customLabel = month
? monthName(month, 'long', true)
: custom
? `${custom.from ? formatDay(custom.from, true) : 'Start'} – ${custom.to ? formatDay(custom.to, true) : 'today'}${custom.from && custom.to && custom.from.slice(0, 4) === custom.to.slice(0, 4) ? ` ${custom.from.slice(0, 4)}` : ''}`
: '';
const period = custom ? 'custom' : (params.get('period') ?? 'all');
const q = params.get('q') ?? '';
const subscriptions = useSubscriptions();
const subscription = subscriptionId ? subscriptions.data?.find((x) => String(x.id) === subscriptionId) : undefined;
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value && value !== 'all') next.set(key, value);
else next.delete(key);
if (key === 'period') ['month', 'from', 'to'].forEach((k) => next.delete(k));
setParams(next, { replace: true });
setLimit(PAGE);
};
const query = useMemo(() => {
const filters = useMemo(() => {
const s = new URLSearchParams();
if (q) s.set('q', q);
if (accountId) s.set('accountId', accountId);
if (categoryId) s.set('categoryId', categoryId);
if (tag) s.set('tag', tag);
const r = periodRange(period);
if (subscriptionId) s.set('subscriptionId', subscriptionId);
const r = custom ?? periodRange(period);
if (r.from) s.set('from', r.from);
if (r.to) s.set('to', r.to);
s.set('limit', String(limit));
return s.toString();
}, [q, accountId, categoryId, tag, period, limit]);
}, [q, accountId, categoryId, tag, subscriptionId, period, custom?.from, custom?.to]);
const query = `${filters}${filters ? '&' : ''}limit=${limit}`;
const list = useQuery({
queryKey: ['transactions', query],
@@ -204,7 +221,7 @@ export default function ActivityPage() {
return out;
}, [list.data]);
const filterCount = [accountId, categoryId, tag].filter(Boolean).length;
const filterCount = [accountId, categoryId, tag, subscriptionId].filter(Boolean).length;
const category = categoryId && categoryId !== 'none' ? byId.get(Number(categoryId)) : undefined;
return (
@@ -214,11 +231,33 @@ export default function ActivityPage() {
titleOnPhone={false}
description="Every transaction from every account. Tap one to change its category, split it, tag it or add a note."
actions={
me.access === 'full' ? (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus /> Cash entry
</Button>
) : undefined
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Download /> <span className="hidden sm:inline">Export</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href={`/api/v1/export/transactions.xlsx?${filters}`} download>
Excel workbook
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/api/v1/export/transactions.csv?${filters}`} download>
CSV file
</a>
</DropdownMenuItem>
<p className="px-2.5 pb-1.5 pt-1 text-[12px] text-muted-foreground">Exactly what’s listed, with the same search and filters.</p>
</DropdownMenuContent>
</DropdownMenu>
{me.access === 'full' ? (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus /> Cash entry
</Button>
) : null}
</>
}
/>
@@ -253,18 +292,25 @@ export default function ActivityPage() {
['month', 'This month'],
['last', 'Last month'],
['year', 'This year'],
...(custom ? [['custom', customLabel]] : []),
].map(([value, label]) => (
<button
key={value}
onClick={() => setParam('period', value!)}
onClick={() => setParam('period', value === 'custom' ? 'all' : value!)}
className={cn(
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
period === value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{label}
{value === 'custom' ? <X className="-mr-1 ml-1 inline size-3.5" aria-label="Clear dates" /> : null}
</button>
))}
{subscriptionId ? (
<button onClick={() => setParam('subscriptionId', '')} className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-transparent bg-accent px-3.5 text-[13px] font-medium text-accent-foreground">
{subscription ? `${subscription.name} charges` : 'Subscription charges'} <X className="size-3.5" aria-label="Clear" />
</button>
) : null}
</div>
{showFilters ? (
<Card className="grid gap-3 p-4 sm:grid-cols-3">

View File

@@ -0,0 +1,466 @@
import { addMonths, monthName, type BudgetLine, type Category, type CategoryGroup } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CalendarRange, ChevronLeft, ChevronRight, Copy, Inbox, LayoutList, Sparkles, Table2, Wand2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { BudgetEditor, type EditTarget } from '@/components/BudgetEditor';
import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter';
import { CategoryIcon } from '@/components/money';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
import { Segmented, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useBudgets, useCategories, useMonthsReport, useOverview } from '@/lib/data';
import { budgetStatus, thisMonth, whole } from '@/lib/money';
import { useMediaQuery } from '@/lib/useMediaQuery';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
const effective = (line: BudgetLine | undefined, i: number) => (line ? (line.months[i] ?? line.monthly ?? 0) : 0);
const VIEW_KEY = 'sl-budget-view';
interface Row {
category: Category;
budget: number[];
actual: number[];
line: BudgetLine | undefined;
}
function useBudgetRows(year: number) {
const budgets = useBudgets(year);
const report = useMonthsReport(`${year}-01`, `${year}-12`);
const { data: groups } = useCategories();
const sections = useMemo(() => {
if (!groups || !budgets.data || !report.data) return null;
const lines = new Map(budgets.data.items.map((l) => [l.categoryId, l]));
const actuals = new Map(report.data.categories.map((c) => [c.categoryId, c.actual]));
const zero = Array(12).fill(0) as number[];
const build = (g: CategoryGroup) => {
const rows: Row[] = g.categories
.filter((c) => c.kind !== 'transfer')
.map((c) => ({ category: c, line: lines.get(c.id), budget: Array.from({ length: 12 }, (_, i) => effective(lines.get(c.id), i)), actual: actuals.get(c.id) ?? zero }))
.filter((r) => !r.category.archived || r.budget.some(Boolean) || r.actual.some(Boolean));
return { group: g, rows };
};
const all = groups.map(build).filter((s) => s.rows.length);
const kindOf = (s: (typeof all)[number]) => (s.rows.every((r) => r.category.kind === 'income') ? 'income' : 'expense');
return { expense: all.filter((s) => kindOf(s) === 'expense'), income: all.filter((s) => kindOf(s) === 'income') };
}, [groups, budgets.data, report.data]);
return { sections, report: report.data, budgets: budgets.data, loading: budgets.isPending || report.isPending };
}
const sum = (rows: Row[], key: 'budget' | 'actual', i: number) => rows.reduce((s, r) => s + r[key][i]!, 0);
function MonthSwitcher({ month, onChange }: { month: string; onChange: (m: string) => void }) {
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => onChange(addMonths(month, -1))} aria-label="Previous month">
<ChevronLeft />
</Button>
<span className="tabular min-w-36 text-center text-[16px] font-semibold">{monthName(month, 'long', true)}</span>
<Button variant="ghost" size="icon" onClick={() => onChange(addMonths(month, 1))} aria-label="Next month">
<ChevronRight />
</Button>
</div>
);
}
function Summary({ spend, spent, income, received }: { spend: number; spent: number; income: number; received: number }) {
const items = [
{ label: 'Planned spending', value: whole(spend), sub: spend ? `${whole(spent)} spent` : 'Not set yet' },
{ label: 'Planned income', value: whole(income), sub: income ? `${whole(received)} received` : 'Optional' },
{ label: 'Planned savings', value: whole(income - spend), sub: income ? `${Math.round(((income - spend) / income) * 100)}% of income` : 'Add income to see it' },
];
return (
<div className="mb-5 grid grid-cols-3 gap-2.5 sm:gap-4">
{items.map((it) => (
<Card key={it.label} className="p-3.5 sm:p-5">
<p className="text-[11.5px] font-medium text-muted-foreground sm:text-[12.5px]">{it.label}</p>
<p className="tabular mt-1 truncate text-[17px] font-semibold tracking-tight sm:text-[24px]">{it.value}</p>
<p className="mt-0.5 truncate text-[11.5px] text-muted-foreground sm:text-[12.5px]">{it.sub}</p>
</Card>
))}
</div>
);
}
function MonthList({ month, sections, canEdit, onEdit, unfiled, pace }: {
month: string;
sections: NonNullable<ReturnType<typeof useBudgetRows>['sections']>;
canEdit: boolean;
onEdit: (row: Row) => void;
unfiled: number;
pace?: number;
}) {
const i = Number(month.slice(5, 7)) - 1;
// Categories with neither a budget nor spending this month wait behind "show more",
// unless nothing is budgeted yet (then every category is there to set).
const [showAll, setShowAll] = useState(false);
const all = [...sections.expense, ...sections.income].flatMap((s) => s.rows);
const active = (r: Row) => r.budget[i]! > 0 || r.actual[i]! !== 0;
const hiddenCount = all.filter((r) => !active(r)).length;
const filtering = !showAll && all.some((r) => r.budget[i]! > 0) && hiddenCount > 0;
const block = (whole_: (typeof sections.expense)[number], income: boolean) => {
const s = { ...whole_, rows: filtering ? whole_.rows.filter(active) : whole_.rows };
if (!s.rows.length) return null;
const budget = sum(s.rows, 'budget', i);
const actual = sum(s.rows, 'actual', i);
return (
<section key={s.group.id}>
<div className="mb-1.5 flex items-baseline justify-between px-1">
<h3 className="text-[13px] font-semibold text-muted-foreground">{s.group.name}</h3>
<span className="tabular text-[12.5px] text-muted-foreground">
{whole(actual)} {budget ? `of ${whole(budget)}` : ''}
</span>
</div>
<Card className="divide-y overflow-hidden">
{s.rows.map((r) => (
<div key={r.category.id} className="flex items-center gap-3 px-4 py-3 sm:px-5">
<Link to={`/activity?categoryId=${r.category.id}&month=${month}`} className="flex min-w-0 flex-1 items-center gap-3">
<CategoryIcon icon={r.category.icon} color={r.category.color} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[14.5px] font-medium">{r.category.name}</span>
<span className="tabular shrink-0 text-[13.5px]">{whole(r.actual[i]!)}</span>
</div>
<ProgressBar spent={r.actual[i]!} budget={r.budget[i]!} pace={income ? undefined : pace} className="mt-1.5" />
<BudgetNote spent={r.actual[i]!} budget={r.budget[i]!} income={income} className="mt-1" />
</div>
</Link>
<button
disabled={!canEdit}
onClick={() => onEdit(r)}
className={cn(
'tabular h-10 min-w-20 shrink-0 rounded-xl border px-3 text-right text-[14px] font-medium transition-colors',
canEdit ? 'bg-card hover:border-primary/40 hover:bg-accent' : 'cursor-default',
!r.budget[i] && 'border-dashed text-muted-foreground',
)}
aria-label={`Budget for ${r.category.name}`}
>
{r.budget[i] ? whole(r.budget[i]!) : canEdit ? 'Set' : '—'}
</button>
</div>
))}
</Card>
</section>
);
};
return (
<div className="grid gap-5">
{unfiled > 0 ? (
<Link to="/review" className="flex items-center gap-3 rounded-2xl border border-dashed px-4 py-3 text-[13.5px] text-muted-foreground hover:bg-muted/60">
<Inbox className="size-4" />
<span className="flex-1">
<span className="font-medium text-foreground">{whole(unfiled)}</span> spent in {monthName(month)} isn’t filed in a category yet.
</span>
<span className="font-medium text-primary">Review</span>
</Link>
) : null}
{sections.expense.map((s) => block(s, false))}
{sections.income.length && sections.income.some((s) => !filtering || s.rows.some(active)) ? <h2 className="mt-2 px-1 text-[15px] font-semibold">Income</h2> : null}
{sections.income.map((s) => block(s, true))}
{hiddenCount && all.some((r) => r.budget[i]! > 0) ? (
<Button variant="ghost" className="justify-self-center text-muted-foreground" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Hide categories without a budget' : `Show ${hiddenCount} more categor${hiddenCount === 1 ? 'y' : 'ies'} without a budget`}
</Button>
) : null}
</div>
);
}
function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: {
year: number;
/** The last month the statements reach; later months have no actuals to show yet. */
through: string;
sections: NonNullable<ReturnType<typeof useBudgetRows>['sections']>;
canEdit: boolean;
onEdit: (row: Row, monthIndex: number) => void;
onMonth: (month: string) => void;
}) {
const months = Array.from({ length: 12 }, (_, i) => `${year}-${String(i + 1).padStart(2, '0')}`);
const now = thisMonth();
const cell = 'tabular px-2 py-2 text-right text-[13px]';
const groupYear = (rows: Row[]) => rows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0);
const groupRows = (s: (typeof sections.expense)[number], income: boolean) => [
<tr key={`g${s.group.id}`} className="bg-muted/50">
<th scope="rowgroup" className="sticky left-0 z-10 bg-muted px-4 py-2 text-left text-[12.5px] font-semibold text-muted-foreground">
{s.group.name}
</th>
{months.map((_, i) => (
<td key={i} className={cn(cell, 'text-[12px] font-medium text-muted-foreground')}>
{sum(s.rows, 'budget', i) ? whole(sum(s.rows, 'budget', i)) : ''}
</td>
))}
<td className={cn(cell, 'text-[12px] font-semibold text-muted-foreground')}>{groupYear(s.rows) ? whole(groupYear(s.rows)) : ''}</td>
</tr>,
...s.rows.map((r) => {
const yearBudget = r.budget.reduce((a, b) => a + b, 0);
const yearActual = r.actual.reduce((a, b) => a + b, 0);
return (
<tr key={r.category.id} className="border-t">
<th scope="row" className="sticky left-0 z-10 max-w-56 bg-card px-4 py-2 text-left font-normal">
<span className="flex items-center gap-2.5">
<CategoryIcon icon={r.category.icon} color={r.category.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
<span className="truncate text-[13.5px] font-medium">{r.category.name}</span>
</span>
</th>
{months.map((m, i) => {
const status = income ? 'none' : budgetStatus(r.actual[i]!, r.budget[i]!);
const past = m <= through;
const own = r.line?.months[i] != null;
return (
<td key={m} className="p-0.5">
<button
disabled={!canEdit}
onClick={() => onEdit(r, i)}
className={cn(
'flex w-full flex-col items-end rounded-lg px-2 py-1.5 transition-colors',
canEdit && 'hover:bg-accent',
past && status === 'over' && 'bg-bad/8',
)}
title={own ? 'Set for this month only' : undefined}
>
<span className={cn('tabular text-[13px] font-medium', !r.budget[i] && 'text-muted-foreground/50')}>
{r.budget[i] ? whole(r.budget[i]!) : '–'}
{own ? <span className="ml-0.5 align-super text-[9px] text-primary">●</span> : null}
</span>
{past && r.actual[i] ? <span className={cn('tabular text-[11px]', status === 'over' ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(r.actual[i]!)}</span> : null}
</button>
</td>
);
})}
<td className={cn(cell, 'border-l')}>
<div className="font-semibold">{yearBudget ? whole(yearBudget) : '–'}</div>
{yearActual ? <div className={cn('text-[11px]', !income && yearBudget && yearActual > yearBudget ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(yearActual)}</div> : null}
</td>
</tr>
);
}),
];
const expenseRows = sections.expense.flatMap((s) => s.rows);
return (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] border-collapse">
<thead>
<tr className="border-b">
<th className="sticky left-0 z-10 bg-card px-4 py-3 text-left text-[12.5px] font-semibold text-muted-foreground">Category</th>
{months.map((m) => (
<th key={m} className="px-2 py-3 text-right text-[12.5px] font-semibold">
<button onClick={() => onMonth(m)} className={cn('rounded-md px-1.5 py-0.5 hover:bg-muted', m === now && 'bg-accent text-accent-foreground')}>
{monthName(m, 'short')}
</button>
</th>
))}
<th className="border-l px-2 py-3 text-right text-[12.5px] font-semibold">Year</th>
</tr>
</thead>
<tbody>
{sections.expense.flatMap((s) => groupRows(s, false))}
<tr className="border-t-2 bg-muted/40">
<th scope="row" className="sticky left-0 z-10 bg-muted px-4 py-2.5 text-left text-[13px] font-semibold">
Total spending
</th>
{months.map((m, i) => (
<td key={m} className={cell}>
<div className="font-semibold">{whole(sum(expenseRows, 'budget', i))}</div>
{m <= through ? <div className="text-[11px] text-muted-foreground">{whole(sum(expenseRows, 'actual', i))}</div> : null}
</td>
))}
<td className={cn(cell, 'border-l font-semibold')}>{whole(expenseRows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0))}</td>
</tr>
{sections.income.flatMap((s) => groupRows(s, true))}
</tbody>
</table>
</div>
<p className="border-t px-4 py-2.5 text-[12px] text-muted-foreground">
Each cell shows the budget, with what was actually spent beneath it. <span className="text-primary">●</span> marks a month set on its own. Tap any amount to change it.
</p>
</Card>
);
}
export default function BudgetPage() {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const client = useQueryClient();
const wide = useMediaQuery('(min-width: 1024px)');
const overview = useOverview();
const [params, setParams] = useSearchParams();
const month = params.get('month') ?? overview.data?.month ?? thisMonth();
const setMonth = (m: string) => setParams((p) => (p.set('month', m), p), { replace: true });
const year = Number(month.slice(0, 4));
const i = Number(month.slice(5, 7)) - 1;
const [view, setViewState] = useState<'month' | 'year'>(() => {
try {
return (localStorage.getItem(VIEW_KEY) as 'month' | 'year' | null) ?? 'year';
} catch {
return 'year';
}
});
const setView = (v: 'month' | 'year') => {
setViewState(v);
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
/* fine */
}
};
const showYear = wide && view === 'year';
const { sections, report, budgets, loading } = useBudgetRows(year);
const previous = useBudgets(year - 1);
const [editing, setEditing] = useState<EditTarget | null>(null);
useEffect(() => {
if (!params.get('month') && overview.data) setParams((p) => (p.set('month', overview.data!.month), p), { replace: true });
}, [overview.data, params, setParams]);
const refresh = () => {
void client.invalidateQueries({ queryKey: ['budgets'] });
void client.invalidateQueries({ queryKey: ['reports'] });
void client.invalidateQueries({ queryKey: ['alerts'] });
};
const fill = useMutation({
mutationFn: (source: 'last_budget' | 'last_actual' | 'average3') => api<{ filled: number }>('/budgets/fill', { method: 'POST', body: { month, source } }),
onSuccess: ({ filled }) => {
refresh();
toast.success(filled ? `Filled in ${filled} categor${filled === 1 ? 'y' : 'ies'}` : 'Nothing to fill in', {
description: filled ? `As an amount for every month of ${year}. Categories you’d already set kept their amounts.` : 'Every category with recent spending already has a budget.',
});
},
onError: (e) => toast.error(errorMessage(e)),
});
const copy = useMutation({
mutationFn: () => api<{ copied: number }>('/budgets/copy-year', { method: 'POST', body: { from: year - 1, to: year } }),
onSuccess: ({ copied }) => {
refresh();
toast.success(`Copied ${copied} budget${copied === 1 ? '' : 's'} from ${year - 1}`);
},
onError: (e) => toast.error(errorMessage(e)),
});
const edit = (row: Row, monthIndex = i) =>
setEditing({
category: row.category,
month: `${year}-${String(monthIndex + 1).padStart(2, '0')}`,
current: row.budget[monthIndex]!,
hasBudget: !!row.line,
});
const empty = budgets && budgets.items.length === 0;
const canCopy = empty && (previous.data?.items.length ?? 0) > 0;
const offerNextYear = month.slice(5, 7) === '12' && !empty;
const expenseRows = sections?.expense.flatMap((s) => s.rows) ?? [];
const incomeRows = sections?.income.flatMap((s) => s.rows) ?? [];
const pace = month === thisMonth() ? new Date().getDate() / new Date(year, i + 1, 0).getDate() : undefined;
return (
<div>
<PageIntro
title="Budget"
titleOnPhone={false}
description="One amount per category for every month, with any month set differently when you need it. No rollover: each month starts fresh, and the year view keeps the running score."
actions={
canEdit ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Wand2 /> Fill in
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuItem onSelect={() => fill.mutate('average3')}>
<Sparkles /> From my 3-month average
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => fill.mutate('last_actual')}>
<CalendarRange /> From last month’s spending
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => fill.mutate('last_budget')}>
<Copy /> From last month’s budget
</DropdownMenuItem>
<p className="px-2.5 pb-1.5 pt-1 text-[12px] leading-snug text-muted-foreground">Fills only categories without a budget, rounded to friendly amounts.</p>
</DropdownMenuContent>
</DropdownMenu>
) : undefined
}
/>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
{showYear ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => setMonth(`${year - 1}-${month.slice(5)}`)} aria-label="Previous year">
<ChevronLeft />
</Button>
<span className="tabular min-w-20 text-center text-[16px] font-semibold">{year}</span>
<Button variant="ghost" size="icon" onClick={() => setMonth(`${year + 1}-${month.slice(5)}`)} aria-label="Next year">
<ChevronRight />
</Button>
</div>
) : (
<MonthSwitcher month={month} onChange={setMonth} />
)}
{wide ? (
<Segmented
label="View"
value={view}
onChange={setView}
options={[
{ value: 'month', label: 'Month', icon: <LayoutList /> },
{ value: 'year', label: 'Year', icon: <Table2 /> },
]}
/>
) : null}
</div>
{canEdit && canCopy ? (
<Card className="mb-5 flex flex-col gap-3 p-5 sm:flex-row sm:items-center">
<div className="flex-1">
<p className="text-[15px] font-semibold">Start {year} from {year - 1}’s budgets?</p>
<p className="text-[13.5px] text-muted-foreground">Every category and month comes across; change anything afterwards.</p>
</div>
<Button onClick={() => copy.mutate()} loading={copy.isPending}>
<Copy /> Copy {year - 1}
</Button>
</Card>
) : null}
{canEdit && offerNextYear ? (
<Card className="mb-5 flex flex-col gap-3 p-5 sm:flex-row sm:items-center">
<div className="flex-1">
<p className="text-[15px] font-semibold">Plan {year + 1}</p>
<p className="text-[13.5px] text-muted-foreground">Bring this year’s budgets into next year, then adjust.</p>
</div>
<Button variant="outline" onClick={() => setMonth(`${year + 1}-01`)}>
Go to {year + 1}
</Button>
</Card>
) : null}
{loading || !sections ? (
<Skeleton className="h-96 rounded-2xl" />
) : (
<>
{!showYear ? (
<Summary spend={sum(expenseRows, 'budget', i)} spent={sum(expenseRows, 'actual', i)} income={sum(incomeRows, 'budget', i)} received={sum(incomeRows, 'actual', i)} />
) : null}
{empty && canEdit && !canCopy ? (
<div className={cn('mb-5 flex items-start gap-3 rounded-2xl border px-4 py-3.5 text-[13.5px]', STATUS_TONE.none.soft)}>
<Wand2 className="mt-0.5 size-4 shrink-0 text-primary" />
<p>
<span className="font-medium">No budgets for {year} yet.</span> Use <span className="font-medium">Fill in</span> to start every category from your recent spending, or tap any amount to set it.
</p>
</div>
) : null}
{showYear ? (
<YearGrid year={year} sections={sections} canEdit={canEdit} onEdit={edit} onMonth={(m) => (setMonth(m), setView('month'))} through={report?.dataThrough?.slice(0, 7) ?? thisMonth()} />
) : (
<MonthList month={month} sections={sections} canEdit={canEdit} onEdit={(r) => edit(r)} unfiled={report?.unfiled.out[i] ?? 0} pace={pace} />
)}
</>
)}
<BudgetEditor target={editing} onClose={() => setEditing(null)} />
</div>
);
}

View File

@@ -1,12 +1,15 @@
import { formatCents, type TransactionPage } from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { ArrowRight, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Target, Upload } from 'lucide-react';
import type { ReactNode } from 'react';
import { daysInMonth, formatCents, LIABILITY_KINDS, monthName } from '@spendlight/shared';
import { ArrowRight, CalendarClock, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Sparkles, Target, TrendingDown, TrendingUp, Upload } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router';
import { AlertCard } from '@/components/AlertCard';
import { BudgetNote, ProgressBar, Ring } from '@/components/meter';
import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api } from '@/lib/api';
import { useAccounts, useReviewCount } from '@/lib/data';
import { useAccounts, useAlerts, useCategories, useOverview, useUpcoming } from '@/lib/data';
import { whole } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
import { cn, isIos, isStandalone } from '@/lib/utils';
@@ -15,36 +18,19 @@ function greeting(): string {
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}
function Step({
done,
icon,
title,
detail,
action,
later,
}: {
done?: boolean;
icon: ReactNode;
title: string;
detail: ReactNode;
action?: ReactNode;
later?: string;
}) {
function Step({ done, icon, title, detail, action }: { done?: boolean; icon: ReactNode; title: string; detail: ReactNode; action?: ReactNode }) {
return (
<li className="flex gap-4 py-4 first:pt-1 last:pb-1">
<span
className={cn(
'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
done ? 'bg-good/12 text-good' : later ? 'bg-muted text-muted-foreground' : 'bg-accent text-accent-foreground',
done ? 'bg-good/12 text-good' : 'bg-accent text-accent-foreground',
)}
>
{done ? <Check strokeWidth={2.5} /> : icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
{later ? <Badge>{later}</Badge> : null}
</div>
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
{!done ? <p className="mt-0.5 text-[13.5px] leading-relaxed text-muted-foreground">{detail}</p> : null}
</div>
{!done && action ? <div className="shrink-0 self-center">{action}</div> : null}
@@ -52,145 +38,327 @@ function Step({
);
}
const go = (to: string, label: string) => (
<Link to={to} className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
{label} <ArrowRight className="size-4" />
</Link>
);
const HIDE_KEY = 'sl-hide-getting-started';
const touch = () => window.matchMedia('(pointer: coarse)').matches;
function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }: { hasAccounts: boolean; hasMoney: boolean; toReview: number; hasBudgets: boolean; onHide?: () => void }) {
const me = useCurrentUser();
// Adding to the home screen is for phones and tablets.
const installed = isStandalone() || !touch();
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="grid gap-1">
<CardTitle>Getting started</CardTitle>
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
</div>
{onHide ? (
<Button variant="ghost" size="sm" onClick={onHide}>
Hide
</Button>
) : null}
</CardHeader>
<CardContent>
<ol className="divide-y">
<Step done={me.hasPasskey} icon={<ScanFace />} title="Turn on Face ID or fingerprint sign-in" detail="One glance instead of a password, if this device is ever signed out." action={go('/settings/security', 'Set up')} />
<Step
done={installed}
icon={isIos() ? <Share /> : <Smartphone />}
title="Add Spendlight to your home screen"
detail={isIos() ? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.' : 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'}
/>
<Step done={hasAccounts} icon={<Landmark />} title="Add your accounts" detail="Checking, savings and every card. Or skip ahead: importing a statement offers to create its account." action={go('/accounts', 'Add')} />
<Step done={hasMoney} icon={<Upload />} title="Drop in your statements" detail="Download a CSV from each bank and drop them in together. Spendlight recognizes the bank and skips duplicates." action={go('/import', 'Import')} />
<Step done={hasMoney && toReview === 0} icon={<Inbox />} title="Review what came in" detail="Confirm Spendlight’s guesses. It learns from every choice." action={toReview ? go('/review', 'Review') : undefined} />
<Step done={hasBudgets} icon={<Target />} title="Set your monthly budgets" detail="Start from your recent spending in one tap, then adjust." action={go('/budget', 'Budget')} />
</ol>
</CardContent>
</Card>
);
}
function Tile({ label, value, sub, to }: { label: string; value: string | null; sub?: ReactNode; to?: string }) {
const body = (
<>
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
{value !== null ? <p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[21px]">{value}</p> : <Skeleton className="mt-2 h-6 w-16 bg-white/10" />}
{sub ? <p className="mt-0.5 truncate text-[11.5px] text-white/50">{sub}</p> : null}
</>
);
const cls = 'min-w-0 rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur';
return to ? (
<Link to={to} className={cn(cls, 'transition-colors hover:bg-white/10')}>
{body}
</Link>
) : (
<div className={cls}>{body}</div>
);
}
export default function HomePage() {
const me = useCurrentUser();
const firstName = me.displayName.split(' ')[0];
const installed = isStandalone();
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toLocaleDateString('en-CA');
const monthName = now.toLocaleDateString(undefined, { month: 'long' });
const overview = useOverview();
const accounts = useAccounts();
const { data: toReview } = useReviewCount();
const everything = useQuery({ queryKey: ['transactions', 'home-any'], queryFn: () => api<TransactionPage>('/transactions?limit=1') });
// Statements usually arrive after a month ends, so until this month has
// transactions, Home shows the most recent month that does.
const latest = everything.data?.items[0]?.date;
const showStart = latest && latest < monthStart ? `${latest.slice(0, 7)}-01` : monthStart;
const showEnd = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)), 0).toLocaleDateString('en-CA');
const shownName = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)) - 1, 1).toLocaleDateString(undefined, { month: 'long' });
const month = useQuery({
queryKey: ['transactions', 'home-month', showStart],
queryFn: () => api<TransactionPage>(`/transactions?from=${showStart}&to=${showEnd}&limit=1`),
enabled: everything.isSuccess,
});
const alerts = useAlerts(undefined, 3);
const upcoming = useUpcoming(30);
const { byId } = useCategories();
const o = overview.data;
const hasAccounts = (accounts.data?.length ?? 0) > 0;
const hasMoney = (everything.data?.total ?? 0) > 0;
const hasMoney = !!o?.dataThrough;
const left = o ? o.budgetSpend - o.spent : 0;
const month = o ? monthName(o.month) : '';
const saved = o ? o.income - o.spent : 0;
const pace = o && o.isCurrent ? 1 - o.daysLeft / daysInMonth(o.month) : undefined;
const open = accounts.data?.filter((a) => a.status === 'open' && a.balanceConfirmedOn) ?? [];
const cash = open.filter((a) => !LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
const owed = open.filter((a) => LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
const yearGap = o ? o.year.budgetYear - o.year.projected : 0;
const [hidden, setHidden] = useState(() => {
try {
return localStorage.getItem(HIDE_KEY) === '1';
} catch {
return false;
}
});
const hide = () => {
setHidden(true);
try {
localStorage.setItem(HIDE_KEY, '1');
} catch {
/* fine */
}
};
const setupDone = hidden || (me.hasPasskey && (isStandalone() || !touch()) && hasAccounts && hasMoney && o?.toReview === 0 && o.hasBudgets);
let headline: ReactNode = null;
if (o && hasMoney) {
headline = o.hasBudgets ? (
<>
<p className="text-[13px] font-medium text-white/60">{left >= 0 ? `Left to spend in ${month}` : `Over budget in ${month}`}</p>
<p className={cn('tabular mt-1 text-[44px] font-semibold leading-none tracking-tight sm:text-[52px]', left < 0 && 'text-[#fca5a5]')}>{whole(Math.abs(left))}</p>
<p className="mt-3 text-[14px] text-white/70">
{whole(o.spent)} spent of {whole(o.budgetSpend)}
{o.isCurrent ? ` · ${o.daysLeft} day${o.daysLeft === 1 ? '' : 's'} to go` : ''}
</p>
{o.unbudgeted > 0 ? (
<Link to={`/budget?month=${o.month}`} className="mt-1 block text-[13px] text-white/55 underline-offset-2 hover:text-white/80 hover:underline">
Includes {whole(o.unbudgeted)} in categories without a budget
</Link>
) : null}
</>
) : (
<>
<p className="text-[13px] font-medium text-white/60">Spent in {month}</p>
<p className="tabular mt-1 text-[44px] font-semibold leading-none tracking-tight sm:text-[52px]">{whole(o.spent)}</p>
<p className="mt-3 max-w-md text-[14px] text-white/70">Set budgets and this becomes what’s left to spend, with the year’s pace below.</p>
</>
);
}
return (
<div className="grid gap-5 sm:gap-6">
<section className="relative overflow-hidden rounded-3xl bg-night px-6 dark:bg-card dark:ring-1 dark:ring-border py-8 text-white shadow-lift sm:px-9 sm:py-10">
<section className="relative overflow-hidden rounded-3xl bg-night px-6 py-7 text-white shadow-lift dark:bg-card dark:ring-1 dark:ring-border sm:px-9 sm:py-9">
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(251_191_36/0.45),rgb(249_115_98/0.18),transparent)] blur-2xl" aria-hidden />
<div className="relative">
<p className="text-[13px] font-medium text-white/60">{today}</p>
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">
{greeting()}, {firstName}.
</h2>
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
{hasMoney
? showStart === monthStart
? `Here’s ${monthName} so far. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
: `Here’s ${shownName}, your latest month in Spendlight. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
: 'Spendlight is ready for your accounts. Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.'}
<p className="text-[13px] font-medium text-white/60">
{greeting()}, {firstName}
{o && hasMoney && !o.isCurrent ? ` · Showing ${month}, your latest statements` : ''}
</p>
<div className="mt-7 grid max-w-xl grid-cols-3 gap-3">
{[
{ label: 'Spent', value: month.data ? formatCents(-month.data.sums.outCents, { whole: true }) : null },
{ label: 'Money in', value: month.data ? formatCents(month.data.sums.inCents, { whole: true }) : null },
{ label: 'To review', value: toReview !== undefined ? String(toReview) : null, to: '/review' },
].map(({ label, value, to }) => {
const body = (
<>
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
{value !== null && hasMoney ? (
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[22px]">{value}</p>
) : (
<Skeleton className="mt-2 h-6 w-16 bg-white/10" />
)}
</>
);
return to && hasMoney ? (
<Link key={label} to={to} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur transition-colors hover:bg-white/10">
{body}
</Link>
) : (
<div key={label} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur">
{body}
</div>
);
})}
</div>
{overview.isPending ? (
<Skeleton className="mt-4 h-24 w-64 bg-white/10" />
) : headline ? (
<div className="mt-4 flex items-center justify-between gap-6">
<div className="min-w-0">{headline}</div>
{o!.hasBudgets ? (
<Ring spent={o!.spent} budget={o!.budgetSpend} size={132} stroke={12} className="hidden text-white sm:block">
<span className="tabular text-[24px] font-semibold">{o!.budgetSpend ? Math.round((o!.spent / o!.budgetSpend) * 100) : 0}%</span>
<span className="text-[11.5px] text-white/60">of budget</span>
</Ring>
) : null}
</div>
) : (
<>
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">Welcome to Spendlight.</h2>
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.
</p>
</>
)}
{o && hasMoney && o.hasBudgets && o.year.budgetSoFar > 0 ? (
<Link to="/insights" className="mt-5 inline-flex items-center gap-2 rounded-full bg-white/8 px-3.5 py-1.5 text-[13px] font-medium text-white/85 ring-1 ring-white/10 hover:bg-white/12">
{yearGap >= 0 ? <TrendingDown className="size-4 text-[#86efac]" /> : <TrendingUp className="size-4 text-[#fca5a5]" />}
On pace to finish {o.year.year} {whole(Math.abs(yearGap))} {yearGap >= 0 ? 'under' : 'over'} budget
</Link>
) : null}
{hasMoney && o ? (
<div className="mt-6 grid max-w-2xl grid-cols-3 gap-3">
<Tile label="Money in" value={whole(o.income)} />
<Tile label="Money out" value={whole(o.spent)} />
<Tile
label={saved >= 0 ? 'Saved' : 'Overspent'}
value={whole(Math.abs(saved))}
sub={
o.income > 0 ? (
<>
{Math.round((saved / o.income) * 100)}%<span className="hidden sm:inline"> of income</span>
</>
) : undefined
}
/>
</div>
) : null}
</div>
</section>
<Card>
<CardHeader>
<CardTitle>Getting started</CardTitle>
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
</CardHeader>
<CardContent>
<ol className="divide-y">
<Step done icon={<Check />} title="Create your sign-in" detail="" />
<Step
done={me.hasPasskey}
icon={<ScanFace />}
title="Turn on Face ID or fingerprint sign-in"
detail="One glance instead of a password, if this device is ever signed out."
action={
<Link to="/settings/security" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Set up <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={installed}
icon={isIos() ? <Share /> : <Smartphone />}
title="Add Spendlight to your home screen"
detail={
isIos()
? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.'
: 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'
}
/>
<Step
done={hasAccounts}
icon={<Landmark />}
title="Add your accounts"
detail="Checking, savings and every card, each with its own color. Or skip ahead: importing a statement offers to create its account."
action={
<Link to="/accounts" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Add <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={hasMoney}
icon={<Upload />}
title="Drop in your statements"
detail="Download a CSV from each bank’s website and drop them in together. Spendlight recognizes the bank, skips duplicates and fills in categories."
action={
<Link to="/import" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Import <ArrowRight className="size-4" />
</Link>
}
/>
<Step
done={hasMoney && toReview === 0}
icon={<Inbox />}
title="Review what came in"
detail={toReview ? `${toReview} transaction${toReview === 1 ? '' : 's'} waiting, most already filled in. Usually a few taps.` : 'Confirm Spendlight’s guesses. It learns from every choice.'}
action={
toReview ? (
<Link to="/review" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
Review <ArrowRight className="size-4" />
{!hasMoney && o ? <GettingStarted hasAccounts={hasAccounts} hasMoney={hasMoney} toReview={o.toReview} hasBudgets={o.hasBudgets} /> : null}
{o && o.toReview > 0 ? (
<Link to="/review" className="flex items-center gap-4 rounded-2xl border border-primary/25 bg-accent px-5 py-4 text-accent-foreground shadow-soft transition-colors hover:bg-accent/80">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Inbox className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold">
{o.toReview} transaction{o.toReview === 1 ? '' : 's'} need{o.toReview === 1 ? 's' : ''} a category
</p>
<p className="text-[13px] opacity-80">Most are already filled in. A few taps and the numbers here are exact.</p>
</div>
<ArrowRight className="size-5 shrink-0" />
</Link>
) : null}
{hasMoney && o ? (
<div className="grid gap-5 lg:grid-cols-[1.25fr_1fr] lg:gap-6">
<div className="grid content-start gap-5 lg:gap-6">
<Card>
<CardHeader className="flex-row items-center justify-between">
<div className="grid gap-1">
<CardTitle>Where it went in {month}</CardTitle>
<CardDescription>{o.hasBudgets ? 'Each category against its budget.' : 'Your biggest categories this month.'}</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to="/budget">
Budget <ArrowRight />
</Link>
) : undefined
}
/>
<Step icon={<Target />} later="Coming in milestone 2" title="Set your monthly budgets" detail="Start from last month’s spending and adjust from there." />
</ol>
</CardContent>
</Card>
</Button>
</CardHeader>
<CardContent>
{o.categories.length ? (
<ul className="grid gap-4">
{o.categories.slice(0, 7).map((c) => {
const cat = byId.get(c.categoryId);
return (
<li key={c.categoryId}>
<Link to={`/activity?categoryId=${c.categoryId}&month=${o.month}`} className="flex items-center gap-3">
<CategoryIcon icon={cat?.icon} color={cat?.color} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-[14.5px] font-medium">{cat?.name}</span>
<span className="tabular shrink-0 text-[14px] font-medium">
{whole(c.spent)}
{c.budget > 0 ? <span className="font-normal text-muted-foreground"> / {whole(c.budget)}</span> : null}
</span>
</div>
<ProgressBar spent={c.spent} budget={c.budget} pace={pace} scale={o.categories[0]?.spent} className="mt-1.5" />
{c.budget > 0 ? <BudgetNote spent={c.spent} budget={c.budget} className="mt-1" /> : null}
</div>
</Link>
</li>
);
})}
</ul>
) : (
<p className="text-[14px] text-muted-foreground">Nothing spent yet this month.</p>
)}
</CardContent>
</Card>
</div>
<div className="grid content-start gap-5 lg:gap-6">
<section>
<div className="mb-2.5 flex items-center justify-between px-1">
<h2 className="flex items-center gap-2 text-[15px] font-semibold">
<Sparkles className="size-4 text-warn" /> Insights
</h2>
<Link to="/insights" className="text-[13px] font-medium text-primary">
See all
</Link>
</div>
<div className="grid gap-2.5">
{alerts.data?.length ? (
alerts.data.map((a) => <AlertCard key={a.id} alert={a} compact />)
) : (
<p className="rounded-2xl border border-dashed px-4 py-5 text-center text-[13.5px] text-muted-foreground">
Nothing needs your attention. Insights appear here as the months fill in.
</p>
)}
</div>
</section>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Balances</CardTitle>
<Link to="/accounts" className="text-[13px] font-medium text-primary">
Accounts
</Link>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-muted/60 p-3.5">
<p className="text-[12px] font-medium text-muted-foreground">Cash</p>
<p className="tabular mt-0.5 text-[19px] font-semibold">{formatCents(cash, { whole: true })}</p>
</div>
<div className="rounded-xl bg-muted/60 p-3.5">
<p className="text-[12px] font-medium text-muted-foreground">Owed on cards</p>
<p className="tabular mt-0.5 text-[19px] font-semibold">{formatCents(Math.abs(owed), { whole: true })}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CalendarClock className="size-4 text-muted-foreground" /> Coming up
</CardTitle>
<Link to="/subscriptions" className="text-[13px] font-medium text-primary">
Subscriptions
</Link>
</CardHeader>
<CardContent>
{upcoming.data?.length ? (
<ul className="grid gap-2.5">
{upcoming.data.slice(0, 5).map((u) => {
const account = accounts.data?.find((a) => a.id === u.accountId);
return (
<li key={`${u.subscriptionId}-${u.date}`} className="flex items-center gap-3 text-[14px]">
<span className="w-14 shrink-0 text-[12.5px] font-medium text-muted-foreground">{formatDay(u.date, true)}</span>
<span className="min-w-0 flex-1 truncate font-medium">
{u.name}
{u.trial ? <Badge tone="warn" className="ml-2">Trial ends</Badge> : null}
</span>
{account ? <AccountDot color={account.color} /> : null}
<span className="tabular shrink-0">{formatCents(u.amountCents)}</span>
</li>
);
})}
</ul>
) : (
<p className="text-[13.5px] text-muted-foreground">Confirm your subscriptions and their next charges show up here.</p>
)}
</CardContent>
</Card>
</div>
</div>
) : null}
{hasMoney && o && !setupDone ? <GettingStarted hasAccounts={hasAccounts} hasMoney={hasMoney} toReview={o.toReview} hasBudgets={o.hasBudgets} onHide={hide} /> : null}
</div>
);
}

View File

@@ -0,0 +1,613 @@
import { addMonths, formatCents, LIABILITY_KINDS, monthName, type BalanceSeries, type MerchantStat, type MonthsReport, type TagReport } from '@spendlight/shared';
import { useQuery } from '@tanstack/react-query';
import { ArrowRight, ChevronLeft, ChevronRight, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
import { useMemo, useState, type ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Area, Bar, CartesianGrid, ComposedChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { AlertCard } from '@/components/AlertCard';
import { BudgetNote, ProgressBar } from '@/components/meter';
import { AccountDot, CategoryIcon } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Segmented, Skeleton } from '@/components/ui/misc';
import { api } from '@/lib/api';
import { useAccounts, useAlerts, useCategories, useMonthsReport, useOverview } from '@/lib/data';
import { budgetStatus, monthBounds, whole } from '@/lib/money';
import { cn } from '@/lib/utils';
type View = 'overview' | 'budget' | 'categories' | 'merchants' | 'balances' | 'tags';
const VIEWS: { value: View; label: string }[] = [
{ value: 'overview', label: 'Overview' },
{ value: 'budget', label: 'Budget vs actual' },
{ value: 'categories', label: 'Categories' },
{ value: 'merchants', label: 'Merchants' },
{ value: 'balances', label: 'Balances' },
{ value: 'tags', label: 'Tags' },
];
const compact = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });
const axisMoney = (v: number) => compact.format(v / 100);
const tick = { fontSize: 12, fill: 'var(--muted-foreground)' };
function ChartTip({ active, payload, label, labeler }: { active?: boolean; payload?: { name: string; value: number; color: string; dataKey: string }[]; label?: string; labeler?: (l: string) => string }) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-xl border bg-popover px-3 py-2 text-[12.5px] shadow-lift">
<p className="mb-1 font-semibold">{labeler ? labeler(String(label)) : label}</p>
{payload
.filter((p) => p.value !== null && p.value !== undefined)
.map((p) => (
<p key={p.dataKey} className="tabular flex items-center gap-2">
<span className="size-2 rounded-full" style={{ background: p.color }} />
<span className="text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-medium">{formatCents(p.value, { whole: true })}</span>
</p>
))}
</div>
);
}
function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'warn' | 'neutral' }) {
const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles;
return (
<div className="mb-5 flex items-center gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', tone === 'good' ? 'bg-good/12 text-good' : tone === 'warn' ? 'bg-warn/14 text-warn' : 'bg-accent text-accent-foreground')}>
<Icon className="size-5" />
</span>
<p className="text-[16px] font-semibold tracking-tight sm:text-[17px]">{children}</p>
</div>
);
}
function YearSwitcher({ year, onChange }: { year: number; onChange: (y: number) => void }) {
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => onChange(year - 1)} aria-label="Previous year">
<ChevronLeft />
</Button>
<span className="tabular min-w-14 text-center text-[15px] font-semibold">{year}</span>
<Button variant="ghost" size="icon" onClick={() => onChange(year + 1)} aria-label="Next year">
<ChevronRight />
</Button>
</div>
);
}
/** Months of the year that have happened (by the statements' reach). */
const monthsDone = (report: MonthsReport) => {
const through = report.dataThrough?.slice(0, 7);
return report.months.filter((m) => through && m <= through).length;
};
function Overview({ report, year }: { report: MonthsReport; year: number }) {
const overview = useOverview();
const alerts = useAlerts('money');
const done = monthsDone(report);
const o = overview.data?.year.year === year ? overview.data : undefined;
const budgetYear = report.totals.budgetSpend.reduce((a, b) => a + b, 0);
const pacing = useMemo(() => {
let actual = 0;
let budget = 0;
const projectedEnd = o?.year.projected ?? 0;
return report.months.map((m, i) => {
budget += report.totals.budgetSpend[i]!;
if (i < done) actual += report.totals.spent[i]!;
const row: { month: string; actual: number | null; budget: number; projection: number | null } = { month: m, actual: i < done ? actual : null, budget, projection: null };
if (o && i >= done - 1 && done > 0) {
const start = report.totals.spent.slice(0, done).reduce((a, b) => a + b, 0);
row.projection = done >= 12 ? null : Math.round(start + ((projectedEnd - start) * (i - (done - 1))) / (12 - done));
}
return row;
});
}, [report, done, o]);
const flow = report.months.slice(0, Math.max(done, 1)).map((m, i) => ({ month: m, in: report.totals.income[i]!, out: report.totals.spent[i]!, net: report.totals.income[i]! - report.totals.spent[i]! }));
const totalIn = flow.reduce((s, f) => s + f.in, 0);
const totalOut = flow.reduce((s, f) => s + f.out, 0);
const gap = o ? o.year.budgetYear - o.year.projected : 0;
return (
<div className="grid gap-5">
{o && budgetYear > 0 ? (
<Headline tone={gap >= 0 ? 'good' : 'warn'}>
On pace to finish {year} {whole(Math.abs(gap))} {gap >= 0 ? 'under' : 'over'} budget
</Headline>
) : totalIn || totalOut ? (
<Headline tone={totalIn >= totalOut ? 'good' : 'warn'}>
{totalIn >= totalOut ? `Saved ${whole(totalIn - totalOut)} so far in ${year}` : `Spent ${whole(totalOut - totalIn)} more than came in so far in ${year}`}
</Headline>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Year pacing</CardTitle>
<CardDescription>Spending so far against the budget so far{o ? ', with where the year is heading' : ''}.</CardDescription>
</CardHeader>
<CardContent className="h-64 pl-1">
{budgetYear > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={pacing} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={48} />
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} />
<Area type="monotone" dataKey="budget" name="Budget" stroke="var(--muted-foreground)" strokeDasharray="4 4" fill="var(--muted)" fillOpacity={0.5} />
<Line type="monotone" dataKey="actual" name="Spent" stroke="var(--primary)" strokeWidth={2.5} dot={false} connectNulls={false} />
<Line type="monotone" dataKey="projection" name="Projected" stroke="var(--primary)" strokeWidth={2} strokeDasharray="2 5" dot={false} />
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyChart>Set budgets to see how the year is pacing.</EmptyChart>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Money in and out</CardTitle>
<CardDescription>
{totalIn > 0 ? `Saved ${Math.round(((totalIn - totalOut) / totalIn) * 100)}% of income so far in ${year}.` : 'Income and spending by month.'} Transfers between your accounts don’t count.
</CardDescription>
</CardHeader>
<CardContent className="h-64 pl-1">
{done ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={flow} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={48} />
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
<Bar dataKey="in" name="Money in" fill="var(--good)" radius={[6, 6, 0, 0]} maxBarSize={22} />
<Bar dataKey="out" name="Money out" fill="var(--primary)" radius={[6, 6, 0, 0]} maxBarSize={22} />
<Line type="monotone" dataKey="net" name="Saved" stroke="var(--warn)" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyChart>Import statements to see money in and out.</EmptyChart>
)}
</CardContent>
</Card>
</div>
<section>
<h2 className="mb-2.5 flex items-center gap-2 px-1 text-[15px] font-semibold">
<Sparkles className="size-4 text-warn" /> What stands out
</h2>
{alerts.data?.length ? (
<div className="grid gap-2.5 lg:grid-cols-2">
{alerts.data.map((a) => (
<AlertCard key={a.id} alert={a} compact />
))}
</div>
) : (
<p className="rounded-2xl border border-dashed px-4 py-5 text-center text-[13.5px] text-muted-foreground">Nothing unusual right now.</p>
)}
</section>
</div>
);
}
function EmptyChart({ children }: { children: ReactNode }) {
return <div className="flex h-full items-center justify-center rounded-xl bg-muted/50 px-6 text-center text-[13.5px] text-muted-foreground">{children}</div>;
}
function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }) {
const { byId } = useCategories();
const [params, setParams] = useSearchParams();
const done = Math.max(1, monthsDone(report));
const month = params.get('month')?.startsWith(String(year)) ? params.get('month')! : report.months[done - 1]!;
const i = report.months.indexOf(month);
const [span, setSpan] = useState<'month' | 'ytd'>('month');
const rows = report.categories
.filter((c) => byId.get(c.categoryId)?.kind === 'expense')
.map((c) => {
const spent = span === 'month' ? c.actual[i]! : c.actual.slice(0, i + 1).reduce((a, b) => a + b, 0);
const budget = span === 'month' ? c.budget[i]! : c.budget.slice(0, i + 1).reduce((a, b) => a + b, 0);
return { c, spent, budget, gap: spent - budget };
})
.filter((r) => r.spent || r.budget)
.sort((a, b) => b.gap - a.gap);
const totalSpent = rows.reduce((s, r) => s + r.spent, 0);
const totalBudget = rows.reduce((s, r) => s + r.budget, 0);
const label = span === 'month' ? monthName(month) : `${monthName(`${year}-01`, 'short')}–${monthName(month, 'short')} ${year}`;
const { from, to } = span === 'month' ? monthBounds(month) : { from: `${year}-01-01`, to: monthBounds(month).to };
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={() => setParams((p) => (p.set('month', addMonths(month, -1)), p), { replace: true })} disabled={i <= 0} aria-label="Previous month">
<ChevronLeft />
</Button>
<span className="min-w-32 text-center text-[15px] font-semibold">{monthName(month, 'long', true)}</span>
<Button variant="ghost" size="icon" onClick={() => setParams((p) => (p.set('month', addMonths(month, 1)), p), { replace: true })} disabled={i >= 11} aria-label="Next month">
<ChevronRight />
</Button>
</div>
<Segmented
label="Period"
value={span}
onChange={setSpan}
options={[
{ value: 'month', label: 'This month' },
{ value: 'ytd', label: 'Year to date' },
]}
/>
</div>
{totalBudget > 0 ? (
<Headline tone={totalSpent <= totalBudget ? 'good' : 'warn'}>
{label}: {whole(Math.abs(totalBudget - totalSpent))} {totalSpent <= totalBudget ? 'under' : 'over'} budget ({Math.round((totalSpent / totalBudget) * 100)}% used)
</Headline>
) : null}
{rows.length ? (
<Card className="divide-y overflow-hidden">
{rows.map(({ c, spent, budget, gap }) => {
const cat = byId.get(c.categoryId);
const pct = budget ? Math.round((gap / budget) * 100) : null;
return (
<Link key={c.categoryId} to={`/activity?categoryId=${c.categoryId}&from=${from}&to=${to}`} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/60 sm:px-5">
<CategoryIcon icon={cat?.icon} color={cat?.color} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[14.5px] font-medium">{cat?.name}</span>
<span className="tabular shrink-0 text-[13.5px]">
{whole(spent)} <span className="text-muted-foreground">/ {budget ? whole(budget) : 'no budget'}</span>
</span>
</div>
<ProgressBar spent={spent} budget={budget} className="mt-1.5" />
<div className="mt-1 flex justify-between">
<BudgetNote spent={spent} budget={budget} />
{pct !== null && budgetStatus(spent, budget) === 'over' ? <span className="tabular text-[12px] font-medium text-bad">+{pct}%</span> : null}
</div>
</div>
</Link>
);
})}
</Card>
) : (
<EmptyState icon={Sparkles} title="Nothing to compare yet" description="Set budgets and import statements, and each category shows how it did." />
)}
</div>
);
}
function Categories({ report, year }: { report: MonthsReport; year: number }) {
const { byId } = useCategories();
const [params, setParams] = useSearchParams();
const done = Math.max(1, monthsDone(report));
const rows = report.categories
.filter((c) => byId.get(c.categoryId)?.kind === 'expense' && c.actual.some(Boolean))
.sort((a, b) => b.actual.reduce((x, y) => x + y, 0) - a.actual.reduce((x, y) => x + y, 0));
const selected = Number(params.get('categoryId')) || rows[0]?.categoryId;
const current = rows.find((r) => r.categoryId === selected) ?? rows[0];
const cat = current ? byId.get(current.categoryId) : undefined;
const merchants = useQuery({
queryKey: ['reports', 'merchants', year, current?.categoryId],
queryFn: () => api<MerchantStat[]>(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&categoryId=${current!.categoryId}&limit=6`),
enabled: !!current,
});
if (!rows.length) return <EmptyState icon={Sparkles} title="No spending yet" description={`Import statements from ${year} and every category shows up here, month by month.`} />;
const total = current!.actual.reduce((a, b) => a + b, 0);
const average = Math.round(current!.actual.slice(0, done).reduce((a, b) => a + b, 0) / done);
const chart = report.months.map((m, i) => ({ month: m, spent: current!.actual[i]!, budget: current!.budget[i]! || null }));
return (
<div className="grid gap-5">
<Card>
<CardHeader className="flex-row items-start gap-3">
<CategoryIcon icon={cat?.icon} color={cat?.color} />
<div className="grid flex-1 gap-1">
<CardTitle>{cat?.name}</CardTitle>
<CardDescription>
{whole(total)} in {year} · about {whole(average)} a month
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to={`/activity?categoryId=${current!.categoryId}&from=${year}-01-01&to=${year}-12-31`}>
Transactions <ArrowRight />
</Link>
</Button>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chart} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={44} />
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
<Bar dataKey="spent" name="Spent" fill="var(--primary)" radius={[6, 6, 0, 0]} maxBarSize={26} />
<Line type="stepAfter" dataKey="budget" name="Budget" stroke="var(--warn)" strokeWidth={2} dot={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
<div>
<p className="mb-2 text-[13px] font-medium text-muted-foreground">Top merchants</p>
<ul className="grid gap-2">
{merchants.data?.map((m) => (
<li key={m.merchant} className="flex items-center justify-between gap-3 text-[14px]">
<Link to={`/activity?q=${encodeURIComponent(m.merchant)}&from=${year}-01-01&to=${year}-12-31`} className="truncate font-medium hover:underline">
{m.merchant}
</Link>
<span className="tabular shrink-0 text-muted-foreground">
{m.count}× · <span className="font-medium text-foreground">{whole(m.spent)}</span>
</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] border-collapse text-[13px]">
<thead>
<tr className="border-b">
<th className="sticky left-0 z-10 bg-card px-4 py-2.5 text-left font-semibold text-muted-foreground">Category</th>
{report.months.map((m) => (
<th key={m} className="px-2 py-2.5 text-right font-semibold">
{monthName(m, 'short')}
</th>
))}
<th className="border-l px-3 py-2.5 text-right font-semibold">Total</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const c = byId.get(r.categoryId);
return (
<tr key={r.categoryId} className={cn('border-t', r.categoryId === current!.categoryId && 'bg-accent/50')}>
<th scope="row" className="sticky left-0 z-10 bg-card px-4 py-1.5 text-left font-normal">
<button onClick={() => setParams((p) => (p.set('categoryId', String(r.categoryId)), p), { replace: true })} className="flex items-center gap-2 text-left">
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
<span className="truncate font-medium">{c?.name}</span>
</button>
</th>
{r.actual.map((v, i) => {
const over = r.budget[i]! > 0 && v > r.budget[i]!;
return (
<td key={i} className={cn('tabular px-2 py-1.5 text-right', over && 'bg-bad/8 font-medium text-bad', !v && 'text-muted-foreground/40')}>
{v ? whole(v) : '–'}
</td>
);
})}
<td className="tabular border-l px-3 py-1.5 text-right font-semibold">{whole(r.actual.reduce((a, b) => a + b, 0))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="border-t px-4 py-2.5 text-[12px] text-muted-foreground">Red cells ran over that month’s budget. Choose a category to see its trend and merchants.</p>
</Card>
</div>
);
}
function Merchants({ year }: { year: number }) {
const [sort, setSort] = useState<'spent' | 'count'>('spent');
const { byId } = useCategories();
const list = useQuery({ queryKey: ['reports', 'merchants', year, 'all'], queryFn: () => api<MerchantStat[]>(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&limit=100`) });
const rows = [...(list.data ?? [])].sort((a, b) => (sort === 'spent' ? b.spent - a.spent : b.count - a.count)).slice(0, 30);
const top = rows[0] ? (sort === 'spent' ? rows[0].spent : rows[0].count) : 1;
return (
<div>
<div className="mb-4 flex justify-end">
<Segmented
label="Sort"
value={sort}
onChange={setSort}
options={[
{ value: 'spent', label: 'By total' },
{ value: 'count', label: 'By how often' },
]}
/>
</div>
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
{rows.length ? (
<Card className="divide-y overflow-hidden">
{rows.map((m, i) => {
const c = m.categoryId ? byId.get(m.categoryId) : undefined;
const share = (sort === 'spent' ? m.spent : m.count) / top;
return (
<Link key={m.merchant} to={`/activity?q=${encodeURIComponent(m.merchant)}&from=${year}-01-01&to=${year}-12-31`} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/60 sm:px-5">
<span className="tabular w-5 shrink-0 text-right text-[12.5px] text-muted-foreground">{i + 1}</span>
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[14.5px] font-medium">{m.merchant}</span>
<span className="tabular shrink-0 text-[13.5px]">
<span className="text-muted-foreground">{m.count}× · </span>
{whole(m.spent)}
</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary/70" style={{ width: `${Math.max(2, share * 100)}%` }} />
</div>
</div>
</Link>
);
})}
</Card>
) : list.data ? (
<EmptyState icon={Sparkles} title="No spending yet" description={`Merchants from ${year} show up here.`} />
) : null}
</div>
);
}
function Balances() {
const accounts = useAccounts();
const series = useQuery({ queryKey: ['reports', 'balances'], queryFn: () => api<BalanceSeries>('/reports/balances?months=12') });
if (!series.data || !accounts.data) return <Skeleton className="h-72 rounded-2xl" />;
const kindOf = new Map(accounts.data.map((a) => [a.id, a.kind]));
const data = series.data.months.map((m, i) => {
let cash = 0;
let owed = 0;
let any = false;
for (const a of series.data.accounts) {
const v = a.balances[i];
if (v === null || v === undefined) continue;
any = true;
if (LIABILITY_KINDS.includes(kindOf.get(a.accountId)!)) owed += -v;
else cash += v;
}
return { month: m, cash: any ? cash : null, owed: any ? owed : null };
});
const known = accounts.data.filter((a) => a.balanceConfirmedOn);
const unknown = accounts.data.filter((a) => !a.balanceConfirmedOn && a.status === 'open');
return (
<div className="grid gap-5">
<Card>
<CardHeader>
<CardTitle>Cash and cards over 12 months</CardTitle>
<CardDescription>Month-end balances: cash in checking and savings, and what’s owed on cards.</CardDescription>
</CardHeader>
<CardContent className="h-64 pl-1">
{known.length ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={52} />
<Tooltip content={<ChartTip labeler={(m) => `End of ${monthName(m, 'long', true)}`} />} />
<Line type="monotone" dataKey="cash" name="Cash" stroke="var(--good)" strokeWidth={2.5} dot={false} />
<Line type="monotone" dataKey="owed" name="Owed on cards" stroke="var(--bad)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<EmptyChart>Add a balance to an account and its history appears here.</EmptyChart>
)}
</CardContent>
</Card>
<Card className="divide-y overflow-hidden">
{known.map((a) => (
<div key={a.id} className="flex items-center gap-3 px-5 py-3">
<AccountDot color={a.color} />
<span className="flex-1 truncate text-[14.5px] font-medium">{a.name}</span>
<span className="tabular text-[14.5px] font-medium">
{formatCents(LIABILITY_KINDS.includes(a.kind) ? -a.balanceCents : a.balanceCents)}
{LIABILITY_KINDS.includes(a.kind) ? <span className="ml-1 text-[12px] font-normal text-muted-foreground">owed</span> : null}
</span>
</div>
))}
{unknown.length ? (
<Link to="/accounts" className="block px-5 py-3 text-[13px] text-muted-foreground hover:bg-muted/60">
{unknown.map((a) => a.name).join(', ')} {unknown.length === 1 ? 'has' : 'have'} no balance yet. <span className="font-medium text-primary">Add one in Accounts</span>
</Link>
) : null}
</Card>
</div>
);
}
function Tags({ year }: { year: number }) {
const { byId } = useCategories();
const [span, setSpan] = useState<'year' | 'all'>('all');
const q = span === 'year' ? `?from=${year}-01-01&to=${year}-12-31` : '';
const list = useQuery({ queryKey: ['reports', 'tags', span, year], queryFn: () => api<TagReport[]>(`/reports/tags${q}`) });
return (
<div>
<div className="mb-4 flex justify-end">
<Segmented
label="Period"
value={span}
onChange={setSpan}
options={[
{ value: 'all', label: 'All time' },
{ value: 'year', label: String(year) },
]}
/>
</div>
{list.data?.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{list.data.map((t) => (
<Card key={t.tag}>
<CardHeader className="flex-row items-start justify-between">
<div className="grid gap-1">
<CardTitle>{t.tag}</CardTitle>
<CardDescription>
{t.count} transaction{t.count === 1 ? '' : 's'}
{t.income ? ` · ${whole(t.income)} came back` : ''}
</CardDescription>
</div>
<p className="tabular text-[20px] font-semibold">{whole(t.spent)}</p>
</CardHeader>
<CardContent className="grid gap-2">
{t.byCategory.slice(0, 5).map((b) => {
const c = b.categoryId ? byId.get(b.categoryId) : undefined;
return (
<div key={String(b.categoryId)} className="flex items-center gap-2.5 text-[13.5px]">
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
<span className="flex-1 truncate">{c?.name ?? 'Not filed yet'}</span>
<span className="tabular">{whole(b.cents)}</span>
</div>
);
})}
<Link to={`/activity?tag=${encodeURIComponent(t.tag)}`} className="mt-1 inline-flex items-center gap-1 text-[13px] font-medium text-primary">
See transactions <ArrowRight className="size-3.5" />
</Link>
</CardContent>
</Card>
))}
</div>
) : list.data ? (
<EmptyState icon={Sparkles} title="No tags yet" description="Tag transactions like “Vacation 2026” or “Reimbursable” in Activity, and each tag’s total shows up here." />
) : (
<Skeleton className="h-48 rounded-2xl" />
)}
</div>
);
}
export default function InsightsPage() {
const [params, setParams] = useSearchParams();
const overview = useOverview();
const view = (params.get('view') as View) || 'overview';
const focusYear = overview.data ? Number(overview.data.month.slice(0, 4)) : new Date().getFullYear();
const year = Number(params.get('year')) || focusYear;
const report = useMonthsReport(`${year}-01`, `${year}-12`, !!overview.data);
const set = (key: string, value: string) => setParams((p) => (p.set(key, value), p), { replace: true });
return (
<div>
<PageIntro title="Insights" titleOnPhone={false} description="Where the money goes over time, in plain words first. Tap any number to see the transactions behind it." />
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
{VIEWS.map((v) => (
<button
key={v.value}
onClick={() => set('view', v.value)}
className={cn(
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
view === v.value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{v.label}
</button>
))}
</div>
{view !== 'balances' ? <YearSwitcher year={year} onChange={(y) => set('year', String(y))} /> : null}
</div>
{view === 'balances' ? (
<Balances />
) : view === 'merchants' ? (
<Merchants year={year} />
) : view === 'tags' ? (
<Tags year={year} />
) : !report.data ? (
<Skeleton className="h-80 rounded-2xl" />
) : view === 'budget' ? (
<BudgetVsActual report={report.data} year={year} />
) : view === 'categories' ? (
<Categories report={report.data} year={year} />
) : (
<Overview report={report.data} year={year} />
)}
</div>
);
}

View File

@@ -2,12 +2,14 @@ import { ChevronRight, LogOut } from 'lucide-react';
import { Link } from 'react-router';
import { Wordmark, VersionTag } from '@/components/brand';
import { NAV, SETTINGS_NAV } from '@/components/nav';
import { useAlertCounts } from '@/lib/data';
import { useConfig, useSignOut } from '@/lib/session';
/** The phone's "More" tab: everything that doesn't fit on the bottom bar. */
export default function MorePage() {
const signOut = useSignOut();
const { data: config } = useConfig();
const { data: alerts } = useAlertCounts();
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
return (
@@ -19,6 +21,9 @@ export default function MorePage() {
<Icon className="size-[18px]" strokeWidth={1.9} />
</span>
<span className="flex-1 text-[15px] font-medium">{label}</span>
{to === '/subscriptions' && alerts?.subscriptions ? (
<span className="tabular flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground">{alerts.subscriptions}</span>
) : null}
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
))}

View File

@@ -0,0 +1,673 @@
import {
CADENCE_LABEL,
CADENCE_SHORT,
formatCents,
monthName,
SUBSCRIPTION_STATUS_LABEL,
type Cadence,
type Subscription,
type SubscriptionDetail,
type SubscriptionKind,
type SubscriptionStatus,
} from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bell, BellOff, CalendarClock, Check, ExternalLink, Merge, Plus, Repeat, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { AlertCard } from '@/components/AlertCard';
import { CategoryButton } from '@/components/CategoryPicker';
import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select, Textarea } from '@/components/ui/form';
import { Badge, Segmented, Skeleton, Switch } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useAccounts, useAlerts, useCategories, useSubscriptions, useUpcoming } from '@/lib/data';
import { centsToInput, nextDue, parseDollars } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
const LIVE: SubscriptionStatus[] = ['active', 'trial'];
const STATUS_TONE: Partial<Record<SubscriptionStatus, 'warn' | 'neutral' | 'bad' | 'primary'>> = { trial: 'warn', paused: 'neutral', cancelled: 'neutral', suggested: 'primary' };
function useRefresh() {
const client = useQueryClient();
return () => {
for (const key of ['subscriptions', 'alerts', 'transactions', 'reports', 'review', 'review-count']) void client.invalidateQueries({ queryKey: [key] });
};
}
function hostOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
function Found({ items }: { items: Subscription[] }) {
const refresh = useRefresh();
const accounts = useAccounts();
const me = useCurrentUser();
const answer = useMutation({
mutationFn: ({ id, status }: { id: number; status: 'active' | 'dismissed' }) => api(`/subscriptions/${id}`, { method: 'PATCH', body: { status } }),
onSuccess: (_, v) => {
refresh();
if (v.status === 'dismissed') toast('Got it, not a subscription', { description: 'Spendlight won’t ask about it again.' });
},
onError: (e) => toast.error(errorMessage(e)),
});
if (!items.length) return null;
return (
<Card className="mb-6 overflow-hidden border-primary/25">
<CardHeader>
<CardTitle>Found in your statements</CardTitle>
<p className="text-[13.5px] text-muted-foreground">Is each of these a regular charge? Your answer is remembered.</p>
</CardHeader>
<ul className="divide-y border-t">
{items.map((s) => {
const account = accounts.data?.find((a) => a.id === s.accountId);
return (
<li key={s.id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:px-6">
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold">
{s.name} <span className="tabular font-medium text-muted-foreground">· {formatCents(s.expectedCents)} {CADENCE_LABEL[s.cadence].toLowerCase()}</span>
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
{account ? <AccountDot color={account.color} /> : null}
{account?.name ?? 'Several accounts'} · {s.chargeCount} charge{s.chargeCount === 1 ? '' : 's'}
{s.firstChargeOn ? ` since ${monthName(s.firstChargeOn.slice(0, 7), 'short', true)}` : ''}
{s.kind === 'bill' ? ' · looks like a bill' : ''}
</p>
</div>
{me.access === 'full' ? (
<div className="flex gap-2">
<Button size="sm" onClick={() => answer.mutate({ id: s.id, status: 'active' })}>
<Check /> Yes{s.kind === 'bill' ? ', a bill' : ''}
</Button>
<Button size="sm" variant="outline" onClick={() => answer.mutate({ id: s.id, status: 'dismissed' })}>
<X /> No
</Button>
</div>
) : null}
</li>
);
})}
</ul>
</Card>
);
}
function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) {
const accounts = useAccounts();
const { byId } = useCategories();
const account = accounts.data?.find((a) => a.id === s.accountId);
const category = s.categoryId ? byId.get(s.categoryId) : undefined;
const live = LIVE.includes(s.status);
return (
<button onClick={onOpen} className={cn('flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5', !live && 'opacity-70')}>
{category ? (
<CategoryIcon icon={category.icon} color={category.color} />
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-[14px] font-semibold text-muted-foreground">{s.name[0]}</span>
)}
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-[15px] font-medium">
<span className="truncate">{s.name}</span>
{s.status !== 'active' ? <Badge tone={STATUS_TONE[s.status] ?? 'neutral'}>{SUBSCRIPTION_STATUS_LABEL[s.status]}</Badge> : null}
{!s.alertsOn ? <BellOff className="size-3.5 shrink-0 text-muted-foreground" aria-label="Alerts off" /> : null}
</p>
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
{account ? <AccountDot color={account.color} /> : null}
<span className="truncate">
{account?.name ?? 'No card yet'}
<span className="hidden sm:inline"> · {CADENCE_LABEL[s.cadence]}</span>
{s.status === 'active' && s.nextExpectedOn ? ` · next ${formatDay(nextDue(s.nextExpectedOn, s.cadence)!, true)}` : ''}
{s.status === 'trial' && s.trialEndsOn ? ` · trial ends ${formatDay(s.trialEndsOn, true)}` : ''}
</span>
</p>
</div>
<div className="shrink-0 text-right">
<p className="tabular text-[15px] font-medium">
{formatCents(s.expectedCents)}
<span className="text-[12px] font-normal text-muted-foreground">{CADENCE_SHORT[s.cadence]}</span>
</p>
{s.cadence !== 'monthly' ? <p className="tabular text-[11.5px] text-muted-foreground">{formatCents(s.monthlyCents)}/mo</p> : null}
</div>
</button>
);
}
function SubscriptionForm({ initial, onDone }: { initial?: SubscriptionDetail; onDone: (s: Subscription) => void }) {
const accounts = useAccounts();
const [name, setName] = useState(initial?.name ?? '');
const [amount, setAmount] = useState(initial ? centsToInput(initial.expectedCents) : '');
const [cadence, setCadence] = useState<Cadence>(initial?.cadence ?? 'monthly');
const [kind, setKind] = useState<SubscriptionKind>(initial?.kind ?? 'subscription');
const [accountId, setAccountId] = useState<number | ''>(initial?.accountId ?? '');
const [next, setNext] = useState(initial?.nextExpectedOn ?? '');
const [categoryId, setCategoryId] = useState<number | null>(initial?.categoryId ?? null);
const [manageUrl, setManageUrl] = useState(initial?.manageUrl ?? '');
const save = useMutation({
mutationFn: () => {
const cents = parseDollars(amount);
if (!cents) throw new Error('Please enter the amount, like 15.99');
const body = {
name,
kind,
cadence,
expectedCents: cents,
accountId: accountId || null,
nextExpectedOn: next || null,
manageUrl: manageUrl.trim() ? (/^https?:\/\//i.test(manageUrl.trim()) ? manageUrl.trim() : `https://${manageUrl.trim()}`) : null,
...(categoryId !== (initial?.categoryId ?? null) ? { categoryId } : {}),
};
return initial ? api<Subscription>(`/subscriptions/${initial.id}`, { method: 'PATCH', body }) : api<Subscription>('/subscriptions', { method: 'POST', body });
},
onSuccess: onDone,
onError: (e) => toast.error(errorMessage(e)),
});
return (
<form
className="grid gap-4"
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
>
<Segmented
label="Kind"
value={kind}
onChange={setKind}
options={[
{ value: 'subscription', label: 'Subscription' },
{ value: 'bill', label: 'Bill' },
]}
/>
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required maxLength={80} placeholder="Netflix" autoFocus={!initial} />}</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Amount">{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="15.99" required />}</Field>
<Field label="How often">
{(id) => (
<Select id={id} value={cadence} onChange={(e) => setCadence(e.target.value as Cadence)}>
{(Object.keys(CADENCE_LABEL) as Cadence[]).map((c) => (
<option key={c} value={c}>
{CADENCE_LABEL[c]}
</option>
))}
</Select>
)}
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Card or account">
{(id) => (
<Select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Not sure</option>
{accounts.data
?.filter((a) => a.status === 'open')
.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
)}
</Field>
<Field label="Next charge">{(id) => <Input id={id} type="date" value={next} onChange={(e) => setNext(e.target.value)} />}</Field>
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Category</span>
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} noTransfers />
{initial ? <p className="text-[12.5px] text-muted-foreground">Changing it also files every charge from this subscription there.</p> : null}
</div>
<Field label="Where to manage or cancel it" hint="The service’s account page, so it’s one tap away.">
{(id) => <Input id={id} value={manageUrl} onChange={(e) => setManageUrl(e.target.value)} placeholder="https://www.netflix.com/account" inputMode="url" />}
</Field>
<Button type="submit" loading={save.isPending}>
{initial ? 'Save' : 'Add'}
</Button>
</form>
);
}
function DetailSheet({ id, onClose, all }: { id: number | null; onClose: () => void; all: Subscription[] }) {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const refresh = useRefresh();
const accounts = useAccounts();
const { byId } = useCategories();
const detail = useQuery({ queryKey: ['subscriptions', 'detail', id], queryFn: () => api<SubscriptionDetail>(`/subscriptions/${id}`), enabled: id !== null });
const [editing, setEditing] = useState(false);
const [merging, setMerging] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [trialEnd, setTrialEnd] = useState('');
const [note, setNote] = useState<string | null>(null);
const s = detail.data;
useEffect(() => {
setEditing(false);
setMerging(false);
setNote(null);
}, [id]);
const patch = useMutation({
mutationFn: (body: Record<string, unknown>) => api<Subscription>(`/subscriptions/${id}`, { method: 'PATCH', body }),
onSuccess: () => refresh(),
onError: (e) => toast.error(errorMessage(e)),
});
const merge = useMutation({
mutationFn: (intoId: number) => api<Subscription>(`/subscriptions/${id}/merge`, { method: 'POST', body: { intoId } }),
onSuccess: (into) => {
refresh();
toast.success(`Merged into ${into.name}`);
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
});
const remove = useMutation({
mutationFn: () => api(`/subscriptions/${id}`, { method: 'DELETE' }),
onSuccess: () => {
refresh();
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
});
const setStatus = (status: SubscriptionStatus) => {
if (status === 'trial') {
if (!trialEnd) return toast.error('Choose the date the free trial ends');
patch.mutate({ status, trialEndsOn: trialEnd });
} else patch.mutate({ status });
};
return (
<Dialog open={id !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent title={s?.name ?? 'Subscription'} description={s ? `${s.kind === 'bill' ? 'Bill' : 'Subscription'} · ${CADENCE_LABEL[s.cadence]}` : undefined} className="sm:max-w-lg">
{!s ? (
<Skeleton className="h-64" />
) : editing ? (
<SubscriptionForm
initial={s}
onDone={() => {
refresh();
setEditing(false);
}}
/>
) : (
<div className="grid gap-5">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-muted/60 p-3.5">
<p className="text-[12px] font-medium text-muted-foreground">{s.kind === 'bill' ? 'Usually' : 'Costs'}</p>
<p className="tabular text-[20px] font-semibold">
{formatCents(s.expectedCents)}
<span className="text-[13px] font-normal text-muted-foreground">{CADENCE_SHORT[s.cadence]}</span>
</p>
<p className="tabular text-[12px] text-muted-foreground">{formatCents(s.monthlyCents * 12)} a year</p>
</div>
<div className="rounded-xl bg-muted/60 p-3.5">
<p className="text-[12px] font-medium text-muted-foreground">Next charge</p>
<p className="text-[16px] font-semibold">{s.nextExpectedOn && s.status === 'active' ? formatDay(nextDue(s.nextExpectedOn, s.cadence)!) : s.status === 'trial' && s.trialEndsOn ? `${formatDay(s.trialEndsOn)} (trial ends)` : '—'}</p>
<p className="flex items-center gap-1.5 truncate text-[12px] text-muted-foreground">
{(() => {
const a = accounts.data?.find((x) => x.id === s.accountId);
return a ? (
<>
<AccountDot color={a.color} /> {a.name}
</>
) : (
'Card not known yet'
);
})()}
</p>
</div>
</div>
{s.manageUrl ? (
<Button variant="outline" asChild>
<a href={s.manageUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink /> Manage or cancel at {hostOf(s.manageUrl)}
</a>
</Button>
) : null}
{canEdit ? (
<div className="grid gap-2">
<span className="text-[13px] font-medium">Status</span>
<div className="flex flex-wrap gap-1.5">
{(['active', 'trial', 'paused', 'cancelled'] as SubscriptionStatus[]).map((st) => (
<button
key={st}
onClick={() => setStatus(st)}
className={cn(
'h-9 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
s.status === st ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
)}
>
{SUBSCRIPTION_STATUS_LABEL[st]}
</button>
))}
</div>
{s.status !== 'trial' ? (
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
For a free trial, choose when it ends:
<Input type="date" value={trialEnd} onChange={(e) => setTrialEnd(e.target.value)} className="h-9 w-40 sm:h-9" aria-label="Trial ends" />
</div>
) : (
<p className="text-[12.5px] text-muted-foreground">Trial ends {s.trialEndsOn ? formatDay(s.trialEndsOn) : 'soon'}; you’ll get a heads-up 3 days before.</p>
)}
{s.status === 'cancelled' && s.cancelledOn ? (
<p className="text-[12.5px] text-muted-foreground">Marked cancelled on {formatDay(s.cancelledOn)}. Spendlight will speak up if it charges again.</p>
) : null}
</div>
) : null}
{s.priceHistory.length > 1 ? (
<div>
<p className="mb-1.5 text-[13px] font-medium">Price history</p>
<p className="text-[14px]">
{s.priceHistory.map((p, i) => (
<span key={p.from}>
{i === 0 ? '' : ', then '}
<span className="tabular font-medium">{formatCents(p.amountCents)}</span>
{i === 0 ? '' : ` from ${monthName(p.from.slice(0, 7))}`}
</span>
))}
</p>
</div>
) : null}
<div>
<div className="mb-1.5 flex items-center justify-between">
<p className="text-[13px] font-medium">Charges</p>
{s.chargeCount ? (
<Link to={`/activity?subscriptionId=${s.id}`} className="text-[12.5px] font-medium text-primary">
See in Activity
</Link>
) : null}
</div>
{s.charges.length ? (
<ul className="max-h-48 divide-y overflow-y-auto rounded-xl border">
{s.charges.slice(0, 24).map((c) => {
const a = accounts.data?.find((x) => x.id === c.accountId);
return (
<li key={c.id} className="flex items-center gap-3 px-3.5 py-2 text-[13.5px]">
<span className="w-24 text-muted-foreground">{formatDay(c.date, true)}</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-muted-foreground">
{a ? <AccountDot color={a.color} /> : null}
{a?.name}
</span>
<span className="tabular font-medium">{formatCents(-c.amountCents)}</span>
</li>
);
})}
</ul>
) : (
<p className="text-[13px] text-muted-foreground">No charges yet. They’ll attach themselves as statements come in.</p>
)}
</div>
{canEdit ? (
<>
<Field label="Note">
{(fid) => (
<Textarea
id={fid}
className="min-h-20"
value={note ?? s.note ?? ''}
onChange={(e) => setNote(e.target.value)}
onBlur={() => note !== null && note !== (s.note ?? '') && patch.mutate({ note: note || null })}
placeholder="Shared with the family plan, renews with the phone bill…"
maxLength={500}
/>
)}
</Field>
<label className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
<span className="flex items-center gap-2.5 text-[14px]">
<Bell className="size-4 text-muted-foreground" /> Alerts for this {s.kind}
</span>
<Switch checked={s.alertsOn} onCheckedChange={(alertsOn) => patch.mutate({ alertsOn })} />
</label>
{merging ? (
<Field label="Merge into" hint="For two entries that are really one service. Charges and names come along.">
{(fid) => (
<Select id={fid} defaultValue="" onChange={(e) => e.target.value && merge.mutate(Number(e.target.value))}>
<option value="">Choose…</option>
{all
.filter((o) => o.id !== s.id)
.map((o) => (
<option key={o.id} value={o.id}>
{o.name} · {formatCents(o.expectedCents)}
</option>
))}
</Select>
)}
</Field>
) : null}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
Edit details
</Button>
<Button variant="outline" size="sm" onClick={() => setMerging((m) => !m)}>
<Merge /> Merge
</Button>
{s.source === 'manual' ? (
<Button variant="ghost" size="sm" className="text-bad" onClick={() => setConfirmDelete(true)}>
<Trash2 /> Remove
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => {
patch.mutate({ status: 'dismissed' });
onClose();
}}
>
Not a subscription
</Button>
)}
</div>
</>
) : null}
{s.note && !canEdit ? <p className="text-[14px] text-muted-foreground">{s.note}</p> : null}
{s.categoryId && byId.get(s.categoryId) ? <p className="text-[12.5px] text-muted-foreground">Filed under {byId.get(s.categoryId)!.name}.</p> : null}
</div>
)}
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title={`Remove ${s?.name}?`}
description="It disappears from Subscriptions. Its charges stay in Activity."
confirmLabel="Remove"
destructive
loading={remove.isPending}
onConfirm={() => remove.mutate()}
/>
</DialogContent>
</Dialog>
);
}
export default function SubscriptionsPage() {
const me = useCurrentUser();
const client = useQueryClient();
const list = useSubscriptions();
const upcoming = useUpcoming(30);
const alerts = useAlerts('subscriptions');
const accounts = useAccounts();
const [params, setParams] = useSearchParams();
const [tab, setTab] = useState<SubscriptionKind>((params.get('tab') as SubscriptionKind) || 'subscription');
const [adding, setAdding] = useState(false);
const openId = params.get('open') ? Number(params.get('open')) : null;
const setOpen = (id: number | null) =>
setParams(
(p) => {
if (id === null) p.delete('open');
else p.set('open', String(id));
return p;
},
{ replace: true },
);
// Opening this screen counts as having seen its alerts, which clears the menu badge.
const read = useMutation({ mutationFn: () => api('/alerts/read', { method: 'POST', body: { area: 'subscriptions' } }), onSuccess: () => client.invalidateQueries({ queryKey: ['alerts', 'counts'] }) });
const readMutate = read.mutate;
useEffect(() => {
if (alerts.data?.some((a) => !a.read)) readMutate();
}, [alerts.data, readMutate]);
const all = list.data ?? [];
const found = all.filter((s) => s.status === 'suggested');
const mine = all.filter((s) => s.status !== 'suggested' && s.kind === tab);
const live = mine.filter((s) => LIVE.includes(s.status));
const stopped = mine.filter((s) => !LIVE.includes(s.status));
const monthly = live.filter((s) => s.status === 'active').reduce((sum, s) => sum + s.monthlyCents, 0);
const counts = useMemo(
() => ({ subscription: all.filter((s) => s.status !== 'suggested' && s.kind === 'subscription').length, bill: all.filter((s) => s.status !== 'suggested' && s.kind === 'bill').length }),
[all],
);
const shownAlerts = alerts.data?.filter((a) => a.kind !== 'sub_found') ?? [];
return (
<div>
<PageIntro
title="Subscriptions"
titleOnPhone={false}
description="Everything that charges you on a schedule, across every card and account. Spendlight finds them; you confirm, and it keeps watch."
actions={
me.access === 'full' ? (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus /> Add by hand
</Button>
) : undefined
}
/>
{shownAlerts.length ? (
<div className="mb-6 grid gap-2.5 lg:grid-cols-2">
{shownAlerts.map((a) => (
<AlertCard key={a.id} alert={a} compact />
))}
</div>
) : null}
<Found items={found} />
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<Segmented
label="Subscriptions or bills"
value={tab}
onChange={(v) => {
setTab(v);
setParams((p) => (p.set('tab', v), p), { replace: true });
}}
options={[
{ value: 'subscription', label: `Subscriptions${counts.subscription ? ` · ${counts.subscription}` : ''}` },
{ value: 'bill', label: `Bills${counts.bill ? ` · ${counts.bill}` : ''}` },
]}
/>
</div>
<div className="mb-5 grid grid-cols-3 gap-2.5 sm:gap-4">
{[
{ label: 'Each month', value: formatCents(monthly, { whole: monthly >= 100_000 }) },
{ label: 'Each year', value: formatCents(monthly * 12, { whole: true }) },
{ label: 'Active', value: String(live.filter((s) => s.status === 'active').length) },
].map((x) => (
<Card key={x.label} className="p-3.5 sm:p-5">
<p className="text-[11.5px] font-medium text-muted-foreground sm:text-[12.5px]">{x.label}</p>
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[24px]">{x.value}</p>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
<div className="grid content-start gap-5">
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
{list.data && !live.length && !stopped.length ? (
<EmptyState
icon={Repeat}
title={tab === 'bill' ? 'No bills yet' : 'No subscriptions yet'}
description={
found.length
? 'Answer the questions above and they’ll appear here.'
: tab === 'bill'
? 'Bills such as electric, insurance and the mortgage appear after two months of statements.'
: 'Import a few months of statements and Spendlight finds them. Known services show up from their first charge.'
}
/>
) : null}
{live.length ? (
<Card className="divide-y overflow-hidden">
{live.map((s) => (
<Row key={s.id} s={s} onOpen={() => setOpen(s.id)} />
))}
</Card>
) : null}
{stopped.length ? (
<section>
<h3 className="mb-1.5 px-1 text-[13px] font-semibold text-muted-foreground">Paused or cancelled</h3>
<Card className="divide-y overflow-hidden">
{stopped.map((s) => (
<Row key={s.id} s={s} onOpen={() => setOpen(s.id)} />
))}
</Card>
</section>
) : null}
</div>
<Card className="self-start">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="size-4 text-muted-foreground" /> Coming up in the next 30 days
</CardTitle>
</CardHeader>
<CardContent>
{upcoming.data?.length ? (
<ul className="grid gap-3">
{upcoming.data.map((u) => {
const a = accounts.data?.find((x) => x.id === u.accountId);
return (
<li key={`${u.subscriptionId}-${u.date}`}>
<button onClick={() => setOpen(u.subscriptionId)} className="flex w-full items-center gap-3 text-left text-[14px]">
<span className="w-16 shrink-0 text-[12.5px] font-medium text-muted-foreground">{formatDay(u.date, true)}</span>
<span className="min-w-0 flex-1 truncate font-medium">
{u.name}
{u.trial ? <Badge tone="warn" className="ml-2">Trial ends</Badge> : null}
</span>
{a ? <AccountDot color={a.color} /> : null}
<span className="tabular shrink-0">{formatCents(u.amountCents)}</span>
</button>
</li>
);
})}
<li className="flex justify-between border-t pt-3 text-[13.5px] font-medium">
<span>Total</span>
<span className="tabular">{formatCents(upcoming.data.reduce((s, u) => s + u.amountCents, 0))}</span>
</li>
</ul>
) : (
<p className="text-[13.5px] text-muted-foreground">Nothing expected in the next 30 days.</p>
)}
</CardContent>
</Card>
</div>
<DetailSheet id={openId} onClose={() => setOpen(null)} all={all.filter((s) => s.status !== 'suggested')} />
<Dialog open={adding} onOpenChange={setAdding}>
<DialogContent title="Add a subscription" description="Handy for yearly plans that haven’t charged yet. Charges attach themselves when they arrive.">
<SubscriptionForm
onDone={() => {
for (const key of ['subscriptions', 'alerts']) void client.invalidateQueries({ queryKey: [key] });
setAdding(false);
toast.success('Added');
}}
/>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,40 +0,0 @@
import { ChartColumnBig, Repeat, Target, type LucideIcon } from 'lucide-react';
import { EmptyState } from '@/components/page';
// Screens that arrive in later milestones, each explaining what it will do.
interface Upcoming {
icon: LucideIcon;
title: string;
description: string;
milestone: string;
}
const SCREENS: Record<string, Upcoming> = {
budget: {
icon: Target,
title: 'Monthly budgets',
description:
'Set a target for each category, copy last month in one tap, and see over or under for the month and the year at a glance.',
milestone: 'Milestone 2 · The full picture',
},
subscriptions: {
icon: Repeat,
title: 'Every subscription in one place',
description:
'Spendlight spots recurring charges across all your cards, totals them by month and year, and speaks up when a price changes or a cancelled service charges again.',
milestone: 'Milestone 2 · The full picture',
},
insights: {
icon: ChartColumnBig,
title: 'Where the money goes',
description:
'Month-by-month spending by category, budget against actual, year pacing, and money in against money out, each in plain words first.',
milestone: 'Milestone 2 · The full picture',
},
};
export default function UpcomingPage({ screen }: { screen: keyof typeof SCREENS }) {
const s = SCREENS[screen]!;
return <EmptyState icon={s.icon} title={s.title} description={s.description} milestone={s.milestone} />;
}

View File

@@ -0,0 +1,58 @@
import type { AiStatus } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { PageIntro } from '@/components/page';
import { Card } from '@/components/ui/card';
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
export default function AiPage() {
const client = useQueryClient();
const status = useQuery({ queryKey: ['ai'], queryFn: () => api<AiStatus>('/ai') });
const toggle = useMutation({
mutationFn: (body: { feature: string; enabled: boolean }) => api<AiStatus>('/ai', { method: 'PATCH', body }),
onSuccess: (next) => client.setQueryData(['ai'], next),
onError: (e) => toast.error(errorMessage(e)),
});
const s = status.data;
return (
<div>
<PageIntro title="AI features" description="Built into Spendlight’s design and switched off. Nothing here runs, and nothing is sent anywhere, until a provider is set up on the server and an admin turns a feature on." />
{!s ? (
<Skeleton className="h-64 rounded-2xl" />
) : (
<>
<div className="mb-5 flex items-start gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-good" />
<p className="text-[14px] leading-relaxed">
{s.providerConfigured ? (
<>
Provider set up on this server: <span className="font-medium">{s.provider}</span>. Each feature below sends only what it says, and only when it’s on.
</>
) : (
<>
<span className="font-medium">No AI provider is set up on this server,</span> so Spendlight makes no outside calls at all. Your financial data stays on this machine.
</>
)}
</p>
</div>
<Card className="divide-y overflow-hidden">
{s.features.map((f) => (
<label key={f.key} className="flex items-start gap-4 px-5 py-4">
<Sparkles className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-[15px] font-medium">
{f.label} {!s.providerConfigured ? <Badge>Off</Badge> : null}
</span>
<span className="mt-0.5 block text-[13px] leading-relaxed text-muted-foreground">{f.description}</span>
</span>
<Switch checked={f.enabled} disabled={!s.providerConfigured || toggle.isPending} onCheckedChange={(enabled) => toggle.mutate({ feature: f.key, enabled })} aria-label={f.label} />
</label>
))}
</Card>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import type { AlertSettings } from '@spendlight/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react';
import { toast } from 'sonner';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Field, Input } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { centsToInput, parseDollars } from '@/lib/money';
import { useCurrentUser } from '@/lib/session';
export default function AlertsPage() {
const me = useCurrentUser();
const client = useQueryClient();
const current = useQuery({ queryKey: ['settings', 'alerts'], queryFn: () => api<AlertSettings>('/settings/alerts') });
const [form, setForm] = useState({ pct: '', min: '', warn: '', unusual: '' });
useEffect(() => {
if (current.data) {
setForm({ pct: String(current.data.priceChangePct), min: centsToInput(current.data.priceChangeMinCents), warn: String(current.data.budgetWarnPct), unusual: String(current.data.unusualMultiple) });
}
}, [current.data]);
const save = useMutation({
mutationFn: () => {
const min = parseDollars(form.min);
if (min === null) throw new Error('Enter the price change in dollars, like 2');
return api<AlertSettings>('/settings/alerts', {
method: 'PUT',
body: { priceChangePct: Number(form.pct), priceChangeMinCents: min, budgetWarnPct: Number(form.warn), unusualMultiple: Number(form.unusual) },
});
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['settings', 'alerts'] });
void client.invalidateQueries({ queryKey: ['alerts'] });
toast.success('Saved');
},
onError: (e) => toast.error(errorMessage(e)),
});
const canEdit = me.access === 'full';
const set = (key: keyof typeof form) => (e: ChangeEvent<HTMLInputElement>) => setForm({ ...form, [key]: e.target.value });
return (
<div>
<PageIntro title="Alerts" description="When Spendlight speaks up. Alerts appear on Home, in Insights, and as a badge on Subscriptions; nothing leaves the app." />
{!current.data ? (
<Skeleton className="h-72 rounded-2xl" />
) : (
<Card>
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
>
<CardContent className="grid gap-5 pt-6 sm:pt-6">
<div>
<p className="mb-2 text-[14px] font-semibold">Subscription price changes</p>
<div className="grid grid-cols-2 gap-3">
<Field label="More than this percent">{(id) => <Input id={id} value={form.pct} onChange={set('pct')} inputMode="numeric" disabled={!canEdit} />}</Field>
<Field label="And more than this many dollars">{(id) => <Input id={id} value={form.min} onChange={set('min')} inputMode="decimal" disabled={!canEdit} />}</Field>
</div>
<p className="mt-1.5 text-[12.5px] text-muted-foreground">With 10% and $2, Netflix going from $15.49 to $17.99 raises an alert; a few cents of tax doesn’t.</p>
</div>
<Field label="Heads-up when a category reaches this share of its budget (%)" hint="While the month is still going.">
{(id) => <Input id={id} value={form.warn} onChange={set('warn')} inputMode="numeric" disabled={!canEdit} className="w-28" />}
</Field>
<Field label="A purchase is unusual at this many times the category’s typical purchase" hint="Only purchases over $100 are checked.">
{(id) => <Input id={id} value={form.unusual} onChange={set('unusual')} inputMode="numeric" disabled={!canEdit} className="w-28" />}
</Field>
<p className="text-[12.5px] text-muted-foreground">Any single subscription’s alerts can be switched off from its page in Subscriptions.</p>
</CardContent>
{canEdit ? (
<CardFooter className="justify-end">
<Button type="submit" loading={save.isPending}>
Save
</Button>
</CardFooter>
) : null}
</form>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,333 @@
import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type PaletteColor } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Archive, ArrowDown, ArrowUp, FolderPlus, Merge, Pencil, Plus, Trash2 } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money';
import { PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input, Select } from '@/components/ui/form';
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { MONEY_KEYS, useCategories } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
const KIND_LABEL: Record<CategoryKind, string> = { expense: 'Money out', income: 'Money in', transfer: 'Transfer' };
function useRefresh() {
const client = useQueryClient();
return () => {
void client.invalidateQueries({ queryKey: ['categories'] });
MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
};
}
function CategoryForm({ category, group, groups, onDone }: { category?: Category; group: CategoryGroup; groups: CategoryGroup[]; onDone: () => void }) {
const refresh = useRefresh();
const [name, setName] = useState(category?.name ?? '');
const [icon, setIcon] = useState(category?.icon ?? 'circle');
const [color, setColor] = useState<PaletteColor>(category?.color ?? 'indigo');
const [groupId, setGroupId] = useState(category?.groupId ?? group.id);
const [kind, setKind] = useState<CategoryKind>(category?.kind ?? group.categories[0]?.kind ?? 'expense');
const [archived, setArchived] = useState(category?.archived ?? false);
const [merging, setMerging] = useState(false);
const [into, setInto] = useState<number | ''>('');
const save = useMutation({
mutationFn: () =>
category
? api(`/categories/${category.id}`, { method: 'PATCH', body: { name, icon, color, groupId, kind, archived } })
: api('/categories', { method: 'POST', body: { name, icon, color, groupId, kind } }),
onSuccess: () => {
refresh();
toast.success(category ? 'Saved' : 'Category added');
onDone();
},
onError: (e) => toast.error(errorMessage(e)),
});
const merge = useMutation({
mutationFn: () => api<{ moved: number }>(`/categories/${category!.id}/merge`, { method: 'POST', body: { intoId: into } }),
onSuccess: ({ moved }) => {
refresh();
toast.success(`Merged ${category!.name}`, { description: `${moved} transaction${moved === 1 ? '' : 's'}, its budgets and rules moved across.` });
onDone();
},
onError: (e) => toast.error(errorMessage(e)),
});
const sameKind = groups.flatMap((g) => g.categories).filter((c) => c.kind === (category?.kind ?? kind) && c.id !== category?.id && !c.archived);
return (
<form
className="grid gap-4"
onSubmit={(e: FormEvent) => {
e.preventDefault();
save.mutate();
}}
>
<div className="flex items-center gap-3">
<CategoryIcon icon={icon} color={color} size="lg" />
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Category name" required maxLength={60} aria-label="Name" autoFocus={!category} />
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Color</span>
<div className="flex flex-wrap gap-2">
{PALETTE.map((p) => (
<button
key={p}
type="button"
onClick={() => setColor(p)}
className={cn('size-8 rounded-full ring-offset-2 ring-offset-popover transition', PALETTE_CLASS[p].dot, color === p && 'ring-2 ring-foreground')}
aria-label={p}
aria-pressed={color === p}
/>
))}
</div>
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Icon</span>
<div className="grid max-h-40 grid-cols-8 gap-1.5 overflow-y-auto rounded-xl border p-2 sm:grid-cols-10">
{ICON_NAMES.map((n) => (
<button key={n} type="button" onClick={() => setIcon(n)} className={cn('flex items-center justify-center rounded-lg p-0.5', icon === n && 'ring-2 ring-primary')} aria-label={n} aria-pressed={icon === n}>
<CategoryIcon icon={n} color={color} size="sm" />
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Group">
{(id) => (
<Select id={id} value={groupId} onChange={(e) => setGroupId(Number(e.target.value))}>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
)}
</Field>
<Field label="Counts as">
{(id) => (
<Select id={id} value={kind} onChange={(e) => setKind(e.target.value as CategoryKind)}>
<option value="expense">Money out</option>
<option value="income">Money in</option>
<option value="transfer">Transfer (not counted)</option>
</Select>
)}
</Field>
</div>
{category ? (
<label className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
<span className="grid">
<span className="flex items-center gap-2 text-[14px] font-medium">
<Archive className="size-4 text-muted-foreground" /> Archived
</span>
<span className="text-[12.5px] text-muted-foreground">Hidden from pickers; its history stays in every report.</span>
</span>
<Switch checked={archived} onCheckedChange={setArchived} />
</label>
) : null}
{category && merging ? (
<div className="grid gap-2 rounded-xl border border-bad/30 bg-bad/5 p-3.5">
<Field label={`Merge ${category.name} into`} hint="Its transactions, split parts, budgets, rules and subscriptions move there, then it’s deleted.">
{(id) => (
<Select id={id} value={into} onChange={(e) => setInto(e.target.value ? Number(e.target.value) : '')}>
<option value="">Choose a category…</option>
{sameKind.map((c) => (
<option key={c.id} value={c.id}>
{c.name} · {c.groupName}
</option>
))}
</Select>
)}
</Field>
<Button type="button" variant="destructive" disabled={!into} loading={merge.isPending} onClick={() => merge.mutate()}>
<Merge /> Merge and delete
</Button>
</div>
) : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
{category ? (
<Button type="button" variant="ghost" className="text-bad" onClick={() => setMerging((m) => !m)}>
<Trash2 /> Delete…
</Button>
) : (
<span />
)}
<Button type="submit" loading={save.isPending}>
{category ? 'Save' : 'Add category'}
</Button>
</div>
</form>
);
}
export default function CategoriesPage() {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const refresh = useRefresh();
const { data: groups } = useCategories();
const [editing, setEditing] = useState<{ category?: Category; group: CategoryGroup } | null>(null);
const [groupEdit, setGroupEdit] = useState<{ group?: CategoryGroup; name: string } | null>(null);
const [deleteGroup, setDeleteGroup] = useState<CategoryGroup | null>(null);
const [showArchived, setShowArchived] = useState(false);
const reorder = useMutation({
mutationFn: (next: CategoryGroup[]) => api('/categories/reorder', { method: 'POST', body: { groups: next.map((g) => ({ id: g.id, categoryIds: g.categories.map((c) => c.id) })) } }),
onSuccess: refresh,
onError: (e) => toast.error(errorMessage(e)),
});
const saveGroup = useMutation({
mutationFn: () => (groupEdit?.group ? api(`/category-groups/${groupEdit.group.id}`, { method: 'PATCH', body: { name: groupEdit.name } }) : api('/category-groups', { method: 'POST', body: { name: groupEdit!.name } })),
onSuccess: () => {
refresh();
setGroupEdit(null);
},
onError: (e) => toast.error(errorMessage(e)),
});
const removeGroup = useMutation({
mutationFn: (id: number) => api(`/category-groups/${id}`, { method: 'DELETE' }),
onSuccess: () => {
refresh();
setDeleteGroup(null);
},
onError: (e) => toast.error(errorMessage(e)),
});
const move = (gi: number, ci: number | null, delta: number) => {
if (!groups) return;
const next = groups.map((g) => ({ ...g, categories: [...g.categories] }));
if (ci === null) {
const j = gi + delta;
if (j < 0 || j >= next.length) return;
[next[gi], next[j]] = [next[j]!, next[gi]!];
} else {
const list = next[gi]!.categories;
const j = ci + delta;
if (j < 0 || j >= list.length) return;
[list[ci], list[j]] = [list[j]!, list[ci]!];
}
reorder.mutate(next);
};
return (
<div>
<PageIntro
title="Categories"
description="One list used everywhere. Rename a category here and every transaction, budget, rule and chart follows."
actions={
canEdit ? (
<Button variant="outline" onClick={() => setGroupEdit({ name: '' })}>
<FolderPlus /> Add group
</Button>
) : undefined
}
/>
<label className="mb-4 flex items-center gap-2.5 text-[13.5px] text-muted-foreground">
<Switch checked={showArchived} onCheckedChange={setShowArchived} /> Show archived categories
</label>
{!groups ? <Skeleton className="h-96 rounded-2xl" /> : null}
<div className="grid gap-5">
{groups?.map((g, gi) => {
const visible = g.categories.filter((c) => showArchived || !c.archived);
const kinds = new Set(g.categories.map((c) => c.kind));
return (
<section key={g.id}>
<div className="mb-1.5 flex items-center gap-1 px-1">
<h3 className="flex-1 text-[13px] font-semibold text-muted-foreground">
{g.name}
{kinds.size === 1 && [...kinds][0] !== 'expense' ? <span className="ml-2 font-normal">· {KIND_LABEL[[...kinds][0]!]}</span> : null}
</h3>
{canEdit ? (
<>
<Button variant="ghost" size="icon" className="size-8" onClick={() => move(gi, null, -1)} disabled={gi === 0} aria-label={`Move ${g.name} up`}>
<ArrowUp />
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => move(gi, null, 1)} disabled={gi === groups.length - 1} aria-label={`Move ${g.name} down`}>
<ArrowDown />
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => setGroupEdit({ group: g, name: g.name })} aria-label={`Rename ${g.name}`}>
<Pencil />
</Button>
{!g.categories.length ? (
<Button variant="ghost" size="icon" className="size-8 text-bad" onClick={() => setDeleteGroup(g)} aria-label={`Remove ${g.name}`}>
<Trash2 />
</Button>
) : null}
</>
) : null}
</div>
<Card className="divide-y overflow-hidden">
{visible.map((c) => {
const ci = g.categories.indexOf(c);
return (
<div key={c.id} className={cn('flex items-center gap-3 px-4 py-2.5 sm:px-5', c.archived && 'opacity-60')}>
<button disabled={!canEdit} onClick={() => setEditing({ category: c, group: g })} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
{kinds.size > 1 ? <Badge>{KIND_LABEL[c.kind]}</Badge> : null}
{c.archived ? <Badge>Archived</Badge> : null}
</button>
{canEdit ? (
<span className="flex shrink-0">
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => move(gi, ci, -1)} disabled={ci === 0} aria-label={`Move ${c.name} up`}>
<ArrowUp />
</Button>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => move(gi, ci, 1)} disabled={ci === g.categories.length - 1} aria-label={`Move ${c.name} down`}>
<ArrowDown />
</Button>
</span>
) : null}
</div>
);
})}
{canEdit ? (
<button onClick={() => setEditing({ group: g })} className="flex w-full items-center gap-3 px-4 py-2.5 text-[14px] font-medium text-primary hover:bg-muted/60 sm:px-5">
<Plus className="size-4" /> Add to {g.name}
</button>
) : null}
</Card>
</section>
);
})}
</div>
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
{editing && groups ? (
<DialogContent title={editing.category ? `Edit ${editing.category.name}` : `New category in ${editing.group.name}`} className="sm:max-w-lg">
<CategoryForm key={editing.category?.id ?? `new-${editing.group.id}`} category={editing.category} group={editing.group} groups={groups} onDone={() => setEditing(null)} />
</DialogContent>
) : null}
</Dialog>
<Dialog open={!!groupEdit} onOpenChange={(o) => !o && setGroupEdit(null)}>
{groupEdit ? (
<DialogContent title={groupEdit.group ? 'Rename group' : 'New group'} description="Groups add up the budgets of the categories inside them.">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
saveGroup.mutate();
}}
>
<Input value={groupEdit.name} onChange={(e) => setGroupEdit({ ...groupEdit, name: e.target.value })} required maxLength={60} autoFocus aria-label="Group name" placeholder="Side business" />
<Button type="submit" loading={saveGroup.isPending}>
Save
</Button>
</form>
</DialogContent>
) : null}
</Dialog>
<ConfirmDialog
open={!!deleteGroup}
onOpenChange={(o) => !o && setDeleteGroup(null)}
title={`Remove ${deleteGroup?.name}?`}
description="The group is empty, so nothing else changes."
confirmLabel="Remove"
destructive
loading={removeGroup.isPending}
onConfirm={() => deleteGroup && removeGroup.mutate(deleteGroup.id)}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { FileSpreadsheet, FileText, Sheet } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageIntro } from '@/components/page';
import { Card } from '@/components/ui/card';
function Download({ href, icon, title, detail }: { href: string; icon: ReactNode; title: string; detail: string }) {
return (
<a href={href} download className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/60">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-foreground [&_svg]:size-5">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-medium">{title}</span>
<span className="block text-[13px] text-muted-foreground">{detail}</span>
</span>
</a>
);
}
export default function ExportPage() {
const year = new Date().getFullYear();
return (
<div>
<PageIntro title="Export" description="Take everything with you, any time. Files download straight from this server; nothing is sent anywhere else." />
<Card className="divide-y overflow-hidden">
<Download href="/api/v1/export/everything.xlsx" icon={<FileSpreadsheet />} title="Everything, as Excel" detail="Transactions, budgets, categories, accounts, subscriptions and rules, one sheet each." />
<Download href={`/api/v1/export/transactions.xlsx?from=${year}-01-01&to=${year}-12-31`} icon={<Sheet />} title={`${year}’s transactions, as Excel`} detail="Handy for taxes. Split purchases come out one row per part." />
<Download href="/api/v1/export/transactions.csv" icon={<FileText />} title="All transactions, as CSV" detail="Opens in Excel, Numbers, Google Sheets or anything else." />
</Card>
<p className="mt-4 px-1 text-[13px] text-muted-foreground">To export just part of your history, search or filter in Activity and use its Export button.</p>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import type { Rule } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Trash2, Wand2 } from 'lucide-react';
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { CategoryButton } from '@/components/CategoryPicker';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Field, Input } from '@/components/ui/form';
import { Badge, Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useRules } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
import { timeAgo } from '@/lib/utils';
function RuleRow({ rule, canEdit }: { rule: Rule; canEdit: boolean }) {
const client = useQueryClient();
const [rename, setRename] = useState(rule.renameTo ?? '');
const refresh = () => client.invalidateQueries({ queryKey: ['rules'] });
const patch = useMutation({
mutationFn: (body: Record<string, unknown>) => api(`/rules/${rule.id}`, { method: 'PATCH', body }),
onSuccess: refresh,
onError: (e) => toast.error(errorMessage(e)),
});
const remove = useMutation({
mutationFn: () => api(`/rules/${rule.id}`, { method: 'DELETE' }),
onSuccess: () => {
void refresh();
toast(`Forgot the rule for ${rule.merchant}`);
},
});
return (
<div className="grid gap-2.5 px-4 py-3.5 sm:grid-cols-[1fr_auto] sm:items-center sm:px-5">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-[14.5px] font-medium">
<span className="truncate">{rule.merchant}</span>
<Badge tone={rule.source === 'manual' ? 'primary' : 'neutral'}>{rule.source === 'manual' ? 'Set by hand' : 'Learned'}</Badge>
</p>
<p className="text-[12.5px] text-muted-foreground">
Used {rule.hits} time{rule.hits === 1 ? '' : 's'} on import{rule.lastUsedAt ? `, last ${timeAgo(rule.lastUsedAt)}` : ''}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<CategoryButton categoryId={rule.categoryId} onPick={(c) => canEdit && patch.mutate({ categoryId: c.id })} placeholder="No category" className="w-48" />
<Input
value={rename}
disabled={!canEdit}
onChange={(e) => setRename(e.target.value)}
onBlur={() => rename !== (rule.renameTo ?? '') && patch.mutate({ renameTo: rename.trim() || null })}
placeholder="Keep its name"
aria-label={`New name for ${rule.merchant}`}
className="h-10 w-44 sm:h-10"
/>
{canEdit ? (
<Button variant="ghost" size="icon" onClick={() => remove.mutate()} aria-label={`Forget the rule for ${rule.merchant}`}>
<Trash2 />
</Button>
) : null}
</div>
</div>
);
}
export default function RulesPage() {
const me = useCurrentUser();
const canEdit = me.access === 'full';
const client = useQueryClient();
const rules = useRules();
const [q, setQ] = useState('');
const [adding, setAdding] = useState(false);
const [merchant, setMerchant] = useState('');
const [categoryId, setCategoryId] = useState<number | null>(null);
const add = useMutation({
mutationFn: () => {
if (!categoryId) throw new Error('Choose a category for it');
return api('/rules', { method: 'POST', body: { merchant, categoryId } });
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['rules'] });
setAdding(false);
setMerchant('');
setCategoryId(null);
},
onError: (e) => toast.error(errorMessage(e)),
});
const shown = rules.data?.filter((r) => r.merchant.toLowerCase().includes(q.toLowerCase()) || r.renameTo?.toLowerCase().includes(q.toLowerCase())) ?? [];
return (
<div>
<PageIntro
title="Rules"
description="Merchants Spendlight always files, or renames, the same way. Most are learned when you say “also file the others”; you never have to manage them."
actions={
canEdit ? (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus /> Add rule
</Button>
) : undefined
}
/>
{rules.data?.length ? (
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a merchant" className="pl-10" aria-label="Find a merchant" type="search" />
</div>
) : null}
{rules.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
{rules.data && !rules.data.length ? (
<EmptyState icon={Wand2} title="No rules yet" description="When you file a merchant and choose “also file the others”, Spendlight remembers it here." />
) : null}
{shown.length ? (
<Card className="divide-y overflow-hidden">
{shown.map((r) => (
<RuleRow key={r.id} rule={r} canEdit={canEdit} />
))}
</Card>
) : null}
<Dialog open={adding} onOpenChange={setAdding}>
<DialogContent title="Add a rule" description="Every future transaction from this merchant arrives filed in this category.">
<form
className="grid gap-4"
onSubmit={(e: FormEvent) => {
e.preventDefault();
add.mutate();
}}
>
<Field label="Merchant" hint="As it appears in Activity, such as “Kroger”.">
{(id) => <Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} required maxLength={80} autoFocus />}
</Field>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium">Category</span>
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} />
</div>
<Button type="submit" loading={add.isPending}>
Add rule
</Button>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { Archive, Info, Lightbulb, ShieldCheck, UserRound, Users } from 'lucide-react';
import { Archive, Bell, Download, Info, Lightbulb, Shapes, ShieldCheck, Sparkles, Tag, UserRound, Users, Wand2 } from 'lucide-react';
import { NavLink, Outlet } from 'react-router';
import { useConfig, useCurrentUser } from '@/lib/session';
import { cn } from '@/lib/utils';
@@ -9,8 +9,14 @@ export default function SettingsLayout() {
const sections = [
{ to: 'profile', label: 'Profile', icon: UserRound, show: true },
{ to: 'security', label: 'Sign-in & devices', icon: ShieldCheck, show: true },
{ to: 'categories', label: 'Categories', icon: Shapes, show: true },
{ to: 'rules', label: 'Rules', icon: Wand2, show: true },
{ to: 'tags', label: 'Tags', icon: Tag, show: true },
{ to: 'alerts', label: 'Alerts', icon: Bell, show: true },
{ to: 'export', label: 'Export', icon: Download, show: true },
{ to: 'people', label: 'People', icon: Users, show: me.isAdmin },
{ to: 'backups', label: 'Backups', icon: Archive, show: me.isAdmin },
{ to: 'ai', label: 'AI features', icon: Sparkles, show: me.isAdmin },
{ to: 'ideas', label: 'Ideas', icon: Lightbulb, show: me.isAdmin && !!config?.ideasEnabled },
{ to: 'about', label: 'About', icon: Info, show: true },
].filter((s) => s.show);

View File

@@ -0,0 +1,93 @@
import type { TagInfo } from '@spendlight/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowRight, Tag, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { EmptyState, PageIntro } from '@/components/page';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ConfirmDialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/misc';
import { api, errorMessage } from '@/lib/api';
import { useTags } from '@/lib/data';
import { useCurrentUser } from '@/lib/session';
function TagRow({ tag, canEdit, onDelete }: { tag: TagInfo; canEdit: boolean; onDelete: () => void }) {
const client = useQueryClient();
const [name, setName] = useState(tag.name);
const rename = useMutation({
mutationFn: () => api(`/tags/${tag.id}`, { method: 'PATCH', body: { name: name.trim() } }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['tags'] });
void client.invalidateQueries({ queryKey: ['transactions'] });
void client.invalidateQueries({ queryKey: ['reports'] });
},
onError: (e) => {
toast.error(errorMessage(e));
setName(tag.name);
},
});
return (
<div className="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<Tag className="size-4 shrink-0 text-muted-foreground" />
<Input
value={name}
disabled={!canEdit}
onChange={(e) => setName(e.target.value)}
onBlur={() => name.trim() && name.trim() !== tag.name && rename.mutate()}
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
aria-label={`Rename ${tag.name}`}
className="h-9 flex-1 border-transparent bg-transparent px-2 shadow-none hover:border-input sm:h-9"
/>
<Link to={`/activity?tag=${encodeURIComponent(tag.name)}`} className="flex shrink-0 items-center gap-1 text-[13px] text-muted-foreground hover:text-foreground">
{tag.count} <ArrowRight className="size-3.5" />
</Link>
{canEdit ? (
<Button variant="ghost" size="icon" className="size-9" onClick={onDelete} aria-label={`Remove ${tag.name}`}>
<Trash2 />
</Button>
) : null}
</div>
);
}
export default function TagsPage() {
const me = useCurrentUser();
const client = useQueryClient();
const tags = useTags();
const [removing, setRemoving] = useState<TagInfo | null>(null);
const remove = useMutation({
mutationFn: (id: number) => api(`/tags/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['tags'] });
void client.invalidateQueries({ queryKey: ['transactions'] });
setRemoving(null);
},
});
return (
<div>
<PageIntro title="Tags" description="Labels that cut across categories, like “Vacation 2026” or “Reimbursable”. Rename one to an existing tag to merge the two." />
{tags.isPending ? <Skeleton className="h-48 rounded-2xl" /> : null}
{tags.data && !tags.data.length ? <EmptyState icon={Tag} title="No tags yet" description="Add tags to any transaction in Activity; they appear here with their totals in Insights." /> : null}
{tags.data?.length ? (
<Card className="divide-y overflow-hidden">
{tags.data.map((t) => (
<TagRow key={`${t.id}-${t.name}`} tag={t} canEdit={me.access === 'full'} onDelete={() => setRemoving(t)} />
))}
</Card>
) : null}
<ConfirmDialog
open={!!removing}
onOpenChange={(o) => !o && setRemoving(null)}
title={`Remove the tag “${removing?.name}”?`}
description={`It comes off ${removing?.count ?? 0} transaction${removing?.count === 1 ? '' : 's'}. The transactions themselves stay.`}
confirmLabel="Remove tag"
destructive
loading={remove.isPending}
onConfirm={() => removing && remove.mutate(removing.id)}
/>
</div>
);
}

View File

@@ -9,11 +9,13 @@ import SignInPage from './pages/SignInPage';
// Each screen loads its own code, so a phone's first visit stays small.
const HomePage = lazy(() => import('./pages/HomePage'));
const UpcomingPage = lazy(() => import('./pages/Upcoming'));
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
const ImportPage = lazy(() => import('./pages/ImportPage'));
const BudgetPage = lazy(() => import('./pages/BudgetPage'));
const SubscriptionsPage = lazy(() => import('./pages/SubscriptionsPage'));
const InsightsPage = lazy(() => import('./pages/InsightsPage'));
const MorePage = lazy(() => import('./pages/MorePage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
@@ -22,6 +24,12 @@ const SecurityPage = lazy(() => import('./pages/settings/SecurityPage'));
const PeoplePage = lazy(() => import('./pages/settings/PeoplePage'));
const BackupsPage = lazy(() => import('./pages/settings/BackupsPage'));
const IdeasPage = lazy(() => import('./pages/settings/IdeasPage'));
const CategoriesPage = lazy(() => import('./pages/settings/CategoriesPage'));
const RulesPage = lazy(() => import('./pages/settings/RulesPage'));
const TagsPage = lazy(() => import('./pages/settings/TagsPage'));
const AlertsPage = lazy(() => import('./pages/settings/AlertsPage'));
const ExportPage = lazy(() => import('./pages/settings/ExportPage'));
const AiPage = lazy(() => import('./pages/settings/AiPage'));
const AboutPage = lazy(() => import('./pages/settings/AboutPage'));
function AdminOnly({ children }: { children: ReactNode }) {
@@ -48,9 +56,9 @@ export const router = createBrowserRouter([
{ path: 'review', element: <ReviewPage /> },
{ path: 'activity', element: <ActivityPage /> },
{ path: 'import', element: <ImportPage /> },
{ path: 'budget', element: <UpcomingPage screen="budget" /> },
{ path: 'subscriptions', element: <UpcomingPage screen="subscriptions" /> },
{ path: 'insights', element: <UpcomingPage screen="insights" /> },
{ path: 'budget', element: <BudgetPage /> },
{ path: 'subscriptions', element: <SubscriptionsPage /> },
{ path: 'insights', element: <InsightsPage /> },
{ path: 'accounts', element: <AccountsPage /> },
{ path: 'more', element: <MorePage /> },
{
@@ -60,6 +68,12 @@ export const router = createBrowserRouter([
{ index: true, element: <Navigate to="profile" replace /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'security', element: <SecurityPage /> },
{ path: 'categories', element: <CategoriesPage /> },
{ path: 'rules', element: <RulesPage /> },
{ path: 'tags', element: <TagsPage /> },
{ path: 'alerts', element: <AlertsPage /> },
{ path: 'export', element: <ExportPage /> },
{ path: 'ai', element: <AdminOnly><AiPage /></AdminOnly> },
{ path: 'people', element: <AdminOnly><PeoplePage /></AdminOnly> },
{ path: 'backups', element: <AdminOnly><BackupsPage /></AdminOnly> },
{ path: 'ideas', element: <AdminOnly><IdeasPage /></AdminOnly> },