Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.npm-cache
|
||||
data
|
||||
backups
|
||||
samples
|
||||
.env
|
||||
*.db
|
||||
*.tsbuildinfo
|
||||
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Spendlight settings. Copy this file to .env and adjust.
|
||||
|
||||
# The address people use to open Spendlight, through your HTTPS proxy.
|
||||
# Needed for Face ID / fingerprint sign-in. Example: https://spendlight.example.com
|
||||
PUBLIC_URL=
|
||||
|
||||
# Port on this server that the proxy forwards to.
|
||||
HOST_PORT=8095
|
||||
|
||||
# Household time zone: used for "today", nightly backups and version stamps.
|
||||
TZ=America/New_York
|
||||
|
||||
# The ideas box (lightbulb at the top right). Handy while building; set to
|
||||
# false to hide it completely.
|
||||
IDEAS_ENABLED=true
|
||||
|
||||
# Where the database and the backups live on this server.
|
||||
DATA_PATH=./data
|
||||
BACKUP_PATH=./backups
|
||||
|
||||
# Nightly backup time (24-hour clock) and how many copies to keep.
|
||||
BACKUP_TIME=02:30
|
||||
BACKUP_KEEP_DAILY=14
|
||||
BACKUP_KEEP_MONTHLY=12
|
||||
|
||||
# The user and group the app runs as, matching the owner of the folders above.
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
14
.githooks/pre-commit
Executable file
14
.githooks/pre-commit
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Spendlight safety check: refuse any commit that would include real
|
||||
# statements, databases or backups. Only made-up test files under a
|
||||
# fixtures/synthetic/ folder may be CSV, OFX, QFX, QBO or Excel.
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
blocked=$(printf '%s\n' "$staged" \
|
||||
| grep -E -i '^(samples|data|backups)/|\.(csv|ofx|qfx|qbo|xls|xlsx|sqlite|sqlite3|db)$' \
|
||||
| grep -v -E '^samples/README\.md$|(^|/)fixtures/synthetic/' || true)
|
||||
if [ -n "$blocked" ]; then
|
||||
echo "Commit blocked: these files look like real financial data." >&2
|
||||
printf ' %s\n' $blocked >&2
|
||||
echo "Statement files stay on the server. Test files belong in fixtures/synthetic/." >&2
|
||||
exit 1
|
||||
fi
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Real financial data never enters Git
|
||||
/samples/*
|
||||
!/samples/README.md
|
||||
/data/
|
||||
/backups/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Secrets and local config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Build output and dependencies
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
.npm-cache/
|
||||
*.tsbuildinfo
|
||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@@ -0,0 +1,5 @@
|
||||
# Every native dependency here (better-sqlite3, argon2, esbuild, sharp) ships
|
||||
# ready-made binaries, so install scripts are never needed. Turning them off
|
||||
# keeps installs reproducible (no compiler required, even as root in Docker)
|
||||
# and stops packages from running their own code, such as telemetry.
|
||||
ignore-scripts=true
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Spendlight: one image, one data folder. Builds for whatever architecture
|
||||
# the Docker host runs (arm64 on the Pi, amd64 elsewhere).
|
||||
|
||||
FROM node:24-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV npm_config_update_notifier=false \
|
||||
npm_config_fund=false \
|
||||
npm_config_audit=false
|
||||
|
||||
# All dependencies, for building
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
COPY shared/package.json shared/
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci
|
||||
|
||||
FROM deps AS build
|
||||
ARG APP_VERSION=dev
|
||||
ENV VITE_APP_VERSION=${APP_VERSION}
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Only what the server needs at runtime
|
||||
FROM base AS prod-deps
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
COPY shared/package.json shared/
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci --omit=dev --workspace server
|
||||
|
||||
FROM base AS runtime
|
||||
ARG APP_VERSION=dev
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${APP_VERSION} \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/data \
|
||||
BACKUP_DIR=/backups \
|
||||
WEB_DIST=/app/web/dist
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/server/drizzle ./server/drizzle
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
COPY deploy/spendlight-cli.sh /usr/local/bin/spendlight
|
||||
RUN chmod 755 /usr/local/bin/spendlight \
|
||||
&& mkdir -p /data /backups \
|
||||
&& chown node:node /data /backups
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data", "/backups"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
LABEL org.opencontainers.image.title="Spendlight" \
|
||||
org.opencontainers.image.description="Private, self-hosted household budgeting" \
|
||||
org.opencontainers.image.version="${APP_VERSION}"
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Spendlight
|
||||
|
||||
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.
|
||||
34
compose.yaml
Normal file
34
compose.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Spendlight. Start with scripts/deploy.sh, which builds a versioned image
|
||||
# first. Settings live in .env (copy .env.example to begin).
|
||||
name: spendlight
|
||||
|
||||
services:
|
||||
spendlight:
|
||||
image: spendlight:latest
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: spendlight
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
TZ: ${TZ:-America/New_York}
|
||||
# HTTPS is handled by a reverse proxy (NGINX) in front of the app.
|
||||
TRUST_PROXY: "true"
|
||||
ports:
|
||||
- "${HOST_PORT:-8095}:3000"
|
||||
volumes:
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
- ${BACKUP_PATH:-./backups}:/backups
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
mem_limit: 256m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
31
deploy/nginx.conf.example
Normal file
31
deploy/nginx.conf.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Example NGINX site for Spendlight. Spendlight itself speaks plain HTTP on
|
||||
# HOST_PORT; NGINX adds HTTPS. The forwarded headers matter: Spendlight uses
|
||||
# them to keep sign-in cookies secure and to verify Face ID sign-in.
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name spendlight.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/spendlight/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/spendlight/privkey.pem;
|
||||
|
||||
# Statement files are small, but leave room for a year of them at once.
|
||||
client_max_body_size 25m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8095;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name spendlight.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
5
deploy/spendlight-cli.sh
Executable file
5
deploy/spendlight-cli.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Admin commands inside the container, for example:
|
||||
# docker compose exec spendlight spendlight ideas
|
||||
# docker compose exec spendlight spendlight reset-password chris
|
||||
exec node /app/server/dist/cli.js "$@"
|
||||
6846
package-lock.json
generated
Normal file
6846
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "spendlight",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:server": "npm run dev -w server",
|
||||
"dev:web": "npm run dev -w web",
|
||||
"build": "npm run build -w web && npm run build -w server",
|
||||
"typecheck": "npm run typecheck -w shared -w server -w web",
|
||||
"test": "npm test -w server",
|
||||
"db:generate": "npm run db:generate -w server",
|
||||
"icons": "npm run icons -w web"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
18
samples/README.md
Normal file
18
samples/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Statement samples
|
||||
|
||||
Drop your bank and card exports here, exactly as downloaded.
|
||||
|
||||
1. Make one folder per account, named however you like, e.g. `bofa-checking`,
|
||||
`bofa-savings`, `discover-1`, `discover-2`, `chase-prime-visa`.
|
||||
2. Put that account's export in its folder untouched: same file name, same
|
||||
contents. Two or three months per account is plenty.
|
||||
3. That's it. Tell Claude when they're in place.
|
||||
|
||||
How these files are protected:
|
||||
|
||||
- Git ignores everything in this folder except this note, and a pre-commit
|
||||
check blocks statement files anywhere in the project.
|
||||
- Only the `pi` user can open this folder.
|
||||
- Claude never opens the raw files. A script on this server turns each one
|
||||
into a masked profile (column headers, date and amount formats, value
|
||||
shapes such as `Aaaaaa #999` and `-99.99`), and Claude works from that.
|
||||
18
scripts/build.sh
Executable file
18
scripts/build.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Spendlight container image and stamp it with the next version.
|
||||
# The VERSION file is only updated once the build succeeds.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
version="$(scripts/next-version.sh)"
|
||||
echo "Building Spendlight ${version}"
|
||||
|
||||
docker build \
|
||||
--build-arg APP_VERSION="${version}" \
|
||||
--label org.opencontainers.image.version="${version}" \
|
||||
-t "spendlight:${version}" \
|
||||
-t spendlight:latest \
|
||||
.
|
||||
|
||||
echo "${version}" > VERSION
|
||||
echo "Built spendlight:${version}"
|
||||
18
scripts/deploy.sh
Executable file
18
scripts/deploy.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a new version and (re)start Spendlight with it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env from .env.example; review it, then run this again if needed."
|
||||
fi
|
||||
|
||||
# Read the folder settings so the bind mounts exist and belong to this user
|
||||
# (otherwise Docker creates them as root and the app can't write).
|
||||
set -a; . ./.env; set +a
|
||||
mkdir -p "${DATA_PATH:-./data}" "${BACKUP_PATH:-./backups}"
|
||||
|
||||
scripts/build.sh
|
||||
docker compose up -d
|
||||
echo "Spendlight $(cat VERSION) is starting on port ${HOST_PORT:-8095}."
|
||||
24
scripts/dev.sh
Executable file
24
scripts/dev.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a command with the project's Node 24 toolchain, inside the official
|
||||
# Node image, so the host needs nothing but Docker.
|
||||
#
|
||||
# scripts/dev.sh npm install
|
||||
# scripts/dev.sh npm test
|
||||
# scripts/dev.sh npm run dev:server
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
tty_flag=()
|
||||
if [ -t 0 ] && [ -t 1 ]; then tty_flag=(-t); fi
|
||||
|
||||
exec docker run --rm -i "${tty_flag[@]}" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--network host \
|
||||
-e HOME=/tmp \
|
||||
-e npm_config_cache=/app/.npm-cache \
|
||||
-e npm_config_update_notifier=false \
|
||||
-e npm_config_fund=false \
|
||||
-e npm_config_audit=false \
|
||||
-e TZ="${TZ:-America/New_York}" \
|
||||
-v "$PWD":/app -w /app \
|
||||
node:24-bookworm-slim "$@"
|
||||
19
scripts/next-version.sh
Executable file
19
scripts/next-version.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the next build version, YYYYMMDD.NN: today's date in the household's
|
||||
# time zone, then a counter that starts at .01 each day.
|
||||
#
|
||||
# Reads the last version from the VERSION file (or $1). Set TODAY=YYYYMMDD to
|
||||
# pretend it's another day, which the tests use.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
previous="${1:-$(cat VERSION 2>/dev/null || true)}"
|
||||
today="${TODAY:-$(TZ="${TZ:-America/New_York}" date +%Y%m%d)}"
|
||||
|
||||
if [[ "$previous" =~ ^${today}\.([0-9]+)$ ]]; then
|
||||
counter=$((10#${BASH_REMATCH[1]} + 1))
|
||||
else
|
||||
counter=1
|
||||
fi
|
||||
|
||||
printf '%s.%02d\n' "$today" "$counter"
|
||||
155
scripts/profile-samples.mjs
Normal file
155
scripts/profile-samples.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// Describe statement files without revealing their contents.
|
||||
//
|
||||
// node scripts/profile-samples.mjs [folder] (default: samples)
|
||||
//
|
||||
// Prints, for each file: its layout, header labels, and value *shapes* only.
|
||||
// Every letter becomes A/a and every digit 9, so "KROGER #123" reads
|
||||
// "AAAAAA #999" and "-54.20" reads "-99.99". Rows made only of labels (no
|
||||
// digits at all, such as a CSV header) are shown as they are. This is what
|
||||
// Claude reads to build importers; the raw files never leave this server.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, extname, join, relative } from 'node:path';
|
||||
|
||||
const root = process.argv[2] ?? 'samples';
|
||||
const SKIP = new Set(['README.md', '.DS_Store']);
|
||||
|
||||
const mask = (s) => s.replace(/[A-Z]/g, 'A').replace(/[a-z]/g, 'a').replace(/[0-9]/g, '9').replace(/[^\x20-\x7e]/g, '?');
|
||||
const shape = (s) =>
|
||||
s.trim() === ''
|
||||
? '(empty)'
|
||||
: s.trim().replace(/[0-9]+/g, '9').replace(/[A-Z]+/g, 'A').replace(/[a-z]+/g, 'a').replace(/[^\x20-\x7e]/g, '?');
|
||||
const maskName = (name) => name.replace(/[0-9]{3,}/g, (d) => '9'.repeat(d.length));
|
||||
|
||||
function files(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
if (SKIP.has(name) || name.startsWith('_') || name.startsWith('.')) return [];
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? files(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
/** A small CSV reader that understands quoted fields and embedded commas or newlines. */
|
||||
function parseCsv(text, delimiter) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (quoted) {
|
||||
if (ch === '"' && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (ch === '"') quoted = false;
|
||||
else field += ch;
|
||||
} else if (ch === '"') quoted = true;
|
||||
else if (ch === delimiter) {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else field += ch;
|
||||
}
|
||||
if (field !== '' || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const isLabelRow = (cells) => cells.filter((c) => c.trim()).length >= 2 && cells.every((c) => !/[0-9]/.test(c));
|
||||
const showRow = (cells) => (isLabelRow(cells) ? cells.map((c) => c.trim()) : cells.map(mask));
|
||||
const numeric = (s) => /^\s*[-+(]?\s*\$?\s*[0-9][0-9,]*(\.[0-9]+)?\s*\)?\s*(CR|DR)?\s*$/i.test(s);
|
||||
|
||||
function profileCsv(text) {
|
||||
const firstLines = text.split(/\r?\n/).slice(0, 20).join('\n');
|
||||
const counts = { ',': 0, '\t': 0, ';': 0, '|': 0 };
|
||||
for (const ch of firstLines) if (ch in counts) counts[ch]++;
|
||||
const delimiter = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
const rows = parseCsv(text, delimiter).filter((r) => r.some((c) => c.trim() !== ''));
|
||||
const out = [];
|
||||
|
||||
out.push(`- Delimiter: ${JSON.stringify(delimiter)} · rows (non-blank): ${rows.length}`);
|
||||
const widths = {};
|
||||
for (const r of rows) widths[r.length] = (widths[r.length] ?? 0) + 1;
|
||||
out.push(`- Columns per row: ${Object.entries(widths).map(([w, n]) => `${w} cols × ${n}`).join(', ')}`);
|
||||
|
||||
const headerIndex = rows.findIndex((r, i) => isLabelRow(r) && rows.slice(i + 1, i + 4).some((n) => n.length === r.length));
|
||||
out.push(`- Header row: ${headerIndex >= 0 ? `row ${headerIndex + 1}` : 'none found'}`);
|
||||
|
||||
out.push('', '```text');
|
||||
rows.slice(0, 12).forEach((r, i) => out.push(`${String(i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
|
||||
if (rows.length > 15) out.push(' …');
|
||||
rows.slice(Math.max(12, rows.length - 3)).forEach((r, i, arr) => out.push(`${String(rows.length - arr.length + i + 1).padStart(3)}: ${showRow(r).join(' | ')}`));
|
||||
out.push('```');
|
||||
|
||||
if (headerIndex >= 0) {
|
||||
const header = rows[headerIndex];
|
||||
const data = rows.slice(headerIndex + 1).filter((r) => r.length === header.length);
|
||||
out.push('', `Columns (${data.length} data rows):`, '');
|
||||
header.forEach((name, col) => {
|
||||
const values = data.map((r) => r[col] ?? '');
|
||||
const shapes = {};
|
||||
for (const v of values) shapes[shape(v)] = (shapes[shape(v)] ?? 0) + 1;
|
||||
const top = Object.entries(shapes)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([s, n]) => `\`${s}\` ×${n}`)
|
||||
.join(', ');
|
||||
let extra = '';
|
||||
const nums = values.filter((v) => numeric(v));
|
||||
if (nums.length >= values.length * 0.6 && nums.length > 0) {
|
||||
const neg = nums.filter((v) => /^\s*-|\(|DR/i.test(v)).length;
|
||||
extra = ` · numeric: ${nums.length - neg} positive, ${neg} negative`;
|
||||
} else {
|
||||
const distinct = new Set(values.map((v) => v.trim()).filter(Boolean)).size;
|
||||
extra = ` · ${distinct} distinct values`;
|
||||
if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(values.find((v) => v.trim()) ?? '')) {
|
||||
const firsts = values.map((v) => Number(v.split('/')[0])).filter(Number.isFinite);
|
||||
const seconds = values.map((v) => Number(v.split('/')[1])).filter(Number.isFinite);
|
||||
extra += ` · first part max ${Math.max(...firsts)}, second part max ${Math.max(...seconds)}`;
|
||||
}
|
||||
}
|
||||
out.push(`- **${name.trim() || `(column ${col + 1})`}**: ${top}${extra}`);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function profileOfx(text) {
|
||||
const out = [];
|
||||
const headerLines = text.split(/\r?\n/).filter((l) => /^[A-Z]+:[A-Z0-9]+$/.test(l.trim()));
|
||||
if (headerLines.length) out.push(`- OFX header: ${headerLines.join(' ')}`);
|
||||
const tags = {};
|
||||
const examples = {};
|
||||
for (const m of text.matchAll(/<([A-Z0-9.]+)>([^<\r\n]*)/g)) {
|
||||
tags[m[1]] = (tags[m[1]] ?? 0) + 1;
|
||||
if (m[2].trim() && !examples[m[1]]) examples[m[1]] = mask(m[2].trim());
|
||||
}
|
||||
out.push(`- Transactions (STMTTRN): ${tags.STMTTRN ?? 0}`);
|
||||
out.push('', 'Tags (count, masked example):', '');
|
||||
for (const [tag, n] of Object.entries(tags)) out.push(`- ${tag} ×${n}${examples[tag] ? ` · \`${examples[tag]}\`` : ''}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
const list = files(root);
|
||||
console.log(`# Sample profiles\n\n${list.length} file(s) under ${root}/. Values are masked: letters → A/a, digits → 9.\n`);
|
||||
for (const path of list) {
|
||||
const buffer = readFileSync(path);
|
||||
const bom = buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf;
|
||||
const text = buffer.toString('utf8').replace(/^/, '');
|
||||
const ext = extname(path).toLowerCase();
|
||||
const rel = relative(root, path);
|
||||
console.log(`## ${maskName(rel)}\n`);
|
||||
console.log(`- Size: ${buffer.length} bytes · extension: ${ext || '(none)'} · BOM: ${bom ? 'yes' : 'no'} · line endings: ${text.includes('\r\n') ? 'CRLF' : 'LF'} · non-ASCII bytes: ${/[^\x00-\x7f]/.test(text) ? 'yes' : 'no'}`);
|
||||
const looksOfx = /<OFX>|OFXHEADER/i.test(text.slice(0, 2000));
|
||||
const lines = looksOfx ? profileOfx(text) : profileCsv(text);
|
||||
console.log(lines.join('\n') + '\n');
|
||||
}
|
||||
void basename;
|
||||
19
server/build.mjs
Normal file
19
server/build.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Bundle the server into dist/ with esbuild. npm packages stay external and
|
||||
// are installed in the image; our own shared workspace is bundled in.
|
||||
import { build } from 'esbuild';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
const external = Object.keys(pkg.dependencies).filter((name) => name !== '@spendlight/shared');
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/index.ts', 'src/cli.ts'],
|
||||
outdir: 'dist',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node24',
|
||||
sourcemap: true,
|
||||
external,
|
||||
logLevel: 'info',
|
||||
});
|
||||
7
server/drizzle.config.ts
Normal file
7
server/drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
});
|
||||
77
server/drizzle/0000_foundation.sql
Normal file
77
server/drizzle/0000_foundation.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
CREATE TABLE `change_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`at` text NOT NULL,
|
||||
`user_id` integer,
|
||||
`action` text NOT NULL,
|
||||
`entity` text NOT NULL,
|
||||
`entity_id` text,
|
||||
`detail` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `change_log_at_idx` ON `change_log` (`at`);--> statement-breakpoint
|
||||
CREATE TABLE `idea` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`description` text DEFAULT '' NOT NULL,
|
||||
`status` text DEFAULT 'new' NOT NULL,
|
||||
`admin_note` text,
|
||||
`path` text,
|
||||
`app_version` text,
|
||||
`user_agent` text,
|
||||
`user_id` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idea_status_idx` ON `idea` (`status`);--> statement-breakpoint
|
||||
CREATE TABLE `passkey` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`public_key` blob NOT NULL,
|
||||
`counter` integer DEFAULT 0 NOT NULL,
|
||||
`transports` text,
|
||||
`device_type` text,
|
||||
`backed_up` integer DEFAULT false NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`kind` text DEFAULT 'browser' NOT NULL,
|
||||
`device_label` text NOT NULL,
|
||||
`user_agent` text,
|
||||
`ip` text,
|
||||
`created_at` text NOT NULL,
|
||||
`last_seen_at` text NOT NULL,
|
||||
`expires_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `setting` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`display_name` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`access` text DEFAULT 'full' NOT NULL,
|
||||
`is_admin` integer DEFAULT false NOT NULL,
|
||||
`theme` text DEFAULT 'system' NOT NULL,
|
||||
`must_change_password` integer DEFAULT false NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
`deleted_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);
|
||||
145
server/drizzle/0001_money_in.sql
Normal file
145
server/drizzle/0001_money_in.sql
Normal file
@@ -0,0 +1,145 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`institution` text,
|
||||
`last4` text,
|
||||
`color` text DEFAULT 'indigo' NOT NULL,
|
||||
`status` text DEFAULT 'open' NOT NULL,
|
||||
`balance_cents` integer DEFAULT 0 NOT NULL,
|
||||
`balance_as_of` text,
|
||||
`balance_source` text,
|
||||
`layout_key` text,
|
||||
`file_name_pattern` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
`retired_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `category` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`group_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`kind` text DEFAULT 'expense' NOT NULL,
|
||||
`icon` text DEFAULT 'circle' NOT NULL,
|
||||
`color` text DEFAULT 'slate' NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `category_group`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `category_group_idx` ON `category` (`group_id`);--> statement-breakpoint
|
||||
CREATE TABLE `category_group` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `import_layout` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`signature` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`created_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `import` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`account_id` integer NOT NULL,
|
||||
`file_name` text NOT NULL,
|
||||
`file_hash` text NOT NULL,
|
||||
`layout_key` text NOT NULL,
|
||||
`date_from` text,
|
||||
`date_to` text,
|
||||
`row_count` integer DEFAULT 0 NOT NULL,
|
||||
`added_count` integer DEFAULT 0 NOT NULL,
|
||||
`duplicate_count` integer DEFAULT 0 NOT NULL,
|
||||
`skipped_count` integer DEFAULT 0 NOT NULL,
|
||||
`transfer_count` integer DEFAULT 0 NOT NULL,
|
||||
`status` text DEFAULT 'committed' NOT NULL,
|
||||
`previous_balance` text,
|
||||
`user_id` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`undone_at` text,
|
||||
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `import_account_idx` ON `import` (`account_id`);--> statement-breakpoint
|
||||
CREATE TABLE `rule` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`merchant` text NOT NULL,
|
||||
`account_id` integer,
|
||||
`category_id` integer,
|
||||
`rename_to` text,
|
||||
`source` text DEFAULT 'learned' NOT NULL,
|
||||
`hits` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `rule_merchant_idx` ON `rule` (`merchant`);--> statement-breakpoint
|
||||
CREATE TABLE `split` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`transaction_id` integer NOT NULL,
|
||||
`category_id` integer NOT NULL,
|
||||
`amount_cents` integer NOT NULL,
|
||||
`note` text,
|
||||
FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `split_txn_idx` ON `split` (`transaction_id`);--> statement-breakpoint
|
||||
CREATE INDEX `split_category_idx` ON `split` (`category_id`);--> statement-breakpoint
|
||||
CREATE TABLE `tag` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text DEFAULT 'slate' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `txn_tag` (
|
||||
`transaction_id` integer NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `txn_tag_pk` ON `txn_tag` (`transaction_id`,`tag_id`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_tag_tag_idx` ON `txn_tag` (`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `txn` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`account_id` integer NOT NULL,
|
||||
`import_id` integer,
|
||||
`date` text NOT NULL,
|
||||
`posted_date` text,
|
||||
`amount_cents` integer NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`merchant` text NOT NULL,
|
||||
`category_id` integer,
|
||||
`needs_review` integer DEFAULT true NOT NULL,
|
||||
`suggested_by` text,
|
||||
`is_split` integer DEFAULT false NOT NULL,
|
||||
`bank_category` text,
|
||||
`bank_type` text,
|
||||
`note` text,
|
||||
`transfer_peer_id` integer,
|
||||
`source` text DEFAULT 'import' NOT NULL,
|
||||
`dedupe_key` text 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 no action,
|
||||
FOREIGN KEY (`import_id`) REFERENCES `import`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `txn_account_date_idx` ON `txn` (`account_id`,`date`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_date_idx` ON `txn` (`date`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_category_date_idx` ON `txn` (`category_id`,`date`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_merchant_idx` ON `txn` (`merchant`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_dedupe_idx` ON `txn` (`dedupe_key`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_review_idx` ON `txn` (`needs_review`);--> statement-breakpoint
|
||||
CREATE INDEX `txn_import_idx` ON `txn` (`import_id`);
|
||||
533
server/drizzle/meta/0000_snapshot.json
Normal file
533
server/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,533 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1d213858-0a17-4e4b-bce5-77f99e612d9f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"change_log": {
|
||||
"name": "change_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"at": {
|
||||
"name": "at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity": {
|
||||
"name": "entity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"change_log_at_idx": {
|
||||
"name": "change_log_at_idx",
|
||||
"columns": [
|
||||
"at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"change_log_user_id_user_id_fk": {
|
||||
"name": "change_log_user_id_user_id_fk",
|
||||
"tableFrom": "change_log",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"idea": {
|
||||
"name": "idea",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'new'"
|
||||
},
|
||||
"admin_note": {
|
||||
"name": "admin_note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"app_version": {
|
||||
"name": "app_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idea_status_idx": {
|
||||
"name": "idea_status_idx",
|
||||
"columns": [
|
||||
"status"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"idea_user_id_user_id_fk": {
|
||||
"name": "idea_user_id_user_id_fk",
|
||||
"tableFrom": "idea",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"passkey": {
|
||||
"name": "passkey",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"counter": {
|
||||
"name": "counter",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"transports": {
|
||||
"name": "transports",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_type": {
|
||||
"name": "device_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"backed_up": {
|
||||
"name": "backed_up",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"passkey_user_idx": {
|
||||
"name": "passkey_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"passkey_user_id_user_id_fk": {
|
||||
"name": "passkey_user_id_user_id_fk",
|
||||
"tableFrom": "passkey",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'browser'"
|
||||
},
|
||||
"device_label": {
|
||||
"name": "device_label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_user_idx": {
|
||||
"name": "session_user_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setting": {
|
||||
"name": "setting",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access": {
|
||||
"name": "access",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'full'"
|
||||
},
|
||||
"is_admin": {
|
||||
"name": "is_admin",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'system'"
|
||||
},
|
||||
"must_change_password": {
|
||||
"name": "must_change_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
1548
server/drizzle/meta/0001_snapshot.json
Normal file
1548
server/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
server/drizzle/meta/_journal.json
Normal file
20
server/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1791402989316,
|
||||
"tag": "0000_foundation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1791404838766,
|
||||
"tag": "0001_money_in",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
35
server/package.json
Normal file
35
server/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@spendlight/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "node build.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.1.3",
|
||||
"@hono/zod-openapi": "^1.6.3",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"@spendlight/shared": "*",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"hono": "^4.13.13",
|
||||
"papaparse": "^5.7.0",
|
||||
"swagger-ui-dist": "^5.33.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"esbuild": "^0.28.2",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
20
server/src/accounts/balance.ts
Normal file
20
server/src/accounts/balance.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import type { DB } from '../db/client';
|
||||
import { transactions, type AccountRow } from '../db/schema';
|
||||
|
||||
/**
|
||||
* An account's balance: its last known balance (from a statement's running
|
||||
* balance, or typed in), plus every transaction dated after that day. Money
|
||||
* owed on a card is negative.
|
||||
*/
|
||||
export function currentBalance(db: DB, account: AccountRow): number {
|
||||
const where = account.balanceAsOf
|
||||
? and(eq(transactions.accountId, account.id), gt(transactions.date, account.balanceAsOf))
|
||||
: eq(transactions.accountId, account.id);
|
||||
const later = db
|
||||
.select({ total: sql<number>`coalesce(sum(${transactions.amountCents}), 0)` })
|
||||
.from(transactions)
|
||||
.where(where)
|
||||
.get();
|
||||
return account.balanceCents + (later?.total ?? 0);
|
||||
}
|
||||
107
server/src/app.ts
Normal file
107
server/src/app.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { sessionMiddleware } from './auth/guards';
|
||||
import type { AppContext } from './context';
|
||||
import { docsRoutes } from './docs';
|
||||
import { fail, requestOrigin, type Env } from './http';
|
||||
import { accountRoutes } from './routes/accounts';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { backupRoutes } from './routes/backups';
|
||||
import { deviceRoutes } from './routes/devices';
|
||||
import { ideaRoutes } from './routes/ideas';
|
||||
import { importRoutes } from './routes/imports';
|
||||
import { passkeyRoutes } from './routes/passkeys';
|
||||
import { peopleRoutes } from './routes/people';
|
||||
import { systemRoutes } from './routes/system';
|
||||
import { transactionRoutes } from './routes/transactions';
|
||||
import { validationHook } from './schemas';
|
||||
import { webApp } from './static';
|
||||
|
||||
/** Changes must come from Spendlight's own pages, never from another site. */
|
||||
function sameOriginWrites(ctx: AppContext): MiddlewareHandler<Env> {
|
||||
return async (c, next) => {
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||
const origin = c.req.header('origin');
|
||||
if (origin) {
|
||||
const allowed = new Set([requestOrigin(c, ctx)]);
|
||||
if (ctx.config.publicUrl) allowed.add(ctx.config.publicUrl.origin);
|
||||
if (!allowed.has(origin)) fail(403, 'wrong_origin', 'This request didn’t come from Spendlight.');
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(ctx: AppContext) {
|
||||
const app = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
app.onError((error, c) => {
|
||||
if (error instanceof HTTPException) {
|
||||
const code = (error.cause as { code?: string } | undefined)?.code ?? 'error';
|
||||
return c.json({ error: { code, message: error.message } }, error.status);
|
||||
}
|
||||
ctx.log.error(`${c.req.method} ${new URL(c.req.url).pathname} failed`, error);
|
||||
return c.json({ error: { code: 'server_error', message: 'Something went wrong on our side. Please try again.' } }, 500);
|
||||
});
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
contentSecurityPolicy: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
manifestSrc: ["'self'"],
|
||||
workerSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
referrerPolicy: 'same-origin',
|
||||
}),
|
||||
);
|
||||
app.use('*', async (c, next) => {
|
||||
await next();
|
||||
c.header('X-Spendlight-Version', ctx.config.version);
|
||||
});
|
||||
app.use('/api/*', async (c, next) => {
|
||||
await next();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
});
|
||||
app.use('/api/*', sessionMiddleware(ctx));
|
||||
app.use('/api/*', sameOriginWrites(ctx));
|
||||
|
||||
app.route('/api/v1', systemRoutes(ctx));
|
||||
app.route('/api/v1', authRoutes(ctx));
|
||||
app.route('/api/v1', passkeyRoutes(ctx));
|
||||
app.route('/api/v1', deviceRoutes(ctx));
|
||||
app.route('/api/v1', peopleRoutes(ctx));
|
||||
app.route('/api/v1', backupRoutes(ctx));
|
||||
app.route('/api/v1', accountRoutes(ctx));
|
||||
app.route('/api/v1', importRoutes(ctx));
|
||||
app.route('/api/v1', transactionRoutes(ctx));
|
||||
if (ctx.config.ideasEnabled) app.route('/api/v1', ideaRoutes(ctx));
|
||||
|
||||
app.doc31('/api/v1/openapi.json', {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Spendlight API',
|
||||
version: ctx.config.version,
|
||||
description:
|
||||
'Everything the Spendlight screens do goes through this API. Sign in at /api/v1/auth/login; the session cookie authenticates the rest.',
|
||||
},
|
||||
});
|
||||
app.route('/api/docs', docsRoutes());
|
||||
|
||||
app.all('/api/*', (c) => c.json({ error: { code: 'not_found', message: 'No such address in the Spendlight API.' } }, 404));
|
||||
app.use('*', webApp(ctx.config.webDist));
|
||||
|
||||
return app;
|
||||
}
|
||||
24
server/src/audit.ts
Normal file
24
server/src/audit.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AppContext } from './context';
|
||||
import { changeLog } from './db/schema';
|
||||
|
||||
/** Record who changed what, for history and (later) undo. */
|
||||
export function logChange(
|
||||
ctx: AppContext,
|
||||
userId: number | null,
|
||||
action: string,
|
||||
entity: string,
|
||||
entityId?: string | number | null,
|
||||
detail?: Record<string, unknown>,
|
||||
): void {
|
||||
ctx.db
|
||||
.insert(changeLog)
|
||||
.values({
|
||||
at: new Date().toISOString(),
|
||||
userId,
|
||||
action,
|
||||
entity,
|
||||
entityId: entityId == null ? null : String(entityId),
|
||||
detail: detail ? JSON.stringify(detail) : null,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
57
server/src/auth/guards.ts
Normal file
57
server/src/auth/guards.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import type { AppContext } from '../context';
|
||||
import type { UserRow } from '../db/schema';
|
||||
import { clientIp, fail, hostnameOf, isLocalHost, requestHost, requestProto, type Env } from '../http';
|
||||
import { clearSessionCookie, loadSession, SESSION_COOKIE, setSessionCookie, touchSession } from './sessions';
|
||||
|
||||
/** Attach the signed-in person (if any) to every API request, and keep their session fresh. */
|
||||
export function sessionMiddleware(ctx: AppContext): MiddlewareHandler<Env> {
|
||||
return async (c, next) => {
|
||||
c.set('user', null);
|
||||
c.set('session', null);
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) {
|
||||
const found = loadSession(ctx, token);
|
||||
if (found) {
|
||||
c.set('user', found.user);
|
||||
c.set('session', found.session);
|
||||
if (touchSession(ctx, found.session, clientIp(c, ctx))) setSessionCookie(c, ctx, token);
|
||||
} else {
|
||||
clearSessionCookie(c);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export function requireUser(c: Context<Env>): UserRow {
|
||||
const user = c.get('user');
|
||||
if (!user) fail(401, 'signed_out', 'Please sign in to continue.');
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Changing household data needs Full access; Read-only people can look but not touch. */
|
||||
export function requireWrite(c: Context<Env>): UserRow {
|
||||
const user = requireUser(c);
|
||||
if (user.access !== 'full') fail(403, 'read_only', 'Your access is read-only, so this change isn’t available.');
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAdmin(c: Context<Env>): UserRow {
|
||||
const user = requireUser(c);
|
||||
if (!user.isAdmin) fail(403, 'admin_only', 'Only an admin can do that.');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse password sign-in over plain HTTP from anywhere but this machine, so
|
||||
* passwords and cookies only ever travel encrypted.
|
||||
*/
|
||||
export function assertSecureSignIn(c: Context<Env>, ctx: AppContext): void {
|
||||
if (ctx.config.allowInsecureSignin) return;
|
||||
if (requestProto(c, ctx) === 'https') return;
|
||||
if (isLocalHost(hostnameOf(requestHost(c, ctx)))) return;
|
||||
const where = ctx.config.publicUrl ? `: ${ctx.config.publicUrl.origin}` : '';
|
||||
fail(400, 'insecure_connection', `For your security, open Spendlight at its secure (https) address to sign in${where}.`);
|
||||
}
|
||||
35
server/src/auth/password.ts
Normal file
35
server/src/auth/password.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/** Argon2id with the library's defaults (19 MiB, 2 passes): about 75 ms on the Pi. */
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return hash(password);
|
||||
}
|
||||
|
||||
let dummyHash: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Check a password. With no stored hash (unknown username) it still does the
|
||||
* same work, so response times don't reveal which usernames exist.
|
||||
*/
|
||||
export async function verifyPassword(stored: string | undefined, password: string): Promise<boolean> {
|
||||
if (!stored) {
|
||||
dummyHash ??= hash('spendlight-timing-equalizer');
|
||||
await verify(await dummyHash, password).catch(() => false);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await verify(stored, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A temporary password that's easy to read aloud, such as "kq7m-2xpd-9tfa". */
|
||||
export function temporaryPassword(): string {
|
||||
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
const group = () => Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join('');
|
||||
return `${group()}-${group()}-${group()}`;
|
||||
}
|
||||
75
server/src/auth/ratelimit.ts
Normal file
75
server/src/auth/ratelimit.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Slows down password guessing. After 5 failures the key is locked for a
|
||||
// minute, doubling with each further failure up to 15 minutes. Quiet for 15
|
||||
// minutes and the slate is wiped.
|
||||
|
||||
interface Entry {
|
||||
failures: number;
|
||||
lastFailure: number;
|
||||
lockedUntil: number;
|
||||
}
|
||||
|
||||
const FREE_FAILURES = 5;
|
||||
const BASE_LOCK_MS = 60_000;
|
||||
const MAX_LOCK_MS = 15 * 60_000;
|
||||
const FORGET_AFTER_MS = 15 * 60_000;
|
||||
|
||||
export class RateLimiter {
|
||||
private entries = new Map<string, Entry>();
|
||||
|
||||
constructor(private now: () => number = Date.now) {}
|
||||
|
||||
/** Seconds to wait before another attempt, or 0 if allowed now. */
|
||||
retryAfter(key: string): number {
|
||||
const entry = this.fresh(key);
|
||||
if (!entry) return 0;
|
||||
const wait = entry.lockedUntil - this.now();
|
||||
return wait > 0 ? Math.ceil(wait / 1000) : 0;
|
||||
}
|
||||
|
||||
fail(key: string): void {
|
||||
const now = this.now();
|
||||
const entry = this.fresh(key) ?? { failures: 0, lastFailure: now, lockedUntil: 0 };
|
||||
entry.failures += 1;
|
||||
entry.lastFailure = now;
|
||||
if (entry.failures >= FREE_FAILURES) {
|
||||
const lock = Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** (entry.failures - FREE_FAILURES));
|
||||
entry.lockedUntil = now + lock;
|
||||
}
|
||||
this.entries.set(key, entry);
|
||||
}
|
||||
|
||||
succeed(key: string): void {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
private fresh(key: string): Entry | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (entry && this.now() - entry.lastFailure > FORGET_AFTER_MS && entry.lockedUntil < this.now()) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/** Short-lived WebAuthn challenges, held in memory for 5 minutes. */
|
||||
export class ChallengeStore {
|
||||
private entries = new Map<string, { challenge: string; expires: number }>();
|
||||
|
||||
put(key: string, challenge: string, ttlMs = 5 * 60_000): void {
|
||||
this.sweep();
|
||||
this.entries.set(key, { challenge, expires: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
/** The challenge for `key`, removed so it can be used only once. */
|
||||
take(key: string): string | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
this.entries.delete(key);
|
||||
return entry && entry.expires > Date.now() ? entry.challenge : undefined;
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) if (entry.expires <= now) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
135
server/src/auth/sessions.ts
Normal file
135
server/src/auth/sessions.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, setCookie } from 'hono/cookie';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { AppContext } from '../context';
|
||||
import { sessions, users, type SessionRow, type UserRow } from '../db/schema';
|
||||
import { requestProto } from '../http';
|
||||
|
||||
export const SESSION_COOKIE = 'sl_session';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
/** "iPhone · Safari", "Mac · Chrome" and so on, for the devices list. */
|
||||
export function deviceLabel(userAgent: string | null | undefined): string {
|
||||
const ua = userAgent ?? '';
|
||||
const device = /iPhone/.test(ua)
|
||||
? 'iPhone'
|
||||
: /iPad/.test(ua)
|
||||
? 'iPad'
|
||||
: /Android/.test(ua)
|
||||
? 'Android'
|
||||
: /Macintosh|Mac OS X/.test(ua)
|
||||
? 'Mac'
|
||||
: /Windows/.test(ua)
|
||||
? 'Windows PC'
|
||||
: /CrOS/.test(ua)
|
||||
? 'Chromebook'
|
||||
: /Linux/.test(ua)
|
||||
? 'Linux'
|
||||
: 'Unknown device';
|
||||
const browser = /Edg\//.test(ua)
|
||||
? 'Edge'
|
||||
: /Firefox\//.test(ua)
|
||||
? 'Firefox'
|
||||
: /Chrome\/|CriOS\//.test(ua)
|
||||
? 'Chrome'
|
||||
: /Safari\//.test(ua)
|
||||
? 'Safari'
|
||||
: null;
|
||||
return browser ? `${device} · ${browser}` : device;
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
ctx: AppContext,
|
||||
userId: number,
|
||||
meta: { userAgent?: string | null; ip?: string | null },
|
||||
now = new Date(),
|
||||
): { token: string; session: SessionRow } {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const session = ctx.db
|
||||
.insert(sessions)
|
||||
.values({
|
||||
id: hashToken(token),
|
||||
userId,
|
||||
kind: 'browser',
|
||||
deviceLabel: deviceLabel(meta.userAgent),
|
||||
userAgent: meta.userAgent ?? null,
|
||||
ip: meta.ip ?? null,
|
||||
createdAt: now.toISOString(),
|
||||
lastSeenAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + ctx.config.sessionDays * DAY_MS).toISOString(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return { token, session };
|
||||
}
|
||||
|
||||
export function loadSession(ctx: AppContext, token: string, now = new Date()): { session: SessionRow; user: UserRow } | null {
|
||||
const row = ctx.db
|
||||
.select({ session: sessions, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, hashToken(token)), isNull(users.deletedAt)))
|
||||
.get();
|
||||
if (!row) return null;
|
||||
if (Date.parse(row.session.expiresAt) <= now.getTime()) {
|
||||
ctx.db.delete(sessions).where(eq(sessions.id, row.session.id)).run();
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a session alive. The expiry slides forward (at most once a day, to
|
||||
* save writes) so a device in regular use never signs out. Returns true when
|
||||
* the cookie should be re-sent with a fresh lifetime.
|
||||
*/
|
||||
export function touchSession(ctx: AppContext, session: SessionRow, ip: string, now = new Date()): boolean {
|
||||
const fullLife = ctx.config.sessionDays * DAY_MS;
|
||||
const remaining = Date.parse(session.expiresAt) - now.getTime();
|
||||
if (remaining < fullLife - DAY_MS) {
|
||||
ctx.db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: new Date(now.getTime() + fullLife).toISOString(),
|
||||
lastSeenAt: now.toISOString(),
|
||||
ip,
|
||||
})
|
||||
.where(eq(sessions.id, session.id))
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
if (now.getTime() - Date.parse(session.lastSeenAt) > 5 * 60_000) {
|
||||
ctx.db.update(sessions).set({ lastSeenAt: now.toISOString(), ip }).where(eq(sessions.id, session.id)).run();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function revokeSession(ctx: AppContext, sessionId: string): void {
|
||||
ctx.db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
}
|
||||
|
||||
/** Sign a person out everywhere, optionally keeping the device they're on. */
|
||||
export function revokeUserSessions(ctx: AppContext, userId: number, exceptSessionId?: string): void {
|
||||
const where = exceptSessionId
|
||||
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
|
||||
: eq(sessions.userId, userId);
|
||||
ctx.db.delete(sessions).where(where).run();
|
||||
}
|
||||
|
||||
export function setSessionCookie(c: Context, ctx: AppContext, token: string): void {
|
||||
setCookie(c, SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: requestProto(c, ctx) === 'https',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: ctx.config.sessionDays * 86_400,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(c: Context): void {
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
162
server/src/backups/backup.ts
Normal file
162
server/src/backups/backup.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { BackupInfo, BackupKind } from '@spendlight/shared';
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Sqlite } from '../db/client';
|
||||
import type { Logger } from '../log';
|
||||
|
||||
// Backup files are named spendlight-<kind>-<YYYYMMDD>-<HHMMSS>.db in the
|
||||
// household's local time. Each is written to a hidden partial file first and
|
||||
// renamed when complete, so an outside backup tool never collects half a file.
|
||||
|
||||
const NAME = /^spendlight-(nightly|manual|pre-upgrade|pre-restore)-(\d{8})-(\d{6})\.db$/;
|
||||
const PARTIAL = /^\.spendlight-.*\.partial$/;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
function stampOf(d: Date): string {
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function parseName(name: string): { kind: BackupKind; date: string; createdAt: Date } | null {
|
||||
const m = NAME.exec(name);
|
||||
if (!m) return null;
|
||||
const [, kind, date, time] = m as unknown as [string, BackupKind, string, string];
|
||||
const createdAt = new Date(
|
||||
Number(date.slice(0, 4)),
|
||||
Number(date.slice(4, 6)) - 1,
|
||||
Number(date.slice(6, 8)),
|
||||
Number(time.slice(0, 2)),
|
||||
Number(time.slice(2, 4)),
|
||||
Number(time.slice(4, 6)),
|
||||
);
|
||||
return { kind, date, createdAt };
|
||||
}
|
||||
|
||||
export async function createBackup(
|
||||
sqlite: Sqlite,
|
||||
dir: string,
|
||||
kind: BackupKind,
|
||||
now = new Date(),
|
||||
): Promise<BackupInfo> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
// Two backups in the same second get consecutive timestamps instead of colliding.
|
||||
let when = new Date(now);
|
||||
let name = `spendlight-${kind}-${stampOf(when)}.db`;
|
||||
while (existsSync(join(dir, name))) {
|
||||
when = new Date(when.getTime() + 1000);
|
||||
name = `spendlight-${kind}-${stampOf(when)}.db`;
|
||||
}
|
||||
const partial = join(dir, `.${name}.partial`);
|
||||
await sqlite.backup(partial);
|
||||
renameSync(partial, join(dir, name));
|
||||
return { name, kind, size: statSync(join(dir, name)).size, createdAt: when.toISOString() };
|
||||
}
|
||||
|
||||
export function listBackups(dir: string): BackupInfo[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const list: BackupInfo[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const parsed = parseName(name);
|
||||
if (!parsed) continue;
|
||||
list.push({
|
||||
name,
|
||||
kind: parsed.kind,
|
||||
size: statSync(join(dir, name)).size,
|
||||
createdAt: parsed.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
return list.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export interface RetentionPolicy {
|
||||
keepDaily: number;
|
||||
keepMonthly: number;
|
||||
keepManual?: number;
|
||||
keepUpgrade?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which backups to delete. Nightly copies keep the newest `keepDaily`, plus
|
||||
* the newest copy from each of the last `keepMonthly` months. Manual copies
|
||||
* keep the newest 10; safety copies taken before upgrades or restores, 5 each.
|
||||
*/
|
||||
export function planRetention(backups: Pick<BackupInfo, 'name' | 'kind' | 'createdAt'>[], policy: RetentionPolicy): string[] {
|
||||
const newestFirst = [...backups].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const ofKind = (kind: BackupKind) => newestFirst.filter((b) => b.kind === kind);
|
||||
const doomed: string[] = [];
|
||||
|
||||
const nightly = ofKind('nightly');
|
||||
const keep = new Set(nightly.slice(0, policy.keepDaily).map((b) => b.name));
|
||||
const months = new Set<string>();
|
||||
for (const b of nightly) {
|
||||
const month = parseName(b.name)?.date.slice(0, 6) ?? b.createdAt.slice(0, 7);
|
||||
if (months.has(month)) continue;
|
||||
months.add(month);
|
||||
if (months.size <= policy.keepMonthly) keep.add(b.name);
|
||||
}
|
||||
doomed.push(...nightly.filter((b) => !keep.has(b.name)).map((b) => b.name));
|
||||
|
||||
doomed.push(...ofKind('manual').slice(policy.keepManual ?? 10).map((b) => b.name));
|
||||
doomed.push(...ofKind('pre-upgrade').slice(policy.keepUpgrade ?? 5).map((b) => b.name));
|
||||
doomed.push(...ofKind('pre-restore').slice(policy.keepUpgrade ?? 5).map((b) => b.name));
|
||||
return doomed;
|
||||
}
|
||||
|
||||
export function pruneBackups(dir: string, policy: RetentionPolicy): string[] {
|
||||
const doomed = planRetention(listBackups(dir), policy);
|
||||
for (const name of doomed) unlinkSync(join(dir, name));
|
||||
// Clear partial files left behind by an interrupted backup.
|
||||
if (existsSync(dir)) {
|
||||
const dayAgo = Date.now() - 86_400_000;
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (PARTIAL.test(name) && statSync(join(dir, name)).mtimeMs < dayAgo) unlinkSync(join(dir, name));
|
||||
}
|
||||
}
|
||||
return doomed;
|
||||
}
|
||||
|
||||
/** The full path of a backup, or null when the name isn't a Spendlight backup (blocks path tricks). */
|
||||
export function backupPath(dir: string, name: string): string | null {
|
||||
return NAME.test(name) ? join(dir, name) : null;
|
||||
}
|
||||
|
||||
/** Run a nightly backup at the configured local time, plus a catch-up if the last one is overdue. */
|
||||
export function scheduleNightlyBackups(options: {
|
||||
sqlite: Sqlite;
|
||||
dir: string;
|
||||
at: { hour: number; minute: number };
|
||||
policy: RetentionPolicy;
|
||||
log: Logger;
|
||||
}): { stop(): void } {
|
||||
const { sqlite, dir, at, policy, log } = options;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const backup = await createBackup(sqlite, dir, 'nightly');
|
||||
const removed = pruneBackups(dir, policy);
|
||||
log.info(`Nightly backup ${backup.name} (${removed.length} old copies removed)`);
|
||||
} catch (error) {
|
||||
log.error('Nightly backup failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(at.hour, at.minute, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
timer = setTimeout(async () => {
|
||||
await run();
|
||||
scheduleNext();
|
||||
}, next.getTime() - now.getTime());
|
||||
timer.unref();
|
||||
};
|
||||
|
||||
const newest = listBackups(dir).find((b) => b.kind === 'nightly');
|
||||
const overdue = !newest || Date.now() - Date.parse(newest.createdAt) > 26 * 3_600_000;
|
||||
if (overdue) setTimeout(run, 60_000).unref();
|
||||
|
||||
scheduleNext();
|
||||
return { stop: () => clearTimeout(timer) };
|
||||
}
|
||||
132
server/src/categories/seed.ts
Normal file
132
server/src/categories/seed.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { CategoryKind, PaletteColor } from '@spendlight/shared';
|
||||
import { count } from 'drizzle-orm';
|
||||
import type { DB } from '../db/client';
|
||||
import { categories, categoryGroups } from '../db/schema';
|
||||
|
||||
// The starter category set from the plan. Everything references categories
|
||||
// by ID, so renaming one later changes it everywhere.
|
||||
|
||||
type Starter = [name: string, icon: string, color: PaletteColor];
|
||||
|
||||
const STARTER: { group: string; kind: CategoryKind; items: Starter[] }[] = [
|
||||
{
|
||||
group: 'Income',
|
||||
kind: 'income',
|
||||
items: [
|
||||
['Paycheck', 'banknote', 'emerald'],
|
||||
['Bonus', 'award', 'emerald'],
|
||||
['Interest and Dividends', 'percent', 'teal'],
|
||||
['Other Income', 'circle-dollar-sign', 'teal'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Home',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Mortgage or Rent', 'house', 'indigo'],
|
||||
['Home Insurance', 'shield', 'indigo'],
|
||||
['Property Tax', 'landmark', 'indigo'],
|
||||
['Repairs and Maintenance', 'wrench', 'indigo'],
|
||||
['Furnishings', 'sofa', 'indigo'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Utilities',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Electric and Gas', 'zap', 'amber'],
|
||||
['Water and Trash', 'droplets', 'sky'],
|
||||
['Internet', 'wifi', 'sky'],
|
||||
['Mobile Phone', 'smartphone', 'sky'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Food',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Groceries', 'shopping-cart', 'lime'],
|
||||
['Dining Out', 'utensils', 'orange'],
|
||||
['Coffee and Snacks', 'coffee', 'orange'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Transportation',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Fuel', 'fuel', 'cyan'],
|
||||
['Car Payment', 'car', 'cyan'],
|
||||
['Car Insurance', 'shield-check', 'cyan'],
|
||||
['Car Service', 'car-front', 'cyan'],
|
||||
['Parking and Tolls', 'circle-parking', 'cyan'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Shopping',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Clothing', 'shirt', 'pink'],
|
||||
['Household Supplies', 'spray-can', 'pink'],
|
||||
['Electronics', 'monitor-smartphone', 'violet'],
|
||||
['General Merchandise', 'shopping-bag', 'violet'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Health and Personal',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Medical and Dental', 'stethoscope', 'rose'],
|
||||
['Pharmacy', 'pill', 'rose'],
|
||||
['Personal Care', 'sparkles', 'pink'],
|
||||
['Fitness', 'dumbbell', 'rose'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Fun',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Entertainment', 'ticket', 'violet'],
|
||||
['Streaming and Subscriptions', 'tv', 'violet'],
|
||||
['Hobbies', 'palette', 'violet'],
|
||||
['Travel', 'plane', 'sky'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Family and Giving',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Kids', 'baby', 'amber'],
|
||||
['Pets', 'paw-print', 'amber'],
|
||||
['Gifts', 'gift', 'rose'],
|
||||
['Charity', 'heart-handshake', 'rose'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Financial',
|
||||
kind: 'expense',
|
||||
items: [
|
||||
['Bank Fees and Interest', 'receipt', 'slate'],
|
||||
['Taxes', 'scale', 'slate'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Transfers',
|
||||
kind: 'transfer',
|
||||
items: [
|
||||
['Credit Card Payment', 'credit-card', 'slate'],
|
||||
['Transfer Between Accounts', 'arrow-left-right', 'slate'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Add the starter categories the first time Spendlight runs. */
|
||||
export function seedCategories(db: DB): void {
|
||||
if ((db.select({ n: count() }).from(categoryGroups).get()?.n ?? 0) > 0) return;
|
||||
db.transaction((tx) => {
|
||||
STARTER.forEach((group, g) => {
|
||||
const { id } = tx.insert(categoryGroups).values({ name: group.group, sortOrder: g }).returning().get();
|
||||
group.items.forEach(([name, icon, color], i) => {
|
||||
tx.insert(categories).values({ groupId: id, name, kind: group.kind, icon, color, sortOrder: i }).run();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
231
server/src/categorize/known.ts
Normal file
231
server/src/categorize/known.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Built-in knowledge: common merchants and the category they usually belong
|
||||
// to, plus how the banks' own categories map onto Spendlight's. Every entry is
|
||||
// only a first guess; your own choices always win.
|
||||
|
||||
/** A pattern, the merchant's proper name (null: keep the cleaned-up name), and its usual category. */
|
||||
type Known = [pattern: RegExp, name: string | null, category: string | null];
|
||||
|
||||
export const KNOWN_MERCHANTS: Known[] = [
|
||||
// Amazon family (order matters: specific before general)
|
||||
[/PRIME ?VIDEO/i, 'Prime Video', 'Streaming and Subscriptions'],
|
||||
[/AMAZON PRIME|PRIME MEMBERSHIP|AMZN PRIME/i, 'Amazon Prime', 'Streaming and Subscriptions'],
|
||||
[/AUDIBLE/i, 'Audible', 'Streaming and Subscriptions'],
|
||||
[/KINDLE/i, 'Kindle', 'Hobbies'],
|
||||
[/WHOLE ?FOODS|\bWFM\b/i, 'Whole Foods', 'Groceries'],
|
||||
[/AMZN|AMAZON/i, 'Amazon', null],
|
||||
// Streaming and software
|
||||
[/NETFLIX/i, 'Netflix', 'Streaming and Subscriptions'],
|
||||
[/SPOTIFY/i, 'Spotify', 'Streaming and Subscriptions'],
|
||||
[/HULU/i, 'Hulu', 'Streaming and Subscriptions'],
|
||||
[/DISNEY ?\+|DISNEYPLUS|DISNEY PLUS/i, 'Disney+', 'Streaming and Subscriptions'],
|
||||
[/HBO ?MAX|\bMAX\.COM/i, 'Max', 'Streaming and Subscriptions'],
|
||||
[/YOUTUBE ?PREMIUM|YOUTUBEPREMIUM|YOUTUBE TV/i, 'YouTube', 'Streaming and Subscriptions'],
|
||||
[/PEACOCK/i, 'Peacock', 'Streaming and Subscriptions'],
|
||||
[/PARAMOUNT/i, 'Paramount+', 'Streaming and Subscriptions'],
|
||||
[/SIRIUS ?XM/i, 'SiriusXM', 'Streaming and Subscriptions'],
|
||||
[/APPLE\.COM\/BILL|APPLE\.COM BILL|ITUNES/i, 'Apple', 'Streaming and Subscriptions'],
|
||||
[/GOOGLE \*?(STORAGE|ONE)|GOOGLE ONE/i, 'Google One', 'Streaming and Subscriptions'],
|
||||
[/OPENAI|CHATGPT/i, 'OpenAI', 'Streaming and Subscriptions'],
|
||||
[/ANTHROPIC|CLAUDE\.AI/i, 'Anthropic', 'Streaming and Subscriptions'],
|
||||
[/MICROSOFT ?\*?(365|OFFICE)|MSFT \*/i, 'Microsoft 365', 'Streaming and Subscriptions'],
|
||||
[/ADOBE/i, 'Adobe', 'Streaming and Subscriptions'],
|
||||
// Groceries and warehouse clubs
|
||||
[/COSTCO GAS/i, 'Costco Gas', 'Fuel'],
|
||||
[/COSTCO/i, 'Costco', 'Groceries'],
|
||||
[/SAMS ?CLUB|SAM'S CLUB/i, "Sam's Club", 'Groceries'],
|
||||
[/BJ'?S WHOLESALE/i, "BJ's", 'Groceries'],
|
||||
[/KROGER/i, 'Kroger', 'Groceries'],
|
||||
[/PUBLIX/i, 'Publix', 'Groceries'],
|
||||
[/HARRIS ?TEETER/i, 'Harris Teeter', 'Groceries'],
|
||||
[/FOOD ?LION/i, 'Food Lion', 'Groceries'],
|
||||
[/\bALDI\b/i, 'Aldi', 'Groceries'],
|
||||
[/TRADER JOE/i, "Trader Joe's", 'Groceries'],
|
||||
[/WEGMANS/i, 'Wegmans', 'Groceries'],
|
||||
[/SAFEWAY/i, 'Safeway', 'Groceries'],
|
||||
[/\bLIDL\b/i, 'Lidl', 'Groceries'],
|
||||
[/SPROUTS/i, 'Sprouts', 'Groceries'],
|
||||
[/\bH-?E-?B\b/i, 'H-E-B', 'Groceries'],
|
||||
[/MEIJER/i, 'Meijer', 'Groceries'],
|
||||
[/INGLES/i, 'Ingles', 'Groceries'],
|
||||
[/WINN.?DIXIE/i, 'Winn-Dixie', 'Groceries'],
|
||||
[/FRESH MARKET/i, 'The Fresh Market', 'Groceries'],
|
||||
[/GIANT ?(FOOD|EAGLE)?\b/i, 'Giant', 'Groceries'],
|
||||
[/STOP ?& ?SHOP/i, 'Stop & Shop', 'Groceries'],
|
||||
[/HY-?VEE/i, 'Hy-Vee', 'Groceries'],
|
||||
[/INSTACART/i, 'Instacart', 'Groceries'],
|
||||
// Fuel
|
||||
[/\bSHELL\b/i, 'Shell', 'Fuel'],
|
||||
[/EXXON/i, 'Exxon', 'Fuel'],
|
||||
[/\bMOBIL\b/i, 'Mobil', 'Fuel'],
|
||||
[/\bBP\b|BP#/i, 'BP', 'Fuel'],
|
||||
[/CHEVRON/i, 'Chevron', 'Fuel'],
|
||||
[/SUNOCO/i, 'Sunoco', 'Fuel'],
|
||||
[/SPEEDWAY/i, 'Speedway', 'Fuel'],
|
||||
[/CIRCLE ?K\b/i, 'Circle K', 'Fuel'],
|
||||
[/MARATHON PETRO/i, 'Marathon', 'Fuel'],
|
||||
[/RACETRAC/i, 'RaceTrac', 'Fuel'],
|
||||
[/QUIKTRIP|\bQT \d/i, 'QuikTrip', 'Fuel'],
|
||||
[/SHEETZ/i, 'Sheetz', 'Fuel'],
|
||||
[/WAWA/i, 'Wawa', 'Fuel'],
|
||||
[/7-?ELEVEN|SEVEN ?ELEVEN/i, '7-Eleven', 'Fuel'],
|
||||
[/QUICK ?STOP|KANGAROO EXPRESS/i, 'Kangaroo Express', 'Fuel'],
|
||||
[/CITGO/i, 'Citgo', 'Fuel'],
|
||||
[/VALERO/i, 'Valero', 'Fuel'],
|
||||
[/MURPHY ?(USA|EXPRESS)/i, 'Murphy USA', 'Fuel'],
|
||||
// Coffee
|
||||
[/STARBUCKS/i, 'Starbucks', 'Coffee and Snacks'],
|
||||
[/DUNKIN/i, "Dunkin'", 'Coffee and Snacks'],
|
||||
[/PEET'?S/i, "Peet's Coffee", 'Coffee and Snacks'],
|
||||
[/DUTCH BROS/i, 'Dutch Bros', 'Coffee and Snacks'],
|
||||
// Dining out and delivery
|
||||
[/DOORDASH/i, 'DoorDash', 'Dining Out'],
|
||||
[/GRUBHUB/i, 'Grubhub', 'Dining Out'],
|
||||
[/UBER ?EATS/i, 'Uber Eats', 'Dining Out'],
|
||||
[/MCDONALD/i, "McDonald's", 'Dining Out'],
|
||||
[/CHICK-?FIL-?A/i, 'Chick-fil-A', 'Dining Out'],
|
||||
[/CHIPOTLE/i, 'Chipotle', 'Dining Out'],
|
||||
[/TACO BELL/i, 'Taco Bell', 'Dining Out'],
|
||||
[/WENDY'?S/i, "Wendy's", 'Dining Out'],
|
||||
[/BURGER KING/i, 'Burger King', 'Dining Out'],
|
||||
[/PANERA/i, 'Panera Bread', 'Dining Out'],
|
||||
[/\bSUBWAY\b/i, 'Subway', 'Dining Out'],
|
||||
[/DOMINO'?S/i, "Domino's", 'Dining Out'],
|
||||
[/PIZZA HUT/i, 'Pizza Hut', 'Dining Out'],
|
||||
[/PAPA JOHN/i, "Papa John's", 'Dining Out'],
|
||||
[/FIVE GUYS/i, 'Five Guys', 'Dining Out'],
|
||||
[/JERSEY MIKE/i, "Jersey Mike's", 'Dining Out'],
|
||||
[/JIMMY JOHN/i, "Jimmy John's", 'Dining Out'],
|
||||
[/PANDA EXPRESS/i, 'Panda Express', 'Dining Out'],
|
||||
[/OLIVE GARDEN/i, 'Olive Garden', 'Dining Out'],
|
||||
[/CRACKER BARREL/i, 'Cracker Barrel', 'Dining Out'],
|
||||
[/TEXAS ROADHOUSE/i, 'Texas Roadhouse', 'Dining Out'],
|
||||
[/WAFFLE HOUSE/i, 'Waffle House', 'Dining Out'],
|
||||
[/SWEETGREEN/i, 'Sweetgreen', 'Dining Out'],
|
||||
[/\bCAVA\b/i, 'Cava', 'Dining Out'],
|
||||
[/SHAKE SHACK/i, 'Shake Shack', 'Dining Out'],
|
||||
// Health
|
||||
[/\bCVS\b/i, 'CVS', 'Pharmacy'],
|
||||
[/WALGREENS/i, 'Walgreens', 'Pharmacy'],
|
||||
[/RITE ?AID/i, 'Rite Aid', 'Pharmacy'],
|
||||
[/PLANET FITNESS/i, 'Planet Fitness', 'Fitness'],
|
||||
[/\bYMCA\b/i, 'YMCA', 'Fitness'],
|
||||
[/ORANGETHEORY/i, 'Orangetheory', 'Fitness'],
|
||||
[/PELOTON/i, 'Peloton', 'Fitness'],
|
||||
// Home and shopping
|
||||
[/HOME ?DEPOT/i, 'The Home Depot', 'Repairs and Maintenance'],
|
||||
[/LOWE'?S\b/i, "Lowe's", 'Repairs and Maintenance'],
|
||||
[/ACE HARDWARE/i, 'Ace Hardware', 'Repairs and Maintenance'],
|
||||
[/\bIKEA\b/i, 'IKEA', 'Furnishings'],
|
||||
[/WAYFAIR/i, 'Wayfair', 'Furnishings'],
|
||||
[/BEST ?BUY/i, 'Best Buy', 'Electronics'],
|
||||
[/APPLE STORE|APPLE ONLINE/i, 'Apple Store', 'Electronics'],
|
||||
[/\bTARGET\b/i, 'Target', 'General Merchandise'],
|
||||
[/WAL-?MART|WM SUPERCENTER/i, 'Walmart', 'General Merchandise'],
|
||||
[/DOLLAR TREE/i, 'Dollar Tree', 'General Merchandise'],
|
||||
[/DOLLAR GENERAL/i, 'Dollar General', 'General Merchandise'],
|
||||
[/\bETSY\b/i, 'Etsy', 'General Merchandise'],
|
||||
[/\bEBAY\b/i, 'eBay', 'General Merchandise'],
|
||||
[/TJ ?MAXX|T\.J\. MAXX/i, 'TJ Maxx', 'Clothing'],
|
||||
[/MARSHALLS/i, 'Marshalls', 'Clothing'],
|
||||
[/OLD NAVY/i, 'Old Navy', 'Clothing'],
|
||||
[/KOHL'?S/i, "Kohl's", 'Clothing'],
|
||||
[/NORDSTROM/i, 'Nordstrom', 'Clothing'],
|
||||
[/MACY'?S/i, "Macy's", 'Clothing'],
|
||||
[/\bNIKE\b/i, 'Nike', 'Clothing'],
|
||||
[/\bGAP\b/i, 'Gap', 'Clothing'],
|
||||
[/PETSMART/i, 'PetSmart', 'Pets'],
|
||||
[/PETCO/i, 'Petco', 'Pets'],
|
||||
[/CHEWY/i, 'Chewy', 'Pets'],
|
||||
// Bills
|
||||
[/COMCAST|XFINITY/i, 'Xfinity', 'Internet'],
|
||||
[/SPECTRUM/i, 'Spectrum', 'Internet'],
|
||||
[/COX COMM/i, 'Cox', 'Internet'],
|
||||
[/T-?MOBILE/i, 'T-Mobile', 'Mobile Phone'],
|
||||
[/VERIZON/i, 'Verizon', 'Mobile Phone'],
|
||||
[/AT&T|ATT\*/i, 'AT&T', 'Mobile Phone'],
|
||||
[/DUKE ?ENERGY/i, 'Duke Energy', 'Electric and Gas'],
|
||||
[/DOMINION ?ENERGY/i, 'Dominion Energy', 'Electric and Gas'],
|
||||
[/PIEDMONT ?N(ATURAL)? ?GAS/i, 'Piedmont Natural Gas', 'Electric and Gas'],
|
||||
[/GEICO/i, 'GEICO', 'Car Insurance'],
|
||||
[/STATE ?FARM/i, 'State Farm', 'Car Insurance'],
|
||||
[/PROGRESSIVE/i, 'Progressive', 'Car Insurance'],
|
||||
[/ALLSTATE/i, 'Allstate', 'Car Insurance'],
|
||||
[/E-?Z ?PASS|SUNPASS|QUICK ?PASS|PARKMOBILE|SPOTHERO/i, 'Tolls and Parking', 'Parking and Tolls'],
|
||||
// Fun and travel
|
||||
[/\bAMC\b/i, 'AMC Theatres', 'Entertainment'],
|
||||
[/REGAL/i, 'Regal', 'Entertainment'],
|
||||
[/TICKETMASTER/i, 'Ticketmaster', 'Entertainment'],
|
||||
[/STEAM ?(GAMES|POWERED)|STEAMGAMES/i, 'Steam', 'Hobbies'],
|
||||
[/PLAYSTATION|SONY ?INTERACTIVE/i, 'PlayStation', 'Hobbies'],
|
||||
[/NINTENDO/i, 'Nintendo', 'Hobbies'],
|
||||
[/AIRBNB/i, 'Airbnb', 'Travel'],
|
||||
[/MARRIOTT/i, 'Marriott', 'Travel'],
|
||||
[/HILTON/i, 'Hilton', 'Travel'],
|
||||
[/DELTA AIR/i, 'Delta', 'Travel'],
|
||||
[/SOUTHWEST/i, 'Southwest', 'Travel'],
|
||||
[/AMERICAN AIR/i, 'American Airlines', 'Travel'],
|
||||
[/UNITED AIR/i, 'United', 'Travel'],
|
||||
[/EXPEDIA/i, 'Expedia', 'Travel'],
|
||||
// People-to-people
|
||||
[/^VENMO\b(?!.*\b(TO|FROM)\b)/i, 'Venmo', null],
|
||||
[/^ZELLE\b(?!.*\b(TO|FROM)\b)/i, 'Zelle', null],
|
||||
[/PAYPAL/i, 'PayPal', null],
|
||||
[/\bUBER\b/i, 'Uber', null],
|
||||
[/\bLYFT\b/i, 'Lyft', null],
|
||||
// Money in and fees
|
||||
[/PAYROLL|DIR(ECT)? ?DEP|SALARY/i, null, 'Paycheck'],
|
||||
[/INTEREST (EARNED|PAID|PAYMENT)/i, 'Interest', 'Interest and Dividends'],
|
||||
[/CASH ?BACK|CASHBACK|REWARDS? (CREDIT|REDEMPTION)|STATEMENT CREDIT/i, 'Rewards', 'Other Income'],
|
||||
[/INTEREST CHARGE|LATE FEE|ANNUAL FEE|FOREIGN TRANSACTION FEE|OVERDRAFT|SERVICE FEE|MONTHLY MAINTENANCE/i, 'Bank fee', 'Bank Fees and Interest'],
|
||||
[/\bIRS\b|US TREASURY TAX|DEPT OF REVENUE|TAX PAYMENT/i, 'Taxes', 'Taxes'],
|
||||
];
|
||||
|
||||
/** Lower-cased bank category → Spendlight category (null: no useful hint). */
|
||||
export const BANK_CATEGORIES: Record<string, string | null> = {
|
||||
// Discover
|
||||
supermarkets: 'Groceries',
|
||||
restaurants: 'Dining Out',
|
||||
gasoline: 'Fuel',
|
||||
merchandise: 'General Merchandise',
|
||||
'department stores': 'General Merchandise',
|
||||
'warehouse clubs': 'Groceries',
|
||||
'home improvement': 'Repairs and Maintenance',
|
||||
'medical services': 'Medical and Dental',
|
||||
automotive: 'Car Service',
|
||||
'travel/ entertainment': 'Travel',
|
||||
'travel/entertainment': 'Travel',
|
||||
'government services': 'Taxes',
|
||||
'awards and rebate credits': 'Other Income',
|
||||
fees: 'Bank Fees and Interest',
|
||||
interest: 'Bank Fees and Interest',
|
||||
// Chase
|
||||
groceries: 'Groceries',
|
||||
'food & drink': 'Dining Out',
|
||||
gas: 'Fuel',
|
||||
shopping: 'General Merchandise',
|
||||
entertainment: 'Entertainment',
|
||||
travel: 'Travel',
|
||||
'health & wellness': 'Medical and Dental',
|
||||
personal: 'Personal Care',
|
||||
home: 'Furnishings',
|
||||
'gifts & donations': 'Gifts',
|
||||
'fees & adjustments': 'Bank Fees and Interest',
|
||||
// Capital One, Apple and others
|
||||
dining: 'Dining Out',
|
||||
grocery: 'Groceries',
|
||||
'gas/automotive': 'Fuel',
|
||||
'health care': 'Medical and Dental',
|
||||
airfare: 'Travel',
|
||||
lodging: 'Travel',
|
||||
'phone/cable': 'Internet',
|
||||
restaurant: 'Dining Out',
|
||||
};
|
||||
|
||||
/** Words that mark a card payment or a move between accounts. */
|
||||
export const TRANSFER_PATTERN =
|
||||
/PAYMENT\s*-?\s*THANK ?YOU|AUTOPAY|AUTO ?PAY|E-?PAYMENT|EPAY|ONLINE PAYMENT|MOBILE PAYMENT|INTERNET PAYMENT|PAYMENT RECEIVED|DIRECTPAY|CRCARDPMT|CREDIT ?CRD|CARD ?SERVICES|ELECTRONIC PAYMENT|ONLINE BANKING TRANSFER|TRANSFER (TO|FROM)|\bXFER\b/i;
|
||||
|
||||
export function knownMerchant(description: string): { name: string | null; category: string | null } | null {
|
||||
for (const [pattern, name, category] of KNOWN_MERCHANTS) if (pattern.test(description)) return { name, category };
|
||||
return null;
|
||||
}
|
||||
207
server/src/categorize/suggest.ts
Normal file
207
server/src/categorize/suggest.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { and, count, desc, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||
import type { AppContext } from '../context';
|
||||
import { accounts, categories, rules, transactions, type CategoryRow, type RuleRow } from '../db/schema';
|
||||
import { BANK_CATEGORIES, knownMerchant, TRANSFER_PATTERN } from './known';
|
||||
|
||||
// Where a pre-filled category comes from, strongest first:
|
||||
// 1. your rules ("always file X as Y")
|
||||
// 2. your history: the last 3 picks for this merchant (all agreeing = confident)
|
||||
// 3. card payments and transfers, recognized by their wording
|
||||
// 4. built-in knowledge of common merchants
|
||||
// 5. the bank's own category
|
||||
// Your choices always outrank built-in guesses.
|
||||
|
||||
export interface SuggestInput {
|
||||
accountId: number;
|
||||
merchant: string;
|
||||
description: string;
|
||||
amountCents: number;
|
||||
bankCategory?: string | null;
|
||||
bankType?: string | null;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
categoryId: number | null;
|
||||
/** Up to three likely categories, best first. */
|
||||
suggestions: number[];
|
||||
confident: boolean;
|
||||
source: 'rule' | 'history' | 'transfer' | 'known' | 'bank' | null;
|
||||
}
|
||||
|
||||
const CARD_WORDS = /CARD|CRD|DISCOVER|CHASE|AMEX|AMERICAN EXPRESS|CITI|CAPITAL ONE|CAP ONE|SYNCHRONY|BARCLAY|APPLECARD|GSBANK/i;
|
||||
|
||||
export class Suggester {
|
||||
private byName = new Map<string, CategoryRow>();
|
||||
private byId = new Map<number, CategoryRow>();
|
||||
private rulesByMerchant = new Map<string, RuleRow[]>();
|
||||
private accountKind = new Map<number, string>();
|
||||
private popular: { expense: number[]; income: number[] } | null = null;
|
||||
|
||||
constructor(private ctx: AppContext) {
|
||||
for (const c of ctx.db.select().from(categories).where(eq(categories.archived, false)).all()) {
|
||||
this.byName.set(c.name.toLowerCase(), c);
|
||||
this.byId.set(c.id, c);
|
||||
}
|
||||
for (const r of ctx.db.select().from(rules).all()) {
|
||||
const key = r.merchant.toLowerCase();
|
||||
this.rulesByMerchant.set(key, [...(this.rulesByMerchant.get(key) ?? []), r]);
|
||||
}
|
||||
for (const a of ctx.db.select({ id: accounts.id, kind: accounts.kind }).from(accounts).all()) this.accountKind.set(a.id, a.kind);
|
||||
}
|
||||
|
||||
categoryId(name: string): number | null {
|
||||
return this.byName.get(name.toLowerCase())?.id ?? null;
|
||||
}
|
||||
|
||||
/** The rule that renames or files this merchant, if any (an account-specific rule wins). */
|
||||
rule(merchant: string, accountId: number): RuleRow | undefined {
|
||||
const list = this.rulesByMerchant.get(merchant.toLowerCase()) ?? [];
|
||||
return list.find((r) => r.accountId === accountId) ?? list.find((r) => r.accountId === null);
|
||||
}
|
||||
|
||||
isTransferLike(input: Pick<SuggestInput, 'description' | 'bankType' | 'bankCategory'>): boolean {
|
||||
return (
|
||||
TRANSFER_PATTERN.test(input.description) ||
|
||||
/^payment$/i.test(input.bankType ?? '') ||
|
||||
/^payments and credits$/i.test(input.bankCategory ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
transferCategoryFor(accountId: number, description: string, otherAccountId?: number): number | null {
|
||||
const cardInvolved =
|
||||
this.accountKind.get(accountId) === 'credit_card' ||
|
||||
(otherAccountId !== undefined && this.accountKind.get(otherAccountId) === 'credit_card') ||
|
||||
CARD_WORDS.test(description);
|
||||
return this.categoryId(cardInvolved ? 'Credit Card Payment' : 'Transfer Between Accounts');
|
||||
}
|
||||
|
||||
private fits(categoryId: number | null, amountCents: number): categoryId is number {
|
||||
if (categoryId === null) return false;
|
||||
const c = this.byId.get(categoryId);
|
||||
if (!c) return false;
|
||||
// Income categories only make sense for money coming in.
|
||||
return !(c.kind === 'income' && amountCents < 0);
|
||||
}
|
||||
|
||||
private history(merchant: string): number[] {
|
||||
return this.ctx.db
|
||||
.select({ categoryId: transactions.categoryId })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.merchant, merchant),
|
||||
eq(transactions.needsReview, false),
|
||||
eq(transactions.isSplit, false),
|
||||
isNotNull(transactions.categoryId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transactions.date), desc(transactions.id))
|
||||
.limit(20)
|
||||
.all()
|
||||
.map((r) => r.categoryId!);
|
||||
}
|
||||
|
||||
private popularCategories(direction: 'expense' | 'income'): number[] {
|
||||
if (!this.popular) {
|
||||
const rows = this.ctx.db
|
||||
.select({ categoryId: transactions.categoryId, n: count() })
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.needsReview, false), isNotNull(transactions.categoryId)))
|
||||
.groupBy(transactions.categoryId)
|
||||
.orderBy(desc(count()))
|
||||
.limit(30)
|
||||
.all();
|
||||
const used = rows.map((r) => r.categoryId!).filter((id) => this.byId.has(id));
|
||||
const defaults = (names: string[]) => names.map((n) => this.categoryId(n)).filter((id): id is number => id !== null);
|
||||
const of = (kind: 'expense' | 'income') => used.filter((id) => this.byId.get(id)!.kind === kind);
|
||||
this.popular = {
|
||||
expense: [...of('expense'), ...defaults(['Groceries', 'Dining Out', 'General Merchandise', 'Household Supplies'])],
|
||||
income: [...of('income'), ...defaults(['Paycheck', 'Other Income', 'Interest and Dividends'])],
|
||||
};
|
||||
}
|
||||
return this.popular[direction];
|
||||
}
|
||||
|
||||
suggest(input: SuggestInput): Suggestion {
|
||||
const candidates: number[] = [];
|
||||
const add = (id: number | null) => {
|
||||
if (this.fits(id, input.amountCents) && !candidates.includes(id)) candidates.push(id);
|
||||
};
|
||||
let source: Suggestion['source'] = null;
|
||||
let confident = false;
|
||||
|
||||
const rule = this.rule(input.merchant, input.accountId);
|
||||
if (rule?.categoryId) {
|
||||
add(rule.categoryId);
|
||||
source = 'rule';
|
||||
confident = true;
|
||||
}
|
||||
|
||||
const past = this.history(input.merchant);
|
||||
if (past.length) {
|
||||
const last3 = past.slice(0, 3);
|
||||
if (!source) {
|
||||
add(last3[0]!);
|
||||
source = 'history';
|
||||
confident = last3.length === 3 && last3.every((id) => id === last3[0]);
|
||||
}
|
||||
// Other categories this merchant has had, most frequent first.
|
||||
const freq = new Map<number, number>();
|
||||
for (const id of past) freq.set(id, (freq.get(id) ?? 0) + 1);
|
||||
[...freq.entries()].sort((a, b) => b[1] - a[1]).forEach(([id]) => add(id));
|
||||
}
|
||||
|
||||
if (this.isTransferLike(input)) {
|
||||
add(this.transferCategoryFor(input.accountId, input.description));
|
||||
source ??= 'transfer';
|
||||
}
|
||||
|
||||
const known = knownMerchant(input.description);
|
||||
if (known?.category) {
|
||||
add(this.categoryId(known.category));
|
||||
source ??= 'known';
|
||||
}
|
||||
|
||||
const bank = input.bankCategory ? BANK_CATEGORIES[input.bankCategory.trim().toLowerCase()] : undefined;
|
||||
if (bank) {
|
||||
add(this.categoryId(bank));
|
||||
source ??= 'bank';
|
||||
}
|
||||
|
||||
// Fill the alternates with the best guess's neighbours first (Electric and
|
||||
// Gas → Water and Trash, Internet), then with what this household uses most.
|
||||
const lead = candidates[0] !== undefined ? this.byId.get(candidates[0]) : undefined;
|
||||
if (lead) {
|
||||
for (const c of this.byId.values()) {
|
||||
if (candidates.length >= 3) break;
|
||||
if (c.groupId === lead.groupId) add(c.id);
|
||||
}
|
||||
}
|
||||
for (const id of this.popularCategories(input.amountCents > 0 && this.accountKind.get(input.accountId) !== 'credit_card' ? 'income' : 'expense')) {
|
||||
if (candidates.length >= 3) break;
|
||||
add(id);
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: source ? (candidates[0] ?? null) : null,
|
||||
suggestions: candidates.slice(0, 3),
|
||||
confident,
|
||||
source: source && candidates.length ? source : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Remember "always file this merchant this way" (one rule per merchant, newest wins). */
|
||||
export function rememberRule(ctx: AppContext, merchant: string, categoryId: number, source: 'learned' | 'manual' = 'learned') {
|
||||
const now = new Date().toISOString();
|
||||
const existing = ctx.db
|
||||
.select()
|
||||
.from(rules)
|
||||
.where(and(eq(rules.merchant, merchant), isNull(rules.accountId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
ctx.db.update(rules).set({ categoryId, source, lastUsedAt: now }).where(eq(rules.id, existing.id)).run();
|
||||
return existing.id;
|
||||
}
|
||||
return ctx.db.insert(rules).values({ merchant, categoryId, source, createdAt: now }).returning().get().id;
|
||||
}
|
||||
90
server/src/cli.ts
Normal file
90
server/src/cli.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { IDEA_STATUSES, type IdeaStatus } from '@spendlight/shared';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { hashPassword, temporaryPassword } from './auth/password';
|
||||
import { revokeUserSessions } from './auth/sessions';
|
||||
import { createBackup, pruneBackups } from './backups/backup';
|
||||
import { loadConfig } from './config';
|
||||
import type { AppContext } from './context';
|
||||
import { openDatabase } from './db/client';
|
||||
import { users } from './db/schema';
|
||||
import { ideasToMarkdown } from './ideas/markdown';
|
||||
import { listIdeas } from './ideas/queries';
|
||||
import { silentLogger } from './log';
|
||||
import { findActiveUser } from './users';
|
||||
|
||||
// Admin commands, run inside the container:
|
||||
// spendlight ideas [--status=new|planned|done|declined]
|
||||
// spendlight reset-password <username>
|
||||
// spendlight backup
|
||||
// spendlight version
|
||||
|
||||
const HELP = `Spendlight admin commands
|
||||
|
||||
spendlight ideas [--status=new] Print ideas as Markdown (all statuses by default)
|
||||
spendlight reset-password <user> Give someone a temporary password
|
||||
spendlight backup Back up the database now
|
||||
spendlight version Show the running version
|
||||
`;
|
||||
|
||||
const config = loadConfig();
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
|
||||
function openExisting() {
|
||||
const file = join(config.dataDir, 'spendlight.db');
|
||||
if (!existsSync(file)) {
|
||||
console.error(`No Spendlight database found at ${file}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return openDatabase(file);
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'ideas': {
|
||||
const statusArg = args.find((a) => a.startsWith('--status='))?.split('=')[1];
|
||||
if (statusArg && !IDEA_STATUSES.includes(statusArg as IdeaStatus)) {
|
||||
console.error(`Unknown status "${statusArg}". Use one of: ${IDEA_STATUSES.join(', ')}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { db } = openExisting();
|
||||
process.stdout.write(ideasToMarkdown(listIdeas(db, statusArg as IdeaStatus | undefined)));
|
||||
break;
|
||||
}
|
||||
case 'reset-password': {
|
||||
const username = args[0];
|
||||
if (!username) {
|
||||
console.error('Usage: spendlight reset-password <username>');
|
||||
process.exit(1);
|
||||
}
|
||||
const { db, sqlite } = openExisting();
|
||||
const ctx = { db, sqlite, config, log: silentLogger } as unknown as AppContext;
|
||||
const user = findActiveUser(ctx, { username });
|
||||
if (!user) {
|
||||
console.error(`Nobody uses the username "${username}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
const password = temporaryPassword();
|
||||
db.update(users)
|
||||
.set({ passwordHash: await hashPassword(password), mustChangePassword: true, updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
revokeUserSessions(ctx, user.id);
|
||||
console.log(`Temporary password for ${user.displayName} (${user.username}): ${password}`);
|
||||
console.log('They will choose a new one when they sign in.');
|
||||
break;
|
||||
}
|
||||
case 'backup': {
|
||||
const { sqlite } = openExisting();
|
||||
const backup = await createBackup(sqlite, config.backupDir, 'manual');
|
||||
pruneBackups(config.backupDir, { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly });
|
||||
console.log(`Backed up to ${join(config.backupDir, backup.name)}`);
|
||||
break;
|
||||
}
|
||||
case 'version':
|
||||
console.log(config.version);
|
||||
break;
|
||||
default:
|
||||
process.stdout.write(HELP);
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
59
server/src/config.ts
Normal file
59
server/src/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { DEFAULT_WEB_DIST } from './paths';
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
webDist: string;
|
||||
/** The HTTPS address people use, when set. Pins Face ID sign-in to it. */
|
||||
publicUrl: URL | null;
|
||||
/** Trust X-Forwarded-* headers from the reverse proxy in front of the app. */
|
||||
trustProxy: boolean;
|
||||
ideasEnabled: boolean;
|
||||
backupTime: { hour: number; minute: number };
|
||||
keepDaily: number;
|
||||
keepMonthly: number;
|
||||
sessionDays: number;
|
||||
/** Allow password sign-in over plain HTTP from a non-local address. */
|
||||
allowInsecureSignin: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const truthy = (value: string | undefined) =>
|
||||
value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
|
||||
function int(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||
const n = Number.parseInt(value ?? '', 10);
|
||||
return Number.isFinite(n) && n >= min && n <= max ? n : fallback;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
let publicUrl: URL | null = null;
|
||||
const rawUrl = env.PUBLIC_URL?.trim();
|
||||
if (rawUrl) {
|
||||
try {
|
||||
publicUrl = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`PUBLIC_URL is not a valid address: ${rawUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [hour, minute] = (env.BACKUP_TIME ?? '02:30').split(':');
|
||||
|
||||
return {
|
||||
port: int(env.PORT, 3000, 1, 65535),
|
||||
dataDir: resolve(env.DATA_DIR ?? './data'),
|
||||
backupDir: resolve(env.BACKUP_DIR ?? './backups'),
|
||||
webDist: resolve(env.WEB_DIST ?? DEFAULT_WEB_DIST),
|
||||
publicUrl,
|
||||
trustProxy: truthy(env.TRUST_PROXY),
|
||||
ideasEnabled: truthy(env.IDEAS_ENABLED),
|
||||
backupTime: { hour: int(hour, 2, 0, 23), minute: int(minute, 30, 0, 59) },
|
||||
keepDaily: int(env.BACKUP_KEEP_DAILY, 14, 1, 365),
|
||||
keepMonthly: int(env.BACKUP_KEEP_MONTHLY, 12, 0, 120),
|
||||
sessionDays: int(env.SESSION_DAYS, 400, 1, 400),
|
||||
allowInsecureSignin: truthy(env.ALLOW_INSECURE_SIGNIN),
|
||||
version: env.APP_VERSION?.trim() || 'dev',
|
||||
};
|
||||
}
|
||||
46
server/src/context.ts
Normal file
46
server/src/context.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ChallengeStore, RateLimiter } from './auth/ratelimit';
|
||||
import { seedCategories } from './categories/seed';
|
||||
import { createBackup } from './backups/backup';
|
||||
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 { MIGRATIONS_DIR } from './paths';
|
||||
|
||||
export interface AppContext {
|
||||
config: Config;
|
||||
db: DB;
|
||||
sqlite: Sqlite;
|
||||
limiter: RateLimiter;
|
||||
challenges: ChallengeStore;
|
||||
staging: StagingStore;
|
||||
startedAt: Date;
|
||||
log: Logger;
|
||||
}
|
||||
|
||||
export async function createContext(config: Config, options: { log?: Logger } = {}): Promise<AppContext> {
|
||||
const log = options.log ?? consoleLogger;
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
mkdirSync(config.backupDir, { recursive: true });
|
||||
|
||||
const { sqlite, db } = openDatabase(join(config.dataDir, 'spendlight.db'));
|
||||
const { pending } = await migrateDatabase(db, sqlite, MIGRATIONS_DIR, async () => {
|
||||
const backup = await createBackup(sqlite, config.backupDir, 'pre-upgrade');
|
||||
log.info(`Backed up before upgrading the database: ${backup.name}`);
|
||||
});
|
||||
if (pending > 0) log.info(`Database upgraded (${pending} step${pending === 1 ? '' : 's'})`);
|
||||
seedCategories(db);
|
||||
|
||||
return {
|
||||
config,
|
||||
db,
|
||||
sqlite,
|
||||
limiter: new RateLimiter(),
|
||||
challenges: new ChallengeStore(),
|
||||
staging: new StagingStore(),
|
||||
startedAt: new Date(),
|
||||
log,
|
||||
};
|
||||
}
|
||||
55
server/src/db/client.ts
Normal file
55
server/src/db/client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as schema from './schema';
|
||||
|
||||
export type DB = BetterSQLite3Database<typeof schema>;
|
||||
export type Sqlite = Database.Database;
|
||||
|
||||
export const APP_MARKER = { key: 'app', value: JSON.stringify('spendlight') };
|
||||
|
||||
export function openDatabase(file: string, options: { readonly?: boolean } = {}) {
|
||||
const readonly = options.readonly ?? false;
|
||||
const sqlite = new Database(file, { readonly, fileMustExist: readonly });
|
||||
if (!readonly) sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.pragma('busy_timeout = 5000');
|
||||
const db = drizzle(sqlite, { schema });
|
||||
return { sqlite, db };
|
||||
}
|
||||
|
||||
/** How many migrations are waiting, and how many have already run. */
|
||||
export function migrationStatus(sqlite: Sqlite, migrationsDir: string) {
|
||||
const journal = JSON.parse(readFileSync(join(migrationsDir, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const hasTable = sqlite
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")
|
||||
.get();
|
||||
const applied = hasTable
|
||||
? (sqlite.prepare('SELECT COUNT(*) AS n FROM __drizzle_migrations').get() as { n: number }).n
|
||||
: 0;
|
||||
return { pending: Math.max(0, journal.entries.length - applied), applied };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the database up to date. When an existing database is about to be
|
||||
* changed, `beforeUpgrade` runs first so a backup exists if anything goes wrong.
|
||||
*/
|
||||
export async function migrateDatabase(
|
||||
db: DB,
|
||||
sqlite: Sqlite,
|
||||
migrationsDir: string,
|
||||
beforeUpgrade?: () => Promise<void>,
|
||||
) {
|
||||
const { pending, applied } = migrationStatus(sqlite, migrationsDir);
|
||||
if (pending > 0 && applied > 0) await beforeUpgrade?.();
|
||||
if (pending > 0) migrate(db, { migrationsFolder: migrationsDir });
|
||||
sqlite
|
||||
.prepare('INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING')
|
||||
.run(APP_MARKER.key, APP_MARKER.value);
|
||||
return { pending, applied };
|
||||
}
|
||||
291
server/src/db/schema.ts
Normal file
291
server/src/db/schema.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { blob, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Timestamps are ISO 8601 strings in UTC. Money, when it arrives in later
|
||||
// milestones, is stored as whole cents.
|
||||
|
||||
export const users = sqliteTable(
|
||||
'user',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
username: text('username').notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
access: text('access', { enum: ['read', 'full'] }).notNull().default('full'),
|
||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||
theme: text('theme', { enum: ['system', 'light', 'dark'] }).notNull().default('system'),
|
||||
mustChangePassword: integer('must_change_password', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
deletedAt: text('deleted_at'),
|
||||
},
|
||||
(t) => [uniqueIndex('user_username_unique').on(t.username)],
|
||||
);
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
'session',
|
||||
{
|
||||
/** SHA-256 of the cookie token; the token itself is never stored. */
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
kind: text('kind', { enum: ['browser', 'token'] }).notNull().default('browser'),
|
||||
deviceLabel: text('device_label').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
ip: text('ip'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
lastSeenAt: text('last_seen_at').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
},
|
||||
(t) => [index('session_user_idx').on(t.userId)],
|
||||
);
|
||||
|
||||
export const passkeys = sqliteTable(
|
||||
'passkey',
|
||||
{
|
||||
/** The credential ID, base64url. */
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
publicKey: blob('public_key', { mode: 'buffer' }).notNull(),
|
||||
counter: integer('counter').notNull().default(0),
|
||||
transports: text('transports'),
|
||||
deviceType: text('device_type'),
|
||||
backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
},
|
||||
(t) => [index('passkey_user_idx').on(t.userId)],
|
||||
);
|
||||
|
||||
/** Household-wide settings as JSON values. Also holds the "app" marker that identifies a Spendlight database. */
|
||||
export const settings = sqliteTable('setting', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const changeLog = sqliteTable(
|
||||
'change_log',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
at: text('at').notNull(),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
entity: text('entity').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
detail: text('detail'),
|
||||
},
|
||||
(t) => [index('change_log_at_idx').on(t.at)],
|
||||
);
|
||||
|
||||
export const ideas = sqliteTable(
|
||||
'idea',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
status: text('status', { enum: ['new', 'planned', 'done', 'declined'] }).notNull().default('new'),
|
||||
adminNote: text('admin_note'),
|
||||
path: text('path'),
|
||||
appVersion: text('app_version'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
},
|
||||
(t) => [index('idea_status_idx').on(t.status)],
|
||||
);
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
export type PasskeyRow = typeof passkeys.$inferSelect;
|
||||
export type IdeaRow = typeof ideas.$inferSelect;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone 1: money in. Amounts are whole cents; money out is negative.
|
||||
// Dates from statements are plain calendar days, YYYY-MM-DD.
|
||||
|
||||
export const ACCOUNT_KINDS = ['checking', 'savings', 'credit_card', 'cash', 'loan', 'mortgage', 'heloc'] as const;
|
||||
|
||||
export const accounts = sqliteTable('account', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind', { enum: ACCOUNT_KINDS }).notNull(),
|
||||
institution: text('institution'),
|
||||
last4: text('last4'),
|
||||
color: text('color').notNull().default('indigo'),
|
||||
status: text('status', { enum: ['open', 'retired'] }).notNull().default('open'),
|
||||
/** A known balance at the end of `balanceAsOf`; later transactions are added to it. */
|
||||
balanceCents: integer('balance_cents').notNull().default(0),
|
||||
balanceAsOf: text('balance_as_of'),
|
||||
balanceSource: text('balance_source', { enum: ['entered', 'statement'] }),
|
||||
/** Hidden fingerprints that help match statement files to this account. */
|
||||
layoutKey: text('layout_key'),
|
||||
fileNamePattern: text('file_name_pattern'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
retiredAt: text('retired_at'),
|
||||
});
|
||||
|
||||
export const categoryGroups = sqliteTable('category_group', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const categories = sqliteTable(
|
||||
'category',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
groupId: integer('group_id')
|
||||
.notNull()
|
||||
.references(() => categoryGroups.id),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind', { enum: ['expense', 'income', 'transfer'] }).notNull().default('expense'),
|
||||
icon: text('icon').notNull().default('circle'),
|
||||
color: text('color').notNull().default('slate'),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
},
|
||||
(t) => [index('category_group_idx').on(t.groupId)],
|
||||
);
|
||||
|
||||
/** Column layouts saved from the mapping pop-up (built-in layouts live in code). */
|
||||
export const importLayouts = sqliteTable('import_layout', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
signature: text('signature').notNull(),
|
||||
config: text('config').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const imports = sqliteTable(
|
||||
'import',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: integer('account_id')
|
||||
.notNull()
|
||||
.references(() => accounts.id),
|
||||
fileName: text('file_name').notNull(),
|
||||
fileHash: text('file_hash').notNull(),
|
||||
layoutKey: text('layout_key').notNull(),
|
||||
dateFrom: text('date_from'),
|
||||
dateTo: text('date_to'),
|
||||
rowCount: integer('row_count').notNull().default(0),
|
||||
addedCount: integer('added_count').notNull().default(0),
|
||||
duplicateCount: integer('duplicate_count').notNull().default(0),
|
||||
skippedCount: integer('skipped_count').notNull().default(0),
|
||||
transferCount: integer('transfer_count').notNull().default(0),
|
||||
status: text('status', { enum: ['committed', 'undone'] }).notNull().default('committed'),
|
||||
/** The account's balance checkpoint before this import changed it, so undo can restore it. */
|
||||
previousBalance: text('previous_balance'),
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
undoneAt: text('undone_at'),
|
||||
},
|
||||
(t) => [index('import_account_idx').on(t.accountId)],
|
||||
);
|
||||
|
||||
export const transactions = sqliteTable(
|
||||
'txn',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: integer('account_id')
|
||||
.notNull()
|
||||
.references(() => accounts.id),
|
||||
importId: integer('import_id').references(() => imports.id),
|
||||
date: text('date').notNull(),
|
||||
postedDate: text('posted_date'),
|
||||
amountCents: integer('amount_cents').notNull(),
|
||||
description: text('description').notNull(),
|
||||
merchant: text('merchant').notNull(),
|
||||
/** Null when the purchase is split (see `split`) or not yet categorized. */
|
||||
categoryId: integer('category_id').references(() => categories.id),
|
||||
needsReview: integer('needs_review', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Where the pre-filled category came from: rule, history, known, bank, transfer. */
|
||||
suggestedBy: text('suggested_by'),
|
||||
isSplit: integer('is_split', { mode: 'boolean' }).notNull().default(false),
|
||||
bankCategory: text('bank_category'),
|
||||
bankType: text('bank_type'),
|
||||
note: text('note'),
|
||||
transferPeerId: integer('transfer_peer_id'),
|
||||
source: text('source', { enum: ['import', 'manual'] }).notNull().default('import'),
|
||||
/** account|date|amount|cleaned description: how duplicates are recognized. */
|
||||
dedupeKey: text('dedupe_key').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('txn_account_date_idx').on(t.accountId, t.date),
|
||||
index('txn_date_idx').on(t.date),
|
||||
index('txn_category_date_idx').on(t.categoryId, t.date),
|
||||
index('txn_merchant_idx').on(t.merchant),
|
||||
index('txn_dedupe_idx').on(t.dedupeKey),
|
||||
index('txn_review_idx').on(t.needsReview),
|
||||
index('txn_import_idx').on(t.importId),
|
||||
],
|
||||
);
|
||||
|
||||
export const splits = sqliteTable(
|
||||
'split',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
transactionId: integer('transaction_id')
|
||||
.notNull()
|
||||
.references(() => transactions.id, { onDelete: 'cascade' }),
|
||||
categoryId: integer('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
amountCents: integer('amount_cents').notNull(),
|
||||
note: text('note'),
|
||||
},
|
||||
(t) => [index('split_txn_idx').on(t.transactionId), index('split_category_idx').on(t.categoryId)],
|
||||
);
|
||||
|
||||
export const tags = sqliteTable(
|
||||
'tag',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
color: text('color').notNull().default('slate'),
|
||||
},
|
||||
(t) => [uniqueIndex('tag_name_unique').on(t.name)],
|
||||
);
|
||||
|
||||
export const transactionTags = sqliteTable(
|
||||
'txn_tag',
|
||||
{
|
||||
transactionId: integer('transaction_id')
|
||||
.notNull()
|
||||
.references(() => transactions.id, { onDelete: 'cascade' }),
|
||||
tagId: integer('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(t) => [uniqueIndex('txn_tag_pk').on(t.transactionId, t.tagId), index('txn_tag_tag_idx').on(t.tagId)],
|
||||
);
|
||||
|
||||
/** "Always file this merchant this way." Created when someone says so, or by hand. */
|
||||
export const rules = sqliteTable(
|
||||
'rule',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
merchant: text('merchant').notNull(),
|
||||
accountId: integer('account_id').references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
categoryId: integer('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||
renameTo: text('rename_to'),
|
||||
source: text('source', { enum: ['learned', 'manual'] }).notNull().default('learned'),
|
||||
hits: integer('hits').notNull().default(0),
|
||||
createdAt: text('created_at').notNull(),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
},
|
||||
(t) => [index('rule_merchant_idx').on(t.merchant)],
|
||||
);
|
||||
|
||||
export type AccountRow = typeof accounts.$inferSelect;
|
||||
export type CategoryRow = typeof categories.$inferSelect;
|
||||
export type TransactionRow = typeof transactions.$inferSelect;
|
||||
export type RuleRow = typeof rules.$inferSelect;
|
||||
50
server/src/docs.ts
Normal file
50
server/src/docs.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Hono } from 'hono';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { requireUser } from './auth/guards';
|
||||
import type { Env } from './http';
|
||||
|
||||
// Interactive API documentation at /api/docs, served from the swagger-ui-dist
|
||||
// package on this server (no outside scripts), for anyone signed in.
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const swaggerDir = dirname(require.resolve('swagger-ui-dist/package.json'));
|
||||
|
||||
const FILES: Record<string, string> = {
|
||||
'swagger-ui.css': 'text/css; charset=utf-8',
|
||||
'swagger-ui-bundle.js': 'text/javascript; charset=utf-8',
|
||||
};
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Spendlight API</title>
|
||||
<link rel="stylesheet" href="/api/docs/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/api/docs/swagger-ui-bundle.js"></script>
|
||||
<script src="/api/docs/init.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const INIT = `window.ui = SwaggerUIBundle({ url: '/api/v1/openapi.json', dom_id: '#swagger-ui', deepLinking: true });`;
|
||||
|
||||
export function docsRoutes() {
|
||||
const r = new Hono<Env>();
|
||||
r.get('/', (c) => {
|
||||
requireUser(c);
|
||||
return c.html(PAGE);
|
||||
});
|
||||
r.get('/init.js', (c) => c.body(INIT, 200, { 'Content-Type': 'text/javascript; charset=utf-8' }));
|
||||
r.get('/:file', (c) => {
|
||||
const file = c.req.param('file');
|
||||
const type = FILES[file];
|
||||
if (!type) return c.notFound();
|
||||
return c.body(readFileSync(join(swaggerDir, file)), 200, { 'Content-Type': type });
|
||||
});
|
||||
return r;
|
||||
}
|
||||
59
server/src/http.ts
Normal file
59
server/src/http.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import type { Context } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import type { AppContext } from './context';
|
||||
import type { SessionRow, UserRow } from './db/schema';
|
||||
|
||||
export type Env = {
|
||||
Variables: {
|
||||
user: UserRow | null;
|
||||
session: SessionRow | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** Stop the request with a friendly, structured error: { error: { code, message } }. */
|
||||
export function fail(status: ContentfulStatusCode, code: string, message: string): never {
|
||||
throw new HTTPException(status, { message, cause: { code } });
|
||||
}
|
||||
|
||||
export const nowIso = () => new Date().toISOString();
|
||||
|
||||
const firstHeaderValue = (value: string | undefined) => value?.split(',')[0]?.trim() || undefined;
|
||||
|
||||
/** http or https, as the visitor's browser sees it. */
|
||||
export function requestProto(c: Context, ctx: AppContext): 'http' | 'https' {
|
||||
const forwarded = ctx.config.trustProxy ? firstHeaderValue(c.req.header('x-forwarded-proto')) : undefined;
|
||||
const proto = forwarded ?? new URL(c.req.url).protocol.replace(':', '');
|
||||
return proto === 'https' ? 'https' : 'http';
|
||||
}
|
||||
|
||||
/** host[:port], as the visitor's browser sees it. */
|
||||
export function requestHost(c: Context, ctx: AppContext): string {
|
||||
const forwarded = ctx.config.trustProxy ? firstHeaderValue(c.req.header('x-forwarded-host')) : undefined;
|
||||
return forwarded ?? c.req.header('host') ?? new URL(c.req.url).host;
|
||||
}
|
||||
|
||||
export function requestOrigin(c: Context, ctx: AppContext): string {
|
||||
return `${requestProto(c, ctx)}://${requestHost(c, ctx)}`;
|
||||
}
|
||||
|
||||
export function hostnameOf(host: string): string {
|
||||
return host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]!;
|
||||
}
|
||||
|
||||
export function isLocalHost(hostname: string): boolean {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.endsWith('.localhost');
|
||||
}
|
||||
|
||||
export function clientIp(c: Context, ctx: AppContext): string {
|
||||
if (ctx.config.trustProxy) {
|
||||
const forwarded = firstHeaderValue(c.req.header('x-forwarded-for')) ?? c.req.header('x-real-ip');
|
||||
if (forwarded) return forwarded;
|
||||
}
|
||||
try {
|
||||
return getConnInfo(c).remote.address ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
18
server/src/ideas/markdown.ts
Normal file
18
server/src/ideas/markdown.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Idea } from '@spendlight/shared';
|
||||
|
||||
/** Ideas as Markdown, ready to paste into a conversation with Claude. */
|
||||
export function ideasToMarkdown(ideas: Idea[], heading = 'Spendlight ideas'): string {
|
||||
const lines = [`# ${heading}`, '', `${ideas.length} idea${ideas.length === 1 ? '' : 's'}, exported ${new Date().toISOString().slice(0, 10)}.`, ''];
|
||||
for (const idea of ideas) {
|
||||
lines.push(`## #${idea.id}: ${idea.title}`, '');
|
||||
lines.push(
|
||||
`- Status: ${idea.status}`,
|
||||
`- From: ${idea.author ?? 'unknown'} on ${idea.createdAt.slice(0, 10)}`,
|
||||
`- Screen: ${idea.path ?? 'unknown'}`,
|
||||
`- Version: ${idea.appVersion ?? 'unknown'}`,
|
||||
);
|
||||
if (idea.adminNote) lines.push(`- Note: ${idea.adminNote}`);
|
||||
lines.push('', idea.description.trim() || '_No description._', '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
30
server/src/ideas/queries.ts
Normal file
30
server/src/ideas/queries.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Idea, IdeaStatus } from '@spendlight/shared';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { DB } from '../db/client';
|
||||
import { ideas, users } from '../db/schema';
|
||||
|
||||
export function listIdeas(db: DB, status?: IdeaStatus): Idea[] {
|
||||
const query = db
|
||||
.select({ idea: ideas, author: users.displayName })
|
||||
.from(ideas)
|
||||
.leftJoin(users, eq(users.id, ideas.userId))
|
||||
.orderBy(desc(ideas.createdAt));
|
||||
const rows = status ? query.where(eq(ideas.status, status)).all() : query.all();
|
||||
return rows.map(({ idea, author }) => ({
|
||||
id: idea.id,
|
||||
title: idea.title,
|
||||
description: idea.description,
|
||||
status: idea.status,
|
||||
adminNote: idea.adminNote,
|
||||
path: idea.path,
|
||||
appVersion: idea.appVersion,
|
||||
userAgent: idea.userAgent,
|
||||
author: author ?? null,
|
||||
createdAt: idea.createdAt,
|
||||
updatedAt: idea.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getIdea(db: DB, id: number): Idea | undefined {
|
||||
return listIdeas(db).find((i) => i.id === id);
|
||||
}
|
||||
199
server/src/import/layouts.ts
Normal file
199
server/src/import/layouts.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { AccountKind, ImportColumnMap } from '@spendlight/shared';
|
||||
import { normalizeHeader } from './parse';
|
||||
|
||||
// Statement layouts Spendlight recognizes on sight. Each was confirmed against
|
||||
// the masked profile of a real export; anything else goes to the mapping pop-up,
|
||||
// and the answer is saved as a custom layout.
|
||||
|
||||
export interface Layout {
|
||||
key: string;
|
||||
name: string;
|
||||
institution: string | null;
|
||||
kind: AccountKind | null;
|
||||
/** Normalized header names that must all be present. */
|
||||
headers: string[];
|
||||
/** Header name → role. Missing roles are simply not used. */
|
||||
columns: {
|
||||
date: string;
|
||||
description: string;
|
||||
amount?: string;
|
||||
debit?: string;
|
||||
credit?: string;
|
||||
postedDate?: string;
|
||||
category?: string;
|
||||
type?: string;
|
||||
balance?: string;
|
||||
status?: string;
|
||||
cardNumber?: string;
|
||||
};
|
||||
positiveIs: 'out' | 'in';
|
||||
dateOrder: 'MDY' | 'DMY' | 'YMD';
|
||||
}
|
||||
|
||||
export const BUILT_IN_LAYOUTS: Layout[] = [
|
||||
{
|
||||
key: 'chase-card',
|
||||
name: 'Chase credit card',
|
||||
institution: 'Chase',
|
||||
kind: 'credit_card',
|
||||
headers: ['transaction date', 'post date', 'description', 'category', 'type', 'amount', 'memo'],
|
||||
columns: { date: 'transaction date', postedDate: 'post date', description: 'description', category: 'category', type: 'type', amount: 'amount' },
|
||||
positiveIs: 'in',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
{
|
||||
key: 'discover-card',
|
||||
name: 'Discover card',
|
||||
institution: 'Discover',
|
||||
kind: 'credit_card',
|
||||
headers: ['trans. date', 'post date', 'description', 'amount', 'category'],
|
||||
columns: { date: 'trans. date', postedDate: 'post date', description: 'description', amount: 'amount', category: 'category' },
|
||||
positiveIs: 'out',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
{
|
||||
key: 'bofa-deposit',
|
||||
name: 'Bank of America checking or savings',
|
||||
institution: 'Bank of America',
|
||||
kind: 'checking',
|
||||
headers: ['date', 'description', 'amount', 'running bal.'],
|
||||
columns: { date: 'date', description: 'description', amount: 'amount', balance: 'running bal.' },
|
||||
positiveIs: 'in',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
{
|
||||
key: 'bofa-card',
|
||||
name: 'Bank of America credit card',
|
||||
institution: 'Bank of America',
|
||||
kind: 'credit_card',
|
||||
headers: ['posted date', 'reference number', 'payee', 'address', 'amount'],
|
||||
columns: { date: 'posted date', description: 'payee', amount: 'amount' },
|
||||
positiveIs: 'in',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
{
|
||||
key: 'capital-one-card',
|
||||
name: 'Capital One card',
|
||||
institution: 'Capital One',
|
||||
kind: 'credit_card',
|
||||
headers: ['transaction date', 'posted date', 'card no.', 'description', 'category', 'debit', 'credit'],
|
||||
columns: { date: 'transaction date', postedDate: 'posted date', description: 'description', category: 'category', debit: 'debit', credit: 'credit', cardNumber: 'card no.' },
|
||||
positiveIs: 'out',
|
||||
dateOrder: 'YMD',
|
||||
},
|
||||
{
|
||||
key: 'citi-card',
|
||||
name: 'Citi card',
|
||||
institution: 'Citi',
|
||||
kind: 'credit_card',
|
||||
headers: ['status', 'date', 'description', 'debit', 'credit'],
|
||||
columns: { date: 'date', description: 'description', debit: 'debit', credit: 'credit', status: 'status' },
|
||||
positiveIs: 'out',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
{
|
||||
key: 'apple-card',
|
||||
name: 'Apple Card',
|
||||
institution: 'Apple',
|
||||
kind: 'credit_card',
|
||||
headers: ['transaction date', 'clearing date', 'description', 'merchant', 'category', 'type', 'amount (usd)', 'purchased by'],
|
||||
columns: { date: 'transaction date', postedDate: 'clearing date', description: 'description', category: 'category', type: 'type', amount: 'amount (usd)' },
|
||||
positiveIs: 'out',
|
||||
dateOrder: 'MDY',
|
||||
},
|
||||
];
|
||||
|
||||
export interface ResolvedLayout {
|
||||
key: string;
|
||||
name: string;
|
||||
institution: string | null;
|
||||
kind: AccountKind | null;
|
||||
headerRow: number;
|
||||
map: ImportColumnMap & { type?: number; status?: number; cardNumber?: number };
|
||||
}
|
||||
|
||||
const rowSignature = (row: string[]) => row.map(normalizeHeader).join('|');
|
||||
|
||||
/** Find the header row and a built-in layout that fits it, scanning the top of the file. */
|
||||
export function detectBuiltIn(rows: string[][]): ResolvedLayout | null {
|
||||
const limit = Math.min(rows.length, 40);
|
||||
let best: { layout: Layout; row: number } | null = null;
|
||||
for (let r = 0; r < limit; r++) {
|
||||
const names = new Set(rows[r]!.map(normalizeHeader));
|
||||
for (const layout of BUILT_IN_LAYOUTS) {
|
||||
if (!layout.headers.every((h) => names.has(h))) continue;
|
||||
if (!best || layout.headers.length > best.layout.headers.length) best = { layout, row: r };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
const header = rows[best.row]!.map(normalizeHeader);
|
||||
const at = (name?: string) => (name ? header.indexOf(name) : -1);
|
||||
const index = (name?: string) => (at(name) >= 0 ? at(name) : undefined);
|
||||
const c = best.layout.columns;
|
||||
return {
|
||||
key: best.layout.key,
|
||||
name: best.layout.name,
|
||||
institution: best.layout.institution,
|
||||
kind: best.layout.kind,
|
||||
headerRow: best.row,
|
||||
map: {
|
||||
date: at(c.date),
|
||||
description: at(c.description),
|
||||
amount: index(c.amount),
|
||||
debit: index(c.debit),
|
||||
credit: index(c.credit),
|
||||
postedDate: index(c.postedDate),
|
||||
category: index(c.category),
|
||||
balance: index(c.balance),
|
||||
type: index(c.type),
|
||||
status: index(c.status),
|
||||
cardNumber: index(c.cardNumber),
|
||||
positiveIs: best.layout.positiveIs,
|
||||
dateOrder: best.layout.dateOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the column headings are, for a file nobody has seen before. */
|
||||
export function findHeaderRow(rows: string[][]): number {
|
||||
for (let r = 0; r < Math.min(rows.length, 40); r++) {
|
||||
const row = rows[r]!;
|
||||
const labels = row.filter((c) => c && !/\d/.test(c)).length;
|
||||
const next = rows.slice(r + 1, r + 4);
|
||||
if (labels >= 2 && next.length > 0 && next.every((n) => n.length === row.length)) return r;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function headerSignature(rows: string[][], headerRow: number): string {
|
||||
return headerRow >= 0 ? rowSignature(rows[headerRow]!) : '';
|
||||
}
|
||||
|
||||
/** Best guesses for the mapping pop-up, from heading names. */
|
||||
export function guessMap(header: string[]): ImportColumnMap | null {
|
||||
const names = header.map(normalizeHeader);
|
||||
const find = (...patterns: RegExp[]) => {
|
||||
for (const p of patterns) {
|
||||
const i = names.findIndex((n) => p.test(n));
|
||||
if (i >= 0) return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const date = find(/^(transaction |trans\.? )?date$/, /date/);
|
||||
const description = find(/description/, /payee|merchant|name/, /memo|details/);
|
||||
const amount = find(/^amount/, /amount/);
|
||||
const debit = find(/debit|withdrawal|charge/);
|
||||
const credit = find(/credit|deposit/);
|
||||
const balance = find(/balance/);
|
||||
const category = find(/category/);
|
||||
if (date < 0 || description < 0 || (amount < 0 && debit < 0)) return null;
|
||||
return {
|
||||
date,
|
||||
description,
|
||||
...(amount >= 0 ? { amount } : { debit, ...(credit >= 0 ? { credit } : {}) }),
|
||||
...(balance >= 0 ? { balance } : {}),
|
||||
...(category >= 0 ? { category } : {}),
|
||||
positiveIs: 'in',
|
||||
dateOrder: 'MDY',
|
||||
};
|
||||
}
|
||||
109
server/src/import/merchant.ts
Normal file
109
server/src/import/merchant.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { knownMerchant } from '../categorize/known';
|
||||
|
||||
// Turning "SQ *JOES COFFEE 8823 CHARLOTTE NC" into "Joes Coffee". Known
|
||||
// merchants get their proper names; everything else is tidied by rules.
|
||||
|
||||
const US_STATES = new Set(
|
||||
'AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR'.split(
|
||||
' ',
|
||||
),
|
||||
);
|
||||
|
||||
// Payment processors that put their own name in front of the merchant's.
|
||||
const PROCESSOR = /^(SQ|TST|SP|PY|DD|IC|PP|BT|CKO|FSP|PAR|SNACK|LS|SPO|PAYPAL|PP\*|GOOGLE|STRIPE)\s*\*\s*/i;
|
||||
const NOISE_PREFIX =
|
||||
/^(POS|DEBIT CARD PURCHASE|CHECKCARD|CHECK CARD|PURCHASE AUTHORIZED ON \d{1,2}\/\d{1,2}|PURCHASE|RECURRING|PREAUTHORIZED|ACH|WEB|ONLINE)\s+/i;
|
||||
const KEEP_UPPER = new Set(['CVS', 'BP', 'AMC', 'UPS', 'USPS', 'IRS', 'YMCA', 'IKEA', 'AT&T', 'TJ', 'BJ', 'H&M', 'ATM', 'DMV', 'QT', 'KFC', 'USA', 'LLC', 'TV']);
|
||||
const SMALL_WORDS = new Set(['and', 'of', 'the', 'for', 'at', 'in', 'on', 'to', 'a']);
|
||||
|
||||
function titleCase(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((word, i) => {
|
||||
if (KEEP_UPPER.has(word.toUpperCase())) return word.toUpperCase();
|
||||
if (i > 0 && SMALL_WORDS.has(word)) return word;
|
||||
return word.replace(/(^|[-/.'&])([a-z])/g, (_, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export interface CleanedMerchant {
|
||||
merchant: string;
|
||||
/** Last four digits of another account mentioned in the text, as in "transfer to SAV 1234". */
|
||||
mentionsLast4: string | null;
|
||||
}
|
||||
|
||||
export function cleanMerchant(description: string): CleanedMerchant {
|
||||
const raw = description.replace(/\s+/g, ' ').trim();
|
||||
const last4 = /\b(?:SAV|CHK|CRD|CHECKING|SAVINGS|ACCT|ACCOUNT|CARD|X{2,}|\*{2,}|ENDING(?: IN)?)\s*#?(\d{4})\b/i.exec(raw)?.[1] ?? null;
|
||||
|
||||
// Bank of America's own transfers and card payments read best spelled out.
|
||||
const boa = /^Online Banking transfer (to|from) (SAV|CHK)\b/i.exec(raw);
|
||||
if (boa) {
|
||||
const kind = boa[2]!.toUpperCase() === 'SAV' ? 'savings' : 'checking';
|
||||
return { merchant: `Transfer ${boa[1]!.toLowerCase()} ${kind}`, mentionsLast4: last4 };
|
||||
}
|
||||
if (/^Online Banking payment to CRD\b/i.test(raw)) {
|
||||
return { merchant: last4 ? `Payment to card ${last4}` : 'Card payment', mentionsLast4: last4 };
|
||||
}
|
||||
// Person-to-person payments keep the person's name.
|
||||
const p2p = /^(Zelle|Venmo|Cash ?App)\s+(?:payment|transfer|instant pmt)?\s*(to|from)\s+(.+?)(?:\s+(?:Conf(?:irmation)?#?|ID:|on\s+\d).*)?$/i.exec(raw);
|
||||
if (p2p) {
|
||||
const service = p2p[1]!.replace(/\s+/g, ' ');
|
||||
return { merchant: `${titleCase(service)} ${p2p[2]!.toLowerCase()} ${titleCase(p2p[3]!.trim())}`, mentionsLast4: null };
|
||||
}
|
||||
|
||||
const known = knownMerchant(raw);
|
||||
if (known?.name) return { merchant: known.name, mentionsLast4: last4 };
|
||||
|
||||
let s = raw;
|
||||
// ACH entries: "COMPANY NAME DES:E-PAYMENT ID:123 INDN:... CO ID:... WEB"
|
||||
const ach = /^(.*?)\s+DES:/i.exec(s);
|
||||
if (ach) s = ach[1]!;
|
||||
s = s.replace(NOISE_PREFIX, '');
|
||||
const processor = PROCESSOR.exec(s);
|
||||
if (processor) s = s.slice(processor[0].length);
|
||||
// "MERCHANT*ORDER123" keeps the merchant; anything after a * is a reference.
|
||||
if (s.includes('*')) s = s.split('*')[0]!.trim() || s.replace(/\*/g, ' ');
|
||||
// Card descriptors are written in capitals; that is judged on the merchant
|
||||
// part alone, since bank text around it (like "INDN:Jane Doe") can be mixed.
|
||||
const shouting = s === s.toUpperCase();
|
||||
|
||||
// Bill-pay entries name the payee, then say how it was paid.
|
||||
s = s.replace(/\s+(Bill Payment|Bill Pay|Online Pmt|Online Payment|Web Pmt|ACH Pmt|Autopay)$/i, '');
|
||||
// "NAME - CODE123 ..." : the part after the dash is a reference.
|
||||
const dash = / - (?=.*\d)/.exec(s);
|
||||
if (dash && dash.index > 2) s = s.slice(0, dash.index);
|
||||
// Everything after a store number is location detail.
|
||||
const store = /\s#\s?\d+/.exec(s);
|
||||
if (store) s = s.slice(0, store.index);
|
||||
const hadLocation = !!store;
|
||||
s = s
|
||||
.replace(/\b\d{3}[-.]\d{3}[-.]\d{4}\b/g, ' ') // phone numbers
|
||||
.replace(/[A-Z.]*(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])\b[A-Z0-9]{6,}\b/gi, ' ') // reference codes mixing letters and digits
|
||||
.replace(/\b\d{3,}\b/g, ' ') // long numbers
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Card descriptors (written in capitals) end with "CITY ST": drop the state,
|
||||
// then the city word. Bill-pay payees in mixed case keep every word.
|
||||
const words = s.split(' ');
|
||||
if ((shouting || hadLocation) && words.length >= 3 && US_STATES.has(words.at(-1)!.toUpperCase()) && words.at(-1) === words.at(-1)!.toUpperCase()) {
|
||||
words.pop();
|
||||
if (words.length >= 2) words.pop();
|
||||
s = words.join(' ');
|
||||
}
|
||||
s = s.replace(/[\s,.;:/-]+$/, '').trim();
|
||||
|
||||
// Capitals-only descriptors get title case; text already in normal case keeps its own.
|
||||
const tidy = (text: string) => (shouting ? titleCase(text) : text.charAt(0).toUpperCase() + text.slice(1));
|
||||
const merchant = tidy(s || raw.slice(0, 40));
|
||||
return { merchant, mentionsLast4: last4 };
|
||||
}
|
||||
|
||||
/** How duplicates are recognized: same account, day, amount and cleaned description. */
|
||||
export function dedupeKey(accountId: number, date: string, amountCents: number, description: string): string {
|
||||
const desc = description.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
return `${accountId}|${date}|${amountCents}|${desc}`;
|
||||
}
|
||||
72
server/src/import/parse.ts
Normal file
72
server/src/import/parse.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import Papa from 'papaparse';
|
||||
|
||||
// Reading statement files into plain rows, and turning their text into dates
|
||||
// and cents. Banks disagree on almost everything, so these are forgiving.
|
||||
|
||||
/** Every non-blank row of a delimited file, as trimmed strings. */
|
||||
export function readTable(text: string): string[][] {
|
||||
const clean = text.replace(/^/, '');
|
||||
const result = Papa.parse<string[]>(clean, { skipEmptyLines: 'greedy', delimitersToGuess: [',', '\t', ';', '|'] });
|
||||
return result.data.map((row) => row.map((cell) => (cell ?? '').trim()));
|
||||
}
|
||||
|
||||
export const normalizeHeader = (cell: string) => cell.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
/**
|
||||
* "1,234.56" → 123456, "-$5.00" → -500, "(5.00)" → -500, "5.00 CR" → 500 with
|
||||
* the sign flipped by the caller's rules. Returns null for anything else.
|
||||
*/
|
||||
export function parseCents(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return null;
|
||||
let s = raw.trim().replace(/[−–]/g, '-');
|
||||
if (!s) return null;
|
||||
let negative = false;
|
||||
if (/^\(.*\)$/.test(s)) {
|
||||
negative = true;
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
const suffix = /\s*(CR|DR)$/i.exec(s);
|
||||
if (suffix) s = s.slice(0, suffix.index);
|
||||
s = s.replace(/[$,\s]/g, '');
|
||||
if (s.startsWith('-')) {
|
||||
negative = !negative;
|
||||
s = s.slice(1);
|
||||
} else if (s.startsWith('+')) s = s.slice(1);
|
||||
if (!/^\d+(\.\d{1,4})?$|^\.\d{1,4}$/.test(s)) return null;
|
||||
const cents = Math.round(Number(s) * 100);
|
||||
return negative ? -cents : cents;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
function validDate(y: number, m: number, d: number): string | null {
|
||||
if (y < 1990 || y > 2100 || m < 1 || m > 12 || d < 1 || d > 31) return null;
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
if (date.getUTCMonth() !== m - 1) return null;
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
/** A statement date as YYYY-MM-DD, or null. `order` settles 03/04/2026. */
|
||||
export function parseDate(raw: string | undefined, order: 'MDY' | 'DMY' | 'YMD' = 'MDY'): string | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim();
|
||||
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s);
|
||||
if (m) return validDate(+m[1]!, +m[2]!, +m[3]!);
|
||||
m = /^(\d{4})(\d{2})(\d{2})(\d{6})?/.exec(s);
|
||||
if (m && s.length >= 8 && /^\d{8}/.test(s)) return validDate(+m[1]!, +m[2]!, +m[3]!);
|
||||
m = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(s);
|
||||
if (!m) return null;
|
||||
let [a, b, c] = [+m[1]!, +m[2]!, +m[3]!];
|
||||
if (m[1]!.length === 4) return validDate(a, b, c);
|
||||
if (c < 100) c += 2000;
|
||||
return order === 'DMY' ? validDate(c, b, a) : validDate(c, a, b);
|
||||
}
|
||||
|
||||
/** Days between two YYYY-MM-DD dates. */
|
||||
export function daysBetween(a: string, b: string): number {
|
||||
return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);
|
||||
}
|
||||
|
||||
export function shiftDate(date: string, days: number): string {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
534
server/src/import/service.ts
Normal file
534
server/src/import/service.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import type { AccountKind, ImportColumnMap, ImportPreview, ImportSummary } from '@spendlight/shared';
|
||||
import { and, between, count, desc, eq, inArray } 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 { fail } from '../http';
|
||||
import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts';
|
||||
import { cleanMerchant, dedupeKey } from './merchant';
|
||||
import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse';
|
||||
import { pairTransfers } from './transfers';
|
||||
|
||||
// The import pipeline: stage a file, preview what it would add, then commit.
|
||||
// Nothing is written until commit, and every commit can be undone whole.
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
const STAGE_TTL_MS = 60 * 60_000;
|
||||
|
||||
interface Staged {
|
||||
id: string;
|
||||
fileName: string;
|
||||
text: string;
|
||||
hash: string;
|
||||
userId: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class StagingStore {
|
||||
private files = new Map<string, Staged>();
|
||||
|
||||
add(fileName: string, text: string, userId: number): Staged {
|
||||
this.sweep();
|
||||
const staged = {
|
||||
id: randomUUID(),
|
||||
fileName,
|
||||
text,
|
||||
hash: createHash('sha256').update(text).digest('hex'),
|
||||
userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.files.set(staged.id, staged);
|
||||
return staged;
|
||||
}
|
||||
|
||||
get(id: string, userId: number): Staged {
|
||||
const staged = this.files.get(id);
|
||||
if (!staged || staged.userId !== userId) fail(404, 'expired', 'That file has expired. Please add it again.');
|
||||
return staged;
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.files.delete(id);
|
||||
}
|
||||
|
||||
private sweep() {
|
||||
for (const [id, f] of this.files) if (Date.now() - f.createdAt > STAGE_TTL_MS) this.files.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Row {
|
||||
date: string;
|
||||
postedDate: string | null;
|
||||
description: string;
|
||||
merchant: string;
|
||||
mentionsLast4: string | null;
|
||||
amountCents: number;
|
||||
bankCategory: string | null;
|
||||
bankType: string | null;
|
||||
balanceCents: number | null;
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
layout: ResolvedLayout | null;
|
||||
rows: Row[];
|
||||
skipped: number;
|
||||
header: string[] | null;
|
||||
sample: string[][];
|
||||
suggestedMap: ImportColumnMap | null;
|
||||
signature: string;
|
||||
last4Hint: string | null;
|
||||
openingBalance: { date: string; cents: number } | null;
|
||||
}
|
||||
|
||||
function fileNameLast4(fileName: string): string | null {
|
||||
return /^[A-Za-z]+(\d{4})[_-]/.exec(fileName)?.[1] ?? /(?:x|ending|acct|card)[-_ ]?(\d{4})\b/i.exec(fileName)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** The file name with its digits generalized, so "Chase1234_Activity_20261007.csv" matches next month's file. */
|
||||
const fileNamePattern = (fileName: string) => fileName.replace(/\d{5,}/g, '#').replace(/\(\d+\)/g, '');
|
||||
|
||||
function parseFile(ctx: AppContext, staged: Staged, mapping?: ImportColumnMap): Parsed {
|
||||
const table = readTable(staged.text);
|
||||
let layout = mapping ? null : detectBuiltIn(table);
|
||||
const headerRow = layout?.headerRow ?? findHeaderRow(table);
|
||||
const signature = headerSignature(table, headerRow);
|
||||
|
||||
if (!layout && headerRow >= 0) {
|
||||
const custom = mapping
|
||||
? { name: 'Your saved layout', config: JSON.stringify(mapping) }
|
||||
: ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, signature)).get();
|
||||
if (custom) {
|
||||
layout = {
|
||||
key: 'id' in custom ? `custom-${custom.id}` : 'custom',
|
||||
name: custom.name,
|
||||
institution: null,
|
||||
kind: null,
|
||||
headerRow,
|
||||
map: JSON.parse(custom.config) as ImportColumnMap,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const header = headerRow >= 0 ? table[headerRow]! : null;
|
||||
const sample = table.slice(Math.max(0, headerRow + 1), Math.max(0, headerRow + 1) + 5);
|
||||
if (!layout) {
|
||||
return { layout: null, rows: [], skipped: 0, header, sample, suggestedMap: header ? guessMap(header) : null, signature, last4Hint: null, openingBalance: null };
|
||||
}
|
||||
|
||||
const m = layout.map;
|
||||
const rows: Row[] = [];
|
||||
let skipped = 0;
|
||||
let openingBalance: Parsed['openingBalance'] = null;
|
||||
let last4Hint = fileNameLast4(staged.fileName);
|
||||
|
||||
for (const cells of table.slice(layout.headerRow + 1)) {
|
||||
if (cells.every((c) => !c)) continue;
|
||||
const date = parseDate(cells[m.date], m.dateOrder);
|
||||
const description = (cells[m.description] ?? '').trim();
|
||||
const balanceCents = m.balance !== undefined ? parseCents(cells[m.balance]) : null;
|
||||
if (!date) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// "Beginning balance as of ..." rows carry a balance but no amount.
|
||||
let amount: number | null;
|
||||
if (m.amount !== undefined) {
|
||||
const raw = parseCents(cells[m.amount]);
|
||||
amount = raw === null ? null : m.positiveIs === 'out' ? -raw : raw;
|
||||
} else {
|
||||
const debit = m.debit !== undefined ? parseCents(cells[m.debit]) : null;
|
||||
const credit = m.credit !== undefined ? parseCents(cells[m.credit]) : null;
|
||||
amount = debit ? -Math.abs(debit) : credit ? Math.abs(credit) : debit === 0 || credit === 0 ? 0 : null;
|
||||
}
|
||||
if (amount === null) {
|
||||
if (balanceCents !== null && /beginning balance/i.test(description)) openingBalance = { date, cents: balanceCents };
|
||||
else skipped++;
|
||||
continue;
|
||||
}
|
||||
if ('status' in m && m.status !== undefined && /pending/i.test(cells[m.status] ?? '')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if ('cardNumber' in m && m.cardNumber !== undefined) last4Hint ??= /(\d{4})\s*$/.exec(cells[m.cardNumber] ?? '')?.[1] ?? null;
|
||||
const { merchant, mentionsLast4 } = cleanMerchant(description);
|
||||
rows.push({
|
||||
date,
|
||||
postedDate: m.postedDate !== undefined ? parseDate(cells[m.postedDate], m.dateOrder) : null,
|
||||
description,
|
||||
merchant,
|
||||
mentionsLast4,
|
||||
amountCents: amount,
|
||||
bankCategory: m.category !== undefined ? cells[m.category] || null : null,
|
||||
bankType: 'type' in m && m.type !== undefined ? cells[m.type] || null : null,
|
||||
balanceCents,
|
||||
});
|
||||
}
|
||||
return { layout, rows, skipped, header, sample, suggestedMap: null, signature, last4Hint, openingBalance };
|
||||
}
|
||||
|
||||
/** Which account a file belongs to, judged from fingerprints the person never has to manage. */
|
||||
function matchAccount(ctx: AppContext, parsed: Parsed, fileName: string): ImportPreview['account'] {
|
||||
const open = ctx.db.select().from(accounts).where(eq(accounts.status, 'open')).all();
|
||||
if (!open.length || !parsed.layout) return null;
|
||||
|
||||
if (parsed.last4Hint) {
|
||||
const byLast4 = open.filter((a) => a.last4 === parsed.last4Hint);
|
||||
if (byLast4.length === 1) return { id: byLast4[0]!.id, confidence: 'certain', reason: `The file is for the account ending ${parsed.last4Hint}.` };
|
||||
}
|
||||
|
||||
// Transactions already in an account are the strongest evidence.
|
||||
if (parsed.rows.length) {
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
let best: { account: AccountRow; hits: number } | null = null;
|
||||
for (const account of open) {
|
||||
const keys = new Set(
|
||||
ctx.db
|
||||
.select({ key: transactions.dedupeKey })
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.accountId, account.id), between(transactions.date, dates[0]!, dates.at(-1)!)))
|
||||
.all()
|
||||
.map((r) => r.key),
|
||||
);
|
||||
const hits = parsed.rows.filter((r) => keys.has(dedupeKey(account.id, r.date, r.amountCents, r.description))).length;
|
||||
if (hits && (!best || hits > best.hits)) best = { account, hits };
|
||||
}
|
||||
if (best && (best.hits >= 3 || best.hits >= parsed.rows.length * 0.3)) {
|
||||
return { id: best.account.id, confidence: 'certain', reason: `${best.hits} of these transactions are already in ${best.account.name}.` };
|
||||
}
|
||||
}
|
||||
|
||||
// A statement that starts exactly where an account's last one ended.
|
||||
if (parsed.openingBalance) {
|
||||
const continues = open.filter(
|
||||
(a) => a.balanceSource === 'statement' && a.balanceCents === parsed.openingBalance!.cents && a.layoutKey === parsed.layout!.key,
|
||||
);
|
||||
if (continues.length === 1) return { id: continues[0]!.id, confidence: 'certain', reason: `It picks up where ${continues[0]!.name}’s last statement ended.` };
|
||||
}
|
||||
|
||||
// Softer hints only count when no other account at the same bank could be meant.
|
||||
const institution = parsed.layout.institution?.toLowerCase();
|
||||
const sameBank = open.filter((a) => a.layoutKey === parsed.layout!.key || (institution && a.institution?.toLowerCase() === institution));
|
||||
const sameLayout = open.filter((a) => a.layoutKey === parsed.layout!.key);
|
||||
const samePattern = sameLayout.filter((a) => a.fileNamePattern === fileNamePattern(fileName));
|
||||
if (sameBank.length === 1 && samePattern.length === 1) return { id: samePattern[0]!.id, confidence: 'likely', reason: `Files like this have gone to ${samePattern[0]!.name} before.` };
|
||||
if (sameBank.length === 1 && sameLayout.length === 1) return { id: sameLayout[0]!.id, confidence: 'likely', reason: `${sameLayout[0]!.name} uses this bank’s layout.` };
|
||||
|
||||
const fresh = open.filter(
|
||||
(a) => !a.layoutKey && a.institution && parsed.layout!.institution && a.institution.toLowerCase() === parsed.layout!.institution.toLowerCase(),
|
||||
);
|
||||
if (fresh.length === 1) return { id: fresh[0]!.id, confidence: 'likely', reason: `It’s a ${parsed.layout.institution} file.` };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Planned {
|
||||
row: Row;
|
||||
key: string;
|
||||
status: 'new' | 'duplicate' | 'possible';
|
||||
possibleId: string | null;
|
||||
existingDate: string | null;
|
||||
}
|
||||
|
||||
/** Count-based duplicate check: import only copies beyond those already stored. */
|
||||
function planRows(ctx: AppContext, accountId: number, rows: Row[]): Planned[] {
|
||||
if (!rows.length) return [];
|
||||
const dates = rows.map((r) => r.date).sort();
|
||||
const from = dates[0]!;
|
||||
const to = dates.at(-1)!;
|
||||
const existing = ctx.db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.accountId, accountId), between(transactions.date, shiftDate(from, -3), shiftDate(to, 3))))
|
||||
.all();
|
||||
const existingCount = new Map<string, number>();
|
||||
for (const t of existing) existingCount.set(t.dedupeKey, (existingCount.get(t.dedupeKey) ?? 0) + 1);
|
||||
|
||||
const seen = new Map<string, number>();
|
||||
const planned: Planned[] = rows.map((row) => {
|
||||
const key = dedupeKey(accountId, row.date, row.amountCents, row.description);
|
||||
const n = (seen.get(key) ?? 0) + 1;
|
||||
seen.set(key, n);
|
||||
return { row, key, status: n <= (existingCount.get(key) ?? 0) ? 'duplicate' : 'new', possibleId: null, existingDate: null };
|
||||
});
|
||||
|
||||
// Near matches: same amount and merchant a day or three apart, inside this
|
||||
// file's dates, with no exact twin in the file. Usually a pending charge that
|
||||
// later posted with a new date.
|
||||
const fileKeys = new Set(planned.map((p) => p.key));
|
||||
const used = new Set<number>();
|
||||
for (const p of planned) {
|
||||
if (p.status !== 'new') continue;
|
||||
const near = existing.find(
|
||||
(t) =>
|
||||
!used.has(t.id) &&
|
||||
!fileKeys.has(t.dedupeKey) &&
|
||||
t.amountCents === p.row.amountCents &&
|
||||
t.merchant === p.row.merchant &&
|
||||
t.date !== p.row.date &&
|
||||
t.date >= from &&
|
||||
t.date <= to &&
|
||||
Math.abs(daysBetween(t.date, p.row.date)) <= 3,
|
||||
);
|
||||
if (near) {
|
||||
used.add(near.id);
|
||||
p.status = 'possible';
|
||||
p.possibleId = `${p.key}#${seen.get(p.key)}`;
|
||||
p.existingDate = near.date;
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function accountHint(parsed: Parsed): ImportPreview['accountHint'] {
|
||||
if (!parsed.layout) return null;
|
||||
const kind: AccountKind = parsed.layout.kind ?? 'checking';
|
||||
const institution = parsed.layout.institution;
|
||||
const last4 = parsed.last4Hint;
|
||||
const what = kind === 'credit_card' ? 'card' : kind;
|
||||
const name = [institution, what].filter(Boolean).join(' ') + (last4 ? ` ${last4}` : '');
|
||||
return { kind, institution, last4, name: name.charAt(0).toUpperCase() + name.slice(1) };
|
||||
}
|
||||
|
||||
export function previewImport(
|
||||
ctx: AppContext,
|
||||
stagingId: string,
|
||||
userId: number,
|
||||
options: { mapping?: ImportColumnMap; accountId?: number },
|
||||
): ImportPreview {
|
||||
const staged = ctx.staging.get(stagingId, userId);
|
||||
const parsed = parseFile(ctx, staged, options.mapping);
|
||||
const account = options.accountId
|
||||
? { id: options.accountId, confidence: 'certain' as const, reason: 'You chose this account.' }
|
||||
: matchAccount(ctx, parsed, staged.fileName);
|
||||
const planned = account ? planRows(ctx, account.id, parsed.rows) : parsed.rows.map((row) => ({ row, status: 'new' as const }));
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
const fresh = planned.filter((p) => p.status === 'new');
|
||||
|
||||
return {
|
||||
stagingId,
|
||||
fileName: staged.fileName,
|
||||
layout: parsed.layout ? { key: parsed.layout.key, name: parsed.layout.name } : null,
|
||||
sampleRows: parsed.layout ? [] : parsed.sample,
|
||||
headerRow: parsed.layout ? null : parsed.header,
|
||||
suggestedMap: parsed.suggestedMap,
|
||||
account,
|
||||
dateFrom: dates[0] ?? null,
|
||||
dateTo: dates.at(-1) ?? null,
|
||||
counts: {
|
||||
rows: parsed.rows.length,
|
||||
new: fresh.length,
|
||||
duplicates: planned.filter((p) => p.status === 'duplicate').length,
|
||||
possibleDuplicates: planned.filter((p) => p.status === 'possible').length,
|
||||
skipped: parsed.skipped,
|
||||
},
|
||||
newRows: fresh.slice(0, 8).map((p) => ({ date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents })),
|
||||
possibleDuplicates: planned
|
||||
.filter((p): p is Planned => p.status === 'possible')
|
||||
.map((p) => ({ key: p.possibleId!, date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents, existingDate: p.existingDate! })),
|
||||
accountHint: accountHint(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
export function commitImport(
|
||||
ctx: AppContext,
|
||||
stagingId: string,
|
||||
userId: number,
|
||||
options: { accountId: number; mapping?: ImportColumnMap; includePossible?: string[] },
|
||||
): ImportSummary {
|
||||
const staged = ctx.staging.get(stagingId, userId);
|
||||
const account = ctx.db.select().from(accounts).where(eq(accounts.id, options.accountId)).get();
|
||||
if (!account || account.status !== 'open') fail(400, 'no_account', 'Please choose an open account for this file.');
|
||||
const parsed = parseFile(ctx, staged, options.mapping);
|
||||
if (!parsed.layout) fail(400, 'needs_mapping', 'Spendlight needs to know which column is which before importing this file.');
|
||||
if (!parsed.rows.length) fail(400, 'empty', 'No transactions were found in this file.');
|
||||
|
||||
const include = new Set(options.includePossible ?? []);
|
||||
const planned = planRows(ctx, account.id, parsed.rows).filter(
|
||||
(p) => p.status === 'new' || (p.status === 'possible' && include.has(p.possibleId!)),
|
||||
);
|
||||
const duplicateCount = parsed.rows.length - planned.length;
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = ctx.sqlite.transaction(() => {
|
||||
const suggester = new Suggester(ctx);
|
||||
const record = ctx.db
|
||||
.insert(imports)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
fileName: staged.fileName,
|
||||
fileHash: staged.hash,
|
||||
layoutKey: parsed.layout!.key,
|
||||
dateFrom: dates[0] ?? null,
|
||||
dateTo: dates.at(-1) ?? null,
|
||||
rowCount: parsed.rows.length,
|
||||
addedCount: planned.length,
|
||||
duplicateCount,
|
||||
skippedCount: parsed.skipped,
|
||||
previousBalance: JSON.stringify({
|
||||
balanceCents: account.balanceCents,
|
||||
balanceAsOf: account.balanceAsOf,
|
||||
balanceSource: account.balanceSource,
|
||||
}),
|
||||
userId,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const { row, key } of planned) {
|
||||
const rule = suggester.rule(row.merchant, account.id);
|
||||
const merchant = rule?.renameTo || row.merchant;
|
||||
const s = suggester.suggest({ ...row, merchant, accountId: account.id });
|
||||
const inserted = ctx.db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
importId: record.id,
|
||||
date: row.date,
|
||||
postedDate: row.postedDate,
|
||||
amountCents: row.amountCents,
|
||||
description: row.description,
|
||||
merchant,
|
||||
categoryId: s.categoryId,
|
||||
suggestedBy: s.source,
|
||||
needsReview: true,
|
||||
bankCategory: row.bankCategory,
|
||||
bankType: row.bankType,
|
||||
dedupeKey: key,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({ id: transactions.id })
|
||||
.get();
|
||||
ids.push(inserted.id);
|
||||
}
|
||||
|
||||
const transferCount = pairTransfers(ctx, suggester, ids);
|
||||
ctx.db.update(imports).set({ transferCount }).where(eq(imports.id, record.id)).run();
|
||||
|
||||
// A layout from the mapping pop-up is saved, and remembered by its stored key.
|
||||
let layoutKey = parsed.layout!.key;
|
||||
if (options.mapping) {
|
||||
const existing = ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, parsed.signature)).get();
|
||||
const layoutId = existing
|
||||
? (ctx.db.update(importLayouts).set({ config: JSON.stringify(options.mapping) }).where(eq(importLayouts.id, existing.id)).run(), existing.id)
|
||||
: ctx.db
|
||||
.insert(importLayouts)
|
||||
.values({ name: `${account.name} layout`, signature: parsed.signature, config: JSON.stringify(options.mapping), createdAt: now })
|
||||
.returning()
|
||||
.get().id;
|
||||
layoutKey = `custom-${layoutId}`;
|
||||
ctx.db.update(imports).set({ layoutKey }).where(eq(imports.id, record.id)).run();
|
||||
}
|
||||
|
||||
// Learn this account's fingerprints and, from a running balance, its balance.
|
||||
const updates: Partial<AccountRow> = {
|
||||
layoutKey,
|
||||
fileNamePattern: fileNamePattern(staged.fileName),
|
||||
updatedAt: now,
|
||||
};
|
||||
if (!account.last4 && parsed.last4Hint) updates.last4 = parsed.last4Hint;
|
||||
const withBalance = parsed.rows.filter((r) => r.balanceCents !== null);
|
||||
const last = withBalance.reduce<Row | null>((acc, r) => (!acc || r.date >= acc.date ? r : acc), null);
|
||||
if (last && (!account.balanceAsOf || last.date >= account.balanceAsOf || account.balanceSource !== 'statement')) {
|
||||
updates.balanceCents = last.balanceCents!;
|
||||
updates.balanceAsOf = last.date;
|
||||
updates.balanceSource = 'statement';
|
||||
}
|
||||
ctx.db.update(accounts).set(updates).where(eq(accounts.id, account.id)).run();
|
||||
return { record, transferCount };
|
||||
})();
|
||||
|
||||
// Keep the original file so a fixed importer can read it again later.
|
||||
try {
|
||||
const dir = join(ctx.config.dataDir, 'imports');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${result.record.id}-${staged.hash.slice(0, 12)}${extname(staged.fileName) || '.csv'}`), staged.text);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Could not keep a copy of ${staged.fileName}: ${(error as Error).message}`);
|
||||
}
|
||||
ctx.staging.delete(stagingId);
|
||||
logChange(ctx, userId, 'import', 'import', result.record.id, { file: staged.fileName, added: planned.length });
|
||||
return importSummary(ctx, result.record.id);
|
||||
}
|
||||
|
||||
export function importSummary(ctx: AppContext, id: number): ImportSummary {
|
||||
const row = ctx.db
|
||||
.select({ i: imports, accountName: accounts.name })
|
||||
.from(imports)
|
||||
.innerJoin(accounts, eq(accounts.id, imports.accountId))
|
||||
.where(eq(imports.id, id))
|
||||
.get();
|
||||
if (!row) fail(404, 'not_found', 'That import wasn’t found.');
|
||||
const toReview =
|
||||
ctx.db
|
||||
.select({ n: count() })
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.importId, id), eq(transactions.needsReview, true)))
|
||||
.get()?.n ?? 0;
|
||||
const { i } = row;
|
||||
return {
|
||||
id: i.id,
|
||||
accountId: i.accountId,
|
||||
accountName: row.accountName,
|
||||
fileName: i.fileName,
|
||||
layoutKey: i.layoutKey,
|
||||
dateFrom: i.dateFrom,
|
||||
dateTo: i.dateTo,
|
||||
addedCount: i.addedCount,
|
||||
duplicateCount: i.duplicateCount,
|
||||
skippedCount: i.skippedCount,
|
||||
transferCount: i.transferCount,
|
||||
toReview,
|
||||
status: i.status,
|
||||
createdAt: i.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listImports(ctx: AppContext): ImportSummary[] {
|
||||
return ctx.db
|
||||
.select({ id: imports.id })
|
||||
.from(imports)
|
||||
.orderBy(desc(imports.createdAt))
|
||||
.limit(100)
|
||||
.all()
|
||||
.map((r) => importSummary(ctx, r.id));
|
||||
}
|
||||
|
||||
/** Remove everything one import added. Paired transfers in other accounts go back to Review. */
|
||||
export function undoImport(ctx: AppContext, id: number, userId: number): ImportSummary {
|
||||
const record = ctx.db.select().from(imports).where(eq(imports.id, id)).get();
|
||||
if (!record) fail(404, 'not_found', 'That import wasn’t found.');
|
||||
if (record.status === 'undone') fail(400, 'already_undone', 'That import was already undone.');
|
||||
const now = new Date().toISOString();
|
||||
|
||||
ctx.sqlite.transaction(() => {
|
||||
const added = ctx.db.select().from(transactions).where(eq(transactions.importId, id)).all();
|
||||
const addedIds = added.map((t) => t.id);
|
||||
const peers = added.map((t) => t.transferPeerId).filter((p): p is number => p !== null && !addedIds.includes(p));
|
||||
if (peers.length) {
|
||||
ctx.db.update(transactions).set({ transferPeerId: null, needsReview: true, updatedAt: now }).where(inArray(transactions.id, peers)).run();
|
||||
}
|
||||
if (addedIds.length) ctx.db.delete(transactions).where(inArray(transactions.id, addedIds)).run();
|
||||
|
||||
// Put the balance back if this import was the one that set it.
|
||||
const account = ctx.db.select().from(accounts).where(eq(accounts.id, record.accountId)).get();
|
||||
if (record.previousBalance && account?.balanceSource === 'statement' && account.balanceAsOf) {
|
||||
const fromThisFile = account.balanceAsOf >= (record.dateFrom ?? '') && account.balanceAsOf <= (record.dateTo ?? '');
|
||||
if (fromThisFile) {
|
||||
const prev = JSON.parse(record.previousBalance) as Pick<AccountRow, 'balanceCents' | 'balanceAsOf' | 'balanceSource'>;
|
||||
ctx.db.update(accounts).set({ ...prev, updatedAt: now }).where(eq(accounts.id, account.id)).run();
|
||||
}
|
||||
}
|
||||
ctx.db.update(imports).set({ status: 'undone', undoneAt: now }).where(eq(imports.id, id)).run();
|
||||
})();
|
||||
logChange(ctx, userId, 'undo_import', 'import', id);
|
||||
return importSummary(ctx, id);
|
||||
}
|
||||
|
||||
export { MAX_BYTES };
|
||||
89
server/src/import/transfers.ts
Normal file
89
server/src/import/transfers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { and, between, eq, inArray, isNull, ne } from 'drizzle-orm';
|
||||
import type { Suggester } from '../categorize/suggest';
|
||||
import type { AppContext } from '../context';
|
||||
import { accounts, transactions, type TransactionRow } from '../db/schema';
|
||||
import { daysBetween, shiftDate } from './parse';
|
||||
|
||||
// Pairs the two halves of a card payment or a move between your own accounts:
|
||||
// money leaving one account and the same amount arriving in another within a
|
||||
// few days. Paired halves are filed as transfers and never count as spending
|
||||
// or income.
|
||||
|
||||
const WINDOW_DAYS = 5;
|
||||
|
||||
export function pairTransfers(ctx: AppContext, suggester: Suggester, ids: number[]): number {
|
||||
if (!ids.length) return 0;
|
||||
const last4ByAccount = new Map(
|
||||
ctx.db
|
||||
.select({ id: accounts.id, last4: accounts.last4 })
|
||||
.from(accounts)
|
||||
.all()
|
||||
.map((a) => [a.id, a.last4]),
|
||||
);
|
||||
const fresh = ctx.db.select().from(transactions).where(inArray(transactions.id, ids)).all();
|
||||
let paired = 0;
|
||||
|
||||
for (const t of fresh) {
|
||||
if (t.transferPeerId || t.amountCents === 0) continue;
|
||||
const current = ctx.db.select().from(transactions).where(eq(transactions.id, t.id)).get();
|
||||
if (!current || current.transferPeerId) continue;
|
||||
|
||||
const candidates = ctx.db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
ne(transactions.accountId, t.accountId),
|
||||
eq(transactions.amountCents, -t.amountCents),
|
||||
isNull(transactions.transferPeerId),
|
||||
eq(transactions.isSplit, false),
|
||||
between(transactions.date, shiftDate(t.date, -WINDOW_DAYS), shiftDate(t.date, WINDOW_DAYS)),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const tLike = suggester.isTransferLike(t);
|
||||
const score = (c: TransactionRow) => {
|
||||
const cLike = suggester.isTransferLike(c);
|
||||
if (!tLike && !cLike) return -1;
|
||||
let s = (tLike ? 2 : 0) + (cLike ? 2 : 0);
|
||||
const cLast4 = last4ByAccount.get(c.accountId);
|
||||
const tLast4 = last4ByAccount.get(t.accountId);
|
||||
if (cLast4 && t.description.includes(cLast4)) s += 3;
|
||||
if (tLast4 && c.description.includes(tLast4)) s += 3;
|
||||
return s * 10 - Math.abs(daysBetween(t.date, c.date));
|
||||
};
|
||||
const best = candidates
|
||||
.map((c) => ({ c, s: score(c) }))
|
||||
.filter((x) => x.s >= 0)
|
||||
.sort((a, b) => b.s - a.s)[0];
|
||||
if (!best) continue;
|
||||
|
||||
const categoryId = suggester.transferCategoryFor(t.accountId, t.description, best.c.accountId);
|
||||
const now = new Date().toISOString();
|
||||
for (const [self, peer] of [
|
||||
[t.id, best.c.id],
|
||||
[best.c.id, t.id],
|
||||
] as const) {
|
||||
ctx.db
|
||||
.update(transactions)
|
||||
.set({ transferPeerId: peer, categoryId, needsReview: false, suggestedBy: 'transfer', isSplit: false, updatedAt: now })
|
||||
.where(eq(transactions.id, self))
|
||||
.run();
|
||||
}
|
||||
paired++;
|
||||
}
|
||||
return paired;
|
||||
}
|
||||
|
||||
/** Undo a pairing, putting both halves back in Review. */
|
||||
export function unpair(ctx: AppContext, transactionId: number): void {
|
||||
const t = ctx.db.select().from(transactions).where(eq(transactions.id, transactionId)).get();
|
||||
if (!t?.transferPeerId) return;
|
||||
const now = new Date().toISOString();
|
||||
ctx.db
|
||||
.update(transactions)
|
||||
.set({ transferPeerId: null, needsReview: true, updatedAt: now })
|
||||
.where(inArray(transactions.id, [t.id, t.transferPeerId]))
|
||||
.run();
|
||||
}
|
||||
37
server/src/index.ts
Normal file
37
server/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app';
|
||||
import { scheduleNightlyBackups } from './backups/backup';
|
||||
import { loadConfig } from './config';
|
||||
import { createContext } from './context';
|
||||
|
||||
const config = loadConfig();
|
||||
const ctx = await createContext(config);
|
||||
const app = createApp(ctx);
|
||||
|
||||
const backups = scheduleNightlyBackups({
|
||||
sqlite: ctx.sqlite,
|
||||
dir: config.backupDir,
|
||||
at: config.backupTime,
|
||||
policy: { keepDaily: config.keepDaily, keepMonthly: config.keepMonthly },
|
||||
log: ctx.log,
|
||||
});
|
||||
|
||||
const server = serve({ fetch: app.fetch, port: config.port, hostname: '0.0.0.0' }, (info) => {
|
||||
ctx.log.info(
|
||||
`Spendlight ${config.version} listening on port ${info.port}` +
|
||||
` (ideas ${config.ideasEnabled ? 'on' : 'off'}, data in ${config.dataDir}, backups in ${config.backupDir})`,
|
||||
);
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
ctx.log.info(`${signal} received, shutting down`);
|
||||
backups.stop();
|
||||
server.close(() => {
|
||||
ctx.sqlite.close();
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
15
server/src/log.ts
Normal file
15
server/src/log.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface Logger {
|
||||
info(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string, error?: unknown): void;
|
||||
}
|
||||
|
||||
const stamp = () => new Date().toISOString();
|
||||
|
||||
export const consoleLogger: Logger = {
|
||||
info: (message) => console.log(`${stamp()} ${message}`),
|
||||
warn: (message) => console.warn(`${stamp()} WARN ${message}`),
|
||||
error: (message, error) => console.error(`${stamp()} ERROR ${message}`, error ?? ''),
|
||||
};
|
||||
|
||||
export const silentLogger: Logger = { info() {}, warn() {}, error() {} };
|
||||
6
server/src/paths.ts
Normal file
6
server/src/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Resolved from this file, which sits one level below server/ both as source
|
||||
// (server/src) and once bundled (server/dist), so the same paths work in both.
|
||||
export const MIGRATIONS_DIR = fileURLToPath(new URL('../drizzle', import.meta.url));
|
||||
export const DEFAULT_WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
|
||||
167
server/src/routes/accounts.ts
Normal file
167
server/src/routes/accounts.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { logChange } from '../audit';
|
||||
import { requireUser, requireWrite } from '../auth/guards';
|
||||
import type { AppContext } from '../context';
|
||||
import { accounts, transactions } from '../db/schema';
|
||||
import { fail, nowIso, type Env } from '../http';
|
||||
import { AccountKindSchema, AccountSchema, Cents, DateString, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas';
|
||||
import { toAccount } from '../transactions/dto';
|
||||
|
||||
const today = () => new Date().toLocaleDateString('en-CA');
|
||||
|
||||
export function accountRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
const find = (id: number) => ctx.db.select().from(accounts).where(eq(accounts.id, id)).get() ?? fail(404, 'not_found', 'That account wasn’t found.');
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/accounts',
|
||||
tags: ['Accounts'],
|
||||
summary: 'Every account, open and retired, with current balances',
|
||||
responses: jsonResponse(z.array(AccountSchema), 'Accounts'),
|
||||
}),
|
||||
(c) => {
|
||||
requireUser(c);
|
||||
const rows = ctx.db.select().from(accounts).orderBy(asc(accounts.sortOrder), asc(accounts.name)).all();
|
||||
return c.json(rows.map((a) => toAccount(ctx, a)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/accounts',
|
||||
tags: ['Accounts'],
|
||||
summary: 'Add an account',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, 'Please name the account.').max(60),
|
||||
kind: AccountKindSchema,
|
||||
institution: z.string().trim().max(60).nullable().optional(),
|
||||
last4: z.string().regex(/^\d{4}$/, 'Use the last 4 digits.').nullable().optional(),
|
||||
color: PaletteSchema.optional(),
|
||||
/** Today's balance; for a card, the amount owed as a negative number. */
|
||||
balanceCents: Cents.optional(),
|
||||
balanceAsOf: DateString.optional(),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(AccountSchema, 'Added'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const body = c.req.valid('json');
|
||||
const now = nowIso();
|
||||
const row = ctx.db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
name: body.name,
|
||||
kind: body.kind,
|
||||
institution: body.institution ?? null,
|
||||
last4: body.last4 ?? null,
|
||||
color: body.color ?? 'indigo',
|
||||
balanceCents: body.balanceCents ?? 0,
|
||||
balanceAsOf: body.balanceCents !== undefined ? (body.balanceAsOf ?? today()) : null,
|
||||
balanceSource: body.balanceCents !== undefined ? 'entered' : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
logChange(ctx, user.id, 'add_account', 'account', row.id, { name: row.name, kind: row.kind });
|
||||
return c.json(toAccount(ctx, row), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/accounts/{id}',
|
||||
tags: ['Accounts'],
|
||||
summary: 'Rename, recolor, retire or reopen an account',
|
||||
request: {
|
||||
params: IdParam,
|
||||
...jsonBody(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(60).optional(),
|
||||
kind: AccountKindSchema.optional(),
|
||||
institution: z.string().trim().max(60).nullable().optional(),
|
||||
last4: z.string().regex(/^\d{4}$/).nullable().optional(),
|
||||
color: PaletteSchema.optional(),
|
||||
status: z.enum(['open', 'retired']).optional(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
responses: jsonResponse(AccountSchema, 'Updated'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const body = c.req.valid('json');
|
||||
find(id);
|
||||
const now = nowIso();
|
||||
const updated = ctx.db
|
||||
.update(accounts)
|
||||
.set({
|
||||
...body,
|
||||
...(body.status === 'retired' ? { retiredAt: now } : body.status === 'open' ? { retiredAt: null } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.returning()
|
||||
.get()!;
|
||||
logChange(ctx, user.id, 'update_account', 'account', id, body);
|
||||
return c.json(toAccount(ctx, updated), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/accounts/{id}/balance',
|
||||
tags: ['Accounts'],
|
||||
summary: 'Correct an account’s balance, as of a day (today by default)',
|
||||
request: { params: IdParam, ...jsonBody(z.object({ balanceCents: Cents, asOf: DateString.optional() })) },
|
||||
responses: jsonResponse(AccountSchema, 'Balance set'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const { balanceCents, asOf } = c.req.valid('json');
|
||||
find(id);
|
||||
const updated = ctx.db
|
||||
.update(accounts)
|
||||
.set({ balanceCents, balanceAsOf: asOf ?? today(), balanceSource: 'entered', updatedAt: nowIso() })
|
||||
.where(eq(accounts.id, id))
|
||||
.returning()
|
||||
.get()!;
|
||||
logChange(ctx, user.id, 'set_balance', 'account', id, { balanceCents, asOf });
|
||||
return c.json(toAccount(ctx, updated), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/accounts/{id}',
|
||||
tags: ['Accounts'],
|
||||
summary: 'Delete an account that has no transactions (retire it otherwise)',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Deleted'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const account = find(id);
|
||||
const used = ctx.db.select({ id: transactions.id }).from(transactions).where(eq(transactions.accountId, id)).limit(1).get();
|
||||
if (used) fail(400, 'has_transactions', 'This account has transactions. Retire it instead, so its history stays in your reports.');
|
||||
ctx.db.delete(accounts).where(eq(accounts.id, id)).run();
|
||||
logChange(ctx, user.id, 'delete_account', 'account', id, { name: account.name });
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
175
server/src/routes/auth.ts
Normal file
175
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { logChange } from '../audit';
|
||||
import { assertSecureSignIn, requireUser } from '../auth/guards';
|
||||
import { hashPassword, verifyPassword } from '../auth/password';
|
||||
import { clearSessionCookie, createSession, revokeSession, revokeUserSessions, setSessionCookie } from '../auth/sessions';
|
||||
import type { AppContext } from '../context';
|
||||
import { users } from '../db/schema';
|
||||
import { clientIp, fail, nowIso, type Env } from '../http';
|
||||
import {
|
||||
DisplayName,
|
||||
jsonBody,
|
||||
jsonResponse,
|
||||
MeSchema,
|
||||
OkSchema,
|
||||
Password,
|
||||
ThemeSchema,
|
||||
Username,
|
||||
validationHook,
|
||||
} from '../schemas';
|
||||
import { activeUserCount, findActiveUser, toMe } from '../users';
|
||||
|
||||
export function authRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/setup',
|
||||
tags: ['Sign-in'],
|
||||
summary: 'Create the first login (only works while there are no people yet)',
|
||||
request: jsonBody(z.object({ displayName: DisplayName, username: Username, password: Password })),
|
||||
responses: jsonResponse(MeSchema, 'Created and signed in'),
|
||||
}),
|
||||
async (c) => {
|
||||
assertSecureSignIn(c, ctx);
|
||||
const body = c.req.valid('json');
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
// Check and insert in one transaction so two setups can't race.
|
||||
const user = ctx.sqlite.transaction(() => {
|
||||
if (activeUserCount(ctx) > 0) fail(409, 'already_set_up', 'Spendlight is already set up. Please sign in.');
|
||||
const now = nowIso();
|
||||
return ctx.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: body.username,
|
||||
displayName: body.displayName,
|
||||
passwordHash,
|
||||
access: 'full',
|
||||
isAdmin: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
})();
|
||||
logChange(ctx, user.id, 'setup', 'user', user.id);
|
||||
const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c, ctx) });
|
||||
setSessionCookie(c, ctx, token);
|
||||
return c.json(toMe(ctx, user), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/auth/login',
|
||||
tags: ['Sign-in'],
|
||||
summary: 'Sign in with username and password',
|
||||
request: jsonBody(z.object({ username: z.string().trim().min(1).max(40), password: z.string().min(1).max(200) })),
|
||||
responses: jsonResponse(MeSchema, 'Signed in'),
|
||||
}),
|
||||
async (c) => {
|
||||
assertSecureSignIn(c, ctx);
|
||||
const { username, password } = c.req.valid('json');
|
||||
const ip = clientIp(c, ctx);
|
||||
const keys = [`ip:${ip}`, `user:${username.toLowerCase()}`];
|
||||
const wait = Math.max(...keys.map((k) => ctx.limiter.retryAfter(k)));
|
||||
if (wait > 0) {
|
||||
c.header('Retry-After', String(wait));
|
||||
fail(429, 'slow_down', `Too many tries. Please wait ${wait > 60 ? `${Math.ceil(wait / 60)} minutes` : `${wait} seconds`} and try again.`);
|
||||
}
|
||||
|
||||
const user = findActiveUser(ctx, { username });
|
||||
if (!(await verifyPassword(user?.passwordHash, password)) || !user) {
|
||||
keys.forEach((k) => ctx.limiter.fail(k));
|
||||
fail(401, 'wrong_password', 'That username and password don’t match.');
|
||||
}
|
||||
keys.forEach((k) => ctx.limiter.succeed(k));
|
||||
|
||||
const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip });
|
||||
setSessionCookie(c, ctx, token);
|
||||
return c.json(toMe(ctx, user), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/auth/logout',
|
||||
tags: ['Sign-in'],
|
||||
summary: 'Sign out of this device',
|
||||
responses: jsonResponse(OkSchema, 'Signed out'),
|
||||
}),
|
||||
(c) => {
|
||||
const session = c.get('session');
|
||||
if (session) revokeSession(ctx, session.id);
|
||||
clearSessionCookie(c);
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/me',
|
||||
tags: ['Me'],
|
||||
summary: 'The person signed in on this device',
|
||||
responses: jsonResponse(MeSchema, 'You'),
|
||||
}),
|
||||
(c) => c.json(toMe(ctx, requireUser(c)), 200),
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/me',
|
||||
tags: ['Me'],
|
||||
summary: 'Change your name or theme',
|
||||
request: jsonBody(z.object({ displayName: DisplayName.optional(), theme: ThemeSchema.optional() })),
|
||||
responses: jsonResponse(MeSchema, 'Updated'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const body = c.req.valid('json');
|
||||
const updated = ctx.db
|
||||
.update(users)
|
||||
.set({ ...body, updatedAt: nowIso() })
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
.get();
|
||||
return c.json(toMe(ctx, updated ?? user), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/me/password',
|
||||
tags: ['Me'],
|
||||
summary: 'Change your password (signs out your other devices)',
|
||||
request: jsonBody(z.object({ currentPassword: z.string().min(1).max(200), newPassword: Password })),
|
||||
responses: jsonResponse(MeSchema, 'Password changed'),
|
||||
}),
|
||||
async (c) => {
|
||||
const user = requireUser(c);
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
if (!(await verifyPassword(user.passwordHash, currentPassword))) {
|
||||
fail(400, 'wrong_password', 'Your current password isn’t right.');
|
||||
}
|
||||
if (currentPassword === newPassword) fail(400, 'same_password', 'Please choose a different password.');
|
||||
const updated = ctx.db
|
||||
.update(users)
|
||||
.set({ passwordHash: await hashPassword(newPassword), mustChangePassword: false, updatedAt: nowIso() })
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
.get();
|
||||
revokeUserSessions(ctx, user.id, c.get('session')?.id);
|
||||
logChange(ctx, user.id, 'change_password', 'user', user.id);
|
||||
return c.json(toMe(ctx, updated ?? user), 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
76
server/src/routes/backups.ts
Normal file
76
server/src/routes/backups.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { logChange } from '../audit';
|
||||
import { requireAdmin } from '../auth/guards';
|
||||
import { backupPath, createBackup, listBackups, pruneBackups } from '../backups/backup';
|
||||
import type { AppContext } from '../context';
|
||||
import { fail, type Env } from '../http';
|
||||
import { BackupSchema, jsonResponse, validationHook } from '../schemas';
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
export function backupRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
const { backupDir, backupTime, keepDaily, keepMonthly } = ctx.config;
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/backups',
|
||||
tags: ['Backups'],
|
||||
summary: 'Backups in the backup folder, newest first',
|
||||
responses: jsonResponse(
|
||||
z
|
||||
.object({ backups: z.array(BackupSchema), nightlyAt: z.string(), keepDaily: z.number(), keepMonthly: z.number() })
|
||||
.openapi('BackupOverview'),
|
||||
'Backups',
|
||||
),
|
||||
}),
|
||||
(c) => {
|
||||
requireAdmin(c);
|
||||
return c.json(
|
||||
{
|
||||
backups: listBackups(backupDir),
|
||||
nightlyAt: `${pad(backupTime.hour)}:${pad(backupTime.minute)}`,
|
||||
keepDaily,
|
||||
keepMonthly,
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/backups',
|
||||
tags: ['Backups'],
|
||||
summary: 'Back up right now',
|
||||
responses: jsonResponse(BackupSchema, 'Backed up'),
|
||||
}),
|
||||
async (c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const backup = await createBackup(ctx.sqlite, backupDir, 'manual');
|
||||
pruneBackups(backupDir, { keepDaily, keepMonthly });
|
||||
logChange(ctx, admin.id, 'backup', 'backup', backup.name);
|
||||
return c.json(backup, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// A file download, so it sits outside the JSON API description.
|
||||
r.get('/backups/:name/download', (c) => {
|
||||
requireAdmin(c);
|
||||
const path = backupPath(backupDir, c.req.param('name'));
|
||||
if (!path || !existsSync(path)) fail(404, 'not_found', 'That backup wasn’t found.');
|
||||
return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.sqlite3',
|
||||
'Content-Length': String(statSync(path).size),
|
||||
'Content-Disposition': `attachment; filename="${c.req.param('name')}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
82
server/src/routes/devices.ts
Normal file
82
server/src/routes/devices.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { requireUser } from '../auth/guards';
|
||||
import { revokeSession, revokeUserSessions } from '../auth/sessions';
|
||||
import type { AppContext } from '../context';
|
||||
import { sessions } from '../db/schema';
|
||||
import { fail, type Env } from '../http';
|
||||
import { DeviceSchema, jsonResponse, OkSchema, validationHook } from '../schemas';
|
||||
|
||||
export function deviceRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/devices',
|
||||
tags: ['Me'],
|
||||
summary: 'Devices where you are signed in',
|
||||
responses: jsonResponse(z.array(DeviceSchema), 'Your signed-in devices'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const current = c.get('session')?.id;
|
||||
const rows = ctx.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, user.id))
|
||||
.orderBy(desc(sessions.lastSeenAt))
|
||||
.all();
|
||||
return c.json(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
deviceLabel: s.deviceLabel,
|
||||
createdAt: s.createdAt,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
current: s.id === current,
|
||||
})),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/devices/{id}',
|
||||
tags: ['Me'],
|
||||
summary: 'Sign out one of your devices',
|
||||
request: { params: z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }) },
|
||||
responses: jsonResponse(OkSchema, 'Signed out'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const found = ctx.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, id), eq(sessions.userId, user.id)))
|
||||
.get();
|
||||
if (!found) fail(404, 'not_found', 'That device is already signed out.');
|
||||
revokeSession(ctx, id);
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/devices/sign-out-others',
|
||||
tags: ['Me'],
|
||||
summary: 'Sign out everywhere except this device',
|
||||
responses: jsonResponse(OkSchema, 'Signed out'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
revokeUserSessions(ctx, user.id, c.get('session')?.id);
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
110
server/src/routes/ideas.ts
Normal file
110
server/src/routes/ideas.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { logChange } from '../audit';
|
||||
import { requireAdmin, requireUser } from '../auth/guards';
|
||||
import type { AppContext } from '../context';
|
||||
import { ideas } from '../db/schema';
|
||||
import { fail, nowIso, type Env } from '../http';
|
||||
import { ideasToMarkdown } from '../ideas/markdown';
|
||||
import { getIdea, listIdeas } from '../ideas/queries';
|
||||
import { IdeaSchema, IdeaStatusSchema, IdParam, jsonBody, jsonResponse, validationHook } from '../schemas';
|
||||
|
||||
// The ideas box. Only mounted when IDEAS_ENABLED is on, so with the flag off
|
||||
// these addresses simply don't exist.
|
||||
|
||||
export function ideaRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/ideas',
|
||||
tags: ['Ideas'],
|
||||
summary: 'Share an idea (anyone signed in, including read-only people)',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
title: z.string().trim().min(1, 'Please give your idea a title.').max(120),
|
||||
description: z.string().trim().max(4000).default(''),
|
||||
path: z.string().max(200).optional(),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(IdeaSchema, 'Saved'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const body = c.req.valid('json');
|
||||
const now = nowIso();
|
||||
const row = ctx.db
|
||||
.insert(ideas)
|
||||
.values({
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
path: body.path ?? null,
|
||||
appVersion: ctx.config.version,
|
||||
userAgent: c.req.header('user-agent')?.slice(0, 300) ?? null,
|
||||
userId: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
ctx.log.info(`New idea #${row.id} from ${user.username}: ${row.title}`);
|
||||
return c.json(getIdea(ctx.db, row.id)!, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/ideas',
|
||||
tags: ['Ideas'],
|
||||
summary: 'All ideas, newest first',
|
||||
request: { query: z.object({ status: IdeaStatusSchema.optional() }) },
|
||||
responses: jsonResponse(z.array(IdeaSchema), 'Ideas'),
|
||||
}),
|
||||
(c) => {
|
||||
requireAdmin(c);
|
||||
return c.json(listIdeas(ctx.db, c.req.valid('query').status), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/ideas/{id}',
|
||||
tags: ['Ideas'],
|
||||
summary: 'Set an idea’s status or note',
|
||||
request: {
|
||||
params: IdParam,
|
||||
...jsonBody(z.object({ status: IdeaStatusSchema.optional(), adminNote: z.string().trim().max(2000).nullable().optional() })),
|
||||
},
|
||||
responses: jsonResponse(IdeaSchema, 'Updated'),
|
||||
}),
|
||||
(c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const body = c.req.valid('json');
|
||||
const updated = ctx.db
|
||||
.update(ideas)
|
||||
.set({ ...body, adminNote: body.adminNote === '' ? null : body.adminNote, updatedAt: nowIso() })
|
||||
.where(eq(ideas.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
if (!updated) fail(404, 'not_found', 'That idea wasn’t found.');
|
||||
logChange(ctx, admin.id, 'update_idea', 'idea', id, body);
|
||||
return c.json(getIdea(ctx.db, id)!, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// Markdown for review with Claude, so it sits outside the JSON API description.
|
||||
r.get('/ideas/export.md', (c) => {
|
||||
requireAdmin(c);
|
||||
const status = c.req.query('status');
|
||||
const list = listIdeas(ctx.db, IdeaStatusSchema.safeParse(status).success ? (status as never) : undefined);
|
||||
c.header('Content-Type', 'text/markdown; charset=utf-8');
|
||||
c.header('Content-Disposition', 'attachment; filename="spendlight-ideas.md"');
|
||||
return c.body(ideasToMarkdown(list));
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
120
server/src/routes/imports.ts
Normal file
120
server/src/routes/imports.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import { requireUser, requireWrite } from '../auth/guards';
|
||||
import type { AppContext } from '../context';
|
||||
import { fail, type Env } from '../http';
|
||||
import { commitImport, listImports, MAX_BYTES, previewImport, undoImport } from '../import/service';
|
||||
import { ColumnMapSchema, IdParam, ImportPreviewSchema, ImportSummarySchema, jsonBody, jsonResponse, validationHook } from '../schemas';
|
||||
|
||||
export function importRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/imports/files',
|
||||
tags: ['Import'],
|
||||
summary: 'Upload one statement file and get a preview of what it would add',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'multipart/form-data': {
|
||||
schema: z.object({ file: z.any().openapi({ type: 'string', format: 'binary' }) }),
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
responses: jsonResponse(ImportPreviewSchema, 'Preview'),
|
||||
}),
|
||||
async (c) => {
|
||||
const user = requireWrite(c);
|
||||
const form = await c.req.parseBody();
|
||||
const file = form.file;
|
||||
if (!(file instanceof File)) fail(400, 'no_file', 'Please choose a statement file.');
|
||||
if (file.size > MAX_BYTES) fail(413, 'too_big', 'That file is larger than 10 MB. Statements are usually much smaller; is it the right file?');
|
||||
const text = await file.text();
|
||||
if (/\u0000/.test(text.slice(0, 2000))) fail(400, 'not_text', 'That doesn’t look like a CSV statement. Try your bank’s “CSV” or “spreadsheet” download.');
|
||||
const staged = ctx.staging.add(file.name || 'statement.csv', text, user.id);
|
||||
return c.json(previewImport(ctx, staged.id, user.id, {}), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/imports/preview',
|
||||
tags: ['Import'],
|
||||
summary: 'Preview an uploaded file again, with a chosen account or column layout',
|
||||
request: jsonBody(
|
||||
z.object({ stagingId: z.string().uuid(), accountId: z.number().int().optional(), mapping: ColumnMapSchema.optional() }),
|
||||
),
|
||||
responses: jsonResponse(ImportPreviewSchema, 'Preview'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { stagingId, accountId, mapping } = c.req.valid('json');
|
||||
return c.json(previewImport(ctx, stagingId, user.id, { accountId, mapping }), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/imports',
|
||||
tags: ['Import'],
|
||||
summary: 'Import a previewed file into an account',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
stagingId: z.string().uuid(),
|
||||
accountId: z.number().int(),
|
||||
mapping: ColumnMapSchema.optional(),
|
||||
includePossibleDuplicates: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(ImportSummarySchema, 'Imported'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const body = c.req.valid('json');
|
||||
return c.json(
|
||||
commitImport(ctx, body.stagingId, user.id, {
|
||||
accountId: body.accountId,
|
||||
mapping: body.mapping,
|
||||
includePossible: body.includePossibleDuplicates,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/imports',
|
||||
tags: ['Import'],
|
||||
summary: 'Recent imports, newest first',
|
||||
responses: jsonResponse(z.array(ImportSummarySchema), 'Imports'),
|
||||
}),
|
||||
(c) => {
|
||||
requireUser(c);
|
||||
return c.json(listImports(ctx), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/imports/{id}',
|
||||
tags: ['Import'],
|
||||
summary: 'Undo an import: remove everything it added',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(ImportSummarySchema, 'Undone'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
return c.json(undoImport(ctx, c.req.valid('param').id, user.id), 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
246
server/src/routes/passkeys.ts
Normal file
246
server/src/routes/passkeys.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type AuthenticatorTransport,
|
||||
type RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { logChange } from '../audit';
|
||||
import { requireUser } from '../auth/guards';
|
||||
import { createSession, deviceLabel, setSessionCookie } from '../auth/sessions';
|
||||
import type { AppContext } from '../context';
|
||||
import { passkeys } from '../db/schema';
|
||||
import { clientIp, fail, hostnameOf, nowIso, requestHost, requestOrigin, requestProto, type Env } from '../http';
|
||||
import { jsonBody, jsonResponse, MeSchema, OkSchema, PasskeySchema, validationHook, WebAuthnJson } from '../schemas';
|
||||
import { findActiveUser, toMe } from '../users';
|
||||
|
||||
// Face ID, Touch ID, fingerprint and Windows Hello sign-in, through passkeys
|
||||
// (WebAuthn). Passkeys are tied to the web address, so the relying party is
|
||||
// PUBLIC_URL when set, otherwise the address the browser used.
|
||||
|
||||
const LOGIN_CHALLENGE_COOKIE = 'sl_wa';
|
||||
|
||||
function relyingParty(c: Context<Env>, ctx: AppContext) {
|
||||
if (ctx.config.publicUrl) return { rpID: ctx.config.publicUrl.hostname, origin: ctx.config.publicUrl.origin };
|
||||
return { rpID: hostnameOf(requestHost(c, ctx)), origin: requestOrigin(c, ctx) };
|
||||
}
|
||||
|
||||
const parseTransports = (raw: string | null) =>
|
||||
raw ? (JSON.parse(raw) as AuthenticatorTransport[]) : undefined;
|
||||
|
||||
export function passkeyRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/passkeys',
|
||||
tags: ['Face ID and fingerprint'],
|
||||
summary: 'Your passkeys',
|
||||
responses: jsonResponse(z.array(PasskeySchema), 'Your passkeys'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const rows = ctx.db.select().from(passkeys).where(eq(passkeys.userId, user.id)).all();
|
||||
return c.json(
|
||||
rows.map((p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt })),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/passkeys/register/options',
|
||||
tags: ['Face ID and fingerprint'],
|
||||
summary: 'Start adding a passkey for this device',
|
||||
responses: jsonResponse(WebAuthnJson, 'Options for navigator.credentials.create'),
|
||||
}),
|
||||
async (c) => {
|
||||
const user = requireUser(c);
|
||||
const session = c.get('session')!;
|
||||
const { rpID } = relyingParty(c, ctx);
|
||||
const existing = ctx.db.select().from(passkeys).where(eq(passkeys.userId, user.id)).all();
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: 'Spendlight',
|
||||
rpID,
|
||||
userName: user.username,
|
||||
userDisplayName: user.displayName,
|
||||
userID: new TextEncoder().encode(`spendlight-user-${user.id}`),
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existing.map((p) => ({ id: p.id, transports: parseTransports(p.transports) })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
||||
});
|
||||
ctx.challenges.put(`register:${session.id}`, options.challenge);
|
||||
return c.json(options as unknown as Record<string, unknown>, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/passkeys/register/verify',
|
||||
tags: ['Face ID and fingerprint'],
|
||||
summary: 'Finish adding a passkey',
|
||||
request: jsonBody(z.object({ response: WebAuthnJson, name: z.string().trim().max(60).optional() })),
|
||||
responses: jsonResponse(PasskeySchema, 'Passkey added'),
|
||||
}),
|
||||
async (c) => {
|
||||
const user = requireUser(c);
|
||||
const session = c.get('session')!;
|
||||
const body = c.req.valid('json');
|
||||
const expectedChallenge = ctx.challenges.take(`register:${session.id}`);
|
||||
if (!expectedChallenge) fail(400, 'expired', 'That took a little too long. Please try again.');
|
||||
const { rpID, origin } = relyingParty(c, ctx);
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: body.response as unknown as RegistrationResponseJSON,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Passkey registration rejected: ${(error as Error).message}`);
|
||||
fail(400, 'passkey_rejected', 'This device’s passkey couldn’t be verified. Please try again.');
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
fail(400, 'passkey_rejected', 'This device’s passkey couldn’t be verified. Please try again.');
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
const name = body.name || deviceLabel(c.req.header('user-agent')).split(' · ')[0] || 'Passkey';
|
||||
const row = ctx.db
|
||||
.insert(passkeys)
|
||||
.values({
|
||||
id: credential.id,
|
||||
userId: user.id,
|
||||
name,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
createdAt: nowIso(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
logChange(ctx, user.id, 'add_passkey', 'passkey', row.id, { name });
|
||||
return c.json({ id: row.id, name: row.name, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/passkeys/{id}',
|
||||
tags: ['Face ID and fingerprint'],
|
||||
summary: 'Remove one of your passkeys',
|
||||
request: { params: z.object({ id: z.string().min(1).max(512) }) },
|
||||
responses: jsonResponse(OkSchema, 'Removed'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireUser(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const removed = ctx.db
|
||||
.delete(passkeys)
|
||||
.where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id)))
|
||||
.returning()
|
||||
.get();
|
||||
if (!removed) fail(404, 'not_found', 'That passkey wasn’t found.');
|
||||
logChange(ctx, user.id, 'remove_passkey', 'passkey', id, { name: removed.name });
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/auth/passkey/options',
|
||||
tags: ['Sign-in'],
|
||||
summary: 'Start signing in with Face ID or fingerprint',
|
||||
responses: jsonResponse(WebAuthnJson, 'Options for navigator.credentials.get'),
|
||||
}),
|
||||
async (c) => {
|
||||
const { rpID } = relyingParty(c, ctx);
|
||||
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
|
||||
const key = randomBytes(16).toString('base64url');
|
||||
ctx.challenges.put(`login:${key}`, options.challenge);
|
||||
setCookie(c, LOGIN_CHALLENGE_COOKIE, key, {
|
||||
httpOnly: true,
|
||||
secure: requestProto(c, ctx) === 'https',
|
||||
sameSite: 'Strict',
|
||||
path: '/api/v1/auth/passkey',
|
||||
maxAge: 300,
|
||||
});
|
||||
return c.json(options as unknown as Record<string, unknown>, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/auth/passkey/verify',
|
||||
tags: ['Sign-in'],
|
||||
summary: 'Finish signing in with Face ID or fingerprint',
|
||||
request: jsonBody(z.object({ response: WebAuthnJson })),
|
||||
responses: jsonResponse(MeSchema, 'Signed in'),
|
||||
}),
|
||||
async (c) => {
|
||||
const key = getCookie(c, LOGIN_CHALLENGE_COOKIE);
|
||||
deleteCookie(c, LOGIN_CHALLENGE_COOKIE, { path: '/api/v1/auth/passkey' });
|
||||
const expectedChallenge = key ? ctx.challenges.take(`login:${key}`) : undefined;
|
||||
if (!expectedChallenge) fail(400, 'expired', 'That took a little too long. Please try again.');
|
||||
|
||||
const response = c.req.valid('json').response as unknown as AuthenticationResponseJSON;
|
||||
const stored = ctx.db.select().from(passkeys).where(eq(passkeys.id, String(response.id))).get();
|
||||
const user = stored ? findActiveUser(ctx, { id: stored.userId }) : undefined;
|
||||
if (!stored || !user) {
|
||||
fail(401, 'unknown_passkey', 'This passkey isn’t linked to anyone here. Sign in with your password, then add it in Settings.');
|
||||
}
|
||||
|
||||
const { rpID, origin } = relyingParty(c, ctx);
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: stored.id,
|
||||
publicKey: new Uint8Array(stored.publicKey),
|
||||
counter: stored.counter,
|
||||
transports: parseTransports(stored.transports),
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Passkey sign-in rejected: ${(error as Error).message}`);
|
||||
fail(401, 'passkey_rejected', 'That didn’t work. Please try again, or use your password.');
|
||||
}
|
||||
if (!verification.verified) fail(401, 'passkey_rejected', 'That didn’t work. Please try again, or use your password.');
|
||||
|
||||
ctx.db
|
||||
.update(passkeys)
|
||||
.set({ counter: verification.authenticationInfo.newCounter, lastUsedAt: nowIso() })
|
||||
.where(eq(passkeys.id, stored.id))
|
||||
.run();
|
||||
const { token } = createSession(ctx, user.id, { userAgent: c.req.header('user-agent'), ip: clientIp(c, ctx) });
|
||||
setSessionCookie(c, ctx, token);
|
||||
return c.json(toMe(ctx, user), 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
202
server/src/routes/people.ts
Normal file
202
server/src/routes/people.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
|
||||
import type { Person } from '@spendlight/shared';
|
||||
import { and, eq, isNull, max } from 'drizzle-orm';
|
||||
import { logChange } from '../audit';
|
||||
import { requireAdmin } from '../auth/guards';
|
||||
import { hashPassword, temporaryPassword } from '../auth/password';
|
||||
import { revokeUserSessions } from '../auth/sessions';
|
||||
import type { AppContext } from '../context';
|
||||
import { passkeys, sessions, users, type UserRow } from '../db/schema';
|
||||
import { fail, nowIso, type Env } from '../http';
|
||||
import {
|
||||
AccessSchema,
|
||||
DisplayName,
|
||||
IdParam,
|
||||
jsonBody,
|
||||
jsonResponse,
|
||||
OkSchema,
|
||||
Password,
|
||||
PersonSchema,
|
||||
Username,
|
||||
validationHook,
|
||||
} from '../schemas';
|
||||
import { adminCount, findActiveUser } from '../users';
|
||||
|
||||
// Managing the household's logins. Admin only.
|
||||
|
||||
function toPerson(user: UserRow, lastActiveAt: string | null): Person {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
access: user.access,
|
||||
isAdmin: user.isAdmin,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
createdAt: user.createdAt,
|
||||
lastActiveAt,
|
||||
};
|
||||
}
|
||||
|
||||
const PersonWithPassword = z
|
||||
.object({ person: PersonSchema, temporaryPassword: z.string() })
|
||||
.openapi('PersonWithTemporaryPassword');
|
||||
|
||||
export function peopleRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
const lastActive = (userId: number) =>
|
||||
ctx.db.select({ last: max(sessions.lastSeenAt) }).from(sessions).where(eq(sessions.userId, userId)).get()?.last ??
|
||||
null;
|
||||
|
||||
const target = (id: number) => findActiveUser(ctx, { id }) ?? fail(404, 'not_found', 'That person wasn’t found.');
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/people',
|
||||
tags: ['People'],
|
||||
summary: 'Everyone who can sign in',
|
||||
responses: jsonResponse(z.array(PersonSchema), 'People'),
|
||||
}),
|
||||
(c) => {
|
||||
requireAdmin(c);
|
||||
const rows = ctx.db.select().from(users).where(isNull(users.deletedAt)).orderBy(users.displayName).all();
|
||||
return c.json(
|
||||
rows.map((u) => toPerson(u, lastActive(u.id))),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/people',
|
||||
tags: ['People'],
|
||||
summary: 'Add a person. They choose their own password at first sign-in.',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
displayName: DisplayName,
|
||||
username: Username,
|
||||
access: AccessSchema,
|
||||
isAdmin: z.boolean().default(false),
|
||||
password: Password.optional(),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(PersonWithPassword, 'Added'),
|
||||
}),
|
||||
async (c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const body = c.req.valid('json');
|
||||
if (findActiveUser(ctx, { username: body.username })) {
|
||||
fail(409, 'username_taken', 'Someone already uses that username.');
|
||||
}
|
||||
const password = body.password ?? temporaryPassword();
|
||||
const now = nowIso();
|
||||
const user = ctx.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: body.username,
|
||||
displayName: body.displayName,
|
||||
passwordHash: await hashPassword(password),
|
||||
access: body.access,
|
||||
isAdmin: body.isAdmin,
|
||||
mustChangePassword: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
logChange(ctx, admin.id, 'add_person', 'user', user.id, { access: user.access, isAdmin: user.isAdmin });
|
||||
return c.json({ person: toPerson(user, null), temporaryPassword: password }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/people/{id}',
|
||||
tags: ['People'],
|
||||
summary: 'Change a person’s name, access or admin role',
|
||||
request: {
|
||||
params: IdParam,
|
||||
...jsonBody(z.object({ displayName: DisplayName.optional(), access: AccessSchema.optional(), isAdmin: z.boolean().optional() })),
|
||||
},
|
||||
responses: jsonResponse(PersonSchema, 'Updated'),
|
||||
}),
|
||||
(c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const body = c.req.valid('json');
|
||||
const person = target(id);
|
||||
if (person.isAdmin && body.isAdmin === false && adminCount(ctx) <= 1) {
|
||||
fail(400, 'last_admin', 'Spendlight needs at least one admin. Make someone else an admin first.');
|
||||
}
|
||||
const updated = ctx.db
|
||||
.update(users)
|
||||
.set({ ...body, updatedAt: nowIso() })
|
||||
.where(eq(users.id, id))
|
||||
.returning()
|
||||
.get()!;
|
||||
logChange(ctx, admin.id, 'update_person', 'user', id, body);
|
||||
return c.json(toPerson(updated, lastActive(id)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/people/{id}/reset-password',
|
||||
tags: ['People'],
|
||||
summary: 'Give a person a temporary password and sign them out everywhere',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(PersonWithPassword, 'Reset'),
|
||||
}),
|
||||
async (c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const { id } = c.req.valid('param');
|
||||
target(id);
|
||||
const password = temporaryPassword();
|
||||
const updated = ctx.db
|
||||
.update(users)
|
||||
.set({ passwordHash: await hashPassword(password), mustChangePassword: true, updatedAt: nowIso() })
|
||||
.where(eq(users.id, id))
|
||||
.returning()
|
||||
.get()!;
|
||||
revokeUserSessions(ctx, id);
|
||||
logChange(ctx, admin.id, 'reset_password', 'user', id);
|
||||
return c.json({ person: toPerson(updated, null), temporaryPassword: password }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/people/{id}',
|
||||
tags: ['People'],
|
||||
summary: 'Remove a person. Their past changes keep their name in the history.',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(OkSchema, 'Removed'),
|
||||
}),
|
||||
(c) => {
|
||||
const admin = requireAdmin(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const person = target(id);
|
||||
if (person.id === admin.id) fail(400, 'cannot_remove_self', 'You can’t remove yourself.');
|
||||
if (person.isAdmin && adminCount(ctx) <= 1) fail(400, 'last_admin', 'Spendlight needs at least one admin.');
|
||||
ctx.sqlite.transaction(() => {
|
||||
ctx.db
|
||||
.update(users)
|
||||
.set({ deletedAt: nowIso(), username: `${person.username}#removed-${person.id}`, updatedAt: nowIso() })
|
||||
.where(and(eq(users.id, id), isNull(users.deletedAt)))
|
||||
.run();
|
||||
ctx.db.delete(passkeys).where(eq(passkeys.userId, id)).run();
|
||||
revokeUserSessions(ctx, id);
|
||||
})();
|
||||
logChange(ctx, admin.id, 'remove_person', 'user', id, { username: person.username });
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
49
server/src/routes/system.ts
Normal file
49
server/src/routes/system.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
|
||||
import type { AppContext } from '../context';
|
||||
import type { Env } from '../http';
|
||||
import { HealthSchema, jsonResponse, PublicConfigSchema, validationHook } from '../schemas';
|
||||
import { activeUserCount } from '../users';
|
||||
|
||||
export function systemRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/health',
|
||||
tags: ['System'],
|
||||
summary: 'Is the app up, and which version is running?',
|
||||
responses: jsonResponse(HealthSchema, 'The app is running'),
|
||||
}),
|
||||
(c) =>
|
||||
c.json(
|
||||
{
|
||||
status: 'ok' as const,
|
||||
version: ctx.config.version,
|
||||
uptimeSeconds: Math.round((Date.now() - ctx.startedAt.getTime()) / 1000),
|
||||
},
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/config',
|
||||
tags: ['System'],
|
||||
summary: 'What the sign-in screen needs before anyone signs in',
|
||||
responses: jsonResponse(PublicConfigSchema, 'Version and switches'),
|
||||
}),
|
||||
(c) =>
|
||||
c.json(
|
||||
{
|
||||
version: ctx.config.version,
|
||||
ideasEnabled: ctx.config.ideasEnabled,
|
||||
needsSetup: activeUserCount(ctx) === 0,
|
||||
},
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
return r;
|
||||
}
|
||||
603
server/src/routes/transactions.ts
Normal file
603
server/src/routes/transactions.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
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 { 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 { fail, nowIso, type Env } from '../http';
|
||||
import { dedupeKey } from '../import/merchant';
|
||||
import { unpair } from '../import/transfers';
|
||||
import {
|
||||
CategoryGroupSchema,
|
||||
Cents,
|
||||
DateString,
|
||||
IdParam,
|
||||
jsonBody,
|
||||
jsonResponse,
|
||||
ReviewItemSchema,
|
||||
TransactionSchema,
|
||||
validationHook,
|
||||
} from '../schemas';
|
||||
import { listCategoryGroups, newestFirst, toTransaction, toTransactions } from '../transactions/dto';
|
||||
|
||||
const SimilarSchema = z
|
||||
.object({ merchant: z.string(), count: z.number().int(), categoryId: z.number().int() })
|
||||
.nullable()
|
||||
.openapi('SimilarTransactions');
|
||||
|
||||
const TagList = z.array(z.string().trim().min(1).max(40)).max(20);
|
||||
|
||||
export function transactionRoutes(ctx: AppContext) {
|
||||
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
|
||||
|
||||
const find = (id: number): TransactionRow =>
|
||||
ctx.db.select().from(transactions).where(eq(transactions.id, id)).get() ?? fail(404, 'not_found', 'That transaction wasn’t found.');
|
||||
|
||||
const categoryKind = (id: number) => ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, id)).get()?.kind;
|
||||
|
||||
const assertCategory = (id: number) => {
|
||||
if (!categoryKind(id)) fail(400, 'no_category', 'That category doesn’t exist.');
|
||||
};
|
||||
|
||||
function setTags(transactionId: number, names: string[]) {
|
||||
ctx.db.delete(transactionTags).where(eq(transactionTags.transactionId, transactionId)).run();
|
||||
// The first spelling of a tag wins: "Back to school" and "back to school" are one tag.
|
||||
const byKey = new Map<string, string>();
|
||||
for (const n of names) if (n.trim() && !byKey.has(n.trim().toLowerCase())) byKey.set(n.trim().toLowerCase(), n.trim());
|
||||
const unique = [...byKey.values()];
|
||||
for (const name of unique) {
|
||||
const existing = ctx.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(sql`lower(${tags.name}) = ${name.toLowerCase()}`)
|
||||
.get();
|
||||
const tagId = existing?.id ?? ctx.db.insert(tags).values({ name }).returning().get().id;
|
||||
ctx.db.insert(transactionTags).values({ transactionId, tagId }).onConflictDoNothing().run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Other transactions from the same merchant still waiting in Review, for "also file these?". */
|
||||
function similarWaiting(t: TransactionRow, categoryId: number) {
|
||||
const n =
|
||||
ctx.db
|
||||
.select({ n: count() })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.merchant, t.merchant),
|
||||
eq(transactions.needsReview, true),
|
||||
ne(transactions.id, t.id),
|
||||
),
|
||||
)
|
||||
.get()?.n ?? 0;
|
||||
return n > 0 ? { merchant: t.merchant, count: n, categoryId } : null;
|
||||
}
|
||||
|
||||
// --- Listing ---------------------------------------------------------------
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/transactions',
|
||||
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(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
}),
|
||||
},
|
||||
responses: jsonResponse(
|
||||
z.object({
|
||||
items: z.array(TransactionSchema),
|
||||
total: z.number().int(),
|
||||
sums: z.object({ inCents: z.number().int(), outCents: z.number().int() }),
|
||||
}),
|
||||
'Transactions, newest first',
|
||||
),
|
||||
}),
|
||||
(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 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;
|
||||
const sums = ctx.db
|
||||
.select({
|
||||
inCents: sql<number>`coalesce(sum(case when ${transactions.amountCents} > 0 then ${transactions.amountCents} end), 0)`,
|
||||
outCents: sql<number>`coalesce(sum(case when ${transactions.amountCents} < 0 then ${transactions.amountCents} end), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(categories, eq(categories.id, transactions.categoryId))
|
||||
.where(and(condition, or(isNull(categories.kind), ne(categories.kind, 'transfer'))))
|
||||
.get() ?? { inCents: 0, outCents: 0 };
|
||||
return c.json({ items: toTransactions(ctx, rows), total, sums }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/transactions/{id}',
|
||||
tags: ['Transactions'],
|
||||
summary: 'One transaction',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(TransactionSchema, 'The transaction'),
|
||||
}),
|
||||
(c) => {
|
||||
requireUser(c);
|
||||
return c.json(toTransaction(ctx, find(c.req.valid('param').id)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Changing one ------------------------------------------------------------
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'patch',
|
||||
path: '/transactions/{id}',
|
||||
tags: ['Transactions'],
|
||||
summary: 'File, rename, note or tag a transaction',
|
||||
request: {
|
||||
params: IdParam,
|
||||
...jsonBody(
|
||||
z.object({
|
||||
categoryId: z.number().int().nullable().optional(),
|
||||
merchant: z.string().trim().min(1).max(80).optional(),
|
||||
/** Rename this merchant on every transaction, and on future imports. */
|
||||
renameEverywhere: z.boolean().optional(),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
tags: TagList.optional(),
|
||||
needsReview: z.boolean().optional(),
|
||||
date: DateString.optional(),
|
||||
amountCents: Cents.optional(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
responses: jsonResponse(z.object({ transaction: TransactionSchema, similar: SimilarSchema }), 'Updated'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const body = c.req.valid('json');
|
||||
const t = find(id);
|
||||
const now = nowIso();
|
||||
const set: Partial<TransactionRow> = { updatedAt: now };
|
||||
let similar = null;
|
||||
|
||||
if (body.categoryId !== undefined) {
|
||||
if (body.categoryId !== null) assertCategory(body.categoryId);
|
||||
if (t.transferPeerId && (body.categoryId === null || categoryKind(body.categoryId) !== 'transfer')) unpair(ctx, t.id);
|
||||
if (t.isSplit) ctx.db.delete(splits).where(eq(splits.transactionId, t.id)).run();
|
||||
Object.assign(set, { categoryId: body.categoryId, isSplit: false, needsReview: body.categoryId === null, suggestedBy: null });
|
||||
if (body.categoryId !== null) similar = similarWaiting(t, body.categoryId);
|
||||
}
|
||||
if (body.needsReview !== undefined) set.needsReview = body.needsReview;
|
||||
if (body.note !== undefined) set.note = body.note || null;
|
||||
if ((body.date || body.amountCents !== undefined) && t.source !== 'manual') {
|
||||
fail(400, 'from_statement', 'The date and amount come from your bank’s statement, so they can’t be changed here.');
|
||||
}
|
||||
if (body.date) set.date = body.date;
|
||||
if (body.amountCents !== undefined) set.amountCents = body.amountCents;
|
||||
if (body.merchant && body.merchant !== t.merchant) {
|
||||
set.merchant = body.merchant;
|
||||
if (body.renameEverywhere) {
|
||||
ctx.db.update(transactions).set({ merchant: body.merchant, updatedAt: now }).where(eq(transactions.merchant, t.merchant)).run();
|
||||
const existing = ctx.db.select().from(rules).where(and(eq(rules.merchant, t.merchant), isNull(rules.accountId))).get();
|
||||
if (existing) ctx.db.update(rules).set({ renameTo: body.merchant }).where(eq(rules.id, existing.id)).run();
|
||||
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.sqlite.transaction(() => {
|
||||
ctx.db.update(transactions).set(set).where(eq(transactions.id, id)).run();
|
||||
if (body.tags) setTags(id, body.tags);
|
||||
})();
|
||||
logChange(ctx, user.id, 'update_transaction', 'transaction', id, body);
|
||||
return c.json({ transaction: toTransaction(ctx, find(id)), similar }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'put',
|
||||
path: '/transactions/{id}/split',
|
||||
tags: ['Transactions'],
|
||||
summary: 'Split a purchase across categories (the parts must add up to the total)',
|
||||
request: {
|
||||
params: IdParam,
|
||||
...jsonBody(
|
||||
z.object({
|
||||
parts: z
|
||||
.array(z.object({ categoryId: z.number().int(), amountCents: Cents, note: z.string().trim().max(200).nullable().optional() }))
|
||||
.min(2, 'A split needs at least two parts.')
|
||||
.max(20),
|
||||
}),
|
||||
),
|
||||
},
|
||||
responses: jsonResponse(TransactionSchema, 'Split'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const { parts } = c.req.valid('json');
|
||||
const t = find(id);
|
||||
const total = parts.reduce((sum, p) => sum + p.amountCents, 0);
|
||||
if (total !== t.amountCents) fail(400, 'split_total', 'The parts need to add up to the full amount.');
|
||||
if (parts.some((p) => p.amountCents === 0 || Math.sign(p.amountCents) !== Math.sign(t.amountCents))) {
|
||||
fail(400, 'split_sign', 'Each part needs an amount, going the same way as the purchase.');
|
||||
}
|
||||
parts.forEach((p) => assertCategory(p.categoryId));
|
||||
ctx.sqlite.transaction(() => {
|
||||
if (t.transferPeerId) unpair(ctx, t.id);
|
||||
ctx.db.delete(splits).where(eq(splits.transactionId, id)).run();
|
||||
for (const p of parts) ctx.db.insert(splits).values({ transactionId: id, categoryId: p.categoryId, amountCents: p.amountCents, note: p.note ?? null }).run();
|
||||
ctx.db
|
||||
.update(transactions)
|
||||
.set({ isSplit: true, categoryId: null, needsReview: false, suggestedBy: null, updatedAt: nowIso() })
|
||||
.where(eq(transactions.id, id))
|
||||
.run();
|
||||
})();
|
||||
logChange(ctx, user.id, 'split_transaction', 'transaction', id, { parts: parts.length });
|
||||
return c.json(toTransaction(ctx, find(id)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/transactions/{id}/split',
|
||||
tags: ['Transactions'],
|
||||
summary: 'Undo a split; the transaction goes back to Review',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(TransactionSchema, 'Unsplit'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
find(id);
|
||||
ctx.db.delete(splits).where(eq(splits.transactionId, id)).run();
|
||||
ctx.db.update(transactions).set({ isSplit: false, categoryId: null, needsReview: true, updatedAt: nowIso() }).where(eq(transactions.id, id)).run();
|
||||
logChange(ctx, user.id, 'unsplit_transaction', 'transaction', id);
|
||||
return c.json(toTransaction(ctx, find(id)), 200);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Many at once --------------------------------------------------------------
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/transactions/bulk',
|
||||
tags: ['Transactions'],
|
||||
summary: 'File several transactions in one category, optionally remembering it for their merchants',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
ids: z.array(z.number().int()).max(2000).optional(),
|
||||
/** Or: every transaction waiting in Review from this merchant. */
|
||||
merchant: z.string().max(80).optional(),
|
||||
categoryId: z.number().int(),
|
||||
remember: z.boolean().default(false),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(z.object({ updated: z.number().int() }), 'Filed'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { ids, merchant, categoryId, remember } = c.req.valid('json');
|
||||
assertCategory(categoryId);
|
||||
const targets = merchant
|
||||
? ctx.db.select().from(transactions).where(and(eq(transactions.merchant, merchant), eq(transactions.needsReview, true))).all()
|
||||
: ids?.length
|
||||
? ctx.db.select().from(transactions).where(inArray(transactions.id, ids)).all()
|
||||
: [];
|
||||
ctx.sqlite.transaction(() => {
|
||||
for (const t of targets) {
|
||||
if (t.transferPeerId && categoryKind(categoryId) !== 'transfer') unpair(ctx, t.id);
|
||||
if (t.isSplit) ctx.db.delete(splits).where(eq(splits.transactionId, t.id)).run();
|
||||
}
|
||||
if (targets.length) {
|
||||
ctx.db
|
||||
.update(transactions)
|
||||
.set({ categoryId, isSplit: false, needsReview: false, suggestedBy: null, updatedAt: nowIso() })
|
||||
.where(inArray(transactions.id, targets.map((t) => t.id)))
|
||||
.run();
|
||||
}
|
||||
if (remember) for (const m of new Set(targets.map((t) => t.merchant).concat(merchant ? [merchant] : []))) rememberRule(ctx, m, categoryId);
|
||||
})();
|
||||
logChange(ctx, user.id, 'bulk_categorize', 'transaction', null, { count: targets.length, categoryId, remember });
|
||||
return c.json({ updated: targets.length }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/transactions/accept',
|
||||
tags: ['Review'],
|
||||
summary: 'Accept the pre-filled category on these transactions',
|
||||
request: jsonBody(z.object({ ids: z.array(z.number().int()).min(1).max(2000) })),
|
||||
responses: jsonResponse(z.object({ accepted: z.number().int() }), 'Accepted'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { ids } = c.req.valid('json');
|
||||
const result = ctx.db
|
||||
.update(transactions)
|
||||
.set({ needsReview: false, updatedAt: nowIso() })
|
||||
.where(and(inArray(transactions.id, ids), eq(transactions.needsReview, true), sql`${transactions.categoryId} is not null`))
|
||||
.run();
|
||||
logChange(ctx, user.id, 'accept_suggestions', 'transaction', null, { count: result.changes });
|
||||
return c.json({ accepted: result.changes }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Cash and other entries by hand ------------------------------------------
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'post',
|
||||
path: '/transactions',
|
||||
tags: ['Transactions'],
|
||||
summary: 'Add a transaction by hand, such as cash spending',
|
||||
request: jsonBody(
|
||||
z.object({
|
||||
accountId: z.number().int(),
|
||||
date: DateString,
|
||||
amountCents: Cents.refine((n) => n !== 0, 'Please enter an amount.'),
|
||||
merchant: z.string().trim().min(1, 'Who was it paid to?').max(80),
|
||||
categoryId: z.number().int().nullable().optional(),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
tags: TagList.optional(),
|
||||
}),
|
||||
),
|
||||
responses: jsonResponse(TransactionSchema, 'Added'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const body = c.req.valid('json');
|
||||
const account = ctx.db.select().from(accounts).where(eq(accounts.id, body.accountId)).get();
|
||||
if (!account) fail(400, 'no_account', 'Please choose an account.');
|
||||
if (body.categoryId) assertCategory(body.categoryId);
|
||||
const now = nowIso();
|
||||
const row = ctx.sqlite.transaction(() => {
|
||||
const inserted = ctx.db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
accountId: body.accountId,
|
||||
date: body.date,
|
||||
amountCents: body.amountCents,
|
||||
description: body.merchant,
|
||||
merchant: body.merchant,
|
||||
categoryId: body.categoryId ?? null,
|
||||
needsReview: !body.categoryId,
|
||||
note: body.note ?? null,
|
||||
source: 'manual',
|
||||
dedupeKey: `${dedupeKey(body.accountId, body.date, body.amountCents, body.merchant)}|manual|${now}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
if (body.tags) setTags(inserted.id, body.tags);
|
||||
return inserted;
|
||||
})();
|
||||
logChange(ctx, user.id, 'add_transaction', 'transaction', row.id);
|
||||
return c.json(toTransaction(ctx, row), 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'delete',
|
||||
path: '/transactions/{id}',
|
||||
tags: ['Transactions'],
|
||||
summary: 'Delete a transaction you added by hand',
|
||||
request: { params: IdParam },
|
||||
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Deleted'),
|
||||
}),
|
||||
(c) => {
|
||||
const user = requireWrite(c);
|
||||
const { id } = c.req.valid('param');
|
||||
const t = find(id);
|
||||
if (t.source !== 'manual') fail(400, 'from_statement', 'Transactions from a statement can’t be deleted one by one. Undo the import instead.');
|
||||
if (t.transferPeerId) unpair(ctx, t.id);
|
||||
ctx.db.delete(transactions).where(eq(transactions.id, id)).run();
|
||||
logChange(ctx, user.id, 'delete_transaction', 'transaction', id, { merchant: t.merchant, amountCents: t.amountCents });
|
||||
return c.json({ ok: true as const }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Review ---------------------------------------------------------------------
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/review',
|
||||
tags: ['Review'],
|
||||
summary: 'Transactions waiting to be filed, newest first, with Spendlight’s best guesses',
|
||||
responses: jsonResponse(z.array(ReviewItemSchema), 'Waiting'),
|
||||
}),
|
||||
(c) => {
|
||||
requireUser(c);
|
||||
const rows = ctx.db.select().from(transactions).where(eq(transactions.needsReview, true)).orderBy(...newestFirst).limit(500).all();
|
||||
const suggester = new Suggester(ctx);
|
||||
const transferIds = new Set(
|
||||
ctx.db.select({ id: categories.id }).from(categories).where(eq(categories.kind, 'transfer')).all().map((x) => x.id),
|
||||
);
|
||||
const items: ReviewItem[] = toTransactions(ctx, rows).map((t, i) => {
|
||||
const row = rows[i]!;
|
||||
const s = suggester.suggest({ ...row, accountId: row.accountId });
|
||||
const suggestions = [...new Set([...(t.categoryId ? [t.categoryId] : []), ...s.suggestions])].slice(0, 3);
|
||||
return {
|
||||
...t,
|
||||
suggestions,
|
||||
confident: s.confident && s.categoryId === t.categoryId,
|
||||
unpairedTransfer: !t.transferPeer && (t.categoryId !== null ? transferIds.has(t.categoryId) : suggester.isTransferLike(row)),
|
||||
};
|
||||
});
|
||||
return c.json(items, 200);
|
||||
},
|
||||
);
|
||||
|
||||
r.openapi(
|
||||
createRoute({
|
||||
method: 'get',
|
||||
path: '/review/count',
|
||||
tags: ['Review'],
|
||||
summary: 'How many transactions are waiting',
|
||||
responses: jsonResponse(z.object({ count: z.number().int() }), 'Count'),
|
||||
}),
|
||||
(c) => {
|
||||
requireUser(c);
|
||||
return c.json({ count: ctx.db.select({ n: count() }).from(transactions).where(eq(transactions.needsReview, true)).get()?.n ?? 0 }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
256
server/src/schemas.ts
Normal file
256
server/src/schemas.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { z } from '@hono/zod-openapi';
|
||||
import type { Hook } from '@hono/zod-openapi';
|
||||
import type { Env } from './http';
|
||||
|
||||
// Request and response shapes. They validate input and also describe the
|
||||
// API in the OpenAPI document at /api/v1/openapi.json.
|
||||
|
||||
export const ErrorSchema = z
|
||||
.object({ error: z.object({ code: z.string(), message: z.string() }) })
|
||||
.openapi('Error');
|
||||
|
||||
export const OkSchema = z.object({ ok: z.literal(true) }).openapi('Ok');
|
||||
|
||||
export const AccessSchema = z.enum(['read', 'full']).openapi('Access');
|
||||
export const ThemeSchema = z.enum(['system', 'light', 'dark']).openapi('Theme');
|
||||
export const IdeaStatusSchema = z.enum(['new', 'planned', 'done', 'declined']).openapi('IdeaStatus');
|
||||
|
||||
export const Username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Usernames need at least 2 characters.')
|
||||
.max(40)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Use letters, numbers, dots, dashes or underscores.')
|
||||
.transform((s) => s.toLowerCase());
|
||||
export const Password = z.string().min(8, 'Passwords need at least 8 characters.').max(200);
|
||||
export const DisplayName = z.string().trim().min(1, 'Please enter a name.').max(60);
|
||||
|
||||
export const MeSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
username: z.string(),
|
||||
displayName: z.string(),
|
||||
access: AccessSchema,
|
||||
isAdmin: z.boolean(),
|
||||
theme: ThemeSchema,
|
||||
mustChangePassword: z.boolean(),
|
||||
hasPasskey: z.boolean(),
|
||||
})
|
||||
.openapi('Me');
|
||||
|
||||
export const PublicConfigSchema = z
|
||||
.object({ version: z.string(), ideasEnabled: z.boolean(), needsSetup: z.boolean() })
|
||||
.openapi('PublicConfig');
|
||||
|
||||
export const HealthSchema = z
|
||||
.object({ status: z.literal('ok'), version: z.string(), uptimeSeconds: z.number() })
|
||||
.openapi('Health');
|
||||
|
||||
export const PersonSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
username: z.string(),
|
||||
displayName: z.string(),
|
||||
access: AccessSchema,
|
||||
isAdmin: z.boolean(),
|
||||
mustChangePassword: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
lastActiveAt: z.string().nullable(),
|
||||
})
|
||||
.openapi('Person');
|
||||
|
||||
export const DeviceSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
deviceLabel: z.string(),
|
||||
createdAt: z.string(),
|
||||
lastSeenAt: z.string(),
|
||||
current: z.boolean(),
|
||||
})
|
||||
.openapi('Device');
|
||||
|
||||
export const PasskeySchema = z
|
||||
.object({ id: z.string(), name: z.string(), createdAt: z.string(), lastUsedAt: z.string().nullable() })
|
||||
.openapi('Passkey');
|
||||
|
||||
export const BackupSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
kind: z.enum(['nightly', 'manual', 'pre-upgrade', 'pre-restore']),
|
||||
size: z.number().int(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
.openapi('Backup');
|
||||
|
||||
export const IdeaSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
status: IdeaStatusSchema,
|
||||
adminNote: z.string().nullable(),
|
||||
path: z.string().nullable(),
|
||||
appVersion: z.string().nullable(),
|
||||
userAgent: z.string().nullable(),
|
||||
author: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.openapi('Idea');
|
||||
|
||||
/** WebAuthn payloads are defined by the browser standard; passed through as JSON. */
|
||||
export const WebAuthnJson = z.record(z.string(), z.unknown()).openapi('WebAuthnJson');
|
||||
|
||||
export const IdParam = z.object({
|
||||
id: z.coerce.number().int().positive().openapi({ param: { name: 'id', in: 'path' } }),
|
||||
});
|
||||
|
||||
export const jsonBody = <T extends z.ZodType>(schema: T) => ({
|
||||
body: { content: { 'application/json': { schema } }, required: true },
|
||||
});
|
||||
|
||||
export const jsonResponse = <T extends z.ZodType>(schema: T, description: string) => ({
|
||||
200: { content: { 'application/json': { schema } }, description },
|
||||
});
|
||||
|
||||
/** Turn validation failures into the same friendly error shape as everything else. */
|
||||
export const validationHook: Hook<any, Env, any, any> = (result, c) => {
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const field = issue?.path.join('.');
|
||||
const message = issue ? (field ? `${field}: ${issue.message}` : issue.message) : 'Invalid input.';
|
||||
return c.json({ error: { code: 'invalid_input', message } }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Milestone 1: money in --------------------------------------------------
|
||||
|
||||
export const AccountKindSchema = z.enum(['checking', 'savings', 'credit_card', 'cash', 'loan', 'mortgage', 'heloc']).openapi('AccountKind');
|
||||
export const PaletteSchema = z
|
||||
.enum(['indigo', 'sky', 'teal', 'emerald', 'lime', 'amber', 'orange', 'rose', 'pink', 'violet', 'cyan', 'slate'])
|
||||
.openapi('PaletteColor');
|
||||
export const DateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use a date like 2026-10-07.');
|
||||
export const Cents = z.number().int().min(-1_000_000_000).max(1_000_000_000);
|
||||
|
||||
export const AccountSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
kind: AccountKindSchema,
|
||||
institution: z.string().nullable(),
|
||||
last4: z.string().nullable(),
|
||||
color: PaletteSchema,
|
||||
status: z.enum(['open', 'retired']),
|
||||
balanceCents: z.number().int(),
|
||||
balanceConfirmedOn: z.string().nullable(),
|
||||
transactionCount: z.number().int(),
|
||||
lastImportAt: z.string().nullable(),
|
||||
lastTransactionDate: z.string().nullable(),
|
||||
})
|
||||
.openapi('Account');
|
||||
|
||||
export const CategorySchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
groupId: z.number().int(),
|
||||
groupName: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.enum(['expense', 'income', 'transfer']),
|
||||
icon: z.string(),
|
||||
color: PaletteSchema,
|
||||
archived: z.boolean(),
|
||||
})
|
||||
.openapi('Category');
|
||||
|
||||
export const CategoryGroupSchema = z
|
||||
.object({ id: z.number().int(), name: z.string(), categories: z.array(CategorySchema) })
|
||||
.openapi('CategoryGroup');
|
||||
|
||||
export const SplitSchema = z
|
||||
.object({ id: z.number().int(), categoryId: z.number().int(), amountCents: z.number().int(), note: z.string().nullable() })
|
||||
.openapi('Split');
|
||||
|
||||
export const TransactionSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
accountId: z.number().int(),
|
||||
date: z.string(),
|
||||
amountCents: z.number().int(),
|
||||
description: z.string(),
|
||||
merchant: z.string(),
|
||||
categoryId: z.number().int().nullable(),
|
||||
needsReview: z.boolean(),
|
||||
suggestedBy: z.string().nullable(),
|
||||
isSplit: z.boolean(),
|
||||
splits: z.array(SplitSchema),
|
||||
note: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
transferPeer: z.object({ id: z.number().int(), accountId: z.number().int(), accountName: z.string() }).nullable(),
|
||||
source: z.enum(['import', 'manual']),
|
||||
bankCategory: z.string().nullable(),
|
||||
importId: z.number().int().nullable(),
|
||||
})
|
||||
.openapi('Transaction');
|
||||
|
||||
export const ReviewItemSchema = TransactionSchema.extend({
|
||||
suggestions: z.array(z.number().int()),
|
||||
confident: z.boolean(),
|
||||
unpairedTransfer: z.boolean(),
|
||||
}).openapi('ReviewItem');
|
||||
|
||||
export const ColumnMapSchema = z
|
||||
.object({
|
||||
date: z.number().int().min(0),
|
||||
description: z.number().int().min(0),
|
||||
amount: z.number().int().min(0).optional(),
|
||||
debit: z.number().int().min(0).optional(),
|
||||
credit: z.number().int().min(0).optional(),
|
||||
postedDate: z.number().int().min(0).optional(),
|
||||
category: z.number().int().min(0).optional(),
|
||||
balance: z.number().int().min(0).optional(),
|
||||
positiveIs: z.enum(['out', 'in']),
|
||||
dateOrder: z.enum(['MDY', 'DMY', 'YMD']),
|
||||
})
|
||||
.refine((m) => m.amount !== undefined || m.debit !== undefined, { message: 'Choose the amount column.' })
|
||||
.openapi('ImportColumnMap');
|
||||
|
||||
export const ImportPreviewSchema = z
|
||||
.object({
|
||||
stagingId: z.string(),
|
||||
fileName: z.string(),
|
||||
layout: z.object({ key: z.string(), name: z.string() }).nullable(),
|
||||
sampleRows: z.array(z.array(z.string())),
|
||||
headerRow: z.array(z.string()).nullable(),
|
||||
suggestedMap: z.any().nullable(),
|
||||
account: z.object({ id: z.number().int(), confidence: z.enum(['certain', 'likely']), reason: z.string() }).nullable(),
|
||||
dateFrom: z.string().nullable(),
|
||||
dateTo: z.string().nullable(),
|
||||
counts: z.object({ rows: z.number(), new: z.number(), duplicates: z.number(), possibleDuplicates: z.number(), skipped: z.number() }),
|
||||
newRows: z.array(z.object({ date: z.string(), merchant: z.string(), amountCents: z.number() })),
|
||||
possibleDuplicates: z.array(
|
||||
z.object({ key: z.string(), date: z.string(), merchant: z.string(), amountCents: z.number(), existingDate: z.string() }),
|
||||
),
|
||||
accountHint: z
|
||||
.object({ kind: AccountKindSchema, institution: z.string().nullable(), last4: z.string().nullable(), name: z.string() })
|
||||
.nullable(),
|
||||
})
|
||||
.openapi('ImportPreview');
|
||||
|
||||
export const ImportSummarySchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
accountId: z.number().int(),
|
||||
accountName: z.string(),
|
||||
fileName: z.string(),
|
||||
layoutKey: z.string(),
|
||||
dateFrom: z.string().nullable(),
|
||||
dateTo: z.string().nullable(),
|
||||
addedCount: z.number().int(),
|
||||
duplicateCount: z.number().int(),
|
||||
skippedCount: z.number().int(),
|
||||
transferCount: z.number().int(),
|
||||
toReview: z.number().int(),
|
||||
status: z.enum(['committed', 'undone']),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
.openapi('ImportSummary');
|
||||
63
server/src/static.ts
Normal file
63
server/src/static.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { extname, join, normalize, sep } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
// Serves the built web app. Hashed files under /assets are cached for a year;
|
||||
// everything else revalidates so a new version shows up straight away. Any
|
||||
// path that isn't a file gets index.html, so the app can route it.
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
function cacheControlFor(urlPath: string): string {
|
||||
if (urlPath.startsWith('/assets/')) return 'public, max-age=31536000, immutable';
|
||||
if (urlPath === '/' || urlPath.endsWith('.html') || urlPath === '/sw.js' || urlPath.endsWith('.webmanifest')) {
|
||||
return 'no-cache';
|
||||
}
|
||||
return 'public, max-age=86400';
|
||||
}
|
||||
|
||||
function fileResponse(path: string, urlPath: string, head: boolean): Response {
|
||||
const headers = {
|
||||
'Content-Type': TYPES[extname(path)] ?? 'application/octet-stream',
|
||||
'Content-Length': String(statSync(path).size),
|
||||
'Cache-Control': cacheControlFor(urlPath),
|
||||
};
|
||||
const body = head ? null : (Readable.toWeb(createReadStream(path)) as ReadableStream);
|
||||
return new Response(body, { headers });
|
||||
}
|
||||
|
||||
export function webApp(root: string): MiddlewareHandler {
|
||||
const rootWithSep = root.endsWith(sep) ? root : root + sep;
|
||||
const index = join(root, 'index.html');
|
||||
|
||||
return async (c, next) => {
|
||||
const method = c.req.method;
|
||||
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
if ((method !== 'GET' && method !== 'HEAD') || urlPath.startsWith('/api/')) return next();
|
||||
|
||||
const candidate = normalize(join(root, urlPath));
|
||||
if (candidate.startsWith(rootWithSep) && existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return fileResponse(candidate, urlPath, method === 'HEAD');
|
||||
}
|
||||
if (extname(urlPath)) return c.text('Not found', 404);
|
||||
if (!existsSync(index)) {
|
||||
return c.text('The Spendlight web app is not built yet. Run "npm run build" (or "npm run dev:web" while developing).', 503);
|
||||
}
|
||||
return fileResponse(index, '/index.html', method === 'HEAD');
|
||||
};
|
||||
}
|
||||
111
server/src/transactions/dto.ts
Normal file
111
server/src/transactions/dto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Account, Category, CategoryGroup, PaletteColor, Transaction } from '@spendlight/shared';
|
||||
import { and, asc, count, desc, eq, inArray, max } from 'drizzle-orm';
|
||||
import { currentBalance } from '../accounts/balance';
|
||||
import type { AppContext } from '../context';
|
||||
import { accounts, categories, categoryGroups, imports, splits, tags, transactions, transactionTags, type AccountRow, type TransactionRow } from '../db/schema';
|
||||
|
||||
/** Full transactions, with splits, tags and the other half of any transfer, loaded in a few queries. */
|
||||
export function toTransactions(ctx: AppContext, rows: TransactionRow[]): Transaction[] {
|
||||
if (!rows.length) return [];
|
||||
const ids = rows.map((r) => r.id);
|
||||
const splitRows = ctx.db.select().from(splits).where(inArray(splits.transactionId, ids)).orderBy(asc(splits.id)).all();
|
||||
const tagRows = ctx.db
|
||||
.select({ transactionId: transactionTags.transactionId, name: tags.name })
|
||||
.from(transactionTags)
|
||||
.innerJoin(tags, eq(tags.id, transactionTags.tagId))
|
||||
.where(inArray(transactionTags.transactionId, ids))
|
||||
.all();
|
||||
const peerIds = rows.map((r) => r.transferPeerId).filter((p): p is number => p !== null);
|
||||
const peers = peerIds.length
|
||||
? ctx.db
|
||||
.select({ id: transactions.id, accountId: transactions.accountId, accountName: accounts.name })
|
||||
.from(transactions)
|
||||
.innerJoin(accounts, eq(accounts.id, transactions.accountId))
|
||||
.where(inArray(transactions.id, peerIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
accountId: r.accountId,
|
||||
date: r.date,
|
||||
amountCents: r.amountCents,
|
||||
description: r.description,
|
||||
merchant: r.merchant,
|
||||
categoryId: r.categoryId,
|
||||
needsReview: r.needsReview,
|
||||
suggestedBy: r.suggestedBy,
|
||||
isSplit: r.isSplit,
|
||||
splits: splitRows
|
||||
.filter((s) => s.transactionId === r.id)
|
||||
.map((s) => ({ id: s.id, categoryId: s.categoryId, amountCents: s.amountCents, note: s.note })),
|
||||
note: r.note,
|
||||
tags: tagRows.filter((t) => t.transactionId === r.id).map((t) => t.name),
|
||||
transferPeer: peers.find((p) => p.id === r.transferPeerId) ?? null,
|
||||
source: r.source,
|
||||
bankCategory: r.bankCategory,
|
||||
importId: r.importId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toTransaction(ctx: AppContext, row: TransactionRow): Transaction {
|
||||
return toTransactions(ctx, [row])[0]!;
|
||||
}
|
||||
|
||||
export function toAccount(ctx: AppContext, a: AccountRow): Account {
|
||||
const stats = ctx.db
|
||||
.select({ n: count(), last: max(transactions.date) })
|
||||
.from(transactions)
|
||||
.where(eq(transactions.accountId, a.id))
|
||||
.get();
|
||||
const lastImport = ctx.db
|
||||
.select({ at: max(imports.createdAt) })
|
||||
.from(imports)
|
||||
.where(and(eq(imports.accountId, a.id), eq(imports.status, 'committed')))
|
||||
.get();
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
institution: a.institution,
|
||||
last4: a.last4,
|
||||
color: a.color as PaletteColor,
|
||||
status: a.status,
|
||||
balanceCents: currentBalance(ctx.db, a),
|
||||
balanceConfirmedOn: a.balanceAsOf,
|
||||
transactionCount: stats?.n ?? 0,
|
||||
lastImportAt: lastImport?.at ?? null,
|
||||
lastTransactionDate: stats?.last ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function listCategoryGroups(ctx: AppContext, includeArchived = false): CategoryGroup[] {
|
||||
const groups = ctx.db.select().from(categoryGroups).orderBy(asc(categoryGroups.sortOrder), asc(categoryGroups.id)).all();
|
||||
const rows = ctx.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(includeArchived ? undefined : eq(categories.archived, false))
|
||||
.orderBy(asc(categories.sortOrder), asc(categories.id))
|
||||
.all();
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
categories: rows
|
||||
.filter((c) => c.groupId === g.id)
|
||||
.map(
|
||||
(c): Category => ({
|
||||
id: c.id,
|
||||
groupId: g.id,
|
||||
groupName: g.name,
|
||||
name: c.name,
|
||||
kind: c.kind,
|
||||
icon: c.icon,
|
||||
color: c.color as PaletteColor,
|
||||
archived: c.archived,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The newest-first ordering used everywhere transactions are listed. */
|
||||
export const newestFirst = [desc(transactions.date), desc(transactions.id)] as const;
|
||||
42
server/src/users.ts
Normal file
42
server/src/users.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Me } from '@spendlight/shared';
|
||||
import { and, count, eq, isNull } from 'drizzle-orm';
|
||||
import type { AppContext } from './context';
|
||||
import { passkeys, users, type UserRow } from './db/schema';
|
||||
|
||||
export function toMe(ctx: AppContext, user: UserRow): Me {
|
||||
const keys = ctx.db.select({ n: count() }).from(passkeys).where(eq(passkeys.userId, user.id)).get();
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
access: user.access,
|
||||
isAdmin: user.isAdmin,
|
||||
theme: user.theme,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
hasPasskey: (keys?.n ?? 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function activeUserCount(ctx: AppContext): number {
|
||||
return ctx.db.select({ n: count() }).from(users).where(isNull(users.deletedAt)).get()?.n ?? 0;
|
||||
}
|
||||
|
||||
export function adminCount(ctx: AppContext): number {
|
||||
return (
|
||||
ctx.db
|
||||
.select({ n: count() })
|
||||
.from(users)
|
||||
.where(and(isNull(users.deletedAt), eq(users.isAdmin, true)))
|
||||
.get()?.n ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export function findActiveUser(ctx: AppContext, where: { id?: number; username?: string }): UserRow | undefined {
|
||||
const condition =
|
||||
where.id !== undefined ? eq(users.id, where.id) : eq(users.username, (where.username ?? '').toLowerCase());
|
||||
return ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(condition, isNull(users.deletedAt)))
|
||||
.get();
|
||||
}
|
||||
161
server/test/auth.test.ts
Normal file
161
server/test/auth.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { hashToken, SESSION_COOKIE } from '../src/auth/sessions';
|
||||
import { sessions } from '../src/db/schema';
|
||||
import { Client, makeApp, setUpAdmin, type TestApp } from './helpers';
|
||||
|
||||
let t: TestApp;
|
||||
beforeEach(async () => {
|
||||
t = await makeApp();
|
||||
});
|
||||
afterEach(() => t.cleanup());
|
||||
|
||||
describe('first-run setup', () => {
|
||||
it('asks for setup until the first person exists, then never again', async () => {
|
||||
const visitor = new Client(t);
|
||||
expect((await visitor.get('/api/v1/config')).json).toEqual({
|
||||
version: '20261007.01',
|
||||
ideasEnabled: false,
|
||||
needsSetup: true,
|
||||
});
|
||||
|
||||
const admin = await setUpAdmin(t);
|
||||
const me = (await admin.get('/api/v1/me')).json;
|
||||
expect(me).toMatchObject({ username: 'chris', displayName: 'Chris', access: 'full', isAdmin: true, hasPasskey: false });
|
||||
expect((await visitor.get('/api/v1/config')).json.needsSetup).toBe(false);
|
||||
|
||||
const again = await visitor.post('/api/v1/setup', { displayName: 'Eve', username: 'eve', password: 'password123' });
|
||||
expect(again.status).toBe(409);
|
||||
});
|
||||
|
||||
it('rejects weak input with a friendly message', async () => {
|
||||
const res = await new Client(t).post('/api/v1/setup', { displayName: 'A', username: 'a b', password: 'short' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error.code).toBe('invalid_input');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-in', () => {
|
||||
it('signs in, stays signed in, and signs out', async () => {
|
||||
await setUpAdmin(t);
|
||||
const phone = new Client(t, 'http://localhost', { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1' });
|
||||
expect((await phone.get('/api/v1/me')).status).toBe(401);
|
||||
|
||||
const login = await phone.post('/api/v1/auth/login', { username: 'CHRIS', password: 'correct-horse' });
|
||||
expect(login.status).toBe(200);
|
||||
const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`))!;
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/SameSite=Lax/i);
|
||||
expect(cookie).toMatch(/Max-Age=34560000/); // 400 days
|
||||
|
||||
const devices = (await phone.get('/api/v1/devices')).json;
|
||||
expect(devices.find((d: any) => d.current).deviceLabel).toBe('iPhone · Safari');
|
||||
|
||||
expect((await phone.post('/api/v1/auth/logout')).status).toBe(200);
|
||||
expect((await phone.get('/api/v1/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('refuses a wrong password, then slows down repeated guessing', async () => {
|
||||
await setUpAdmin(t);
|
||||
const guesser = new Client(t);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect((await guesser.post('/api/v1/auth/login', { username: 'chris', password: `guess-${i}` })).status).toBe(401);
|
||||
}
|
||||
const blocked = await guesser.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' });
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.headers.get('retry-after')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renews an ageing session so a device in use never signs out', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const token = admin.cookie(SESSION_COOKIE)!;
|
||||
const id = hashToken(token);
|
||||
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
t.ctx.db.update(sessions).set({ expiresAt: soon }).where(eq(sessions.id, id)).run();
|
||||
|
||||
const res = await admin.get('/api/v1/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.getSetCookie().some((c) => c.startsWith(`${SESSION_COOKIE}=`))).toBe(true);
|
||||
const renewed = t.ctx.db.select().from(sessions).where(eq(sessions.id, id)).get()!;
|
||||
expect(Date.parse(renewed.expiresAt)).toBeGreaterThan(Date.now() + 399 * 86_400_000);
|
||||
});
|
||||
|
||||
it('refuses password sign-in over plain HTTP from another machine', async () => {
|
||||
await setUpAdmin(t);
|
||||
const lan = new Client(t, 'http://192.168.1.20:8095');
|
||||
const res = await lan.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error.code).toBe('insecure_connection');
|
||||
});
|
||||
|
||||
it('trusts the HTTPS proxy and marks cookies Secure behind it', async () => {
|
||||
t.cleanup();
|
||||
t = await makeApp({ TRUST_PROXY: 'true' });
|
||||
const viaProxy = new Client(t, 'http://192.168.1.20:8095', {
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-Host': 'spendlight.example.com',
|
||||
});
|
||||
const res = await viaProxy.post('/api/v1/setup', { displayName: 'Chris', username: 'chris', password: 'correct-horse' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.getSetCookie().find((c) => c.startsWith(SESSION_COOKIE))).toMatch(/Secure/);
|
||||
});
|
||||
|
||||
it('blocks changes sent from another website', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const res = await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'https://evil.example' });
|
||||
expect(res.status).toBe(403);
|
||||
expect((await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'http://localhost' })).json.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('changes a password and signs out other devices', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const laptop = new Client(t);
|
||||
await laptop.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' });
|
||||
|
||||
const wrong = await admin.post('/api/v1/me/password', { currentPassword: 'nope', newPassword: 'battery-staple' });
|
||||
expect(wrong.status).toBe(400);
|
||||
const ok = await admin.post('/api/v1/me/password', { currentPassword: 'correct-horse', newPassword: 'battery-staple' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect((await admin.get('/api/v1/me')).status).toBe(200);
|
||||
expect((await laptop.get('/api/v1/me')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('people and roles', () => {
|
||||
it('lets an admin add a read-only person who must choose a password', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read' });
|
||||
expect(added.status).toBe(200);
|
||||
expect(added.json.temporaryPassword).toMatch(/^[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/);
|
||||
|
||||
const sam = new Client(t);
|
||||
const login = await sam.post('/api/v1/auth/login', { username: 'sam', password: added.json.temporaryPassword });
|
||||
expect(login.json).toMatchObject({ access: 'read', isAdmin: false, mustChangePassword: true });
|
||||
expect((await sam.get('/api/v1/people')).status).toBe(403);
|
||||
expect((await sam.post('/api/v1/backups')).status).toBe(403);
|
||||
|
||||
const changed = await sam.post('/api/v1/me/password', {
|
||||
currentPassword: added.json.temporaryPassword,
|
||||
newPassword: 'sams-own-password',
|
||||
});
|
||||
expect(changed.json.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it('never leaves the household without an admin', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const me = (await admin.get('/api/v1/me')).json;
|
||||
expect((await admin.patch(`/api/v1/people/${me.id}`, { isAdmin: false })).json.error.code).toBe('last_admin');
|
||||
expect((await admin.del(`/api/v1/people/${me.id}`)).json.error.code).toBe('cannot_remove_self');
|
||||
});
|
||||
|
||||
it('removes a person, signs them out, and frees their username', async () => {
|
||||
const admin = await setUpAdmin(t);
|
||||
const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'full', password: 'sams-password' });
|
||||
const sam = new Client(t);
|
||||
await sam.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' });
|
||||
|
||||
expect((await admin.del(`/api/v1/people/${added.json.person.id}`)).status).toBe(200);
|
||||
expect((await sam.get('/api/v1/me')).status).toBe(401);
|
||||
expect((await admin.post('/api/v1/people', { displayName: 'Sam 2', username: 'sam', access: 'read' })).status).toBe(200);
|
||||
});
|
||||
});
|
||||
96
server/test/backups.test.ts
Normal file
96
server/test/backups.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createBackup, listBackups, planRetention } from '../src/backups/backup';
|
||||
import { makeApp, setUpAdmin, type TestApp } from './helpers';
|
||||
|
||||
const nightly = (y: number, m: number, d: number) => {
|
||||
const date = `${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}`;
|
||||
return {
|
||||
name: `spendlight-nightly-${date}-023000.db`,
|
||||
kind: 'nightly' as const,
|
||||
createdAt: new Date(y, m - 1, d, 2, 30).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
describe('backup retention', () => {
|
||||
it('keeps 14 daily copies plus the newest of each of 12 months', () => {
|
||||
const list = [];
|
||||
// Every night from Jan 1 2025 to Oct 7 2026.
|
||||
for (let d = new Date(2025, 0, 1); d <= new Date(2026, 9, 7); d.setDate(d.getDate() + 1)) {
|
||||
list.push(nightly(d.getFullYear(), d.getMonth() + 1, d.getDate()));
|
||||
}
|
||||
const doomed = new Set(planRetention(list, { keepDaily: 14, keepMonthly: 12 }));
|
||||
const kept = list.filter((b) => !doomed.has(b.name)).map((b) => b.name.slice(19, 27));
|
||||
|
||||
expect(kept.filter((d) => d >= '20260924')).toHaveLength(14); // the last 14 nights
|
||||
const months = new Set(kept.map((d) => d.slice(0, 6)));
|
||||
expect(months.size).toBe(12); // Nov 2025 through Oct 2026
|
||||
expect(kept).toContain('20251130'); // newest of the oldest kept month
|
||||
expect(kept).not.toContain('20251031');
|
||||
});
|
||||
|
||||
it('keeps the newest 10 manual and 5 pre-upgrade copies', () => {
|
||||
const make = (kind: 'manual' | 'pre-upgrade', i: number) => ({
|
||||
name: `spendlight-${kind}-202610${String(i).padStart(2, '0')}-120000.db`,
|
||||
kind,
|
||||
createdAt: new Date(2026, 9, i, 12).toISOString(),
|
||||
});
|
||||
const list = [...Array.from({ length: 12 }, (_, i) => make('manual', i + 1)), ...Array.from({ length: 7 }, (_, i) => make('pre-upgrade', i + 1))];
|
||||
const doomed = planRetention(list, { keepDaily: 14, keepMonthly: 12 });
|
||||
expect(doomed.filter((n) => n.includes('manual'))).toEqual([
|
||||
'spendlight-manual-20261002-120000.db',
|
||||
'spendlight-manual-20261001-120000.db',
|
||||
]);
|
||||
expect(doomed.filter((n) => n.includes('pre-upgrade'))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup files', () => {
|
||||
let t: TestApp;
|
||||
afterEach(() => t.cleanup());
|
||||
|
||||
it('writes a complete, valid Spendlight database and lists it', async () => {
|
||||
t = await makeApp();
|
||||
await setUpAdmin(t);
|
||||
const dir = t.ctx.config.backupDir;
|
||||
const backup = await createBackup(t.ctx.sqlite, dir, 'manual');
|
||||
expect(readdirSync(dir).filter((f) => f.endsWith('.partial'))).toHaveLength(0);
|
||||
expect(listBackups(dir).map((b) => b.name)).toContain(backup.name);
|
||||
|
||||
const copy = new Database(join(dir, backup.name), { readonly: true });
|
||||
expect((copy.prepare("SELECT value FROM setting WHERE key = 'app'").get() as { value: string }).value).toBe('"spendlight"');
|
||||
expect((copy.prepare('SELECT username FROM user').get() as { username: string }).username).toBe('chris');
|
||||
copy.close();
|
||||
});
|
||||
|
||||
it('lets an admin back up from the app and download the file', async () => {
|
||||
t = await makeApp();
|
||||
const admin = await setUpAdmin(t);
|
||||
const made = await admin.post('/api/v1/backups');
|
||||
expect(made.status).toBe(200);
|
||||
expect((await admin.get('/api/v1/backups')).json).toMatchObject({ nightlyAt: '02:30', keepDaily: 14, keepMonthly: 12 });
|
||||
|
||||
const download = await admin.get(`/api/v1/backups/${made.json.name}/download`);
|
||||
expect(download.status).toBe(200);
|
||||
expect(download.headers.get('content-disposition')).toContain(made.json.name);
|
||||
expect((await admin.get('/api/v1/backups/..%2F..%2Fetc%2Fpasswd/download')).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version stamps', () => {
|
||||
const next = (previous: string, today: string) =>
|
||||
execFileSync('bash', [fileURLToPath(new URL('../../scripts/next-version.sh', import.meta.url)), previous], { env: { ...process.env, TODAY: today } })
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
it('counts up through the day and restarts at .01 the next', () => {
|
||||
expect(next('dev', '20261007')).toBe('20261007.01');
|
||||
expect(next('20261007.01', '20261007')).toBe('20261007.02');
|
||||
expect(next('20261007.09', '20261007')).toBe('20261007.10');
|
||||
expect(next('20261007.14', '20261008')).toBe('20261008.01');
|
||||
});
|
||||
});
|
||||
6
server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv
vendored
Normal file
6
server/test/fixtures/synthetic/Chase9876_Activity_20261007.csv
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
Transaction Date,Post Date,Description,Category,Type,Amount,Memo
|
||||
09/02/2026,09/03/2026,AMAZON MKTPL*AB12CD3E4,Shopping,Sale,-23.99,
|
||||
09/04/2026,09/05/2026,Amazon.com*ZX98YW7V6,Shopping,Sale,-112.45,
|
||||
09/06/2026,09/07/2026,AMAZON MKTPL*QR56ST7U8,Shopping,Return,18.50,
|
||||
09/09/2026,09/10/2026,JOES DINER,Food & Drink,Sale,-41.20,
|
||||
09/16/2026,09/16/2026,Payment Thank You-Mobile,,Payment,150.00,
|
||||
|
9
server/test/fixtures/synthetic/Discover-Statement-20261007.csv
vendored
Normal file
9
server/test/fixtures/synthetic/Discover-Statement-20261007.csv
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
Trans. Date,Post Date,Description,Amount,Category
|
||||
09/03/2026,09/03/2026,KROGER #123 CHARLOTTE NC,54.20,Supermarkets
|
||||
09/03/2026,09/03/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants
|
||||
09/03/2026,09/03/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants
|
||||
09/08/2026,09/08/2026,JOES DINER #12 CHARLOTTE NC,38.75,Restaurants
|
||||
09/11/2026,09/11/2026,INTERNET PAYMENT - THANK YOU,-500.00,Payments and Credits
|
||||
09/14/2026,09/14/2026,SHELL OIL 57444 CHARLOTTE NC,47.10,Gasoline
|
||||
09/18/2026,09/18/2026,NETFLIX.COM 866-579-7172 CA,17.99,Services
|
||||
09/22/2026,09/22/2026,BLUE MOON BOOKS #9 ASHEVILLE NC,22.00,Merchandise
|
||||
|
13
server/test/fixtures/synthetic/bofa-checking.csv
vendored
Normal file
13
server/test/fixtures/synthetic/bofa-checking.csv
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
Description,,Summary Amt.
|
||||
Beginning balance as of 09/01/2026,,"2,000.00"
|
||||
Total credits,,"3,000.00"
|
||||
Total debits,,"-1,070.00"
|
||||
Ending balance as of 09/30/2026,,"3,930.00"
|
||||
|
||||
Date,Description,Amount,Running Bal.
|
||||
09/01/2026,Beginning balance as of 09/01/2026,,"2,000.00"
|
||||
09/05/2026,"ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD","3,000.00","5,000.00"
|
||||
09/10/2026,"DISCOVER DES:E-PAYMENT ID:XXXXX99999 INDN:JANE DOE CO ID:XXXXX99999 WEB","-500.00","4,500.00"
|
||||
09/12/2026,"Online Banking transfer to SAV 5678 Confirmation# XXXXX12345","-300.00","4,200.00"
|
||||
09/15/2026,"CHASE CREDIT CRD DES:EPAY ID:XXXXX55555 INDN:JANE DOE CO ID:XXXXX55555 WEB","-150.00","4,050.00"
|
||||
09/20/2026,"DUKE ENERGY DES:BILL PAY ID:XXXXX77777 INDN:JANE DOE CO ID:XXXXX77777 WEB","-120.00","3,930.00"
|
||||
|
Can't render this file because it has a wrong number of fields in line 7.
|
10
server/test/fixtures/synthetic/bofa-savings.csv
vendored
Normal file
10
server/test/fixtures/synthetic/bofa-savings.csv
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
Description,,Summary Amt.
|
||||
Beginning balance as of 09/01/2026,,"10,000.00"
|
||||
Total credits,,"304.20"
|
||||
Total debits,,"0.00"
|
||||
Ending balance as of 09/30/2026,,"10,304.20"
|
||||
|
||||
Date,Description,Amount,Running Bal.
|
||||
09/01/2026,Beginning balance as of 09/01/2026,,"10,000.00"
|
||||
09/12/2026,"Online Banking transfer from CHK 1234 Confirmation# XXXXX12345","300.00","10,300.00"
|
||||
09/30/2026,Interest Earned,4.20,"10,304.20"
|
||||
|
Can't render this file because it has a wrong number of fields in line 7.
|
4
server/test/fixtures/synthetic/unknown-bank.csv
vendored
Normal file
4
server/test/fixtures/synthetic/unknown-bank.csv
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Posting Date;Payee Name;Withdrawal;Deposit;Balance
|
||||
2026-09-02;Corner Hardware;12.50;;987.50
|
||||
2026-09-05;Paycheck Employer;;1,200.00;2,187.50
|
||||
2026-09-07;Corner Hardware;8.25;;2,179.25
|
||||
|
104
server/test/helpers.ts
Normal file
104
server/test/helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createApp } from '../src/app';
|
||||
import { loadConfig } from '../src/config';
|
||||
import { createContext, type AppContext } from '../src/context';
|
||||
import { silentLogger } from '../src/log';
|
||||
|
||||
export interface TestApp {
|
||||
ctx: AppContext;
|
||||
app: ReturnType<typeof createApp>;
|
||||
dir: string;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export async function makeApp(env: Record<string, string> = {}): Promise<TestApp> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'spendlight-test-'));
|
||||
const config = loadConfig({
|
||||
DATA_DIR: join(dir, 'data'),
|
||||
BACKUP_DIR: join(dir, 'backups'),
|
||||
WEB_DIST: join(dir, 'web'),
|
||||
APP_VERSION: '20261007.01',
|
||||
...env,
|
||||
});
|
||||
const ctx = await createContext(config, { log: silentLogger });
|
||||
const app = createApp(ctx);
|
||||
return {
|
||||
ctx,
|
||||
app,
|
||||
dir,
|
||||
cleanup() {
|
||||
ctx.sqlite.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A tiny browser: keeps cookies between calls and sends JSON. */
|
||||
export class Client {
|
||||
private cookies = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private t: TestApp,
|
||||
private base = 'http://localhost',
|
||||
private extraHeaders: Record<string, string> = {},
|
||||
) {}
|
||||
|
||||
async call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
const res = await this.t.app.request(`${this.base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}),
|
||||
...this.extraHeaders,
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const [pair] = line.split(';');
|
||||
const [name, ...rest] = pair!.split('=');
|
||||
const value = rest.join('=');
|
||||
if (!value || /max-age=0/i.test(line)) this.cookies.delete(name!);
|
||||
else this.cookies.set(name!, value);
|
||||
}
|
||||
const text = await res.text();
|
||||
let json: any = undefined;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return { status: res.status, json, text, headers: res.headers };
|
||||
}
|
||||
|
||||
/** Send a file the way the browser's file picker does. */
|
||||
async upload(path: string, fileName: string, content: string) {
|
||||
const form = new FormData();
|
||||
form.append('file', new File([content], fileName, { type: 'text/csv' }));
|
||||
const res = await this.t.app.request(`${this.base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {},
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: JSON.parse(text), text };
|
||||
}
|
||||
|
||||
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);
|
||||
del = (path: string, headers?: Record<string, string>) => this.call('DELETE', path, undefined, headers);
|
||||
|
||||
cookie(name: string) {
|
||||
return this.cookies.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUpAdmin(t: TestApp) {
|
||||
const admin = new Client(t);
|
||||
const res = await admin.post('/api/v1/setup', { displayName: 'Chris', username: 'Chris', password: 'correct-horse' });
|
||||
if (res.status !== 200) throw new Error(`setup failed: ${res.text}`);
|
||||
return admin;
|
||||
}
|
||||
49
server/test/ideas.test.ts
Normal file
49
server/test/ideas.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Client, makeApp, setUpAdmin, type TestApp } from './helpers';
|
||||
|
||||
let t: TestApp;
|
||||
afterEach(() => t.cleanup());
|
||||
|
||||
describe('ideas box', () => {
|
||||
it('does not exist when IDEAS_ENABLED is off', async () => {
|
||||
t = await makeApp({ IDEAS_ENABLED: 'false' });
|
||||
const admin = await setUpAdmin(t);
|
||||
expect((await admin.get('/api/v1/config')).json.ideasEnabled).toBe(false);
|
||||
expect((await admin.post('/api/v1/ideas', { title: 'Hello' })).status).toBe(404);
|
||||
expect((await admin.get('/api/v1/ideas')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('takes ideas from anyone signed in and lets an admin triage and export them', async () => {
|
||||
t = await makeApp({ IDEAS_ENABLED: 'true' });
|
||||
const admin = await setUpAdmin(t);
|
||||
const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read', password: 'sams-password' });
|
||||
expect(added.status).toBe(200);
|
||||
const sam = new Client(t);
|
||||
await sam.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' });
|
||||
|
||||
expect((await new Client(t).post('/api/v1/ideas', { title: 'Anonymous' })).status).toBe(401);
|
||||
expect((await sam.post('/api/v1/ideas', { title: ' ' })).status).toBe(400);
|
||||
|
||||
const idea = await sam.post('/api/v1/ideas', {
|
||||
title: 'Show a coffee total',
|
||||
description: 'How much did we spend on coffee this year?',
|
||||
path: '/insights',
|
||||
});
|
||||
expect(idea.status).toBe(200);
|
||||
expect(idea.json).toMatchObject({ status: 'new', author: 'Sam', appVersion: '20261007.01', path: '/insights' });
|
||||
|
||||
expect((await sam.get('/api/v1/ideas')).status).toBe(403);
|
||||
const list = (await admin.get('/api/v1/ideas')).json;
|
||||
expect(list).toHaveLength(1);
|
||||
|
||||
const planned = await admin.patch(`/api/v1/ideas/${idea.json.id}`, { status: 'planned', adminNote: 'Milestone 2' });
|
||||
expect(planned.json).toMatchObject({ status: 'planned', adminNote: 'Milestone 2' });
|
||||
expect((await admin.get('/api/v1/ideas?status=new')).json).toHaveLength(0);
|
||||
|
||||
const md = await admin.get('/api/v1/ideas/export.md');
|
||||
expect(md.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(md.text).toContain(`## #${idea.json.id}: Show a coffee total`);
|
||||
expect(md.text).toContain('- Status: planned');
|
||||
expect(md.text).toContain('How much did we spend on coffee this year?');
|
||||
});
|
||||
});
|
||||
268
server/test/import.test.ts
Normal file
268
server/test/import.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { cleanMerchant } from '../src/import/merchant';
|
||||
import { parseCents, parseDate } from '../src/import/parse';
|
||||
import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers';
|
||||
|
||||
// All fixture files are made up, shaped like the real exports' masked profiles.
|
||||
const fixture = (name: string) => readFileSync(new URL(`./fixtures/synthetic/${name}`, import.meta.url), 'utf8');
|
||||
const CHASE = 'Chase9876_Activity_20261007.csv';
|
||||
const DISCOVER = 'Discover-Statement-20261007.csv';
|
||||
|
||||
let t: TestApp;
|
||||
let admin: Client;
|
||||
let ids: { checking: number; savings: number; discover: number; chase: number };
|
||||
|
||||
async function addAccount(body: Record<string, unknown>) {
|
||||
const res = await admin.post('/api/v1/accounts', body);
|
||||
expect(res.status).toBe(200);
|
||||
return res.json.id as number;
|
||||
}
|
||||
|
||||
async function importFile(name: string, accountId?: number) {
|
||||
const preview = await admin.upload('/api/v1/imports/files', name, fixture(name));
|
||||
expect(preview.status).toBe(200);
|
||||
const target = accountId ?? preview.json.account?.id;
|
||||
const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: target });
|
||||
expect(done.status).toBe(200);
|
||||
return { preview: preview.json, summary: done.json };
|
||||
}
|
||||
|
||||
const categoryId = async (name: string) => {
|
||||
const groups = (await admin.get('/api/v1/categories')).json as { categories: { id: number; name: string }[] }[];
|
||||
return groups.flatMap((g) => g.categories).find((c) => c.name === name)!.id;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await makeApp();
|
||||
admin = await setUpAdmin(t);
|
||||
ids = {
|
||||
checking: await addAccount({ name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' }),
|
||||
savings: await addAccount({ name: 'Savings', kind: 'savings', institution: 'Bank of America', last4: '5678' }),
|
||||
discover: await addAccount({ name: 'Discover', kind: 'credit_card', institution: 'Discover' }),
|
||||
chase: await addAccount({ name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }),
|
||||
};
|
||||
});
|
||||
afterEach(() => t.cleanup());
|
||||
|
||||
describe('reading statements', () => {
|
||||
it('parses money and dates the way banks write them', () => {
|
||||
expect(parseCents('1,234.56')).toBe(123456);
|
||||
expect(parseCents('-$5.00')).toBe(-500);
|
||||
expect(parseCents('(12.50)')).toBe(-1250);
|
||||
expect(parseCents('')).toBeNull();
|
||||
expect(parseDate('09/03/2026')).toBe('2026-09-03');
|
||||
expect(parseDate('2026-09-03')).toBe('2026-09-03');
|
||||
expect(parseDate('9/3/26')).toBe('2026-09-03');
|
||||
expect(parseDate('02/30/2026')).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans merchant names', () => {
|
||||
expect(cleanMerchant('KROGER #123 CHARLOTTE NC').merchant).toBe('Kroger');
|
||||
expect(cleanMerchant('AMAZON MKTPL*AB12CD3E4').merchant).toBe('Amazon');
|
||||
expect(cleanMerchant('JOES DINER #12 CHARLOTTE NC').merchant).toBe('Joes Diner');
|
||||
expect(cleanMerchant('BLUE MOON BOOKS #9 ASHEVILLE NC').merchant).toBe('Blue Moon Books');
|
||||
expect(cleanMerchant('DUKE ENERGY DES:BILL PAY ID:XXXXX77777 INDN:JANE DOE').merchant).toBe('Duke Energy');
|
||||
expect(cleanMerchant('SQ *CORNER BAKERY 8823 CHARLOTTE NC').merchant).toBe('Corner Bakery');
|
||||
expect(cleanMerchant('Town of Riverside NC Bill Payment').merchant).toBe('Town of Riverside NC');
|
||||
expect(cleanMerchant('Zelle payment to Jordan Lee Conf# h3k2l1m9').merchant).toBe('Zelle to Jordan Lee');
|
||||
expect(cleanMerchant('Zelle payment from Pat Doe Conf# XyZ987abc').merchant).toBe('Zelle from Pat Doe');
|
||||
expect(cleanMerchant('Online Banking payment to CRD 4321 Confirmation# 1234567890')).toEqual({ merchant: 'Payment to card 4321', mentionsLast4: '4321' });
|
||||
expect(cleanMerchant('WAKE PARKS - NC123 RALEIGH NC 27601').merchant).toBe('Wake Parks');
|
||||
expect(cleanMerchant('BLUE RIDGE OUTFITTERS ID1234567890ABC').merchant).toBe('Blue Ridge Outfitters');
|
||||
expect(cleanMerchant('7-ELEVEN 34567 CHARLOTTE NC').merchant).toBe('7-Eleven');
|
||||
expect(cleanMerchant('ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD').merchant).toBe('Acme Corp');
|
||||
expect(cleanMerchant('CITY UTILITIES DES:UTIL PYMT ID:XXXXX123 INDN:Jane Doe CO ID:XXXXX WEB').merchant).toBe('City Utilities');
|
||||
const transfer = cleanMerchant('Online Banking transfer to SAV 5678 Confirmation# XXXXX12345');
|
||||
expect(transfer).toEqual({ merchant: 'Transfer to savings', mentionsLast4: '5678' });
|
||||
});
|
||||
|
||||
it('recognizes each bank and gets money in and out the right way round', async () => {
|
||||
const discover = (await admin.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).json;
|
||||
expect(discover.layout.key).toBe('discover-card');
|
||||
expect(discover.counts).toMatchObject({ rows: 8, new: 8 });
|
||||
expect(discover.newRows[0]).toMatchObject({ merchant: 'Kroger', amountCents: -5420 });
|
||||
|
||||
const chase = (await admin.upload('/api/v1/imports/files', CHASE, fixture(CHASE))).json;
|
||||
expect(chase.layout.key).toBe('chase-card');
|
||||
expect(chase.account).toMatchObject({ id: ids.chase, confidence: 'certain' }); // from the file name
|
||||
expect(chase.newRows.find((r: { merchant: string; amountCents: number }) => r.amountCents === 1850)?.merchant).toBe('Amazon'); // a return
|
||||
|
||||
const checking = (await admin.upload('/api/v1/imports/files', 'stmt.csv', fixture('bofa-checking.csv'))).json;
|
||||
expect(checking.layout.key).toBe('bofa-deposit');
|
||||
expect(checking.counts).toMatchObject({ rows: 5, new: 5 }); // the "Beginning balance" line isn't a transaction
|
||||
expect(checking.account).toBeNull(); // two Bank of America accounts: Spendlight asks
|
||||
});
|
||||
});
|
||||
|
||||
describe('importing', () => {
|
||||
it('skips duplicates by count, so identical charges on one day both survive', async () => {
|
||||
const first = await importFile(DISCOVER, ids.discover);
|
||||
expect(first.summary.addedCount).toBe(8);
|
||||
const coffees = (await admin.get(`/api/v1/transactions?accountId=${ids.discover}&q=starbucks`)).json;
|
||||
expect(coffees.total).toBe(2);
|
||||
|
||||
const again = (await admin.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).json;
|
||||
expect(again.account).toMatchObject({ id: ids.discover, confidence: 'certain' }); // recognized by its transactions
|
||||
expect(again.counts).toMatchObject({ new: 0, duplicates: 8 });
|
||||
|
||||
// A longer statement overlapping the first: only the extra line is new.
|
||||
const longer = fixture(DISCOVER) + '09/25/2026,09/25/2026,STARBUCKS #4567 CHARLOTTE NC,5.45,Restaurants\r\n';
|
||||
const overlap = (await admin.upload('/api/v1/imports/files', DISCOVER, longer)).json;
|
||||
expect(overlap.counts).toMatchObject({ new: 1, duplicates: 8 });
|
||||
});
|
||||
|
||||
it('pairs card payments and transfers between accounts, and keeps them out of spending', async () => {
|
||||
await importFile('bofa-checking.csv', ids.checking);
|
||||
await importFile('bofa-savings.csv', ids.savings);
|
||||
await importFile(DISCOVER, ids.discover);
|
||||
const chase = await importFile(CHASE);
|
||||
expect(chase.summary.transferCount).toBe(1);
|
||||
|
||||
const checking = (await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.items as any[];
|
||||
const toDiscover = checking.find((x) => x.amountCents === -50000);
|
||||
const toSavings = checking.find((x) => x.amountCents === -30000);
|
||||
const toChase = checking.find((x) => x.amountCents === -15000);
|
||||
expect(toDiscover).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Discover' } });
|
||||
expect(toSavings).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Savings' } });
|
||||
expect(toChase).toMatchObject({ needsReview: false, transferPeer: { accountName: 'Prime Visa' } });
|
||||
expect(toDiscover.categoryId).toBe(await categoryId('Credit Card Payment'));
|
||||
expect(toSavings.categoryId).toBe(await categoryId('Transfer Between Accounts'));
|
||||
|
||||
// Spending totals leave transfers out.
|
||||
const sums = (await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.sums;
|
||||
expect(sums).toEqual({ inCents: 300000, outCents: -12000 });
|
||||
});
|
||||
|
||||
it('pre-fills categories from the bank and from known merchants', async () => {
|
||||
await importFile(DISCOVER, ids.discover);
|
||||
const review = (await admin.get('/api/v1/review')).json as any[];
|
||||
const by = (m: string) => review.find((r) => r.merchant === m);
|
||||
expect(by('Kroger').categoryId).toBe(await categoryId('Groceries'));
|
||||
expect(by('Shell').categoryId).toBe(await categoryId('Fuel'));
|
||||
expect(by('Netflix').categoryId).toBe(await categoryId('Streaming and Subscriptions'));
|
||||
expect(by('Blue Moon Books').categoryId).toBe(await categoryId('General Merchandise')); // the bank's hint
|
||||
expect(by('Kroger').suggestions.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('learns from your picks: three agreeing picks make the next one confident', async () => {
|
||||
const dining = await categoryId('Dining Out');
|
||||
const coffee = await categoryId('Coffee and Snacks');
|
||||
await importFile(DISCOVER, ids.discover);
|
||||
const diner = ((await admin.get('/api/v1/review')).json as any[]).find((r) => r.merchant === 'Joes Diner');
|
||||
const filed = await admin.patch(`/api/v1/transactions/${diner.id}`, { categoryId: dining });
|
||||
expect(filed.json.similar).toBeNull();
|
||||
|
||||
// Coffee: file one; the prompt offers the other identical one.
|
||||
const coffees = ((await admin.get('/api/v1/review')).json as any[]).filter((r) => r.merchant === 'Starbucks');
|
||||
const firstCoffee = await admin.patch(`/api/v1/transactions/${coffees[0].id}`, { categoryId: coffee });
|
||||
expect(firstCoffee.json.similar).toEqual({ merchant: 'Starbucks', count: 1, categoryId: coffee });
|
||||
const yes = await admin.post('/api/v1/transactions/bulk', { merchant: 'Starbucks', categoryId: coffee, remember: true });
|
||||
expect(yes.json.updated).toBe(1);
|
||||
|
||||
// Next month, the rule files Starbucks confidently.
|
||||
const next = fixture(DISCOVER).replace(/09\/03\/2026/g, '10/03/2026');
|
||||
const preview = (await admin.upload('/api/v1/imports/files', DISCOVER, next)).json;
|
||||
await admin.post('/api/v1/imports', { stagingId: preview.stagingId, accountId: ids.discover });
|
||||
const october = ((await admin.get('/api/v1/review')).json as any[]).filter((r) => r.merchant === 'Starbucks' && r.date === '2026-10-03');
|
||||
expect(october).toHaveLength(2);
|
||||
expect(october.every((r) => r.categoryId === coffee && r.confident)).toBe(true);
|
||||
|
||||
const accepted = await admin.post('/api/v1/transactions/accept', { ids: october.map((r) => r.id) });
|
||||
expect(accepted.json.accepted).toBe(2);
|
||||
});
|
||||
|
||||
it('undoes a whole import and puts the other half of a transfer back in Review', async () => {
|
||||
await importFile('bofa-checking.csv', ids.checking);
|
||||
const discover = await importFile(DISCOVER, ids.discover);
|
||||
const del = await admin.del(`/api/v1/imports/${discover.summary.id}`);
|
||||
expect(del.json.status).toBe('undone');
|
||||
expect((await admin.get(`/api/v1/transactions?accountId=${ids.discover}`)).json.total).toBe(0);
|
||||
const payment = ((await admin.get(`/api/v1/transactions?accountId=${ids.checking}`)).json.items as any[]).find(
|
||||
(x) => x.amountCents === -50000,
|
||||
);
|
||||
expect(payment).toMatchObject({ transferPeer: null, needsReview: true });
|
||||
});
|
||||
|
||||
it('keeps balances: running balance from the bank, and an entered card balance plus new charges', async () => {
|
||||
await importFile('bofa-checking.csv', ids.checking);
|
||||
let accounts = (await admin.get('/api/v1/accounts')).json as any[];
|
||||
expect(accounts.find((a) => a.id === ids.checking)).toMatchObject({ balanceCents: 393000, balanceConfirmedOn: '2026-09-20' });
|
||||
|
||||
await admin.post(`/api/v1/accounts/${ids.discover}/balance`, { balanceCents: -100000, asOf: '2026-09-10' });
|
||||
await importFile(DISCOVER, ids.discover);
|
||||
accounts = (await admin.get('/api/v1/accounts')).json as any[];
|
||||
// Owed $1,000 on Sep 10; then +$500 payment Sep 11 and charges of 47.10 + 17.99 + 22.00 after it.
|
||||
expect(accounts.find((a) => a.id === ids.discover).balanceCents).toBe(-100000 + 50000 - 4710 - 1799 - 2200);
|
||||
});
|
||||
|
||||
it('splits a purchase, adds tags and notes, and records cash by hand', async () => {
|
||||
await importFile(CHASE);
|
||||
const amazon = ((await admin.get(`/api/v1/transactions?accountId=${ids.chase}&q=amazon`)).json.items as any[]).find(
|
||||
(x) => x.amountCents === -11245,
|
||||
);
|
||||
const household = await categoryId('Household Supplies');
|
||||
const clothing = await categoryId('Clothing');
|
||||
const bad = await admin.call('PUT', `/api/v1/transactions/${amazon.id}/split`, {
|
||||
parts: [
|
||||
{ categoryId: household, amountCents: -5000 },
|
||||
{ categoryId: clothing, amountCents: -5000 },
|
||||
],
|
||||
});
|
||||
expect(bad.json.error.code).toBe('split_total');
|
||||
const split = await admin.call('PUT', `/api/v1/transactions/${amazon.id}/split`, {
|
||||
parts: [
|
||||
{ categoryId: household, amountCents: -6245, note: 'Paper towels' },
|
||||
{ categoryId: clothing, amountCents: -5000 },
|
||||
],
|
||||
});
|
||||
expect(split.json).toMatchObject({ isSplit: true, needsReview: false, categoryId: null });
|
||||
expect((await admin.get(`/api/v1/transactions?categoryId=${clothing}`)).json.total).toBe(1);
|
||||
|
||||
const tagged = await admin.patch(`/api/v1/transactions/${amazon.id}`, { tags: ['Back to school', 'back to school', 'Kids'], note: 'For Sam' });
|
||||
expect(tagged.json.transaction).toMatchObject({ tags: ['Back to school', 'Kids'], note: 'For Sam' });
|
||||
expect((await admin.get('/api/v1/transactions?tag=kids')).json.total).toBe(1);
|
||||
|
||||
const cash = await addAccount({ name: 'Cash', kind: 'cash' });
|
||||
const farmers = await admin.post('/api/v1/transactions', {
|
||||
accountId: cash,
|
||||
date: '2026-09-20',
|
||||
amountCents: -2400,
|
||||
merchant: 'Farmers market',
|
||||
categoryId: await categoryId('Groceries'),
|
||||
});
|
||||
expect(farmers.json).toMatchObject({ source: 'manual', needsReview: false });
|
||||
expect((await admin.del(`/api/v1/transactions/${amazon.id}`)).json.error.code).toBe('from_statement');
|
||||
expect((await admin.del(`/api/v1/transactions/${farmers.json.id}`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('asks how to read an unknown file once, then remembers the layout', async () => {
|
||||
const cash = await addAccount({ name: 'Credit union', kind: 'checking', institution: 'Local CU' });
|
||||
const first = (await admin.upload('/api/v1/imports/files', 'export.csv', fixture('unknown-bank.csv'))).json;
|
||||
expect(first.layout).toBeNull();
|
||||
expect(first.headerRow).toEqual(['Posting Date', 'Payee Name', 'Withdrawal', 'Deposit', 'Balance']);
|
||||
expect(first.suggestedMap).toMatchObject({ date: 0, description: 1, debit: 2, credit: 3, balance: 4 });
|
||||
|
||||
const mapping = { ...first.suggestedMap, dateOrder: 'YMD' };
|
||||
const mapped = (await admin.post('/api/v1/imports/preview', { stagingId: first.stagingId, accountId: cash, mapping })).json;
|
||||
expect(mapped.counts).toMatchObject({ rows: 3, new: 3 });
|
||||
expect(mapped.newRows.map((r: { amountCents: number }) => r.amountCents)).toEqual([-1250, 120000, -825]);
|
||||
await admin.post('/api/v1/imports', { stagingId: first.stagingId, accountId: cash, mapping });
|
||||
|
||||
const second = (await admin.upload('/api/v1/imports/files', 'export (1).csv', fixture('unknown-bank.csv'))).json;
|
||||
expect(second.layout).not.toBeNull();
|
||||
expect(second.account).toMatchObject({ id: cash, confidence: 'certain' });
|
||||
expect(second.counts).toMatchObject({ new: 0, duplicates: 3 });
|
||||
});
|
||||
|
||||
it('lets read-only people look but not import or change anything', async () => {
|
||||
const sam = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read', password: 'sams-password' });
|
||||
expect(sam.status).toBe(200);
|
||||
const { Client } = await import('./helpers');
|
||||
const reader = new Client(t);
|
||||
await reader.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' });
|
||||
expect((await reader.get('/api/v1/accounts')).status).toBe(200);
|
||||
expect((await reader.upload('/api/v1/imports/files', DISCOVER, fixture(DISCOVER))).status).toBe(403);
|
||||
expect((await reader.post('/api/v1/accounts', { name: 'X', kind: 'cash' })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
8
server/tsconfig.json
Normal file
8
server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "test", "drizzle.config.ts"]
|
||||
}
|
||||
9
server/vitest.config.ts
Normal file
9
server/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
});
|
||||
11
shared/package.json
Normal file
11
shared/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@spendlight/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
255
shared/src/index.ts
Normal file
255
shared/src/index.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// Types and small helpers shared by the server and the web app.
|
||||
|
||||
export type Access = 'read' | 'full';
|
||||
export type Theme = 'system' | 'light' | 'dark';
|
||||
export type IdeaStatus = 'new' | 'planned' | 'done' | 'declined';
|
||||
export type BackupKind = 'nightly' | 'manual' | 'pre-upgrade' | 'pre-restore';
|
||||
|
||||
export const IDEA_STATUSES: readonly IdeaStatus[] = ['new', 'planned', 'done', 'declined'];
|
||||
|
||||
export interface PublicConfig {
|
||||
version: string;
|
||||
ideasEnabled: boolean;
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
access: Access;
|
||||
isAdmin: boolean;
|
||||
theme: Theme;
|
||||
mustChangePassword: boolean;
|
||||
hasPasskey: boolean;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
access: Access;
|
||||
isAdmin: boolean;
|
||||
mustChangePassword: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt: string | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
deviceLabel: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface PasskeyInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
name: string;
|
||||
kind: BackupKind;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BackupOverview {
|
||||
backups: BackupInfo[];
|
||||
nightlyAt: string;
|
||||
keepDaily: number;
|
||||
keepMonthly: number;
|
||||
}
|
||||
|
||||
export interface Idea {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: IdeaStatus;
|
||||
adminNote: string | null;
|
||||
path: string | null;
|
||||
appVersion: string | null;
|
||||
userAgent: string | null;
|
||||
author: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** Versions look like 20261007.01: the build date, then that day's build counter. */
|
||||
export const VERSION_PATTERN = /^(\d{4})(\d{2})(\d{2})\.(\d{2,})$/;
|
||||
|
||||
/** The calendar date a version was built, or null for development builds. */
|
||||
export function versionDate(version: string): Date | null {
|
||||
const m = VERSION_PATTERN.exec(version);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
export const ACCESS_LABEL: Record<Access, string> = {
|
||||
read: 'Read-only',
|
||||
full: 'Full access',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone 1: money in. Amounts are whole cents; money out is negative.
|
||||
|
||||
export type AccountKind = 'checking' | 'savings' | 'credit_card' | 'cash' | 'loan' | 'mortgage' | 'heloc';
|
||||
export type CategoryKind = 'expense' | 'income' | 'transfer';
|
||||
|
||||
export const ACCOUNT_KIND_LABEL: Record<AccountKind, string> = {
|
||||
checking: 'Checking',
|
||||
savings: 'Savings',
|
||||
credit_card: 'Credit card',
|
||||
cash: 'Cash',
|
||||
loan: 'Loan',
|
||||
mortgage: 'Mortgage',
|
||||
heloc: 'Home equity line',
|
||||
};
|
||||
|
||||
/** Account kinds where the balance is money owed rather than money held. */
|
||||
export const LIABILITY_KINDS: readonly AccountKind[] = ['credit_card', 'loan', 'mortgage', 'heloc'];
|
||||
|
||||
/** Twelve hues tuned to read well in both light and dark themes. */
|
||||
export const PALETTE = ['indigo', 'sky', 'teal', 'emerald', 'lime', 'amber', 'orange', 'rose', 'pink', 'violet', 'cyan', 'slate'] as const;
|
||||
export type PaletteColor = (typeof PALETTE)[number];
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: AccountKind;
|
||||
institution: string | null;
|
||||
last4: string | null;
|
||||
color: PaletteColor;
|
||||
status: 'open' | 'retired';
|
||||
/** Current balance: money held is positive, money owed is negative. */
|
||||
balanceCents: number;
|
||||
balanceConfirmedOn: string | null;
|
||||
transactionCount: number;
|
||||
lastImportAt: string | null;
|
||||
lastTransactionDate: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
groupId: number;
|
||||
groupName: string;
|
||||
name: string;
|
||||
kind: CategoryKind;
|
||||
icon: string;
|
||||
color: PaletteColor;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export interface Split {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
amountCents: number;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
accountId: number;
|
||||
date: string;
|
||||
amountCents: number;
|
||||
description: string;
|
||||
merchant: string;
|
||||
categoryId: number | null;
|
||||
needsReview: boolean;
|
||||
suggestedBy: string | null;
|
||||
isSplit: boolean;
|
||||
splits: Split[];
|
||||
note: string | null;
|
||||
tags: string[];
|
||||
transferPeer: { id: number; accountId: number; accountName: string } | null;
|
||||
source: 'import' | 'manual';
|
||||
bankCategory: string | null;
|
||||
importId: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewItem extends Transaction {
|
||||
/** Up to three likely categories, best first. */
|
||||
suggestions: number[];
|
||||
/** True when Spendlight is sure: a rule, or the last 3 picks agreed. */
|
||||
confident: boolean;
|
||||
/** Set for card payments and transfers with no matching half in another account. */
|
||||
unpairedTransfer: boolean;
|
||||
}
|
||||
|
||||
export interface TransactionPage {
|
||||
items: Transaction[];
|
||||
total: number;
|
||||
sums: { inCents: number; outCents: number };
|
||||
}
|
||||
|
||||
export interface ImportColumnMap {
|
||||
date: number;
|
||||
description: number;
|
||||
/** One signed amount column... */
|
||||
amount?: number;
|
||||
/** ...or separate columns for money out and money in. */
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
postedDate?: number;
|
||||
category?: number;
|
||||
balance?: number;
|
||||
/** Whether a positive number in `amount` is money spent (most credit cards) or money received. */
|
||||
positiveIs: 'out' | 'in';
|
||||
dateOrder: 'MDY' | 'DMY' | 'YMD';
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
stagingId: string;
|
||||
fileName: string;
|
||||
layout: { key: string; name: string } | null;
|
||||
/** When the layout is unknown: the first rows, for the mapping pop-up. */
|
||||
sampleRows: string[][];
|
||||
headerRow: string[] | null;
|
||||
suggestedMap: ImportColumnMap | null;
|
||||
account: { id: number; confidence: 'certain' | 'likely'; reason: string } | null;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
counts: { rows: number; new: number; duplicates: number; possibleDuplicates: number; skipped: number };
|
||||
newRows: { date: string; merchant: string; amountCents: number }[];
|
||||
possibleDuplicates: { key: string; date: string; merchant: string; amountCents: number; existingDate: string }[];
|
||||
/** Account details the file itself suggests, for creating a new account. */
|
||||
accountHint: { kind: AccountKind; institution: string | null; last4: string | null; name: string } | null;
|
||||
}
|
||||
|
||||
export interface ImportSummary {
|
||||
id: number;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
fileName: string;
|
||||
layoutKey: string;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
addedCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
transferCount: number;
|
||||
toReview: number;
|
||||
status: 'committed' | 'undone';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function formatCents(cents: number, options: { sign?: boolean; whole?: boolean } = {}): string {
|
||||
const value = Math.abs(cents) / 100;
|
||||
const digits = options.whole ? 0 : 2;
|
||||
const text = value.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
if (cents < 0) return `−${text}`;
|
||||
return options.sign && cents > 0 ? `+${text}` : text;
|
||||
}
|
||||
7
shared/tsconfig.json
Normal file
7
shared/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
23
web/index.html
Normal file
23
web/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Spendlight</title>
|
||||
<meta name="description" content="Your household's money, in the spotlight." />
|
||||
<meta name="theme-color" content="#FAFAF7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0B1020" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Spendlight" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
web/package.json
Normal file
36
web/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@spendlight/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"icons": "node scripts/make-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@spendlight/shared": "*",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"radix-ui": "^1.7.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.3"
|
||||
}
|
||||
}
|
||||
18
web/public/favicon.svg
Normal file
18
web/public/favicon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="beam" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FBBF24" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="#F97362" stop-opacity="0.18"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="pool" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#FCD34D"/>
|
||||
<stop offset="0.55" stop-color="#F97362" stop-opacity="0.6"/>
|
||||
<stop offset="1" stop-color="#F97362" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="116" fill="#0B1020"/>
|
||||
<path d="M222 140h68l110 240a144 40 0 0 1-288 0z" fill="url(#beam)"/>
|
||||
<ellipse cx="256" cy="380" rx="168" ry="50" fill="url(#pool)"/>
|
||||
<rect x="204" y="96" width="104" height="52" rx="20" fill="#818CF8"/>
|
||||
<rect x="230" y="78" width="52" height="24" rx="10" fill="#6366F1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
BIN
web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
16
web/public/manifest.webmanifest
Normal file
16
web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Spendlight",
|
||||
"short_name": "Spendlight",
|
||||
"description": "Your household's money, in the spotlight.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0B1020",
|
||||
"theme_color": "#0B1020",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
57
web/public/sw.js
Normal file
57
web/public/sw.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Spendlight service worker. Makes the home-screen app open instantly and
|
||||
// show its shell offline. It never touches /api: household data is always
|
||||
// fetched fresh and never stored here.
|
||||
|
||||
const SHELL = 'spendlight-shell';
|
||||
const ASSETS = 'spendlight-assets';
|
||||
const MAX_ASSETS = 80;
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== ASSETS).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Pages: always try the network first so a new version shows up at once.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(SHELL).then((cache) => cache.put('/', copy));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('/').then((cached) => cached || Response.error())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Versioned files never change, so a cached copy is always right.
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(
|
||||
caches.open(ASSETS).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > MAX_ASSETS) await Promise.all(keys.slice(0, keys.length - MAX_ASSETS).map((k) => cache.delete(k)));
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
9
web/public/theme-init.js
Normal file
9
web/public/theme-init.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Runs before the page paints, so dark mode never flashes white.
|
||||
(function () {
|
||||
try {
|
||||
var choice = localStorage.getItem('sl-theme') || 'system';
|
||||
var dark = choice === 'dark' || (choice === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
} catch (e) {}
|
||||
})();
|
||||
19
web/scripts/make-icons.mjs
Normal file
19
web/scripts/make-icons.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// Render the app icons from public/favicon.svg. Run with: npm run icons
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const svg = readFileSync(new URL('../public/favicon.svg', import.meta.url));
|
||||
const out = (name) => new URL(`../public/icons/${name}`, import.meta.url).pathname;
|
||||
const night = { r: 11, g: 16, b: 32, alpha: 1 };
|
||||
|
||||
await sharp(svg, { density: 384 }).resize(192, 192).png().toFile(out('icon-192.png'));
|
||||
await sharp(svg, { density: 384 }).resize(512, 512).png().toFile(out('icon-512.png'));
|
||||
// iOS adds its own rounded corners and dislikes transparency: full-bleed square.
|
||||
await sharp(svg, { density: 384 }).resize(180, 180).flatten({ background: night }).png().toFile(out('apple-touch-icon.png'));
|
||||
// Maskable: the mark inside the 80% safe zone on a full-bleed background.
|
||||
const inner = await sharp(svg, { density: 384 }).resize(360, 360).png().toBuffer();
|
||||
await sharp({ create: { width: 512, height: 512, channels: 4, background: night } })
|
||||
.composite([{ input: inner, gravity: 'center' }])
|
||||
.png()
|
||||
.toFile(out('icon-maskable-512.png'));
|
||||
console.log('Icons written to public/icons/');
|
||||
230
web/src/components/AppShell.tsx
Normal file
230
web/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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 { queueFiles } from '@/lib/pendingFiles';
|
||||
import { useConfig, useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { APP_VERSION } from '@/lib/version';
|
||||
import { LogoMark, VersionTag } from './brand';
|
||||
import { IdeaButton } from './IdeaDialog';
|
||||
import { NAV, SETTINGS_NAV, type NavItem } from './nav';
|
||||
import { Button } from './ui/button';
|
||||
import { Skeleton, Tip } from './ui/misc';
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
function pageTitle(pathname: string): string {
|
||||
if (pathname === '/') return 'Home';
|
||||
if (pathname.startsWith('/settings')) return 'Settings';
|
||||
if (pathname.startsWith('/more')) return 'More';
|
||||
if (pathname.startsWith('/import')) return 'Import';
|
||||
return NAV.find((n) => n.to !== '/' && pathname.startsWith(n.to))?.label ?? 'Spendlight';
|
||||
}
|
||||
|
||||
function CountBadge({ count, className }: { count: number | undefined; className?: string }) {
|
||||
if (!count) return null;
|
||||
return (
|
||||
<span className={cn('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', className)}>
|
||||
{count > 999 ? '999+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex h-11 items-center gap-3 rounded-xl px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:justify-center lg:justify-start',
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="size-[19px] shrink-0" strokeWidth={1.9} />
|
||||
<CountBadge count={badge} className="absolute -right-2.5 -top-2 h-4 min-w-4 px-1 text-[10px] lg:hidden" />
|
||||
</span>
|
||||
<span className="flex-1 md:sr-only lg:not-sr-only">{item.label}</span>
|
||||
<CountBadge count={badge} className="hidden lg:flex" />
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const { data: toReview } = useReviewCount();
|
||||
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">
|
||||
<LogoMark />
|
||||
<span className="hidden text-[17px] font-semibold tracking-tight lg:inline">Spendlight</span>
|
||||
</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} />
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 pb-4">
|
||||
<SideLink item={SETTINGS_NAV} />
|
||||
<div className="mt-3 px-3 md:text-center lg:text-left">
|
||||
<VersionTag />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneTabBar() {
|
||||
const { pathname } = useLocation();
|
||||
const { data: toReview } = useReviewCount();
|
||||
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 (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t bg-card/85 pb-safe backdrop-blur-xl md:hidden" aria-label="Main">
|
||||
<div className="mx-auto grid max-w-md grid-cols-5">
|
||||
{NAV.filter((n) => n.phone).map(({ to, label, icon: Icon }) => (
|
||||
<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}
|
||||
</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
|
||||
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
|
||||
More
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** When the server is running a newer version than these screens, offer a reload. */
|
||||
function UpdateNotice() {
|
||||
const { data } = useConfig();
|
||||
useEffect(() => {
|
||||
if (!data || data.version === 'dev' || APP_VERSION === 'dev' || data.version === APP_VERSION) return;
|
||||
toast('A new version of Spendlight is ready', {
|
||||
id: 'update',
|
||||
description: `Version ${data.version}`,
|
||||
duration: Infinity,
|
||||
action: { label: 'Reload', onClick: () => window.location.reload() },
|
||||
});
|
||||
}, [data]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop statement files anywhere on a big screen to start importing them. */
|
||||
function useFileDrop(enabled: boolean) {
|
||||
const navigate = useNavigate();
|
||||
const [over, setOver] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let depth = 0;
|
||||
const hasFiles = (e: DragEvent) => !!e.dataTransfer?.types.includes('Files');
|
||||
const enter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
depth++;
|
||||
setOver(true);
|
||||
};
|
||||
const leave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (!depth) setOver(false);
|
||||
};
|
||||
const overHandler = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
};
|
||||
const drop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
setOver(false);
|
||||
const files = [...(e.dataTransfer?.files ?? [])];
|
||||
if (files.length && !window.location.pathname.startsWith('/import')) {
|
||||
queueFiles(files);
|
||||
navigate('/import');
|
||||
}
|
||||
};
|
||||
window.addEventListener('dragenter', enter);
|
||||
window.addEventListener('dragleave', leave);
|
||||
window.addEventListener('dragover', overHandler);
|
||||
window.addEventListener('drop', drop);
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', enter);
|
||||
window.removeEventListener('dragleave', leave);
|
||||
window.removeEventListener('dragover', overHandler);
|
||||
window.removeEventListener('drop', drop);
|
||||
};
|
||||
}, [enabled, navigate]);
|
||||
return over;
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { data: config } = useConfig();
|
||||
const me = useCurrentUser();
|
||||
const { pathname } = useLocation();
|
||||
const dropping = useFileDrop(me.access === 'full' && !pathname.startsWith('/import'));
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar />
|
||||
<div className="md:pl-[84px] lg:pl-64">
|
||||
<header className="sticky top-0 z-30 border-b bg-background/80 pt-safe backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6 lg:px-8">
|
||||
<Link to="/" className="mr-1 md:hidden" aria-label="Spendlight home">
|
||||
<LogoMark className="size-7" />
|
||||
</Link>
|
||||
<h1 className="flex-1 truncate text-[17px] font-semibold tracking-tight">{pageTitle(pathname)}</h1>
|
||||
{me.access === 'full' ? (
|
||||
<Tip label="Import statements">
|
||||
<Button variant="ghost" size="icon" asChild aria-label="Import statements">
|
||||
<Link to="/import">
|
||||
<Upload />
|
||||
</Link>
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
{config?.ideasEnabled ? <IdeaButton /> : null}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 pb-32 pt-5 sm:px-6 md:pb-14 md:pt-8 lg:px-8">
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
<PhoneTabBar />
|
||||
<UpdateNotice />
|
||||
{dropping ? (
|
||||
<div className="pointer-events-none fixed inset-0 z-[60] flex items-center justify-center bg-night/50 p-8 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center rounded-3xl border-2 border-dashed border-white/50 px-16 py-12 text-center text-white">
|
||||
<Upload className="mb-3 size-10" strokeWidth={1.6} />
|
||||
<p className="text-xl font-semibold">Drop to import</p>
|
||||
<p className="mt-1 text-white/75">Statements from any of your banks</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
web/src/components/AuthLayout.tsx
Normal file
33
web/src/components/AuthLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useConfig } from '@/lib/session';
|
||||
import { LogoMark, VersionTag } from './brand';
|
||||
|
||||
/** The calm, centered frame for setup, sign-in and the first password change. */
|
||||
export function AuthLayout({ title, subtitle, children }: { title: string; subtitle?: ReactNode; children: ReactNode }) {
|
||||
const { data } = useConfig();
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center px-5 pt-safe">
|
||||
<div className="spotlight pointer-events-none absolute inset-x-0 top-0 h-[460px]" aria-hidden />
|
||||
<div className="relative flex w-full max-w-sm flex-1 flex-col justify-center py-12">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<LogoMark className="mb-5 size-16 rounded-[22px] shadow-lift" />
|
||||
<h1 className="text-[26px] font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 text-[15px] leading-relaxed text-muted-foreground">{subtitle}</p> : null}
|
||||
</div>
|
||||
<div className="animate-fade-up rounded-3xl border bg-card p-6 shadow-lift sm:p-7">{children}</div>
|
||||
</div>
|
||||
<footer className="relative pb-6 pb-safe">
|
||||
<VersionTag version={data?.version} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormError({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p role="alert" className="rounded-xl bg-bad/10 px-3.5 py-2.5 text-[13.5px] leading-snug text-bad">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
202
web/src/components/CategoryPicker.tsx
Normal file
202
web/src/components/CategoryPicker.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { Category } from '@spendlight/shared';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive, Popover } from 'radix-ui';
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { recentCategories, rememberCategory, useCategories } from '@/lib/data';
|
||||
import { useIsPhone } from '@/lib/useMediaQuery';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryIcon } from './money';
|
||||
|
||||
// The one category picker used everywhere: a flat, searchable list. Suggested
|
||||
// and recent picks sit on top, then every category A to Z with its group as a
|
||||
// small label. Phones get a full-height sheet; bigger screens a popover.
|
||||
|
||||
interface PickerProps {
|
||||
value: number | null;
|
||||
suggestions?: number[];
|
||||
onPick: (category: Category) => void;
|
||||
children: ReactNode;
|
||||
/** Hide transfer categories (for splits, which are always spending or income). */
|
||||
noTransfers?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function PickerList({
|
||||
value,
|
||||
suggestions = [],
|
||||
onPick,
|
||||
noTransfers,
|
||||
autoFocus,
|
||||
}: Omit<PickerProps, 'children' | 'open' | 'onOpenChange'> & { autoFocus: boolean }) {
|
||||
const { all, byId } = useCategories();
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const usable = all.filter((c) => !c.archived && !(noTransfers && c.kind === 'transfer'));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q) {
|
||||
const matches = usable
|
||||
.filter((c) => c.name.toLowerCase().includes(q) || c.groupName.toLowerCase().includes(q))
|
||||
.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)) || a.name.localeCompare(b.name));
|
||||
return [{ title: `${matches.length} match${matches.length === 1 ? '' : 'es'}`, items: matches }];
|
||||
}
|
||||
const pick = (ids: number[]) => ids.map((id) => byId.get(id)).filter((c): c is Category => !!c && usable.includes(c));
|
||||
const suggested = pick(suggestions);
|
||||
const recent = pick(recentCategories()).filter((c) => !suggested.includes(c)).slice(0, 4);
|
||||
return [
|
||||
...(suggested.length ? [{ title: 'Suggested', items: suggested }] : []),
|
||||
...(recent.length ? [{ title: 'Recent', items: recent }] : []),
|
||||
{ title: 'All categories', items: [...usable].sort((a, b) => a.name.localeCompare(b.name)) },
|
||||
];
|
||||
}, [all, byId, query, suggestions, noTransfers]);
|
||||
|
||||
const flat = sections.flatMap((s) => s.items);
|
||||
const choose = (c: Category) => {
|
||||
rememberCategory(c.id);
|
||||
onPick(c);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = Math.max(0, Math.min(flat.length - 1, active + (e.key === 'ArrowDown' ? 1 : -1)));
|
||||
setActive(next);
|
||||
listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter' && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]!);
|
||||
}
|
||||
};
|
||||
|
||||
let index = -1;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" onKeyDown={onKeyDown}>
|
||||
<div className="relative border-b p-2.5">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
placeholder="Search categories"
|
||||
aria-label="Search categories"
|
||||
className="h-11 w-full rounded-xl bg-muted pl-9 pr-3 text-[16px] outline-none placeholder:text-muted-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/30 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5" role="listbox" aria-label="Categories">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="mb-1">
|
||||
<p className="px-2.5 pb-1 pt-2 text-[11.5px] font-semibold uppercase tracking-wider text-muted-foreground">{section.title}</p>
|
||||
{section.items.map((c) => {
|
||||
index++;
|
||||
const i = index;
|
||||
return (
|
||||
<button
|
||||
key={`${section.title}-${c.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={c.id === value}
|
||||
data-index={i}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => choose(c)}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center gap-3 rounded-xl px-2.5 text-left sm:h-10',
|
||||
i === active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-medium sm:text-[14px]">{c.name}</span>
|
||||
<span className="shrink-0 text-[12px] text-muted-foreground">{c.groupName}</span>
|
||||
{c.id === value ? <Check className="size-4 shrink-0 text-primary" /> : <span className="size-4 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{section.items.length === 0 ? <p className="px-2.5 py-3 text-[13.5px] text-muted-foreground">Nothing matches that.</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryPicker({ children, open: controlledOpen, onOpenChange, ...props }: PickerProps) {
|
||||
const phone = useIsPhone();
|
||||
const [uncontrolled, setUncontrolled] = useState(false);
|
||||
const open = controlledOpen ?? uncontrolled;
|
||||
const setOpen = onOpenChange ?? setUncontrolled;
|
||||
const pick = (c: Category) => {
|
||||
setOpen(false);
|
||||
props.onPick(c);
|
||||
};
|
||||
|
||||
if (phone) {
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<DialogPrimitive.Trigger asChild>{children}</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-night/40 backdrop-blur-[2px]" />
|
||||
<DialogPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 flex h-[88dvh] animate-sheet-up flex-col rounded-t-3xl border bg-popover pb-safe text-popover-foreground shadow-lift outline-none">
|
||||
<div className="mx-auto mt-2.5 h-1.5 w-10 rounded-full bg-muted" aria-hidden />
|
||||
<DialogPrimitive.Title className="px-4 pt-3 text-[17px] font-semibold">Choose a category</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">Pick the category for this transaction</DialogPrimitive.Description>
|
||||
<PickerList {...props} onPick={pick} autoFocus={false} />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>{children}</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
collisionPadding={12}
|
||||
className="z-50 flex h-[min(440px,var(--radix-popover-content-available-height))] w-80 animate-fade-up flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-lift"
|
||||
>
|
||||
<PickerList {...props} onPick={pick} autoFocus />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tappable chip showing a category (or "Choose category"), opening the picker. */
|
||||
export function CategoryButton({
|
||||
categoryId,
|
||||
suggestions,
|
||||
onPick,
|
||||
className,
|
||||
placeholder = 'Choose category',
|
||||
}: {
|
||||
categoryId: number | null;
|
||||
suggestions?: number[];
|
||||
onPick: (category: Category) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { byId } = useCategories();
|
||||
const c = categoryId ? byId.get(categoryId) : undefined;
|
||||
return (
|
||||
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-10 min-w-0 items-center gap-2 rounded-xl border bg-card px-2.5 text-left text-[14px] font-medium shadow-soft transition-colors hover:bg-muted',
|
||||
!c && 'border-dashed text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{c ? <CategoryIcon icon={c.icon} color={c.color} size="sm" className="-ml-0.5 size-6 rounded-md" /> : null}
|
||||
<span className="truncate">{c?.name ?? placeholder}</span>
|
||||
</button>
|
||||
</CategoryPicker>
|
||||
);
|
||||
}
|
||||
82
web/src/components/IdeaDialog.tsx
Normal file
82
web/src/components/IdeaDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Idea } from '@spendlight/shared';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog';
|
||||
import { Field, Input, Textarea } from './ui/form';
|
||||
import { Tip } from './ui/misc';
|
||||
|
||||
/** The lightbulb at the top right: share an idea in a few seconds. */
|
||||
export function IdeaButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api<Idea>('/ideas', { method: 'POST', body: { title, description, path: location.pathname } }),
|
||||
onSuccess: () => {
|
||||
toast.success('Thanks! Your idea is saved.', { description: 'It’s on the list to review.' });
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) save.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tip label="Share an idea">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Share an idea" className="text-muted-foreground hover:text-warn">
|
||||
<Lightbulb />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Tip>
|
||||
<DialogContent
|
||||
title="Share an idea"
|
||||
description="Spotted something Spendlight should do, or do better? Jot it down and it goes straight onto the list."
|
||||
>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Idea">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="For example: show our coffee total for the year"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Details" hint="What would it help with? Any example helps.">
|
||||
{(id) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={4000}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" loading={save.isPending} disabled={!title.trim()} className="mt-1 w-full sm:w-auto sm:justify-self-end">
|
||||
Save idea
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user