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:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

19
server/build.mjs Normal file
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/db/schema.ts',
out: './drizzle',
});

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

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

View 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": {}
}
}

File diff suppressed because it is too large Load Diff

View 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
View 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"
}
}

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

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

View 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
View 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: '/' });
}

View 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) };
}

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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';
}
}

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

View 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);
}

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

View 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}`;
}

View 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);
}

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

View 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
View 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
View 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
View 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));

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

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

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

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

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

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

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

View 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
View 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
View 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);
});
});

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

View 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,
1 Transaction Date Post Date Description Category Type Amount Memo
2 09/02/2026 09/03/2026 AMAZON MKTPL*AB12CD3E4 Shopping Sale -23.99
3 09/04/2026 09/05/2026 Amazon.com*ZX98YW7V6 Shopping Sale -112.45
4 09/06/2026 09/07/2026 AMAZON MKTPL*QR56ST7U8 Shopping Return 18.50
5 09/09/2026 09/10/2026 JOES DINER Food & Drink Sale -41.20
6 09/16/2026 09/16/2026 Payment Thank You-Mobile Payment 150.00

View 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
1 Trans. Date Post Date Description Amount Category
2 09/03/2026 09/03/2026 KROGER #123 CHARLOTTE NC 54.20 Supermarkets
3 09/03/2026 09/03/2026 STARBUCKS #4567 CHARLOTTE NC 5.45 Restaurants
4 09/03/2026 09/03/2026 STARBUCKS #4567 CHARLOTTE NC 5.45 Restaurants
5 09/08/2026 09/08/2026 JOES DINER #12 CHARLOTTE NC 38.75 Restaurants
6 09/11/2026 09/11/2026 INTERNET PAYMENT - THANK YOU -500.00 Payments and Credits
7 09/14/2026 09/14/2026 SHELL OIL 57444 CHARLOTTE NC 47.10 Gasoline
8 09/18/2026 09/18/2026 NETFLIX.COM 866-579-7172 CA 17.99 Services
9 09/22/2026 09/22/2026 BLUE MOON BOOKS #9 ASHEVILLE NC 22.00 Merchandise

View 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.

View 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.

View 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
1 Posting Date Payee Name Withdrawal Deposit Balance
2 2026-09-02 Corner Hardware 12.50 987.50
3 2026-09-05 Paycheck Employer 1,200.00 2,187.50
4 2026-09-07 Corner Hardware 8.25 2,179.25

104
server/test/helpers.ts Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
testTimeout: 20_000,
},
});