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

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