Milestone 2: the full picture (20261007.12)

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

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

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

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

View File

@@ -0,0 +1,65 @@
CREATE TABLE `alert` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`kind` text NOT NULL,
`subject_type` text NOT NULL,
`subject_id` integer DEFAULT 0 NOT NULL,
`period` text DEFAULT '' NOT NULL,
`area` text DEFAULT 'money' NOT NULL,
`severity` text DEFAULT 'info' NOT NULL,
`title` text NOT NULL,
`body` text,
`link` text,
`rank` integer DEFAULT 0 NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`read_at` text,
`dismissed_at` text,
`resolved_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `alert_unique` ON `alert` (`kind`,`subject_type`,`subject_id`,`period`);--> statement-breakpoint
CREATE INDEX `alert_open_idx` ON `alert` (`resolved_at`,`dismissed_at`);--> statement-breakpoint
CREATE TABLE `budget` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`category_id` integer NOT NULL,
`year` integer NOT NULL,
`month` integer DEFAULT 0 NOT NULL,
`amount_cents` integer NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `budget_unique` ON `budget` (`category_id`,`year`,`month`);--> statement-breakpoint
CREATE INDEX `budget_year_idx` ON `budget` (`year`);--> statement-breakpoint
CREATE TABLE `subscription` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`kind` text DEFAULT 'subscription' NOT NULL,
`merchant` text NOT NULL,
`aliases` text DEFAULT '[]' NOT NULL,
`catalog_key` text,
`cadence` text DEFAULT 'monthly' NOT NULL,
`expected_cents` integer NOT NULL,
`account_id` integer,
`category_id` integer,
`status` text DEFAULT 'suggested' NOT NULL,
`confidence` text DEFAULT 'likely' NOT NULL,
`source` text DEFAULT 'detected' NOT NULL,
`trial_ends_on` text,
`cancelled_on` text,
`first_charge_on` text,
`last_charge_on` text,
`next_expected_on` text,
`manage_url` text,
`note` text,
`alerts_on` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `subscription_merchant_idx` ON `subscription` (`merchant`);--> statement-breakpoint
CREATE INDEX `subscription_status_idx` ON `subscription` (`status`);--> statement-breakpoint
ALTER TABLE `txn` ADD `subscription_id` integer;--> statement-breakpoint
CREATE INDEX `txn_subscription_idx` ON `txn` (`subscription_id`);

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -0,0 +1,65 @@
import type { AiFeature, AiStatus } from '@spendlight/shared';
import type { AppContext } from '../context';
import { getSetting, putSetting } from '../settings';
// The AI module is built into the design and switched off. No provider ships
// with Spendlight and nothing here makes an outside call, so the app stays
// fully offline. Turning a feature on later needs two things: an admin who
// switches it on in Settings, and a provider configured on the server
// (AI_PROVIDER), which plugs in through the interface below.
/** What a provider must offer. Each call receives only what that feature needs. */
export interface AiProvider {
readonly name: string;
/** Suggest a category for a merchant no one has filed before; sends only the merchant text. */
suggestCategory(input: { merchant: string; categories: string[] }): Promise<string | null>;
/** Write short insight cards from summary numbers (never raw transactions). */
summarize(input: { facts: string[] }): Promise<string[]>;
}
export const AI_FEATURES: { key: AiFeature; label: string; description: string }[] = [
{
key: 'categorize',
label: 'Category suggestions for new merchants',
description: 'When no rule, history or built-in knowledge applies, ask the AI. Only the merchant’s name is sent.',
},
{
key: 'insights',
label: 'Written insights',
description: 'Plain-language notes about the month, written from totals by category. No transactions are sent.',
},
{
key: 'assistant',
label: 'Assistant',
description: 'Ask questions such as “what did we spend on the kids this year?” and get answers with the transactions behind them.',
},
{
key: 'agents',
label: 'Agents',
description: 'Let an assistant do routine work, such as filing a month, through the same API and roles as everyone else.',
},
];
type AiSettings = Record<AiFeature, boolean>;
const OFF: AiSettings = { categorize: false, insights: false, assistant: false, agents: false };
export function aiStatus(ctx: AppContext): AiStatus {
const saved = getSetting(ctx, 'ai', OFF);
const configured = !!ctx.config.aiProvider;
return {
providerConfigured: configured,
provider: ctx.config.aiProvider,
features: AI_FEATURES.map((f) => ({ ...f, enabled: configured && saved[f.key] })),
};
}
export function setAiFeature(ctx: AppContext, feature: AiFeature, enabled: boolean): void {
putSetting(ctx, 'ai', { ...getSetting(ctx, 'ai', OFF), [feature]: enabled });
}
/** The provider for a feature, or null when it's off (always, until one is configured). */
export function aiFor(ctx: AppContext, feature: AiFeature): AiProvider | null {
if (!aiStatus(ctx).features.find((f) => f.key === feature)?.enabled) return null;
// Providers register here when they are added. None ships today.
return null;
}

View File

@@ -7,13 +7,20 @@ import type { AppContext } from './context';
import { docsRoutes } from './docs';
import { fail, requestOrigin, type Env } from './http';
import { accountRoutes } from './routes/accounts';
import { aiRoutes } from './routes/ai';
import { alertRoutes } from './routes/alerts';
import { authRoutes } from './routes/auth';
import { backupRoutes } from './routes/backups';
import { budgetRoutes } from './routes/budgets';
import { categoryRoutes } from './routes/categories';
import { deviceRoutes } from './routes/devices';
import { exportRoutes } from './routes/export';
import { ideaRoutes } from './routes/ideas';
import { importRoutes } from './routes/imports';
import { passkeyRoutes } from './routes/passkeys';
import { peopleRoutes } from './routes/people';
import { reportRoutes } from './routes/reports';
import { subscriptionRoutes } from './routes/subscriptions';
import { systemRoutes } from './routes/system';
import { transactionRoutes } from './routes/transactions';
import { validationHook } from './schemas';
@@ -87,6 +94,13 @@ export function createApp(ctx: AppContext) {
app.route('/api/v1', accountRoutes(ctx));
app.route('/api/v1', importRoutes(ctx));
app.route('/api/v1', transactionRoutes(ctx));
app.route('/api/v1', categoryRoutes(ctx));
app.route('/api/v1', budgetRoutes(ctx));
app.route('/api/v1', reportRoutes(ctx));
app.route('/api/v1', subscriptionRoutes(ctx));
app.route('/api/v1', alertRoutes(ctx));
app.route('/api/v1', exportRoutes(ctx));
app.route('/api/v1', aiRoutes(ctx));
if (ctx.config.ideasEnabled) app.route('/api/v1', ideaRoutes(ctx));
app.doc31('/api/v1/openapi.json', {

View File

@@ -0,0 +1,115 @@
import type { BudgetLine, BudgetScope, BudgetYear } from '@spendlight/shared';
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
import type { AppContext } from '../context';
import { budgets } from '../db/schema';
// Each category has one amount for every month of a year (month 0), and any
// month can have its own amount instead: Gifts at $100 a month, $800 in December.
export function budgetYear(ctx: AppContext, year: number): BudgetYear {
const rows = ctx.db.select().from(budgets).where(eq(budgets.year, year)).all();
const lines = new Map<number, BudgetLine>();
for (const r of rows) {
const line = lines.get(r.categoryId) ?? { categoryId: r.categoryId, monthly: null, months: Array(12).fill(null) };
if (r.month === 0) line.monthly = r.amountCents;
else line.months[r.month - 1] = r.amountCents;
lines.set(r.categoryId, line);
}
return { year, items: [...lines.values()] };
}
export const effective = (line: BudgetLine | undefined, monthIndex: number): number =>
line ? (line.months[monthIndex] ?? line.monthly ?? 0) : 0;
/** Budgets for each month in the list ("2026-01"...), by category. */
export function budgetMatrix(ctx: AppContext, months: string[]): Map<number, number[]> {
const years = [...new Set(months.map((m) => Number(m.slice(0, 4))))];
const byYear = new Map(years.map((y) => [y, new Map(budgetYear(ctx, y).items.map((l) => [l.categoryId, l]))]));
const out = new Map<number, number[]>();
months.forEach((month, i) => {
const lines = byYear.get(Number(month.slice(0, 4)))!;
for (const [categoryId, line] of lines) {
const row = out.get(categoryId) ?? Array(months.length).fill(0);
row[i] = effective(line, Number(month.slice(5, 7)) - 1);
out.set(categoryId, row);
}
});
return out;
}
function put(ctx: AppContext, categoryId: number, year: number, month: number, amountCents: number, now: string) {
ctx.db
.insert(budgets)
.values({ categoryId, year, month, amountCents, updatedAt: now })
.onConflictDoUpdate({ target: [budgets.categoryId, budgets.year, budgets.month], set: { amountCents, updatedAt: now } })
.run();
}
/**
* Set a category's budget from one month:
* month: just that month
* rest: that month through December (earlier months keep what they had)
* year: every month of the year
*/
export function setBudget(ctx: AppContext, categoryId: number, year: number, month: number, amountCents: number, scope: BudgetScope): void {
const now = new Date().toISOString();
ctx.sqlite.transaction(() => {
const before = budgetYear(ctx, year).items.find((l) => l.categoryId === categoryId);
if (scope === 'month') {
put(ctx, categoryId, year, month, amountCents, now);
} else if (scope === 'year') {
ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year))).run();
if (amountCents > 0) put(ctx, categoryId, year, 0, amountCents, now);
} else {
// Earlier months that followed the yearly amount keep their old value.
for (let m = 1; m < month; m++) {
if (before?.months[m - 1] == null) {
const old = before?.monthly ?? 0;
if (old !== amountCents) put(ctx, categoryId, year, m, old, now);
}
}
ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year), gte(budgets.month, month), lte(budgets.month, 12))).run();
put(ctx, categoryId, year, 0, amountCents, now);
}
// Tidy up: overrides equal to the yearly amount add nothing.
const after = budgetYear(ctx, year).items.find((l) => l.categoryId === categoryId);
if (after && after.monthly !== null) {
const same = after.months.flatMap((v, i) => (v === after.monthly ? [i + 1] : []));
if (same.length) ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year), inArray(budgets.month, same))).run();
}
if (after && after.monthly === null && after.months.every((v) => v === null || v === 0)) {
ctx.db.delete(budgets).where(and(eq(budgets.categoryId, categoryId), eq(budgets.year, year))).run();
}
})();
}
/** Fill in categories that have no budget for the year yet, as an amount for every month. */
export function fillBudgets(ctx: AppContext, year: number, amounts: Map<number, number>): number {
const now = new Date().toISOString();
const existing = new Set(budgetYear(ctx, year).items.map((l) => l.categoryId));
let filled = 0;
ctx.sqlite.transaction(() => {
for (const [categoryId, cents] of amounts) {
if (existing.has(categoryId) || cents <= 0) continue;
put(ctx, categoryId, year, 0, cents, now);
filled++;
}
})();
return filled;
}
/** Copy one year's budgets into another, for categories the new year doesn't have yet. */
export function copyYear(ctx: AppContext, from: number, to: number): number {
const now = new Date().toISOString();
const target = new Set(budgetYear(ctx, to).items.map((l) => l.categoryId));
const rows = ctx.db.select().from(budgets).where(eq(budgets.year, from)).all();
const copied = new Set<number>();
ctx.sqlite.transaction(() => {
for (const r of rows) {
if (target.has(r.categoryId)) continue;
put(ctx, r.categoryId, to, r.month, r.amountCents, now);
copied.add(r.categoryId);
}
})();
return copied.size;
}

View File

@@ -17,6 +17,8 @@ export interface Config {
sessionDays: number;
/** Allow password sign-in over plain HTTP from a non-local address. */
allowInsecureSignin: boolean;
/** The AI provider configured on this server, if any. None ships with Spendlight. */
aiProvider: string | null;
version: string;
}
@@ -54,6 +56,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
keepMonthly: int(env.BACKUP_KEEP_MONTHLY, 12, 0, 120),
sessionDays: int(env.SESSION_DAYS, 400, 1, 400),
allowInsecureSignin: truthy(env.ALLOW_INSECURE_SIGNIN),
aiProvider: env.AI_PROVIDER?.trim() || null,
version: env.APP_VERSION?.trim() || 'dev',
};
}

View File

@@ -7,6 +7,7 @@ import type { Config } from './config';
import { migrateDatabase, openDatabase, type DB, type Sqlite } from './db/client';
import { consoleLogger, type Logger } from './log';
import { StagingStore } from './import/service';
import { scanSubscriptions } from './subscriptions/engine';
import { MIGRATIONS_DIR } from './paths';
export interface AppContext {
@@ -33,7 +34,7 @@ export async function createContext(config: Config, options: { log?: Logger } =
if (pending > 0) log.info(`Database upgraded (${pending} step${pending === 1 ? '' : 's'})`);
seedCategories(db);
return {
const ctx: AppContext = {
config,
db,
sqlite,
@@ -43,4 +44,15 @@ export async function createContext(config: Config, options: { log?: Logger } =
startedAt: new Date(),
log,
};
// Statements imported before subscriptions existed get looked through once.
const count = (table: string) => (sqlite.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
if (count('subscription') === 0 && count('txn') > 0) {
try {
const { found } = scanSubscriptions(ctx);
if (found) log.info(`Found ${found} possible subscription${found === 1 ? '' : 's'} in existing transactions`);
} catch (error) {
log.error('Could not look for subscriptions', error);
}
}
return ctx;
}

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

@@ -0,0 +1,39 @@
// Calendar days are plain YYYY-MM-DD strings in the household's time zone.
export { daysBetween, shiftDate as addDays } from './import/parse';
/** Today's date where the household lives (the container's TZ). */
export function today(now: Date = new Date()): string {
return now.toLocaleDateString('en-CA');
}
export const monthOf = (date: string) => date.slice(0, 7);
/** Move a date by whole months, keeping the day where the month allows (Jan 31 → Feb 28). */
export function addMonthsToDate(date: string, months: number): string {
const [y, m, d] = date.split('-').map(Number);
const first = new Date(Date.UTC(y!, m! - 1 + months, 1));
const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
first.setUTCDate(Math.min(d!, last));
return first.toISOString().slice(0, 10);
}
export function monthRange(from: string, to: string): string[] {
const out: string[] = [];
let [y, m] = from.split('-').map(Number) as [number, number];
const [ty, tm] = to.split('-').map(Number) as [number, number];
while (y < ty || (y === ty && m <= tm)) {
out.push(`${y}-${String(m).padStart(2, '0')}`);
m++;
if (m > 12) {
m = 1;
y++;
}
}
return out;
}
export function lastDayOf(month: string): string {
const [y, m] = month.split('-').map(Number);
return `${month}-${String(new Date(y!, m!, 0).getDate()).padStart(2, '0')}`;
}

View File

@@ -212,6 +212,8 @@ export const transactions = sqliteTable(
bankType: text('bank_type'),
note: text('note'),
transferPeerId: integer('transfer_peer_id'),
/** The subscription or bill this charge belongs to, once one is recognized. */
subscriptionId: integer('subscription_id'),
source: text('source', { enum: ['import', 'manual'] }).notNull().default('import'),
/** account|date|amount|cleaned description: how duplicates are recognized. */
dedupeKey: text('dedupe_key').notNull(),
@@ -226,6 +228,7 @@ export const transactions = sqliteTable(
index('txn_dedupe_idx').on(t.dedupeKey),
index('txn_review_idx').on(t.needsReview),
index('txn_import_idx').on(t.importId),
index('txn_subscription_idx').on(t.subscriptionId),
],
);
@@ -285,7 +288,98 @@ export const rules = sqliteTable(
(t) => [index('rule_merchant_idx').on(t.merchant)],
);
// ---------------------------------------------------------------------------
// Milestone 2: the full picture.
/**
* Monthly budget targets, always positive. Month 0 is the amount for every
* month of that year; months 1 to 12 override it for one month.
*/
export const budgets = sqliteTable(
'budget',
{
id: integer('id').primaryKey({ autoIncrement: true }),
categoryId: integer('category_id')
.notNull()
.references(() => categories.id, { onDelete: 'cascade' }),
year: integer('year').notNull(),
month: integer('month').notNull().default(0),
amountCents: integer('amount_cents').notNull(),
updatedAt: text('updated_at').notNull(),
},
(t) => [uniqueIndex('budget_unique').on(t.categoryId, t.year, t.month), index('budget_year_idx').on(t.year)],
);
export const SUBSCRIPTION_STATUSES = ['suggested', 'active', 'trial', 'paused', 'cancelled', 'dismissed'] as const;
export const CADENCES = ['weekly', 'monthly', 'quarterly', 'yearly'] as const;
/** A subscription or recurring bill: one entry per service, whichever card it bills to. */
export const subscriptions = sqliteTable(
'subscription',
{
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
kind: text('kind', { enum: ['subscription', 'bill'] }).notNull().default('subscription'),
/** The merchant name its charges carry, plus any others merged into it. */
merchant: text('merchant').notNull(),
aliases: text('aliases').notNull().default('[]'),
catalogKey: text('catalog_key'),
cadence: text('cadence', { enum: CADENCES }).notNull().default('monthly'),
/** The usual charge, as a positive number of cents. */
expectedCents: integer('expected_cents').notNull(),
accountId: integer('account_id').references(() => accounts.id, { onDelete: 'set null' }),
categoryId: integer('category_id').references(() => categories.id, { onDelete: 'set null' }),
status: text('status', { enum: SUBSCRIPTION_STATUSES }).notNull().default('suggested'),
/** For suggestions: two matching charges are likely, three or more confident. */
confidence: text('confidence', { enum: ['likely', 'confident'] }).notNull().default('likely'),
source: text('source', { enum: ['detected', 'catalog', 'manual'] }).notNull().default('detected'),
trialEndsOn: text('trial_ends_on'),
cancelledOn: text('cancelled_on'),
firstChargeOn: text('first_charge_on'),
lastChargeOn: text('last_charge_on'),
nextExpectedOn: text('next_expected_on'),
manageUrl: text('manage_url'),
note: text('note'),
alertsOn: integer('alerts_on', { mode: 'boolean' }).notNull().default(true),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
},
(t) => [index('subscription_merchant_idx').on(t.merchant), index('subscription_status_idx').on(t.status)],
);
/**
* Alerts and insights. Each is worked out again from the data whenever it
* changes; this table remembers which ones people have read or dismissed.
*/
export const alerts = sqliteTable(
'alert',
{
id: integer('id').primaryKey({ autoIncrement: true }),
kind: text('kind').notNull(),
subjectType: text('subject_type').notNull(),
subjectId: integer('subject_id').notNull().default(0),
/** Tells one occurrence from the next, such as the month or the charge. */
period: text('period').notNull().default(''),
area: text('area', { enum: ['money', 'subscriptions'] }).notNull().default('money'),
severity: text('severity', { enum: ['info', 'good', 'warn', 'bad'] }).notNull().default('info'),
title: text('title').notNull(),
body: text('body'),
link: text('link'),
rank: integer('rank').notNull().default(0),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
readAt: text('read_at'),
dismissedAt: text('dismissed_at'),
/** Set when the situation no longer holds, which hides the alert. */
resolvedAt: text('resolved_at'),
},
(t) => [uniqueIndex('alert_unique').on(t.kind, t.subjectType, t.subjectId, t.period), index('alert_open_idx').on(t.resolvedAt, t.dismissedAt)],
);
export type AccountRow = typeof accounts.$inferSelect;
export type CategoryRow = typeof categories.$inferSelect;
export type TransactionRow = typeof transactions.$inferSelect;
export type RuleRow = typeof rules.$inferSelect;
export type BudgetRow = typeof budgets.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type AlertRow = typeof alerts.$inferSelect;

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

@@ -0,0 +1,215 @@
import { crc32, deflateRawSync } from 'node:zlib';
// A small, dependency-free writer for real Excel (.xlsx) files: bold frozen
// headers, filters, column widths, dollar amounts and true dates. An .xlsx is
// a zip of XML parts; this writes exactly the parts Excel needs.
export type Cell = string | number | null | undefined | { date: string } | { money: number };
export interface Sheet {
name: string;
columns: { header: string; width?: number }[];
rows: Cell[][];
}
const STYLE = { header: 1, money: 2, date: 3 } as const;
// Characters XML 1.0 can't carry are dropped.
// eslint-disable-next-line no-control-regex
const INVALID = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
const esc = (s: string) => s.replace(INVALID, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function columnName(index: number): string {
let n = index + 1;
let out = '';
while (n > 0) {
const r = (n - 1) % 26;
out = String.fromCharCode(65 + r) + out;
n = Math.floor((n - 1) / 26);
}
return out;
}
/** Excel counts days from 1899-12-30. */
const serial = (date: string) => Math.round((Date.parse(`${date}T00:00:00Z`) - Date.UTC(1899, 11, 30)) / 86_400_000);
function cellXml(value: Cell, ref: string, header = false): string {
if (value === null || value === undefined || value === '') return '';
if (header) return `<c r="${ref}" s="${STYLE.header}" t="inlineStr"><is><t xml:space="preserve">${esc(String(value))}</t></is></c>`;
if (typeof value === 'number') return `<c r="${ref}"><v>${value}</v></c>`;
if (typeof value === 'object' && 'date' in value) return `<c r="${ref}" s="${STYLE.date}"><v>${serial(value.date)}</v></c>`;
if (typeof value === 'object' && 'money' in value) return `<c r="${ref}" s="${STYLE.money}"><v>${(value.money / 100).toFixed(2)}</v></c>`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${esc(String(value))}</t></is></c>`;
}
function sheetXml(sheet: Sheet): string {
const last = columnName(Math.max(0, sheet.columns.length - 1));
const height = sheet.rows.length + 1;
const cols = sheet.columns.map((c, i) => `<col min="${i + 1}" max="${i + 1}" width="${c.width ?? 14}" customWidth="1"/>`).join('');
const header = `<row r="1">${sheet.columns.map((c, i) => cellXml(c.header, `${columnName(i)}1`, true)).join('')}</row>`;
const body = sheet.rows.map((row, r) => `<row r="${r + 2}">${row.map((v, i) => cellXml(v, `${columnName(i)}${r + 2}`)).join('')}</row>`).join('');
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
`<dimension ref="A1:${last}${height}"/>` +
'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' +
'<sheetFormatPr defaultRowHeight="15"/>' +
`<cols>${cols}</cols>` +
`<sheetData>${header}${body}</sheetData>` +
`<autoFilter ref="A1:${last}${height}"/>` +
'</worksheet>'
);
}
const STYLES =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<numFmts count="2"><numFmt numFmtId="164" formatCode="&quot;$&quot;#,##0.00;[Red]\\-&quot;$&quot;#,##0.00"/><numFmt numFmtId="165" formatCode="yyyy\\-mm\\-dd"/></numFmts>' +
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font><font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts>' +
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="4">' +
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
'<xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
'</cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
'</styleSheet>';
/** Sheet names: at most 31 characters, none of : \ / ? * [ ] */
const sheetName = (name: string) => name.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31) || 'Sheet';
export function buildXlsx(sheets: Sheet[]): Buffer {
const names = sheets.map((s) => sheetName(s.name));
const files: [string, string][] = [
[
'[Content_Types].xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('') +
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' +
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' +
'</Types>',
],
[
'_rels/.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' +
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' +
'</Relationships>',
],
[
'docProps/app.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Spendlight</Application></Properties>',
],
[
'docProps/core.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
`<dc:creator>Spendlight</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString().slice(0, 19)}Z</dcterms:created>` +
'</cp:coreProperties>',
],
[
'xl/workbook.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
`<sheets>${names.map((n, i) => `<sheet name="${esc(n)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('')}</sheets>` +
`<definedNames>${sheets
.map((s, i) => {
const last = columnName(Math.max(0, s.columns.length - 1));
return `<definedName name="_xlnm._FilterDatabase" localSheetId="${i}" hidden="1">'${esc(names[i]!.replace(/'/g, "''"))}'!$A$1:$${last}$${s.rows.length + 1}</definedName>`;
})
.join('')}</definedNames>` +
'</workbook>',
],
[
'xl/_rels/workbook.xml.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('') +
`<Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
'</Relationships>',
],
['xl/styles.xml', STYLES],
...sheets.map((s, i): [string, string] => [`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s)]),
];
return zip(files.map(([name, text]) => ({ name, data: Buffer.from(text, 'utf8') })));
}
/** A plain zip archive with deflated entries. */
function zip(entries: { name: string; data: Buffer }[]): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
// 1980-01-01 00:00, the zip format's epoch; the date inside an export doesn't matter.
const dosTime = 0;
const dosDate = (0 << 9) | (1 << 5) | 1;
for (const e of entries) {
const name = Buffer.from(e.name, 'utf8');
const packed = deflateRawSync(e.data, { level: 6 });
const crc = crc32(e.data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6); // names are UTF-8
local.writeUInt16LE(8, 8); // deflate
local.writeUInt16LE(dosTime, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(packed.length, 18);
local.writeUInt32LE(e.data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, name, packed);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(dosTime, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(packed.length, 20);
central.writeUInt32LE(e.data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
centrals.push(central, name);
offset += local.length + name.length + packed.length;
}
const centralSize = centrals.reduce((n, b) => n + b.length, 0);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralSize, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, ...centrals, end]);
}
/** CSV that opens cleanly in Excel and Numbers; text that looks like a formula is defused. */
export function buildCsv(columns: string[], rows: Cell[][]): string {
const field = (v: Cell): string => {
if (v === null || v === undefined) return '';
let s: string;
if (typeof v === 'number') s = String(v);
else if (typeof v === 'object' && 'date' in v) s = v.date;
else if (typeof v === 'object' && 'money' in v) s = (v.money / 100).toFixed(2);
else {
s = String(v);
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
}
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return '\uFEFF' + [columns.map(field), ...rows.map((r) => r.map(field))].map((r) => r.join(',')).join('\r\n') + '\r\n';
}

View File

@@ -5,7 +5,7 @@ import Papa from 'papaparse';
/** Every non-blank row of a delimited file, as trimmed strings. */
export function readTable(text: string): string[][] {
const clean = text.replace(/^/, '');
const clean = text.replace(/^\uFEFF/, '');
const result = Papa.parse<string[]>(clean, { skipEmptyLines: 'greedy', delimitersToGuess: [',', '\t', ';', '|'] });
return result.data.map((row) => row.map((cell) => (cell ?? '').trim()));
}

View File

@@ -1,13 +1,15 @@
import type { AccountKind, ImportColumnMap, ImportPreview, ImportSummary } from '@spendlight/shared';
import { and, between, count, desc, eq, inArray } from 'drizzle-orm';
import { and, between, count, desc, eq, inArray, sql } from 'drizzle-orm';
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, writeFileSync } from 'node:fs';
import { extname, join } from 'node:path';
import { logChange } from '../audit';
import { Suggester } from '../categorize/suggest';
import type { AppContext } from '../context';
import { accounts, importLayouts, imports, transactions, type AccountRow } from '../db/schema';
import { accounts, importLayouts, imports, rules, transactions, type AccountRow } from '../db/schema';
import { fail } from '../http';
import { refreshAlerts } from '../insights/engine';
import { scanSubscriptions } from '../subscriptions/engine';
import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts';
import { cleanMerchant, dedupeKey } from './merchant';
import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse';
@@ -380,8 +382,10 @@ export function commitImport(
.get();
const ids: number[] = [];
const usedRules = new Set<number>();
for (const { row, key } of planned) {
const rule = suggester.rule(row.merchant, account.id);
if (rule) usedRules.add(rule.id);
const merchant = rule?.renameTo || row.merchant;
const s = suggester.suggest({ ...row, merchant, accountId: account.id });
const inserted = ctx.db
@@ -408,6 +412,14 @@ export function commitImport(
ids.push(inserted.id);
}
if (usedRules.size) {
ctx.db
.update(rules)
.set({ hits: sql`${rules.hits} + 1`, lastUsedAt: now })
.where(inArray(rules.id, [...usedRules]))
.run();
}
const transferCount = pairTransfers(ctx, suggester, ids);
ctx.db.update(imports).set({ transferCount }).where(eq(imports.id, record.id)).run();
@@ -454,6 +466,7 @@ export function commitImport(
}
ctx.staging.delete(stagingId);
logChange(ctx, userId, 'import', 'import', result.record.id, { file: staged.fileName, added: planned.length });
afterMoneyChanged(ctx);
return importSummary(ctx, result.record.id);
}
@@ -528,7 +541,19 @@ export function undoImport(ctx: AppContext, id: number, userId: number): ImportS
ctx.db.update(imports).set({ status: 'undone', undoneAt: now }).where(eq(imports.id, id)).run();
})();
logChange(ctx, userId, 'undo_import', 'import', id);
afterMoneyChanged(ctx);
return importSummary(ctx, id);
}
/** New or removed transactions: look for subscriptions, and work out the alerts again. */
function afterMoneyChanged(ctx: AppContext) {
try {
scanSubscriptions(ctx);
refreshAlerts(ctx);
} catch (error) {
// The import itself is safe; subscriptions and alerts catch up next time.
ctx.log.error('Could not update subscriptions and alerts after an import', error);
}
}
export { MAX_BYTES };

View File

@@ -0,0 +1,427 @@
import { addMonths, daysInMonth, formatCents, monthName, type AlertItem, type Overview, type Severity } from '@spendlight/shared';
import { and, asc, desc, eq, isNull, ne, sql } from 'drizzle-orm';
import type { AppContext } from '../context';
import { accounts, alerts, categories, subscriptions, transactions } from '../db/schema';
import { addDays, daysBetween, monthOf, monthRange, today } from '../dates';
import { categoryKinds, monthsReport } from '../reports/actuals';
import { alertSettings } from '../settings';
import { CYCLE_DAYS, GRACE_DAYS } from '../subscriptions/engine';
// Alerts and insights, worked out from the data each time it changes. Nothing
// here is stored as a fact: if an import is undone or a budget changes, the
// alerts that no longer hold simply disappear. The alert table only remembers
// what people have read or dismissed.
interface Draft {
kind: string;
subjectType: string;
subjectId: number;
period: string;
area: 'money' | 'subscriptions';
severity: Severity;
title: string;
body?: string | null;
link?: string | null;
/** Higher shows first. */
rank: number;
}
const money = (cents: number) => formatCents(cents, { whole: Math.abs(cents) >= 100_000 });
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
const SEVERITY_RANK: Record<Severity, number> = { bad: 300, warn: 200, good: 100, info: 50 };
/** The month Home talks about: this one, or until statements arrive, the latest month with data. */
export function focusMonth(ctx: AppContext): { month: string; isCurrent: boolean; dataThrough: string | null } {
const now = today();
const through = (ctx.sqlite.prepare('SELECT max(date) AS d FROM txn WHERE date <= ?').get(now) as { d: string | null }).d;
const month = through ? monthOf(through) : monthOf(now);
return { month, isCurrent: month === monthOf(now), dataThrough: through };
}
export function overview(ctx: AppContext, requested?: string): Overview {
const focus = focusMonth(ctx);
const month = requested ?? focus.month;
const isCurrent = month === monthOf(today());
const year = Number(month.slice(0, 4));
const report = monthsReport(ctx, monthRange(`${year}-01`, `${year}-12`));
const i = Number(month.slice(5, 7)) - 1;
const kinds = categoryKinds(ctx);
// How far into the month the statements reach, for pacing the budget.
const through = focus.dataThrough && monthOf(focus.dataThrough) === month ? Number(focus.dataThrough.slice(8, 10)) : daysInMonth(month);
const share = isCurrent ? Math.min(1, through / daysInMonth(month)) : 1;
const spentYtd = report.totals.spent.slice(0, i + 1).reduce((a, b) => a + b, 0);
const budgetSoFar = report.totals.budgetSpend.slice(0, i).reduce((a, b) => a + b, 0) + report.totals.budgetSpend[i]! * share;
const budgetYear = report.totals.budgetSpend.reduce((a, b) => a + b, 0);
const elapsedDays = daysBetween(`${year}-01-01`, `${month}-${String(Math.max(1, Math.round(share * daysInMonth(month)))).padStart(2, '0')}`) + 1;
const projected =
budgetSoFar > 0 ? Math.round((spentYtd * budgetYear) / budgetSoFar) : elapsedDays > 0 ? Math.round((spentYtd / elapsedDays) * (year % 4 === 0 ? 366 : 365)) : 0;
const toReview = (ctx.sqlite.prepare('SELECT count(*) AS n FROM txn WHERE needs_review = 1').get() as { n: number }).n;
const hasBudgets = (ctx.sqlite.prepare('SELECT count(*) AS n FROM budget WHERE year = ?').get(year) as { n: number }).n > 0;
const dayOfMonth = Number(today().slice(8, 10));
return {
month,
isCurrent,
daysLeft: isCurrent ? daysInMonth(month) - dayOfMonth : 0,
dataThrough: focus.dataThrough,
spent: report.totals.spent[i]!,
income: report.totals.income[i]!,
budgetSpend: report.totals.budgetSpend[i]!,
budgetIncome: report.totals.budgetIncome[i]!,
hasBudgets,
unbudgeted:
report.unfiled.out[i]! +
report.categories.filter((c) => kinds.get(c.categoryId) === 'expense' && c.budget[i] === 0 && c.actual[i]! > 0).reduce((s, c) => s + c.actual[i]!, 0),
categories: report.categories
.filter((c) => kinds.get(c.categoryId) === 'expense' && (c.actual[i] !== 0 || c.budget[i] !== 0))
.map((c) => ({ categoryId: c.categoryId, spent: c.actual[i]!, budget: c.budget[i]! }))
.sort((a, b) => b.spent - a.spent || b.budget - a.budget),
year: {
year,
spent: spentYtd,
budgetSoFar: Math.round(budgetSoFar),
budgetYear,
projected,
income: report.totals.income.slice(0, i + 1).reduce((a, b) => a + b, 0),
},
toReview,
};
}
function moneyAlerts(ctx: AppContext): Draft[] {
const drafts: Draft[] = [];
const settings = alertSettings(ctx);
const focus = focusMonth(ctx);
if (!focus.dataThrough) return drafts;
const names = new Map(ctx.db.select({ id: categories.id, name: categories.name }).from(categories).all().map((c) => [c.id, c.name]));
const kinds = categoryKinds(ctx);
const year = Number(focus.month.slice(0, 4));
const report = monthsReport(ctx, monthRange(`${year}-01`, `${year}-12`));
const at = (m: string) => Number(m.slice(5, 7)) - 1;
const expense = report.categories.filter((c) => kinds.get(c.categoryId) === 'expense');
// This month, while it's still going: categories close to or over budget.
if (focus.isCurrent) {
const i = at(focus.month);
const left = daysInMonth(focus.month) - Number(today().slice(8, 10));
for (const c of expense) {
const budget = c.budget[i]!;
const spent = c.actual[i]!;
if (budget <= 0 || spent <= 0) continue;
const name = names.get(c.categoryId)!;
const link = `/activity?categoryId=${c.categoryId}&period=month`;
if (spent > budget) {
drafts.push({
kind: 'budget_over', subjectType: 'category', subjectId: c.categoryId, period: focus.month, area: 'money', severity: 'bad',
title: `${name} is ${money(spent - budget)} over budget`,
body: `${money(spent)} spent of ${money(budget)} in ${monthName(focus.month)}${left > 0 ? `, with ${plural(left, 'day')} to go` : ''}.`,
link, rank: SEVERITY_RANK.bad + Math.min(99, Math.round(((spent - budget) / budget) * 50)),
});
} else if (spent >= (budget * settings.budgetWarnPct) / 100 && left > 0) {
const pct = Math.round((spent / budget) * 100);
drafts.push({
kind: 'budget_close', subjectType: 'category', subjectId: c.categoryId, period: focus.month, area: 'money', severity: 'warn',
title: `${name} is at ${pct}% of budget with ${plural(left, 'day')} left`,
body: `${money(budget - spent)} left of ${money(budget)} for ${monthName(focus.month)}.`,
link, rank: SEVERITY_RANK.warn + pct - 50,
});
}
}
}
// The last full month: where it ran over, set against the year so far.
const done = focus.isCurrent ? addMonths(focus.month, -1) : focus.month;
if (Number(done.slice(0, 4)) === year) {
const i = at(done);
const overs = expense
.map((c) => ({ c, over: c.actual[i]! - c.budget[i]! }))
.filter(({ c, over }) => c.budget[i]! > 0 && over >= Math.max(1000, c.budget[i]! * 0.1))
.sort((a, b) => b.over - a.over)
.slice(0, 3);
for (const { c, over } of overs) {
const ytd = c.actual.slice(0, i + 1).reduce((a, b) => a + b, 0) - c.budget.slice(0, i + 1).reduce((a, b) => a + b, 0);
drafts.push({
kind: 'month_category_over', subjectType: 'category', subjectId: c.categoryId, period: done, area: 'money', severity: 'warn',
title: `${names.get(c.categoryId)} ran ${money(over)} over in ${monthName(done)}`,
body: ytd <= 0 ? `You’re still ${money(-ytd)} under budget for the year.` : `That puts it ${money(ytd)} over budget for the year.`,
link: `/insights?view=categories&categoryId=${c.categoryId}`, rank: SEVERITY_RANK.warn - 20,
});
}
const spent = report.totals.spent[i]!;
const budget = report.totals.budgetSpend[i]!;
if (budget > 0 && spent > 0) {
const gap = budget - spent;
drafts.push({
kind: gap >= 0 ? 'month_under' : 'month_over', subjectType: 'month', subjectId: 0, period: done, area: 'money',
severity: gap >= 0 ? 'good' : 'bad',
title: gap >= 0 ? `${monthName(done)} came in ${money(gap)} under budget` : `${monthName(done)} ran ${money(-gap)} over budget`,
body: `${money(spent)} spent against a plan of ${money(budget)}.`,
link: `/insights?view=budget&month=${done}`, rank: (gap >= 0 ? SEVERITY_RANK.good : SEVERITY_RANK.bad) - 10,
});
}
}
// The year: on pace to finish over or under.
const o = overview(ctx);
if (o.year.budgetYear > 0 && o.year.budgetSoFar > 0) {
const gap = o.year.budgetYear - o.year.projected;
drafts.push({
kind: 'year_pace', subjectType: 'year', subjectId: year, period: focus.month, area: 'money',
severity: gap >= 0 ? 'good' : 'warn',
title: gap >= 0 ? `On pace to finish ${year} ${money(gap)} under budget` : `On pace to finish ${year} ${money(-gap)} over budget`,
body: `${money(o.year.spent)} spent so far against ${money(o.year.budgetSoFar)} planned to date.`,
link: '/insights?view=overview', rank: gap >= 0 ? SEVERITY_RANK.good - 5 : SEVERITY_RANK.warn - 5,
});
}
// Unusual purchases: several times what the category usually costs.
const recentFrom = addDays(focus.dataThrough, -45);
const recent = ctx.db
.select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, merchant: transactions.merchant, categoryId: transactions.categoryId })
.from(transactions)
.where(and(sql`${transactions.date} >= ${recentFrom}`, sql`${transactions.amountCents} <= -10000`, eq(transactions.isSplit, false)))
.orderBy(desc(transactions.date))
.limit(200)
.all()
.filter((t) => t.categoryId !== null && kinds.get(t.categoryId) === 'expense');
const typical = ctx.sqlite.prepare(
`SELECT amount_cents AS a FROM txn
WHERE category_id = ? AND amount_cents < 0 AND id != ? AND date >= ? AND date < ? AND is_split = 0
ORDER BY amount_cents`,
);
let unusual = 0;
for (const t of recent) {
if (unusual >= 3) break;
const prior = (typical.all(t.categoryId, t.id, addDays(t.date, -180), t.date) as { a: number }[]).map((r) => -r.a);
if (prior.length < 5) continue;
const med = prior[Math.floor(prior.length / 2)]!;
const amount = -t.amountCents;
if (med <= 0 || amount < med * settings.unusualMultiple) continue;
const times = Math.round(amount / med);
drafts.push({
kind: 'unusual', subjectType: 'transaction', subjectId: t.id, period: '', area: 'money', severity: 'info',
title: `Unusual: ${formatCents(amount)} at ${t.merchant}`,
body: `That’s ${times} times your typical ${names.get(t.categoryId!)} purchase.`,
link: `/activity?q=${encodeURIComponent(t.merchant)}`, rank: SEVERITY_RANK.info + 10,
});
unusual++;
}
return drafts;
}
function subscriptionAlerts(ctx: AppContext): Draft[] {
const drafts: Draft[] = [];
const settings = alertSettings(ctx);
const now = today();
const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
const coverage = new Map(
(ctx.sqlite.prepare('SELECT account_id AS a, max(date) AS d FROM txn GROUP BY account_id').all() as { a: number; d: string }[]).map((r) => [r.a, r.d]),
);
const newest = [...coverage.values()].sort().at(-1) ?? now;
const link = (id: number) => `/subscriptions?open=${id}`;
const per = (cadence: string) => (cadence === 'weekly' ? 'a week' : cadence === 'quarterly' ? 'every 3 months' : cadence === 'yearly' ? 'a year' : 'a month');
for (const s of ctx.db.select().from(subscriptions).where(ne(subscriptions.status, 'dismissed')).all()) {
const charges = ctx.db
.select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId })
.from(transactions)
.where(eq(transactions.subscriptionId, s.id))
.orderBy(asc(transactions.date), asc(transactions.id))
.all();
if (s.status === 'suggested') {
const since = s.firstChargeOn ? ` since ${monthName(monthOf(s.firstChargeOn))}` : '';
drafts.push({
kind: 'sub_found', subjectType: 'subscription', subjectId: s.id, period: '', area: 'subscriptions', severity: 'info',
title: `New recurring ${s.kind === 'bill' ? 'bill' : 'charge'}: ${s.name} at ${formatCents(s.expectedCents)} ${per(s.cadence)}${since}`,
body: s.kind === 'bill' ? 'Is it a regular bill? One tap tells Spendlight.' : 'Is it a subscription? One tap tells Spendlight.',
link: '/subscriptions', rank: SEVERITY_RANK.info + 20,
});
continue;
}
if (!s.alertsOn) continue;
const last = charges.at(-1);
const prev = charges.at(-2);
const fresh = (date: string) => daysBetween(date, newest) <= 45;
// Charged after it was cancelled: the alert most worth having.
if (s.status === 'cancelled' && s.cancelledOn) {
const after = charges.filter((c) => c.date > s.cancelledOn!).at(-1);
if (after && fresh(after.date)) {
drafts.push({
kind: 'sub_after_cancel', subjectType: 'subscription', subjectId: s.id, period: String(after.id), area: 'subscriptions', severity: 'bad',
title: `${s.name} charged ${accountNames.get(after.accountId) ?? 'a card'} after you marked it cancelled`,
body: `${formatCents(-after.amountCents)} on ${after.date}. It may still be running; check with ${s.name}.`,
link: link(s.id), rank: SEVERITY_RANK.bad + 50,
});
}
continue;
}
// Price changes.
if (last && prev && fresh(last.date) && s.status !== 'paused') {
const now$ = -last.amountCents;
if (s.kind === 'subscription') {
const was = -prev.amountCents;
const diff = now$ - was;
if (Math.abs(diff) > Math.max((was * settings.priceChangePct) / 100, settings.priceChangeMinCents)) {
drafts.push({
kind: 'sub_price', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions',
severity: diff > 0 ? 'warn' : 'good',
title: `${s.name} went ${diff > 0 ? 'up' : 'down'} ${formatCents(Math.abs(diff))}`,
body: `Now ${formatCents(now$)} ${per(s.cadence)}, ${diff > 0 ? 'up' : 'down'} from ${formatCents(was)}.`,
link: link(s.id), rank: SEVERITY_RANK.warn + 10,
});
}
} else {
const before = charges.slice(-4, -1).map((c) => -c.amountCents);
const usual = Math.round(before.reduce((a, b) => a + b, 0) / before.length);
const diff = now$ - usual;
if (diff > Math.max(usual * 0.25, 1000)) {
drafts.push({
kind: 'sub_price', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions', severity: 'warn',
title: `Your ${s.name} bill went up ${money(diff)} this ${s.cadence === 'monthly' ? 'month' : 'time'}`,
body: `${formatCents(now$)}, compared with about ${formatCents(usual)} usually.`,
link: link(s.id), rank: SEVERITY_RANK.warn,
});
}
}
}
// Billed on two cards in the same cycle.
if (last && fresh(last.date)) {
const twin = charges.find((c) => c.id !== last.id && c.accountId !== last.accountId && Math.abs(daysBetween(c.date, last.date)) < CYCLE_DAYS[s.cadence] * 0.5);
if (twin) {
drafts.push({
kind: 'sub_two_cards', subjectType: 'subscription', subjectId: s.id, period: String(last.id), area: 'subscriptions', severity: 'warn',
title: `${s.name} was billed on two cards`,
body: `${formatCents(-twin.amountCents)} on ${accountNames.get(twin.accountId)} and ${formatCents(-last.amountCents)} on ${accountNames.get(last.accountId)}, within days of each other.`,
link: link(s.id), rank: SEVERITY_RANK.warn + 20,
});
}
}
if (s.status === 'active' && s.nextExpectedOn) {
// A charge that should have arrived, in statements that reach past it.
const reach = (s.accountId && coverage.get(s.accountId)) || newest;
if (daysBetween(s.nextExpectedOn, reach) > GRACE_DAYS[s.cadence]) {
drafts.push({
kind: 'sub_missing', subjectType: 'subscription', subjectId: s.id, period: s.nextExpectedOn, area: 'subscriptions', severity: 'warn',
title: s.cadence === 'yearly' ? `${s.name} didn’t renew` : `${s.name} didn’t charge in ${monthName(monthOf(s.nextExpectedOn))}`,
body: `It usually charges ${formatCents(s.expectedCents)} around ${s.nextExpectedOn}. If you cancelled it, mark it cancelled.`,
link: link(s.id), rank: SEVERITY_RANK.warn - 10,
});
}
// A yearly renewal coming up.
if (s.cadence === 'yearly' && s.nextExpectedOn >= now && daysBetween(now, s.nextExpectedOn) <= 14) {
drafts.push({
kind: 'sub_renewal', subjectType: 'subscription', subjectId: s.id, period: s.nextExpectedOn, area: 'subscriptions', severity: 'info',
title: `${s.name} renews on ${s.nextExpectedOn}`,
body: `${formatCents(s.expectedCents)} a year${s.accountId ? `, on ${accountNames.get(s.accountId)}` : ''}.`,
link: link(s.id), rank: SEVERITY_RANK.info + 30,
});
}
}
// Free trials about to turn into charges, and ones that just did.
if (s.status === 'trial' && s.trialEndsOn && s.trialEndsOn >= now && daysBetween(now, s.trialEndsOn) <= 3) {
const days = daysBetween(now, s.trialEndsOn);
drafts.push({
kind: 'sub_trial_ending', subjectType: 'subscription', subjectId: s.id, period: s.trialEndsOn, area: 'subscriptions', severity: 'warn',
title: `Your ${s.name} trial ends ${days === 0 ? 'today' : `in ${plural(days, 'day')}`}`,
body: `Then it’s ${formatCents(s.expectedCents)} ${per(s.cadence)}. Cancel before ${s.trialEndsOn} if you don’t want it.`,
link: link(s.id), rank: SEVERITY_RANK.warn + 30,
});
}
if (s.status === 'active' && s.trialEndsOn) {
const first = charges.find((c) => c.date >= s.trialEndsOn! && daysBetween(s.trialEndsOn!, c.date) <= 15);
if (first && fresh(first.date)) {
drafts.push({
kind: 'sub_trial_converted', subjectType: 'subscription', subjectId: s.id, period: s.trialEndsOn, area: 'subscriptions', severity: 'info',
title: `Your ${s.name} trial ended and it charged ${formatCents(-first.amountCents)}`,
body: 'It’s an active subscription now.',
link: link(s.id), rank: SEVERITY_RANK.info + 25,
});
}
}
}
return drafts;
}
/** Work out every alert again and remember the result. */
export function refreshAlerts(ctx: AppContext): void {
const drafts = [...moneyAlerts(ctx), ...subscriptionAlerts(ctx)];
const now = new Date().toISOString();
const keyOf = (d: { kind: string; subjectType: string; subjectId: number; period: string }) => `${d.kind}|${d.subjectType}|${d.subjectId}|${d.period}`;
const live = new Set(drafts.map(keyOf));
ctx.sqlite.transaction(() => {
for (const d of drafts) {
ctx.db
.insert(alerts)
.values({ ...d, body: d.body ?? null, link: d.link ?? null, createdAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [alerts.kind, alerts.subjectType, alerts.subjectId, alerts.period],
set: { title: d.title, body: d.body ?? null, link: d.link ?? null, severity: d.severity, rank: d.rank, area: d.area, resolvedAt: null, updatedAt: now },
})
.run();
}
for (const a of ctx.db.select().from(alerts).where(isNull(alerts.resolvedAt)).all()) {
if (!live.has(keyOf(a))) ctx.db.update(alerts).set({ resolvedAt: now }).where(eq(alerts.id, a.id)).run();
}
})();
fresh.set(ctx, stamp(ctx));
}
// Alerts are refreshed lazily: when anything they depend on has changed since last time.
const fresh = new WeakMap<AppContext, string>();
function stamp(ctx: AppContext): string {
const q = (sqlText: string) => JSON.stringify(ctx.sqlite.prepare(sqlText).get());
return [
today(),
q('SELECT max(updated_at) AS u, count(*) AS n, sum(amount_cents) AS s FROM txn'),
q('SELECT max(updated_at) AS u, count(*) AS n, sum(amount_cents) AS s FROM budget'),
q('SELECT max(updated_at) AS u, count(*) AS n FROM subscription'),
q("SELECT value AS v FROM setting WHERE key = 'alerts'"),
q('SELECT count(*) AS n, max(id) AS m FROM category'),
].join('|');
}
export function ensureFresh(ctx: AppContext): void {
if (fresh.get(ctx) !== stamp(ctx)) refreshAlerts(ctx);
}
export function listAlerts(ctx: AppContext, options: { area?: 'money' | 'subscriptions'; limit?: number } = {}): AlertItem[] {
ensureFresh(ctx);
return ctx.db
.select()
.from(alerts)
.where(and(isNull(alerts.resolvedAt), isNull(alerts.dismissedAt), options.area ? eq(alerts.area, options.area) : undefined))
.orderBy(desc(alerts.rank), desc(alerts.createdAt), desc(alerts.id))
.limit(options.limit ?? 100)
.all()
.map((a) => ({
id: a.id,
kind: a.kind,
area: a.area,
severity: a.severity,
title: a.title,
body: a.body,
link: a.link,
createdAt: a.createdAt,
read: !!a.readAt,
}));
}
export function alertCounts(ctx: AppContext): { money: number; subscriptions: number } {
ensureFresh(ctx);
const rows = ctx.db
.select({ area: alerts.area, n: sql<number>`count(*)` })
.from(alerts)
.where(and(isNull(alerts.resolvedAt), isNull(alerts.dismissedAt), isNull(alerts.readAt)))
.groupBy(alerts.area)
.all();
return { money: rows.find((r) => r.area === 'money')?.n ?? 0, subscriptions: rows.find((r) => r.area === 'subscriptions')?.n ?? 0 };
}

View File

@@ -0,0 +1,101 @@
import type { CategoryKind, MonthsReport } from '@spendlight/shared';
import { budgetMatrix } from '../budgets/budgets';
import type { AppContext } from '../context';
import { categories } from '../db/schema';
import { lastDayOf, today } from '../dates';
// Spending and income by category and month. Split purchases count each part
// in its own category, refunds reduce the category they came back to, and
// transfers between your own accounts never count.
interface Totals {
/** Net cents as stored (money out negative), by category id and month index. */
net: Map<number, number[]>;
unfiledOut: number[];
unfiledIn: number[];
}
export function categoryKinds(ctx: AppContext): Map<number, CategoryKind> {
return new Map(ctx.db.select({ id: categories.id, kind: categories.kind }).from(categories).all().map((c) => [c.id, c.kind]));
}
function netByMonth(ctx: AppContext, months: string[]): Totals {
const index = new Map(months.map((m, i) => [m, i]));
const from = `${months[0]}-01`;
const to = lastDayOf(months.at(-1)!);
const zero = () => Array<number>(months.length).fill(0);
const out: Totals = { net: new Map(), unfiledOut: zero(), unfiledIn: zero() };
const whole = ctx.sqlite
.prepare(
`SELECT substr(date, 1, 7) AS month, category_id AS categoryId,
sum(amount_cents) AS net,
sum(CASE WHEN amount_cents < 0 THEN amount_cents ELSE 0 END) AS outCents,
sum(CASE WHEN amount_cents > 0 THEN amount_cents ELSE 0 END) AS inCents
FROM txn
WHERE is_split = 0 AND date >= ? AND date <= ?
AND NOT (category_id IS NULL AND transfer_peer_id IS NOT NULL)
GROUP BY month, category_id`,
)
.all(from, to) as { month: string; categoryId: number | null; net: number; outCents: number; inCents: number }[];
const parts = ctx.sqlite
.prepare(
`SELECT substr(t.date, 1, 7) AS month, s.category_id AS categoryId, sum(s.amount_cents) AS net
FROM split s JOIN txn t ON t.id = s.transaction_id
WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ?
GROUP BY month, s.category_id`,
)
.all(from, to) as { month: string; categoryId: number; net: number }[];
for (const r of [...whole, ...parts]) {
const i = index.get(r.month);
if (i === undefined) continue;
if (r.categoryId === null) {
const w = r as (typeof whole)[number];
out.unfiledOut[i]! += -w.outCents;
out.unfiledIn[i]! += w.inCents;
continue;
}
const row = out.net.get(r.categoryId) ?? zero();
row[i]! += r.net;
out.net.set(r.categoryId, row);
}
return out;
}
export function dataThrough(ctx: AppContext): string | null {
const row = ctx.sqlite.prepare(`SELECT max(date) AS d FROM txn WHERE date <= ?`).get(today()) as { d: string | null };
return row.d;
}
export function monthsReport(ctx: AppContext, months: string[]): MonthsReport {
const kinds = categoryKinds(ctx);
const totals = netByMonth(ctx, months);
const budgets = budgetMatrix(ctx, months);
const zero = () => Array<number>(months.length).fill(0);
const ids = new Set([...totals.net.keys(), ...budgets.keys()]);
const report: MonthsReport = {
months,
categories: [],
unfiled: { out: totals.unfiledOut, in: totals.unfiledIn },
totals: { spent: [...totals.unfiledOut], income: [...totals.unfiledIn], budgetSpend: zero(), budgetIncome: zero() },
dataThrough: dataThrough(ctx),
};
for (const id of ids) {
const kind = kinds.get(id);
if (!kind || kind === 'transfer') continue;
const net = totals.net.get(id) ?? zero();
const actual = net.map((n) => (kind === 'expense' ? -n : n));
const budget = budgets.get(id) ?? zero();
report.categories.push({ categoryId: id, actual, budget });
actual.forEach((v, i) => {
if (kind === 'expense') report.totals.spent[i]! += v;
else report.totals.income[i]! += v;
});
budget.forEach((v, i) => {
if (kind === 'expense') report.totals.budgetSpend[i]! += v;
else report.totals.budgetIncome[i]! += v;
});
}
return report;
}

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

@@ -0,0 +1,55 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { aiStatus, setAiFeature } from '../ai';
import { logChange } from '../audit';
import { requireAdmin } from '../auth/guards';
import type { AppContext } from '../context';
import { fail, type Env } from '../http';
import { jsonBody, jsonResponse, validationHook } from '../schemas';
const Feature = z.enum(['categorize', 'insights', 'assistant', 'agents']);
const AiStatusSchema = z
.object({
providerConfigured: z.boolean(),
provider: z.string().nullable(),
features: z.array(z.object({ key: Feature, label: z.string(), description: z.string(), enabled: z.boolean() })),
})
.openapi('AiStatus');
export function aiRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
r.openapi(
createRoute({
method: 'get',
path: '/ai',
tags: ['AI'],
summary: 'AI features and whether each is on (admins only)',
responses: jsonResponse(AiStatusSchema, 'Status'),
}),
(c) => {
requireAdmin(c);
return c.json(aiStatus(ctx), 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/ai',
tags: ['AI'],
summary: 'Switch an AI feature on or off (admins only; needs a provider on the server)',
request: jsonBody(z.object({ feature: Feature, enabled: z.boolean() })),
responses: jsonResponse(AiStatusSchema, 'Status'),
}),
(c) => {
const user = requireAdmin(c);
const { feature, enabled } = c.req.valid('json');
if (enabled && !ctx.config.aiProvider) fail(400, 'no_provider', 'No AI provider is set up on this server, so AI features stay off.');
setAiFeature(ctx, feature, enabled);
logChange(ctx, user.id, 'ai_feature', 'setting', 'ai', { feature, enabled });
return c.json(aiStatus(ctx), 200);
},
);
return r;
}

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

@@ -0,0 +1,139 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { and, eq, isNull } from 'drizzle-orm';
import { logChange } from '../audit';
import { requireUser, requireWrite } from '../auth/guards';
import type { AppContext } from '../context';
import { alerts } from '../db/schema';
import { fail, nowIso, type Env } from '../http';
import { alertCounts, listAlerts, refreshAlerts } from '../insights/engine';
import { IdParam, jsonBody, jsonResponse, validationHook } from '../schemas';
import { alertSettings, putSetting } from '../settings';
const Area = z.enum(['money', 'subscriptions']);
const AlertSchema = z
.object({
id: z.number().int(),
kind: z.string(),
area: Area,
severity: z.enum(['info', 'good', 'warn', 'bad']),
title: z.string(),
body: z.string().nullable(),
link: z.string().nullable(),
createdAt: z.string(),
read: z.boolean(),
})
.openapi('Alert');
const AlertSettingsSchema = z
.object({
priceChangePct: z.number().int().min(1).max(100),
priceChangeMinCents: z.number().int().min(0).max(100_000),
budgetWarnPct: z.number().int().min(50).max(100),
unusualMultiple: z.number().int().min(2).max(20),
})
.openapi('AlertSettings');
export function alertRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
r.openapi(
createRoute({
method: 'get',
path: '/alerts',
tags: ['Alerts'],
summary: 'Alerts and insights that still hold, most important first',
request: { query: z.object({ area: Area.optional(), limit: z.coerce.number().int().min(1).max(100).default(50) }) },
responses: jsonResponse(z.array(AlertSchema), 'Alerts'),
}),
(c) => {
requireUser(c);
const q = c.req.valid('query');
return c.json(listAlerts(ctx, q), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/alerts/counts',
tags: ['Alerts'],
summary: 'How many unread alerts there are, for the badges on the menu',
responses: jsonResponse(z.object({ money: z.number().int(), subscriptions: z.number().int() }), 'Counts'),
}),
(c) => {
requireUser(c);
return c.json(alertCounts(ctx), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/alerts/read',
tags: ['Alerts'],
summary: 'Mark every alert in an area as read (they stay listed)',
request: jsonBody(z.object({ area: Area })),
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Read'),
}),
(c) => {
requireUser(c);
const { area } = c.req.valid('json');
ctx.db.update(alerts).set({ readAt: nowIso() }).where(and(eq(alerts.area, area), isNull(alerts.readAt))).run();
return c.json({ ok: true as const }, 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/alerts/{id}/dismiss',
tags: ['Alerts'],
summary: 'Dismiss an alert; it won’t come back for the same occurrence',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Dismissed'),
}),
(c) => {
requireUser(c);
const { id } = c.req.valid('param');
const result = ctx.db.update(alerts).set({ dismissedAt: nowIso(), readAt: nowIso() }).where(eq(alerts.id, id)).run();
if (!result.changes) fail(404, 'not_found', 'That alert wasn’t found.');
return c.json({ ok: true as const }, 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/settings/alerts',
tags: ['Alerts'],
summary: 'When alerts speak up',
responses: jsonResponse(AlertSettingsSchema, 'Settings'),
}),
(c) => {
requireUser(c);
return c.json(alertSettings(ctx), 200);
},
);
r.openapi(
createRoute({
method: 'put',
path: '/settings/alerts',
tags: ['Alerts'],
summary: 'Change when alerts speak up',
request: jsonBody(AlertSettingsSchema),
responses: jsonResponse(AlertSettingsSchema, 'Saved'),
}),
(c) => {
const user = requireWrite(c);
const body = c.req.valid('json');
putSetting(ctx, 'alerts', body);
refreshAlerts(ctx);
logChange(ctx, user.id, 'alert_settings', 'setting', 'alerts', body);
return c.json(alertSettings(ctx), 200);
},
);
return r;
}

View File

@@ -0,0 +1,126 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { addMonths } from '@spendlight/shared';
import { eq } from 'drizzle-orm';
import { logChange } from '../audit';
import { requireUser, requireWrite } from '../auth/guards';
import { budgetMatrix, budgetYear, copyYear, fillBudgets, setBudget } from '../budgets/budgets';
import type { AppContext } from '../context';
import { categories } from '../db/schema';
import { fail, type Env } from '../http';
import { categoryKinds, monthsReport } from '../reports/actuals';
import { jsonBody, jsonResponse, validationHook } from '../schemas';
const Year = z.coerce.number().int().min(2000).max(2100);
const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use a month like 2026-10.');
const BudgetYearSchema = z
.object({
year: z.number().int(),
items: z.array(z.object({ categoryId: z.number().int(), monthly: z.number().int().nullable(), months: z.array(z.number().int().nullable()) })),
})
.openapi('BudgetYear');
/** Budgets read best in round numbers: whole dollars, and steps of $5 from $20 up. */
const roundBudget = (cents: number) => (cents >= 2000 ? Math.ceil(cents / 500) * 500 : Math.ceil(cents / 100) * 100);
export function budgetRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
r.openapi(
createRoute({
method: 'get',
path: '/budgets',
tags: ['Budgets'],
summary: 'A year of budgets: an amount for every month, and any month set on its own',
request: { query: z.object({ year: Year }) },
responses: jsonResponse(BudgetYearSchema, 'Budgets'),
}),
(c) => {
requireUser(c);
return c.json(budgetYear(ctx, c.req.valid('query').year), 200);
},
);
r.openapi(
createRoute({
method: 'put',
path: '/budgets',
tags: ['Budgets'],
summary: 'Set a category’s budget for one month, from a month through December, or for every month',
request: jsonBody(
z.object({
categoryId: z.number().int(),
year: Year,
month: z.number().int().min(1).max(12),
amountCents: z.number().int().min(0).max(100_000_000),
scope: z.enum(['month', 'rest', 'year']),
}),
),
responses: jsonResponse(BudgetYearSchema, 'The year’s budgets'),
}),
(c) => {
const user = requireWrite(c);
const body = c.req.valid('json');
const kind = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, body.categoryId)).get()?.kind;
if (!kind) fail(400, 'no_category', 'That category doesn’t exist.');
if (kind === 'transfer') fail(400, 'transfer', 'Transfers between your accounts don’t need a budget.');
setBudget(ctx, body.categoryId, body.year, body.month, body.amountCents, body.scope);
logChange(ctx, user.id, 'set_budget', 'budget', body.categoryId, body);
return c.json(budgetYear(ctx, body.year), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/budgets/fill',
tags: ['Budgets'],
summary: 'Fill in categories with no budget yet, from last month or a 3-month average',
request: jsonBody(z.object({ month: Month, source: z.enum(['last_budget', 'last_actual', 'average3']) })),
responses: jsonResponse(z.object({ filled: z.number().int(), budgets: BudgetYearSchema }), 'Filled'),
}),
(c) => {
const user = requireWrite(c);
const { month, source } = c.req.valid('json');
const year = Number(month.slice(0, 4));
const kinds = categoryKinds(ctx);
const amounts = new Map<number, number>();
if (source === 'last_budget') {
const prev = addMonths(month, -1);
for (const [id, row] of budgetMatrix(ctx, [prev])) amounts.set(id, row[0]!);
} else {
const span = source === 'average3' ? 3 : 1;
const months = Array.from({ length: span }, (_, i) => addMonths(month, i - span));
for (const cat of monthsReport(ctx, months).categories) {
const avg = cat.actual.reduce((a, b) => a + b, 0) / span;
if (avg > 0) amounts.set(cat.categoryId, roundBudget(avg));
}
}
for (const id of amounts.keys()) if (kinds.get(id) === 'transfer' || !kinds.has(id)) amounts.delete(id);
const filled = fillBudgets(ctx, year, amounts);
logChange(ctx, user.id, 'fill_budgets', 'budget', null, { month, source, filled });
return c.json({ filled, budgets: budgetYear(ctx, year) }, 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/budgets/copy-year',
tags: ['Budgets'],
summary: 'Start a new year from last year’s budgets',
request: jsonBody(z.object({ from: Year, to: Year })),
responses: jsonResponse(z.object({ copied: z.number().int(), budgets: BudgetYearSchema }), 'Copied'),
}),
(c) => {
const user = requireWrite(c);
const { from, to } = c.req.valid('json');
if (from === to) fail(400, 'same_year', 'Choose two different years.');
const copied = copyYear(ctx, from, to);
logChange(ctx, user.id, 'copy_budget_year', 'budget', null, { from, to, copied });
return c.json({ copied, budgets: budgetYear(ctx, to) }, 200);
},
);
return r;
}

View File

@@ -0,0 +1,397 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { and, asc, count, desc, eq, ne, sql } from 'drizzle-orm';
import { logChange } from '../audit';
import { requireUser, requireWrite } from '../auth/guards';
import { rememberRule } from '../categorize/suggest';
import type { AppContext } from '../context';
import { budgets, categories, categoryGroups, rules, splits, subscriptions, tags, transactions, transactionTags, type CategoryRow } from '../db/schema';
import { fail, nowIso, type Env } from '../http';
import { CategoryGroupSchema, CategorySchema, IdParam, jsonBody, jsonResponse, PaletteSchema, validationHook } from '../schemas';
import { listCategoryGroups } from '../transactions/dto';
// Categories live in one place and everything points at them by ID, so a
// rename shows up everywhere at once. Deleting one means merging it into
// another, which takes its transactions, budgets and rules along.
const Name = z.string().trim().min(1, 'Please enter a name.').max(60);
const Icon = z.string().regex(/^[a-z0-9-]{1,40}$/);
const KindSchema = z.enum(['expense', 'income', 'transfer']);
const RuleSchema = z
.object({
id: z.number().int(),
merchant: z.string(),
categoryId: z.number().int().nullable(),
renameTo: z.string().nullable(),
source: z.enum(['learned', 'manual']),
hits: z.number().int(),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
})
.openapi('Rule');
export function categoryRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
const findCategory = (id: number): CategoryRow =>
ctx.db.select().from(categories).where(eq(categories.id, id)).get() ?? fail(404, 'not_found', 'That category wasn’t found.');
const findGroup = (id: number) =>
ctx.db.select().from(categoryGroups).where(eq(categoryGroups.id, id)).get() ?? fail(404, 'not_found', 'That group wasn’t found.');
const assertUniqueName = (name: string, except?: number) => {
const clash = ctx.db
.select({ id: categories.id })
.from(categories)
.where(and(sql`lower(${categories.name}) = ${name.toLowerCase()}`, except ? ne(categories.id, except) : undefined))
.get();
if (clash) fail(400, 'duplicate', `There’s already a category called “${name}”.`);
};
const asCategory = (id: number) => listCategoryGroups(ctx, true).flatMap((g) => g.categories).find((c) => c.id === id)!;
// --- Categories ----------------------------------------------------------------
r.openapi(
createRoute({
method: 'get',
path: '/categories',
tags: ['Categories'],
summary: 'Categories, in their groups',
request: { query: z.object({ includeArchived: z.enum(['true', 'false']).optional() }) },
responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
}),
(c) => {
requireUser(c);
return c.json(listCategoryGroups(ctx, c.req.valid('query').includeArchived === 'true'), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/categories',
tags: ['Categories'],
summary: 'Add a category',
request: jsonBody(z.object({ groupId: z.number().int(), name: Name, kind: KindSchema.optional(), icon: Icon.default('circle'), color: PaletteSchema.default('slate') })),
responses: jsonResponse(CategorySchema, 'Added'),
}),
(c) => {
const user = requireWrite(c);
const body = c.req.valid('json');
findGroup(body.groupId);
assertUniqueName(body.name);
// A new category takes the kind most common in its group.
const sibling = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.groupId, body.groupId)).get();
const last = ctx.db.select({ n: sql<number>`coalesce(max(${categories.sortOrder}), -1)` }).from(categories).where(eq(categories.groupId, body.groupId)).get();
const row = ctx.db
.insert(categories)
.values({ groupId: body.groupId, name: body.name, kind: body.kind ?? sibling?.kind ?? 'expense', icon: body.icon, color: body.color, sortOrder: (last?.n ?? -1) + 1 })
.returning()
.get();
logChange(ctx, user.id, 'add_category', 'category', row.id, { name: body.name });
return c.json(asCategory(row.id), 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/categories/{id}',
tags: ['Categories'],
summary: 'Rename, recolor, move or archive a category',
request: {
params: IdParam,
...jsonBody(z.object({ name: Name.optional(), groupId: z.number().int().optional(), kind: KindSchema.optional(), icon: Icon.optional(), color: PaletteSchema.optional(), archived: z.boolean().optional() })),
},
responses: jsonResponse(CategorySchema, 'Updated'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const body = c.req.valid('json');
findCategory(id);
if (body.name) assertUniqueName(body.name, id);
if (body.groupId) findGroup(body.groupId);
ctx.db.update(categories).set(body).where(eq(categories.id, id)).run();
logChange(ctx, user.id, 'update_category', 'category', id, body);
return c.json(asCategory(id), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/categories/{id}/merge',
tags: ['Categories'],
summary: 'Delete a category by merging it into another',
description: 'Its transactions, split parts, budgets, rules and subscriptions all move to the other category.',
request: { params: IdParam, ...jsonBody(z.object({ intoId: z.number().int() })) },
responses: jsonResponse(z.object({ moved: z.number().int() }), 'Merged'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const { intoId } = c.req.valid('json');
if (id === intoId) fail(400, 'same', 'Choose a different category to merge into.');
const from = findCategory(id);
const into = findCategory(intoId);
if (from.kind !== into.kind) fail(400, 'kind', 'Merge into a category of the same kind: spending into spending, income into income.');
let moved = 0;
ctx.sqlite.transaction(() => {
moved = ctx.db.update(transactions).set({ categoryId: intoId, updatedAt: nowIso() }).where(eq(transactions.categoryId, id)).run().changes;
ctx.db.update(splits).set({ categoryId: intoId }).where(eq(splits.categoryId, id)).run();
ctx.db.update(rules).set({ categoryId: intoId }).where(eq(rules.categoryId, id)).run();
ctx.db.update(subscriptions).set({ categoryId: intoId }).where(eq(subscriptions.categoryId, id)).run();
// Budgets add together where both had one.
for (const b of ctx.db.select().from(budgets).where(eq(budgets.categoryId, id)).all()) {
const target = ctx.db.select().from(budgets).where(and(eq(budgets.categoryId, intoId), eq(budgets.year, b.year), eq(budgets.month, b.month))).get();
if (target) ctx.db.update(budgets).set({ amountCents: target.amountCents + b.amountCents, updatedAt: nowIso() }).where(eq(budgets.id, target.id)).run();
else ctx.db.insert(budgets).values({ categoryId: intoId, year: b.year, month: b.month, amountCents: b.amountCents, updatedAt: nowIso() }).run();
}
ctx.db.delete(budgets).where(eq(budgets.categoryId, id)).run();
ctx.db.delete(categories).where(eq(categories.id, id)).run();
})();
logChange(ctx, user.id, 'merge_category', 'category', intoId, { from: from.name, into: into.name, moved });
return c.json({ moved }, 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/categories/reorder',
tags: ['Categories'],
summary: 'Put groups, and the categories in them, in a new order',
request: jsonBody(z.object({ groups: z.array(z.object({ id: z.number().int(), categoryIds: z.array(z.number().int()) })).max(100) })),
responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
}),
(c) => {
const user = requireWrite(c);
const { groups } = c.req.valid('json');
ctx.sqlite.transaction(() => {
groups.forEach((g, gi) => {
ctx.db.update(categoryGroups).set({ sortOrder: gi }).where(eq(categoryGroups.id, g.id)).run();
g.categoryIds.forEach((cid, ci) => ctx.db.update(categories).set({ groupId: g.id, sortOrder: ci }).where(eq(categories.id, cid)).run());
});
})();
logChange(ctx, user.id, 'reorder_categories', 'category', null);
return c.json(listCategoryGroups(ctx, true), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/category-groups',
tags: ['Categories'],
summary: 'Add a group',
request: jsonBody(z.object({ name: Name })),
responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Added'),
}),
(c) => {
const user = requireWrite(c);
const { name } = c.req.valid('json');
const last = ctx.db.select({ n: sql<number>`coalesce(max(${categoryGroups.sortOrder}), -1)` }).from(categoryGroups).get();
const row = ctx.db.insert(categoryGroups).values({ name, sortOrder: (last?.n ?? -1) + 1 }).returning().get();
logChange(ctx, user.id, 'add_category_group', 'category_group', row.id, { name });
return c.json({ id: row.id, name: row.name }, 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/category-groups/{id}',
tags: ['Categories'],
summary: 'Rename a group',
request: { params: IdParam, ...jsonBody(z.object({ name: Name })) },
responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Renamed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const { name } = c.req.valid('json');
findGroup(id);
ctx.db.update(categoryGroups).set({ name }).where(eq(categoryGroups.id, id)).run();
logChange(ctx, user.id, 'rename_category_group', 'category_group', id, { name });
return c.json({ id, name }, 200);
},
);
r.openapi(
createRoute({
method: 'delete',
path: '/category-groups/{id}',
tags: ['Categories'],
summary: 'Remove an empty group',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
findGroup(id);
const inside = ctx.db.select({ n: count() }).from(categories).where(eq(categories.groupId, id)).get()?.n ?? 0;
if (inside) fail(400, 'not_empty', 'Move or merge the categories in this group first.');
ctx.db.delete(categoryGroups).where(eq(categoryGroups.id, id)).run();
logChange(ctx, user.id, 'delete_category_group', 'category_group', id);
return c.json({ ok: true as const }, 200);
},
);
// --- Tags ------------------------------------------------------------------------
r.openapi(
createRoute({
method: 'get',
path: '/tags',
tags: ['Categories'],
summary: 'Tags, most used first',
responses: jsonResponse(z.array(z.object({ id: z.number().int(), name: z.string(), count: z.number().int() })), 'Tags'),
}),
(c) => {
requireUser(c);
const rows = ctx.db
.select({ id: tags.id, name: tags.name, count: count(transactionTags.transactionId) })
.from(tags)
.leftJoin(transactionTags, eq(transactionTags.tagId, tags.id))
.groupBy(tags.id)
.orderBy(desc(count(transactionTags.transactionId)), tags.name)
.all();
return c.json(rows, 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/tags/{id}',
tags: ['Categories'],
summary: 'Rename a tag (renaming it to an existing tag merges the two)',
request: { params: IdParam, ...jsonBody(z.object({ name: z.string().trim().min(1).max(40) })) },
responses: jsonResponse(z.object({ id: z.number().int(), name: z.string() }), 'Renamed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const { name } = c.req.valid('json');
if (!ctx.db.select().from(tags).where(eq(tags.id, id)).get()) fail(404, 'not_found', 'That tag wasn’t found.');
const other = ctx.db.select().from(tags).where(and(sql`lower(${tags.name}) = ${name.toLowerCase()}`, ne(tags.id, id))).get();
let result = { id, name };
ctx.sqlite.transaction(() => {
if (other) {
for (const link of ctx.db.select().from(transactionTags).where(eq(transactionTags.tagId, id)).all()) {
ctx.db.insert(transactionTags).values({ transactionId: link.transactionId, tagId: other.id }).onConflictDoNothing().run();
}
ctx.db.delete(tags).where(eq(tags.id, id)).run();
result = { id: other.id, name: other.name };
} else ctx.db.update(tags).set({ name }).where(eq(tags.id, id)).run();
})();
logChange(ctx, user.id, 'rename_tag', 'tag', id, { name });
return c.json(result, 200);
},
);
r.openapi(
createRoute({
method: 'delete',
path: '/tags/{id}',
tags: ['Categories'],
summary: 'Remove a tag from every transaction',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
ctx.db.delete(tags).where(eq(tags.id, id)).run();
logChange(ctx, user.id, 'delete_tag', 'tag', id);
return c.json({ ok: true as const }, 200);
},
);
// --- Rules -----------------------------------------------------------------------
r.openapi(
createRoute({
method: 'get',
path: '/rules',
tags: ['Categories'],
summary: 'Saved rules: merchants Spendlight always files or renames the same way',
responses: jsonResponse(z.array(RuleSchema), 'Rules'),
}),
(c) => {
requireUser(c);
const rows = ctx.db.select().from(rules).orderBy(asc(sql`lower(${rules.merchant})`)).all();
return c.json(
rows.map(({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt }) => ({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt })),
200,
);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/rules',
tags: ['Categories'],
summary: 'Always file a merchant in a category',
request: jsonBody(z.object({ merchant: z.string().trim().min(1).max(80), categoryId: z.number().int() })),
responses: jsonResponse(z.object({ id: z.number().int() }), 'Saved'),
}),
(c) => {
const user = requireWrite(c);
const { merchant, categoryId } = c.req.valid('json');
findCategory(categoryId);
const id = rememberRule(ctx, merchant, categoryId, 'manual');
logChange(ctx, user.id, 'add_rule', 'rule', id, { merchant, categoryId });
return c.json({ id }, 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/rules/{id}',
tags: ['Categories'],
summary: 'Change what a rule does',
request: {
params: IdParam,
...jsonBody(z.object({ categoryId: z.number().int().nullable().optional(), renameTo: z.string().trim().max(80).nullable().optional() })),
},
responses: jsonResponse(RuleSchema, 'Updated'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const body = c.req.valid('json');
const rule = ctx.db.select().from(rules).where(eq(rules.id, id)).get() ?? fail(404, 'not_found', 'That rule wasn’t found.');
if (body.categoryId) findCategory(body.categoryId);
const next = { categoryId: body.categoryId !== undefined ? body.categoryId : rule.categoryId, renameTo: body.renameTo !== undefined ? body.renameTo || null : rule.renameTo };
if (!next.categoryId && !next.renameTo) fail(400, 'empty', 'A rule needs a category or a new name. To stop using it, delete it instead.');
ctx.db.update(rules).set({ ...next, source: 'manual' }).where(eq(rules.id, id)).run();
logChange(ctx, user.id, 'update_rule', 'rule', id, body);
const row = ctx.db.select().from(rules).where(eq(rules.id, id)).get()!;
const { merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt } = row;
return c.json({ id, merchant, categoryId, renameTo, source, hits, createdAt, lastUsedAt }, 200);
},
);
r.openapi(
createRoute({
method: 'delete',
path: '/rules/{id}',
tags: ['Categories'],
summary: 'Forget a rule',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
ctx.db.delete(rules).where(eq(rules.id, id)).run();
logChange(ctx, user.id, 'remove_rule', 'rule', id);
return c.json({ ok: true as const }, 200);
},
);
return r;
}

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

@@ -0,0 +1,265 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { ACCOUNT_KIND_LABEL, CADENCE_LABEL, monthName, SUBSCRIPTION_STATUS_LABEL } from '@spendlight/shared';
import { asc, eq, inArray } from 'drizzle-orm';
import { currentBalance } from '../accounts/balance';
import { requireUser } from '../auth/guards';
import { budgetYear, effective } from '../budgets/budgets';
import type { AppContext } from '../context';
import { accounts, budgets, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema';
import { today } from '../dates';
import { buildCsv, buildXlsx, type Cell, type Sheet } from '../export/xlsx';
import type { Env } from '../http';
import { validationHook } from '../schemas';
import { listCategoryGroups, newestFirst } from '../transactions/dto';
import { TransactionQuery, transactionWhere, type TransactionFilter } from '../transactions/filter';
// Everything can leave as CSV or as a real Excel workbook. Split purchases
// export one row per part, so totals by category add up in a spreadsheet.
const MAX_ROWS = 200_000;
function transactionSheet(ctx: AppContext, filter: TransactionFilter): Sheet {
const rows: TransactionRow[] = ctx.db.select().from(transactions).where(transactionWhere(ctx, filter)).orderBy(...newestFirst).limit(MAX_ROWS).all();
const ids = rows.map((r) => r.id);
const cats = new Map(listCategoryGroups(ctx, true).flatMap((g) => g.categories.map((c) => [c.id, c] as const)));
const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
const subNames = new Map(ctx.db.select({ id: subscriptions.id, name: subscriptions.name }).from(subscriptions).all().map((s) => [s.id, s.name]));
const chunks = <T,>(list: T[], size = 900) => Array.from({ length: Math.ceil(list.length / size) }, (_, i) => list.slice(i * size, i * size + size));
const parts = new Map<number, (typeof splits.$inferSelect)[]>();
const tagNames = new Map<number, string[]>();
for (const chunk of chunks(ids)) {
for (const s of ctx.db.select().from(splits).where(inArray(splits.transactionId, chunk)).orderBy(asc(splits.id)).all()) {
parts.set(s.transactionId, [...(parts.get(s.transactionId) ?? []), s]);
}
for (const t of ctx.db
.select({ id: transactionTags.transactionId, name: tags.name })
.from(transactionTags)
.innerJoin(tags, eq(tags.id, transactionTags.tagId))
.where(inArray(transactionTags.transactionId, chunk))
.all()) {
tagNames.set(t.id, [...(tagNames.get(t.id) ?? []), t.name]);
}
}
const peers = new Map(
chunks(rows.map((r) => r.transferPeerId).filter((p): p is number => p !== null)).flatMap((chunk) =>
ctx.db.select({ id: transactions.id, accountId: transactions.accountId }).from(transactions).where(inArray(transactions.id, chunk)).all().map((p) => [p.id, p.accountId] as const),
),
);
const out: Cell[][] = [];
for (const t of rows) {
const base = (categoryId: number | null, cents: number, note: string | null, splitOf: number | null): Cell[] => {
const cat = categoryId ? cats.get(categoryId) : undefined;
return [
{ date: t.date },
accountNames.get(t.accountId) ?? '',
t.merchant,
cat?.name ?? (t.transferPeerId ? 'Transfer' : ''),
cat?.groupName ?? '',
{ money: cents },
splitOf === null ? '' : { money: splitOf },
t.transferPeerId ? (accountNames.get(peers.get(t.transferPeerId) ?? -1) ?? '') : '',
(tagNames.get(t.id) ?? []).join(', '),
note ?? '',
t.subscriptionId ? (subNames.get(t.subscriptionId) ?? '') : '',
t.needsReview ? 'To review' : 'Filed',
t.description,
];
};
const p = parts.get(t.id);
if (t.isSplit && p?.length) for (const part of p) out.push(base(part.categoryId, part.amountCents, part.note ?? t.note, t.amountCents));
else out.push(base(t.categoryId, t.amountCents, t.note, null));
}
return {
name: 'Transactions',
columns: [
{ header: 'Date', width: 12 },
{ header: 'Account', width: 22 },
{ header: 'Merchant', width: 28 },
{ header: 'Category', width: 24 },
{ header: 'Group', width: 18 },
{ header: 'Amount', width: 13 },
{ header: 'Split of', width: 13 },
{ header: 'Transfer with', width: 22 },
{ header: 'Tags', width: 18 },
{ header: 'Note', width: 30 },
{ header: 'Subscription', width: 20 },
{ header: 'Status', width: 11 },
{ header: 'Bank description', width: 44 },
],
rows: out,
};
}
function everything(ctx: AppContext): Sheet[] {
const groups = listCategoryGroups(ctx, true);
const cats = groups.flatMap((g) => g.categories);
const accountNames = new Map(ctx.db.select({ id: accounts.id, name: accounts.name }).from(accounts).all().map((a) => [a.id, a.name]));
const catName = (id: number | null) => (id ? (cats.find((c) => c.id === id)?.name ?? '') : '');
const kindLabel = { expense: 'Money out', income: 'Money in', transfer: 'Transfer (not counted)' } as const;
const years = [...new Set(ctx.db.select({ year: budgets.year }).from(budgets).all().map((b) => b.year))].sort();
const budgetRows: Cell[][] = [];
for (const year of years) {
const lines = new Map(budgetYear(ctx, year).items.map((l) => [l.categoryId, l]));
for (const c of cats) {
const line = lines.get(c.id);
if (!line) continue;
const months = Array.from({ length: 12 }, (_, i) => effective(line, i));
budgetRows.push([year, c.name, c.groupName, ...months.map((m) => ({ money: m })), { money: months.reduce((a, b) => a + b, 0) }]);
}
}
return [
transactionSheet(ctx, {}),
{
name: 'Budgets',
columns: [
{ header: 'Year', width: 8 },
{ header: 'Category', width: 24 },
{ header: 'Group', width: 18 },
...Array.from({ length: 12 }, (_, i) => ({ header: monthName(`2026-${String(i + 1).padStart(2, '0')}`, 'short'), width: 11 })),
{ header: 'Year total', width: 13 },
],
rows: budgetRows,
},
{
name: 'Categories',
columns: [{ header: 'Group', width: 20 }, { header: 'Category', width: 26 }, { header: 'Kind', width: 22 }, { header: 'Archived', width: 10 }],
rows: cats.map((c) => [c.groupName, c.name, kindLabel[c.kind], c.archived ? 'Yes' : '']),
},
{
name: 'Accounts',
columns: [
{ header: 'Account', width: 24 },
{ header: 'Kind', width: 16 },
{ header: 'Bank', width: 20 },
{ header: 'Last 4', width: 8 },
{ header: 'Status', width: 10 },
{ header: 'Balance', width: 14 },
{ header: 'Balance confirmed', width: 18 },
],
rows: ctx.db
.select()
.from(accounts)
.all()
.map((a) => [
a.name,
ACCOUNT_KIND_LABEL[a.kind],
a.institution ?? '',
a.last4 ?? '',
a.status === 'open' ? 'Open' : 'Retired',
a.balanceAsOf ? { money: currentBalance(ctx.db, a) } : '',
a.balanceAsOf ? { date: a.balanceAsOf } : '',
]),
},
{
name: 'Subscriptions',
columns: [
{ header: 'Name', width: 24 },
{ header: 'Type', width: 13 },
{ header: 'Status', width: 14 },
{ header: 'Amount', width: 12 },
{ header: 'How often', width: 16 },
{ header: 'Card or account', width: 22 },
{ header: 'Category', width: 22 },
{ header: 'Last charge', width: 12 },
{ header: 'Next expected', width: 14 },
{ header: 'Manage at', width: 36 },
{ header: 'Note', width: 30 },
],
rows: ctx.db
.select()
.from(subscriptions)
.orderBy(asc(subscriptions.name))
.all()
.map((s) => [
s.name,
s.kind === 'bill' ? 'Bill' : 'Subscription',
SUBSCRIPTION_STATUS_LABEL[s.status],
{ money: s.expectedCents },
CADENCE_LABEL[s.cadence],
s.accountId ? (accountNames.get(s.accountId) ?? '') : '',
catName(s.categoryId),
s.lastChargeOn ? { date: s.lastChargeOn } : '',
s.nextExpectedOn ? { date: s.nextExpectedOn } : '',
s.manageUrl ?? '',
s.note ?? '',
]),
},
{
name: 'Rules',
columns: [{ header: 'Merchant', width: 28 }, { header: 'Files as', width: 24 }, { header: 'Renames to', width: 24 }, { header: 'Made', width: 10 }],
rows: ctx.db
.select()
.from(rules)
.all()
.map((r) => [r.merchant, catName(r.categoryId), r.renameTo ?? '', r.source === 'manual' ? 'By hand' : 'Learned']),
},
];
}
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export function exportRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
const stamp = () => today();
r.openapi(
createRoute({
method: 'get',
path: '/export/transactions.csv',
tags: ['Export'],
summary: 'Transactions as CSV, with the same filters as the list',
request: { query: TransactionQuery },
responses: { 200: { description: 'CSV file', content: { 'text/csv': { schema: z.string() } } } },
}),
(c) => {
requireUser(c);
const sheet = transactionSheet(ctx, c.req.valid('query'));
return c.body(buildCsv(sheet.columns.map((col) => col.header), sheet.rows), 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="spendlight-transactions-${stamp()}.csv"`,
});
},
);
r.openapi(
createRoute({
method: 'get',
path: '/export/transactions.xlsx',
tags: ['Export'],
summary: 'Transactions as an Excel workbook, with the same filters as the list',
request: { query: TransactionQuery },
responses: { 200: { description: 'Excel file', content: { [XLSX]: { schema: z.string() } } } },
}),
(c) => {
requireUser(c);
const file = buildXlsx([transactionSheet(ctx, c.req.valid('query'))]);
return c.body(new Uint8Array(file), 200, {
'Content-Type': XLSX,
'Content-Disposition': `attachment; filename="spendlight-transactions-${stamp()}.xlsx"`,
});
},
);
r.openapi(
createRoute({
method: 'get',
path: '/export/everything.xlsx',
tags: ['Export'],
summary: 'Everything in one Excel workbook: transactions, budgets, categories, accounts, subscriptions and rules',
responses: { 200: { description: 'Excel file', content: { [XLSX]: { schema: z.string() } } } },
}),
(c) => {
requireUser(c);
const file = buildXlsx(everything(ctx));
return c.body(new Uint8Array(file), 200, {
'Content-Type': XLSX,
'Content-Disposition': `attachment; filename="spendlight-${stamp()}.xlsx"`,
});
},
);
return r;
}

View File

@@ -0,0 +1,243 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { addMonths } from '@spendlight/shared';
import { currentBalance } from '../accounts/balance';
import { requireUser } from '../auth/guards';
import type { AppContext } from '../context';
import { accounts } from '../db/schema';
import { lastDayOf, monthOf, monthRange, today } from '../dates';
import { fail, type Env } from '../http';
import { focusMonth, overview } from '../insights/engine';
import { categoryKinds, monthsReport } from '../reports/actuals';
import { DateString, jsonResponse, validationHook } from '../schemas';
const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use a month like 2026-10.');
const Cents = z.number().int();
const MonthsReportSchema = z
.object({
months: z.array(z.string()),
categories: z.array(z.object({ categoryId: z.number().int(), actual: z.array(Cents), budget: z.array(Cents) })),
unfiled: z.object({ out: z.array(Cents), in: z.array(Cents) }),
totals: z.object({ spent: z.array(Cents), income: z.array(Cents), budgetSpend: z.array(Cents), budgetIncome: z.array(Cents) }),
dataThrough: z.string().nullable(),
})
.openapi('MonthsReport');
const OverviewSchema = z
.object({
month: z.string(),
isCurrent: z.boolean(),
daysLeft: z.number().int(),
dataThrough: z.string().nullable(),
spent: Cents,
income: Cents,
budgetSpend: Cents,
budgetIncome: Cents,
hasBudgets: z.boolean(),
unbudgeted: Cents,
categories: z.array(z.object({ categoryId: z.number().int(), spent: Cents, budget: Cents })),
year: z.object({ year: z.number().int(), spent: Cents, budgetSoFar: Cents, budgetYear: Cents, projected: Cents, income: Cents }),
toReview: z.number().int(),
})
.openapi('Overview');
export function reportRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
r.openapi(
createRoute({
method: 'get',
path: '/reports/overview',
tags: ['Reports'],
summary: 'Home’s numbers: the month against budget, and how the year is pacing',
description: 'Without a month, this is the current month, or the latest month that has transactions while statements catch up.',
request: { query: z.object({ month: Month.optional() }) },
responses: jsonResponse(OverviewSchema, 'Overview'),
}),
(c) => {
requireUser(c);
return c.json(overview(ctx, c.req.valid('query').month), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/reports/months',
tags: ['Reports'],
summary: 'Spending, income and budgets by category for a run of months',
description: 'Spending is positive and net of refunds; split purchases count each part in its own category; transfers never count.',
request: { query: z.object({ from: Month.optional(), to: Month.optional() }) },
responses: jsonResponse(MonthsReportSchema, 'Months'),
}),
(c) => {
requireUser(c);
const q = c.req.valid('query');
const to = q.to ?? focusMonth(ctx).month;
const from = q.from ?? `${to.slice(0, 4)}-01`;
if (from > to) fail(400, 'range', 'The first month needs to come before the last.');
const months = monthRange(from, to);
if (months.length > 60) fail(400, 'range', 'Choose up to 5 years at a time.');
return c.json(monthsReport(ctx, months), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/reports/merchants',
tags: ['Reports'],
summary: 'Who gets the money: merchants by total spent and by how often',
request: {
query: z.object({
from: DateString,
to: DateString,
categoryId: z.coerce.number().int().optional(),
limit: z.coerce.number().int().min(1).max(200).default(25),
}),
},
responses: jsonResponse(
z.array(z.object({ merchant: z.string(), spent: Cents, count: z.number().int(), lastDate: z.string(), categoryId: z.number().int().nullable() })),
'Merchants',
),
}),
(c) => {
requireUser(c);
const q = c.req.valid('query');
const kinds = categoryKinds(ctx);
const rows = ctx.sqlite
.prepare(
`SELECT merchant, categoryId, sum(net) AS net, count(*) AS n, max(date) AS last FROM (
SELECT merchant, category_id AS categoryId, amount_cents AS net, date FROM txn
WHERE is_split = 0 AND transfer_peer_id IS NULL AND date >= ? AND date <= ?
UNION ALL
SELECT t.merchant, s.category_id, s.amount_cents, t.date FROM split s JOIN txn t ON t.id = s.transaction_id
WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ?
) GROUP BY merchant, categoryId`,
)
.all(q.from, q.to, q.from, q.to) as { merchant: string; categoryId: number | null; net: number; n: number; last: string }[];
const byMerchant = new Map<string, { merchant: string; spent: number; count: number; lastDate: string; categoryId: number | null; top: number }>();
for (const row of rows) {
const kind = row.categoryId === null ? (row.net < 0 ? 'expense' : 'income') : kinds.get(row.categoryId);
if (kind !== 'expense') continue;
if (q.categoryId !== undefined && row.categoryId !== q.categoryId) continue;
const m = byMerchant.get(row.merchant) ?? { merchant: row.merchant, spent: 0, count: 0, lastDate: row.last, categoryId: row.categoryId, top: 0 };
m.spent += -row.net;
m.count += row.n;
if (row.last > m.lastDate) m.lastDate = row.last;
if (-row.net > m.top) {
m.top = -row.net;
m.categoryId = row.categoryId;
}
byMerchant.set(row.merchant, m);
}
const list = [...byMerchant.values()]
.filter((m) => m.spent > 0)
.sort((a, b) => b.spent - a.spent)
.slice(0, q.limit)
.map(({ top: _top, ...m }) => m);
return c.json(list, 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/reports/balances',
tags: ['Reports'],
summary: 'Each account’s balance at the end of each month',
description: 'Accounts without a known balance are left out (null). Money owed on cards is negative.',
request: { query: z.object({ months: z.coerce.number().int().min(2).max(36).default(12) }) },
responses: jsonResponse(
z.object({ months: z.array(z.string()), accounts: z.array(z.object({ accountId: z.number().int(), balances: z.array(Cents.nullable()) })) }),
'Balances',
),
}),
(c) => {
requireUser(c);
const { months: count } = c.req.valid('query');
const end = monthOf(today());
const months = monthRange(addMonths(end, -(count - 1)), end);
const later = ctx.sqlite.prepare('SELECT coalesce(sum(amount_cents), 0) AS s FROM txn WHERE account_id = ? AND date > ?');
const first = ctx.sqlite.prepare('SELECT min(date) AS d FROM txn WHERE account_id = ?');
const list = ctx.db
.select()
.from(accounts)
.all()
.map((a) => {
if (!a.balanceAsOf) return { accountId: a.id, balances: months.map(() => null) };
const now = currentBalance(ctx.db, a);
const start = (first.get(a.id) as { d: string | null }).d ?? a.balanceAsOf;
const opened = monthOf(start < a.balanceAsOf ? start : a.balanceAsOf);
return {
accountId: a.id,
balances: months.map((m) => {
if (m < opened) return null;
if (m === end) return now;
return now - (later.get(a.id, lastDayOf(m)) as { s: number }).s;
}),
};
});
return c.json({ months, accounts: list }, 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/reports/tags',
tags: ['Reports'],
summary: 'What each tag cost over a period, by category',
request: { query: z.object({ from: DateString.optional(), to: DateString.optional() }) },
responses: jsonResponse(
z.array(
z.object({
tag: z.string(),
spent: Cents,
income: Cents,
count: z.number().int(),
byCategory: z.array(z.object({ categoryId: z.number().int().nullable(), cents: Cents })),
}),
),
'Tags',
),
}),
(c) => {
requireUser(c);
const q = c.req.valid('query');
const from = q.from ?? '0000-01-01';
const to = q.to ?? '9999-12-31';
const kinds = categoryKinds(ctx);
const rows = ctx.sqlite
.prepare(
`SELECT tag, categoryId, sum(net) AS net, count(DISTINCT txnId) AS n FROM (
SELECT tg.name AS tag, t.category_id AS categoryId, t.amount_cents AS net, t.id AS txnId
FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id
WHERE t.is_split = 0 AND t.transfer_peer_id IS NULL AND t.date >= ? AND t.date <= ?
UNION ALL
SELECT tg.name, s.category_id, s.amount_cents, t.id
FROM txn_tag tt JOIN tag tg ON tg.id = tt.tag_id JOIN txn t ON t.id = tt.transaction_id JOIN split s ON s.transaction_id = t.id
WHERE t.is_split = 1 AND t.date >= ? AND t.date <= ?
) GROUP BY tag, categoryId`,
)
.all(from, to, from, to) as { tag: string; categoryId: number | null; net: number; n: number }[];
const byTag = new Map<string, { tag: string; spent: number; income: number; count: number; byCategory: { categoryId: number | null; cents: number }[] }>();
for (const row of rows) {
const kind = row.categoryId === null ? (row.net < 0 ? 'expense' : 'income') : kinds.get(row.categoryId);
if (kind === 'transfer' || !kind) continue;
const t = byTag.get(row.tag) ?? { tag: row.tag, spent: 0, income: 0, count: 0, byCategory: [] };
if (kind === 'expense') {
t.spent += -row.net;
t.byCategory.push({ categoryId: row.categoryId, cents: -row.net });
} else t.income += row.net;
t.count += row.n;
byTag.set(row.tag, t);
}
const list = [...byTag.values()].map((t) => ({ ...t, byCategory: t.byCategory.sort((a, b) => b.cents - a.cents) })).sort((a, b) => b.spent - a.spent);
return c.json(list, 200);
},
);
return r;
}

View File

@@ -0,0 +1,321 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { and, eq, isNull } from 'drizzle-orm';
import { logChange } from '../audit';
import { requireUser, requireWrite } from '../auth/guards';
import type { AppContext } from '../context';
import { accounts, categories, subscriptions, transactions, type SubscriptionRow } from '../db/schema';
import { today } from '../dates';
import { fail, nowIso, type Env } from '../http';
import { refreshAlerts } from '../insights/engine';
import { DateString, IdParam, jsonBody, jsonResponse, validationHook } from '../schemas';
import { chargeCounts, listSubscriptions, scanSubscriptions, subscriptionDetail, toSubscription, upcomingCharges } from '../subscriptions/engine';
const CadenceSchema = z.enum(['weekly', 'monthly', 'quarterly', 'yearly']).openapi('Cadence');
const StatusSchema = z.enum(['suggested', 'active', 'trial', 'paused', 'cancelled', 'dismissed']).openapi('SubscriptionStatus');
const Url = z
.string()
.trim()
.max(300)
.refine((u) => /^https?:\/\//i.test(u), 'Use a web address starting with https://');
const SubscriptionSchema = z
.object({
id: z.number().int(),
name: z.string(),
kind: z.enum(['subscription', 'bill']),
merchant: z.string(),
aliases: z.array(z.string()),
cadence: CadenceSchema,
expectedCents: z.number().int(),
monthlyCents: z.number().int(),
accountId: z.number().int().nullable(),
categoryId: z.number().int().nullable(),
status: StatusSchema,
confidence: z.enum(['likely', 'confident']),
source: z.enum(['detected', 'catalog', 'manual']),
trialEndsOn: z.string().nullable(),
cancelledOn: z.string().nullable(),
firstChargeOn: z.string().nullable(),
lastChargeOn: z.string().nullable(),
nextExpectedOn: z.string().nullable(),
manageUrl: z.string().nullable(),
note: z.string().nullable(),
alertsOn: z.boolean(),
chargeCount: z.number().int(),
})
.openapi('Subscription');
const DetailSchema = SubscriptionSchema.extend({
charges: z.array(z.object({ id: z.number().int(), date: z.string(), amountCents: z.number().int(), accountId: z.number().int() })),
priceHistory: z.array(z.object({ from: z.string(), amountCents: z.number().int() })),
}).openapi('SubscriptionDetail');
export function subscriptionRoutes(ctx: AppContext) {
const r = new OpenAPIHono<Env>({ defaultHook: validationHook });
const find = (id: number): SubscriptionRow =>
ctx.db.select().from(subscriptions).where(eq(subscriptions.id, id)).get() ?? fail(404, 'not_found', 'That subscription wasn’t found.');
const one = (id: number) => toSubscription(find(id), chargeCounts(ctx).get(id) ?? 0);
const assertRefs = (accountId?: number | null, categoryId?: number | null) => {
if (accountId && !ctx.db.select().from(accounts).where(eq(accounts.id, accountId)).get()) fail(400, 'no_account', 'That account doesn’t exist.');
if (categoryId) {
const kind = ctx.db.select({ kind: categories.kind }).from(categories).where(eq(categories.id, categoryId)).get()?.kind;
if (kind !== 'expense') fail(400, 'no_category', 'Choose a spending category.');
}
};
r.openapi(
createRoute({
method: 'get',
path: '/subscriptions',
tags: ['Subscriptions'],
summary: 'Every subscription and recurring bill across all accounts',
request: { query: z.object({ includeDismissed: z.enum(['true', 'false']).optional() }) },
responses: jsonResponse(z.array(SubscriptionSchema), 'Subscriptions'),
}),
(c) => {
requireUser(c);
return c.json(listSubscriptions(ctx, c.req.valid('query').includeDismissed === 'true'), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/subscriptions/upcoming',
tags: ['Subscriptions'],
summary: 'Charges expected in the coming days',
request: { query: z.object({ days: z.coerce.number().int().min(1).max(120).default(30) }) },
responses: jsonResponse(
z.array(
z.object({
subscriptionId: z.number().int(),
name: z.string(),
kind: z.enum(['subscription', 'bill']),
date: z.string(),
amountCents: z.number().int(),
accountId: z.number().int().nullable(),
trial: z.boolean(),
}),
),
'Upcoming',
),
}),
(c) => {
requireUser(c);
return c.json(upcomingCharges(ctx, c.req.valid('query').days), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/subscriptions/{id}',
tags: ['Subscriptions'],
summary: 'One subscription with its charges and price history',
request: { params: IdParam },
responses: jsonResponse(DetailSchema, 'Subscription'),
}),
(c) => {
requireUser(c);
return c.json(subscriptionDetail(ctx, find(c.req.valid('param').id)), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/subscriptions',
tags: ['Subscriptions'],
summary: 'Add a subscription by hand, before it has charged (useful for yearly plans)',
request: jsonBody(
z.object({
name: z.string().trim().min(1, 'Please give it a name.').max(80),
kind: z.enum(['subscription', 'bill']).default('subscription'),
merchant: z.string().trim().max(80).optional(),
cadence: CadenceSchema,
expectedCents: z.number().int().min(1, 'Please enter the amount.').max(100_000_000),
nextExpectedOn: DateString.nullable().optional(),
accountId: z.number().int().nullable().optional(),
categoryId: z.number().int().nullable().optional(),
status: z.enum(['active', 'trial']).default('active'),
trialEndsOn: DateString.nullable().optional(),
manageUrl: Url.nullable().optional(),
note: z.string().trim().max(500).nullable().optional(),
}),
),
responses: jsonResponse(SubscriptionSchema, 'Added'),
}),
(c) => {
const user = requireWrite(c);
const body = c.req.valid('json');
assertRefs(body.accountId, body.categoryId);
if (body.status === 'trial' && !body.trialEndsOn) fail(400, 'trial_end', 'When does the free trial end?');
const now = nowIso();
const row = ctx.db
.insert(subscriptions)
.values({
name: body.name,
kind: body.kind,
merchant: body.merchant || body.name,
cadence: body.cadence,
expectedCents: body.expectedCents,
nextExpectedOn: body.nextExpectedOn ?? null,
accountId: body.accountId ?? null,
categoryId: body.categoryId ?? null,
status: body.status,
confidence: 'confident',
source: 'manual',
trialEndsOn: body.trialEndsOn ?? null,
manageUrl: body.manageUrl ?? null,
note: body.note ?? null,
createdAt: now,
updatedAt: now,
})
.returning()
.get();
scanSubscriptions(ctx);
refreshAlerts(ctx);
logChange(ctx, user.id, 'add_subscription', 'subscription', row.id, { name: body.name });
return c.json(one(row.id), 200);
},
);
r.openapi(
createRoute({
method: 'patch',
path: '/subscriptions/{id}',
tags: ['Subscriptions'],
summary: 'Confirm, cancel, pause or edit a subscription',
description:
'status "active" answers "yes, it’s a subscription"; "dismissed" answers "no" and is remembered. Changing the category also files its charges there.',
request: {
params: IdParam,
...jsonBody(
z.object({
name: z.string().trim().min(1).max(80).optional(),
kind: z.enum(['subscription', 'bill']).optional(),
cadence: CadenceSchema.optional(),
expectedCents: z.number().int().min(1).max(100_000_000).optional(),
nextExpectedOn: DateString.nullable().optional(),
accountId: z.number().int().nullable().optional(),
categoryId: z.number().int().nullable().optional(),
status: StatusSchema.exclude(['suggested']).optional(),
trialEndsOn: DateString.nullable().optional(),
cancelledOn: DateString.nullable().optional(),
manageUrl: Url.nullable().optional(),
note: z.string().trim().max(500).nullable().optional(),
alertsOn: z.boolean().optional(),
}),
),
},
responses: jsonResponse(SubscriptionSchema, 'Updated'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const body = c.req.valid('json');
const s = find(id);
assertRefs(body.accountId, body.categoryId);
const set: Partial<SubscriptionRow> = { updatedAt: nowIso() };
for (const key of ['name', 'kind', 'cadence', 'expectedCents', 'nextExpectedOn', 'accountId', 'categoryId', 'trialEndsOn', 'manageUrl', 'note', 'alertsOn'] as const) {
if (body[key] !== undefined) (set as Record<string, unknown>)[key] = body[key];
}
if (body.status) {
set.status = body.status;
if (body.status === 'cancelled') set.cancelledOn = body.cancelledOn ?? s.cancelledOn ?? today();
else if (body.status !== 'dismissed') set.cancelledOn = null;
if (body.status === 'trial' && !(body.trialEndsOn ?? s.trialEndsOn)) fail(400, 'trial_end', 'When does the free trial end?');
} else if (body.cancelledOn !== undefined) set.cancelledOn = body.cancelledOn;
ctx.sqlite.transaction(() => {
ctx.db.update(subscriptions).set(set).where(eq(subscriptions.id, id)).run();
// Its charges follow its category, except purchases split across several.
if (body.categoryId) {
ctx.db
.update(transactions)
.set({ categoryId: body.categoryId, needsReview: false, updatedAt: nowIso() })
.where(and(eq(transactions.subscriptionId, id), eq(transactions.isSplit, false), isNull(transactions.transferPeerId)))
.run();
}
})();
if (body.cadence || body.kind) scanSubscriptions(ctx);
refreshAlerts(ctx);
logChange(ctx, user.id, 'update_subscription', 'subscription', id, body);
return c.json(one(id), 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/subscriptions/{id}/merge',
tags: ['Subscriptions'],
summary: 'Merge this entry into another that is really the same service',
request: { params: IdParam, ...jsonBody(z.object({ intoId: z.number().int() })) },
responses: jsonResponse(SubscriptionSchema, 'The combined subscription'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const { intoId } = c.req.valid('json');
if (id === intoId) fail(400, 'same', 'Choose a different subscription to merge into.');
const from = find(id);
const into = find(intoId);
const names = new Set([...(JSON.parse(into.aliases) as string[]), from.merchant, ...(JSON.parse(from.aliases) as string[])]);
names.delete(into.merchant);
ctx.sqlite.transaction(() => {
ctx.db.update(transactions).set({ subscriptionId: intoId }).where(eq(transactions.subscriptionId, id)).run();
ctx.db.update(subscriptions).set({ aliases: JSON.stringify([...names]), updatedAt: nowIso() }).where(eq(subscriptions.id, intoId)).run();
ctx.db.delete(subscriptions).where(eq(subscriptions.id, id)).run();
})();
scanSubscriptions(ctx);
refreshAlerts(ctx);
logChange(ctx, user.id, 'merge_subscription', 'subscription', intoId, { from: id });
return c.json(one(intoId), 200);
},
);
r.openapi(
createRoute({
method: 'delete',
path: '/subscriptions/{id}',
tags: ['Subscriptions'],
summary: 'Remove a subscription you added by hand',
description: 'Ones Spendlight found are answered with "Not a subscription" instead, so they aren’t found again.',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
const s = find(id);
if (s.source !== 'manual') fail(400, 'found', 'Spendlight found this one. Mark it “Not a subscription” instead, so it isn’t found again.');
ctx.sqlite.transaction(() => {
ctx.db.update(transactions).set({ subscriptionId: null }).where(eq(transactions.subscriptionId, id)).run();
ctx.db.delete(subscriptions).where(eq(subscriptions.id, id)).run();
})();
refreshAlerts(ctx);
logChange(ctx, user.id, 'delete_subscription', 'subscription', id, { name: s.name });
return c.json({ ok: true as const }, 200);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/subscriptions/scan',
tags: ['Subscriptions'],
summary: 'Look through every transaction for subscriptions again',
responses: jsonResponse(z.object({ linked: z.number().int(), found: z.number().int() }), 'Scanned'),
}),
(c) => {
requireWrite(c);
const result = scanSubscriptions(ctx);
refreshAlerts(ctx);
return c.json(result, 200);
},
);
return r;
}

View File

@@ -1,16 +1,15 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import type { ReviewItem } from '@spendlight/shared';
import { and, count, desc, eq, exists, gte, inArray, isNull, like, lte, ne, or, sql, type SQL } from 'drizzle-orm';
import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import { logChange } from '../audit';
import { requireUser, requireWrite } from '../auth/guards';
import { rememberRule, Suggester } from '../categorize/suggest';
import type { AppContext } from '../context';
import { accounts, categories, rules, splits, tags, transactions, transactionTags, type TransactionRow } from '../db/schema';
import { accounts, categories, rules, splits, subscriptions, tags, transactions, transactionTags, type TransactionRow } from '../db/schema';
import { fail, nowIso, type Env } from '../http';
import { dedupeKey } from '../import/merchant';
import { unpair } from '../import/transfers';
import {
CategoryGroupSchema,
Cents,
DateString,
IdParam,
@@ -20,7 +19,8 @@ import {
TransactionSchema,
validationHook,
} from '../schemas';
import { listCategoryGroups, newestFirst, toTransaction, toTransactions } from '../transactions/dto';
import { newestFirst, toTransaction, toTransactions } from '../transactions/dto';
import { TransactionQuery, transactionWhere } from '../transactions/filter';
const SimilarSchema = z
.object({ merchant: z.string(), count: z.number().int(), categoryId: z.number().int() })
@@ -84,14 +84,7 @@ export function transactionRoutes(ctx: AppContext) {
tags: ['Transactions'],
summary: 'Search and filter transactions across every account',
request: {
query: z.object({
q: z.string().max(100).optional(),
accountId: z.coerce.number().int().optional(),
categoryId: z.union([z.coerce.number().int(), z.literal('none')]).optional(),
tag: z.string().max(40).optional(),
from: DateString.optional(),
to: DateString.optional(),
review: z.enum(['true', 'false']).optional(),
query: TransactionQuery.extend({
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
}),
@@ -108,37 +101,7 @@ export function transactionRoutes(ctx: AppContext) {
(c) => {
requireUser(c);
const q = c.req.valid('query');
const where: SQL[] = [];
if (q.q) {
const term = `%${q.q.replace(/[%_]/g, '')}%`;
where.push(or(like(transactions.merchant, term), like(transactions.description, term), like(transactions.note, term))!);
}
if (q.accountId) where.push(eq(transactions.accountId, q.accountId));
if (q.categoryId === 'none') where.push(and(isNull(transactions.categoryId), eq(transactions.isSplit, false))!);
else if (q.categoryId !== undefined) {
const cid = q.categoryId;
where.push(
or(
eq(transactions.categoryId, cid),
exists(ctx.db.select().from(splits).where(and(eq(splits.transactionId, transactions.id), eq(splits.categoryId, cid)))),
)!,
);
}
if (q.tag) {
where.push(
exists(
ctx.db
.select()
.from(transactionTags)
.innerJoin(tags, eq(tags.id, transactionTags.tagId))
.where(and(eq(transactionTags.transactionId, transactions.id), sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`)),
),
);
}
if (q.from) where.push(gte(transactions.date, q.from));
if (q.to) where.push(lte(transactions.date, q.to));
if (q.review) where.push(eq(transactions.needsReview, q.review === 'true'));
const condition = where.length ? and(...where) : undefined;
const condition = transactionWhere(ctx, q);
const rows = ctx.db.select().from(transactions).where(condition).orderBy(...newestFirst).limit(q.limit).offset(q.offset).all();
const total = ctx.db.select({ n: count() }).from(transactions).where(condition).get()?.n ?? 0;
@@ -228,6 +191,7 @@ export function transactionRoutes(ctx: AppContext) {
else ctx.db.insert(rules).values({ merchant: t.merchant, renameTo: body.merchant, source: 'manual', createdAt: now }).run();
// Rules keyed on the old name now apply to the new one too.
ctx.db.update(rules).set({ merchant: body.merchant }).where(and(eq(rules.merchant, t.merchant), isNull(rules.renameTo))).run();
ctx.db.update(subscriptions).set({ merchant: body.merchant, updatedAt: now }).where(eq(subscriptions.merchant, t.merchant)).run();
}
}
ctx.sqlite.transaction(() => {
@@ -495,109 +459,5 @@ export function transactionRoutes(ctx: AppContext) {
},
);
// --- Categories, tags, rules -------------------------------------------------------
r.openapi(
createRoute({
method: 'get',
path: '/categories',
tags: ['Categories'],
summary: 'Categories, in their groups',
responses: jsonResponse(z.array(CategoryGroupSchema), 'Categories'),
}),
(c) => {
requireUser(c);
return c.json(listCategoryGroups(ctx), 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/tags',
tags: ['Categories'],
summary: 'Tags in use, most used first',
responses: jsonResponse(z.array(z.object({ name: z.string(), count: z.number().int() })), 'Tags'),
}),
(c) => {
requireUser(c);
const rows = ctx.db
.select({ name: tags.name, count: count(transactionTags.transactionId) })
.from(tags)
.leftJoin(transactionTags, eq(transactionTags.tagId, tags.id))
.groupBy(tags.id)
.orderBy(desc(count(transactionTags.transactionId)), tags.name)
.all();
return c.json(rows, 200);
},
);
r.openapi(
createRoute({
method: 'get',
path: '/rules',
tags: ['Categories'],
summary: 'Saved rules: merchants Spendlight always files or renames the same way',
responses: jsonResponse(
z.array(
z.object({
id: z.number().int(),
merchant: z.string(),
categoryId: z.number().int().nullable(),
renameTo: z.string().nullable(),
source: z.enum(['learned', 'manual']),
createdAt: z.string(),
}),
),
'Rules',
),
}),
(c) => {
requireUser(c);
const rows = ctx.db.select().from(rules).orderBy(rules.merchant).all();
return c.json(
rows.map(({ id, merchant, categoryId, renameTo, source, createdAt }) => ({ id, merchant, categoryId, renameTo, source, createdAt })),
200,
);
},
);
r.openapi(
createRoute({
method: 'post',
path: '/rules',
tags: ['Categories'],
summary: 'Always file a merchant in a category',
request: jsonBody(z.object({ merchant: z.string().trim().min(1).max(80), categoryId: z.number().int() })),
responses: jsonResponse(z.object({ id: z.number().int() }), 'Saved'),
}),
(c) => {
const user = requireWrite(c);
const { merchant, categoryId } = c.req.valid('json');
assertCategory(categoryId);
const id = rememberRule(ctx, merchant, categoryId, 'manual');
logChange(ctx, user.id, 'add_rule', 'rule', id, { merchant, categoryId });
return c.json({ id }, 200);
},
);
r.openapi(
createRoute({
method: 'delete',
path: '/rules/{id}',
tags: ['Categories'],
summary: 'Forget a rule',
request: { params: IdParam },
responses: jsonResponse(z.object({ ok: z.literal(true) }), 'Removed'),
}),
(c) => {
const user = requireWrite(c);
const { id } = c.req.valid('param');
ctx.db.delete(rules).where(eq(rules.id, id)).run();
logChange(ctx, user.id, 'remove_rule', 'rule', id);
return c.json({ ok: true as const }, 200);
},
);
return r;
}

View File

@@ -189,6 +189,7 @@ export const TransactionSchema = z
source: z.enum(['import', 'manual']),
bankCategory: z.string().nullable(),
importId: z.number().int().nullable(),
subscriptionId: z.number().int().nullable(),
})
.openapi('Transaction');

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

@@ -0,0 +1,30 @@
import type { AlertSettings } from '@spendlight/shared';
import { eq } from 'drizzle-orm';
import type { AppContext } from './context';
import { settings } from './db/schema';
// Household-wide settings, stored as JSON in the setting table.
export function getSetting<T>(ctx: AppContext, key: string, fallback: T): T {
const row = ctx.db.select().from(settings).where(eq(settings.key, key)).get();
if (!row) return fallback;
try {
return { ...fallback, ...(JSON.parse(row.value) as T) };
} catch {
return fallback;
}
}
export function putSetting(ctx: AppContext, key: string, value: unknown): void {
const json = JSON.stringify(value);
ctx.db.insert(settings).values({ key, value: json }).onConflictDoUpdate({ target: settings.key, set: { value: json } }).run();
}
export const DEFAULT_ALERT_SETTINGS: AlertSettings = {
priceChangePct: 10,
priceChangeMinCents: 200,
budgetWarnPct: 80,
unusualMultiple: 4,
};
export const alertSettings = (ctx: AppContext) => getSetting(ctx, 'alerts', DEFAULT_ALERT_SETTINGS);

View File

@@ -0,0 +1,141 @@
import type { Cadence, SubscriptionKind } from '@spendlight/shared';
// Services Spendlight recognizes from their very first charge. Ships with the
// app, so it works offline. The links open each service's own account page,
// where it can be managed or cancelled; Spendlight never cancels anything itself.
export interface CatalogEntry {
key: string;
name: string;
pattern: RegExp;
cadence: Cadence;
kind: SubscriptionKind;
manageUrl: string | null;
/** Some services sell both monthly and yearly plans: a single charge above this many cents is read as yearly. */
yearlyFrom?: number;
/** False where the same merchant also sells one-off purchases, so a single charge proves nothing. */
single?: boolean;
}
const sub = (key: string, name: string, pattern: RegExp, manageUrl: string | null, extra: Partial<CatalogEntry> = {}): CatalogEntry => ({
key,
name,
pattern,
cadence: 'monthly',
kind: 'subscription',
manageUrl,
...extra,
});
const bill = (key: string, name: string, pattern: RegExp, manageUrl: string | null, extra: Partial<CatalogEntry> = {}): CatalogEntry => ({
key,
name,
pattern,
cadence: 'monthly',
kind: 'bill',
manageUrl,
...extra,
});
export const CATALOG: CatalogEntry[] = [
// Streaming video
sub('netflix', 'Netflix', /NETFLIX/i, 'https://www.netflix.com/account'),
sub('hulu', 'Hulu', /HULU/i, 'https://secure.hulu.com/account'),
sub('disney-plus', 'Disney+', /DISNEY ?\+|DISNEYPLUS|DISNEY PLUS/i, 'https://www.disneyplus.com/account', { yearlyFrom: 6000 }),
sub('hbo-max', 'HBO Max', /HBO ?MAX|\bMAX\.COM/i, 'https://www.hbomax.com'),
sub('peacock', 'Peacock', /PEACOCK/i, 'https://www.peacocktv.com', { yearlyFrom: 5000 }),
sub('paramount-plus', 'Paramount+', /PARAMOUNT/i, 'https://www.paramountplus.com/account/', { yearlyFrom: 5000 }),
sub('youtube-premium', 'YouTube Premium', /YOUTUBE ?PREMIUM|YOUTUBEPREMIUM|GOOGLE \*?YOUTUBE(?! ?TV)/i, 'https://www.youtube.com/paid_memberships'),
sub('youtube-tv', 'YouTube TV', /YOUTUBE ?TV/i, 'https://tv.youtube.com'),
sub('prime-video', 'Prime Video channels', /PRIME ?VIDEO/i, 'https://www.amazon.com/gp/video/settings', { single: false }),
sub('espn-plus', 'ESPN+', /ESPN/i, 'https://plus.espn.com', { yearlyFrom: 6000 }),
sub('crunchyroll', 'Crunchyroll', /CRUNCHYROLL/i, 'https://www.crunchyroll.com/account/membership'),
sub('sling', 'Sling TV', /SLING ?TV|SLING\.COM/i, 'https://www.sling.com/account'),
sub('fubo', 'Fubo', /FUBO/i, 'https://www.fubo.tv'),
sub('philo', 'Philo', /PHILO/i, 'https://www.philo.com'),
// Music and audio
sub('spotify', 'Spotify', /SPOTIFY/i, 'https://www.spotify.com/account/'),
sub('siriusxm', 'SiriusXM', /SIRIUS ?XM/i, 'https://www.siriusxm.com'),
sub('pandora', 'Pandora', /PANDORA(\.COM|\*| ?MEDIA| ?PLUS| ?PREMIUM)/i, 'https://www.pandora.com/settings/subscription'),
sub('tidal', 'Tidal', /TIDAL\.COM|TIDAL MUSIC/i, 'https://account.tidal.com'),
sub('audible', 'Audible', /AUDIBLE/i, 'https://www.audible.com/account/overview'),
// Big-tech bundles and storage
sub('apple', 'Apple subscription', /APPLE\.COM\/BILL|APPLE\.COM BILL|ITUNES/i, 'https://apps.apple.com/account/subscriptions', { single: false }),
sub('google-one', 'Google One', /GOOGLE \*?(STORAGE|ONE)|GOOGLE ONE/i, 'https://play.google.com/store/account/subscriptions', { single: false }),
sub('amazon-prime', 'Amazon Prime', /AMAZON PRIME|PRIME MEMBERSHIP|AMZN PRIME/i, 'https://www.amazon.com/gp/primecentral', { yearlyFrom: 6000 }),
sub('kindle-unlimited', 'Kindle Unlimited', /KINDLE ?UNLTD|KINDLE UNLIMITED/i, 'https://www.amazon.com/kindle-dbs/ku/ku-central'),
sub('microsoft-365', 'Microsoft 365', /MICROSOFT ?\*?(365|OFFICE)|MSFT \*(365|OFFICE)/i, 'https://account.microsoft.com/services', { yearlyFrom: 5000 }),
sub('xbox', 'Xbox Game Pass', /XBOX.*(PASS|ULTIMATE|LIVE)|GAME ?PASS/i, 'https://account.microsoft.com/services'),
sub('playstation', 'PlayStation Plus', /PLAYSTATION ?(PLUS|\+)|PS ?PLUS/i, 'https://www.playstation.com', { yearlyFrom: 4000 }),
sub('nintendo', 'Nintendo Switch Online', /NINTENDO.*(ONLINE|MEMBERSHIP)/i, 'https://accounts.nintendo.com', { yearlyFrom: 1500 }),
sub('dropbox', 'Dropbox', /DROPBOX/i, 'https://www.dropbox.com/account/plan', { yearlyFrom: 6000 }),
sub('adobe', 'Adobe', /ADOBE/i, 'https://account.adobe.com/plans'),
sub('chatgpt', 'ChatGPT', /OPENAI|CHATGPT/i, 'https://chatgpt.com'),
sub('claude', 'Claude', /ANTHROPIC|CLAUDE\.AI/i, 'https://claude.ai/settings/billing'),
sub('github', 'GitHub', /GITHUB/i, 'https://github.com/settings/billing'),
sub('1password', '1Password', /1PASSWORD/i, 'https://my.1password.com'),
sub('norton', 'Norton', /NORTON ?(360|LIFELOCK|\*)|NORTONLIFELOCK|GEN DIGITAL/i, 'https://my.norton.com', { yearlyFrom: 3000 }),
sub('mcafee', 'McAfee', /MCAFEE/i, 'https://home.mcafee.com', { yearlyFrom: 3000 }),
sub('grammarly', 'Grammarly', /GRAMMARLY/i, 'https://account.grammarly.com/subscription'),
sub('canva', 'Canva', /\bCANVA\b/i, 'https://www.canva.com/settings/billing-and-teams'),
sub('zoom', 'Zoom', /ZOOM\.US|ZOOM VIDEO/i, 'https://zoom.us/billing'),
sub('linkedin', 'LinkedIn Premium', /LINKEDIN/i, 'https://www.linkedin.com/premium/manage'),
sub('duolingo', 'Duolingo', /DUOLINGO/i, 'https://www.duolingo.com/settings/subscription', { yearlyFrom: 3000 }),
sub('patreon', 'Patreon', /PATREON/i, 'https://www.patreon.com/settings/memberships'),
// News
sub('nytimes', 'New York Times', /NYTIMES|NY TIMES|NEW YORK TIMES/i, 'https://myaccount.nytimes.com'),
sub('wsj', 'Wall Street Journal', /\bWSJ\b|WALL STREET JOURNAL|DOW JONES/i, 'https://customercenter.wsj.com'),
sub('washington-post', 'Washington Post', /WASHINGTON ?POST|WASHPOST/i, 'https://www.washingtonpost.com'),
// Health and fitness
sub('peloton', 'Peloton', /PELOTON/i, 'https://www.onepeloton.com'),
sub('planet-fitness', 'Planet Fitness', /PLANET ?FITNESS|PLNT ?FITNESS/i, 'https://www.planetfitness.com'),
sub('headspace', 'Headspace', /HEADSPACE/i, 'https://www.headspace.com', { yearlyFrom: 4000 }),
sub('calm', 'Calm', /\bCALM\.COM|CALM APP/i, 'https://www.calm.com', { yearlyFrom: 4000 }),
sub('strava', 'Strava', /STRAVA/i, 'https://www.strava.com/account', { yearlyFrom: 4000 }),
// Food and delivery memberships
sub('hellofresh', 'HelloFresh', /HELLO ?FRESH/i, 'https://www.hellofresh.com'),
sub('factor', 'Factor', /FACTOR ?75|FACTOR MEALS/i, 'https://www.factor75.com'),
sub('dashpass', 'DashPass', /DASHPASS/i, 'https://www.doordash.com'),
sub('uber-one', 'Uber One', /UBER ?ONE/i, 'https://www.uber.com'),
sub('instacart-plus', 'Instacart+', /INSTACART ?\+|INSTACART PLUS|INSTACART MEMBERSHIP/i, 'https://www.instacart.com', { yearlyFrom: 5000 }),
sub('walmart-plus', 'Walmart+', /WALMART ?\+|WALMART PLUS|WMT PLUS/i, 'https://www.walmart.com/plus/account', { yearlyFrom: 5000 }),
sub('costco-membership', 'Costco membership', /COSTCO.*(MEMBER|MBRSHP|RENEW)/i, 'https://www.costco.com', { cadence: 'yearly' }),
sub('sams-membership', "Sam's Club membership", /SAMS? ?CLUB.*(MEMBER|MBRSHP|RENEW)/i, 'https://www.samsclub.com', { cadence: 'yearly' }),
// Home security
sub('ring', 'Ring Protect', /\bRING\b.*(PROTECT|PLAN)|RING\.COM/i, 'https://account.ring.com', { yearlyFrom: 5000 }),
sub('simplisafe', 'SimpliSafe', /SIMPLISAFE/i, 'https://simplisafe.com'),
bill('adt', 'ADT', /\bADT\b/i, 'https://www.adt.com'),
// Phone, internet and TV
bill('xfinity', 'Xfinity', /COMCAST|XFINITY/i, 'https://customer.xfinity.com'),
bill('spectrum', 'Spectrum', /SPECTRUM|CHARTER COMM/i, 'https://www.spectrum.net'),
bill('verizon', 'Verizon', /VERIZON|VZWRLSS|VZ WIRELESS/i, 'https://www.verizon.com/myaccount'),
bill('att', 'AT&T', /\bAT ?& ?T\b|\bATT\b\*?/i, 'https://www.att.com/my'),
bill('t-mobile', 'T-Mobile', /T-?MOBILE/i, 'https://www.t-mobile.com/account'),
bill('cox', 'Cox', /\bCOX\b.*COMM|COX CABLE/i, 'https://www.cox.com/myaccount'),
bill('optimum', 'Optimum', /OPTIMUM|ALTICE/i, 'https://www.optimum.net'),
bill('google-fi', 'Google Fi', /GOOGLE ?\*?FI\b/i, 'https://fi.google.com/account'),
// Utilities
bill('duke-energy', 'Duke Energy', /DUKE ?ENERGY|DUKE-ENERGY/i, 'https://www.duke-energy.com'),
bill('dominion-energy', 'Dominion Energy', /DOMINION ?ENERGY/i, 'https://www.dominionenergy.com'),
bill('pge', 'PG&E', /\bPG ?& ?E\b|PACIFIC GAS/i, 'https://www.pge.com'),
bill('con-edison', 'Con Edison', /CON ?ED/i, 'https://www.coned.com'),
bill('georgia-power', 'Georgia Power', /GEORGIA POWER/i, 'https://www.georgiapower.com'),
bill('fpl', 'FPL', /\bFPL\b|FLORIDA POWER/i, 'https://www.fpl.com'),
bill('xcel', 'Xcel Energy', /XCEL ?ENERGY/i, 'https://www.xcelenergy.com'),
// Insurance
bill('geico', 'GEICO', /GEICO/i, 'https://www.geico.com'),
bill('progressive', 'Progressive', /PROGRESSIVE/i, 'https://www.progressive.com'),
bill('state-farm', 'State Farm', /STATE ?FARM/i, 'https://www.statefarm.com'),
bill('allstate', 'Allstate', /ALLSTATE/i, 'https://www.allstate.com'),
bill('usaa', 'USAA', /\bUSAA\b/i, 'https://www.usaa.com'),
bill('liberty-mutual', 'Liberty Mutual', /LIBERTY ?MUTUAL/i, 'https://www.libertymutual.com'),
];
export function catalogMatch(merchant: string, description: string): CatalogEntry | undefined {
const text = `${merchant} ${description}`;
return CATALOG.find((e) => e.pattern.test(text));
}
export function catalogEntry(key: string | null): CatalogEntry | undefined {
return key ? CATALOG.find((e) => e.key === key) : undefined;
}

View File

@@ -0,0 +1,431 @@
import { monthlyCost, type Cadence, type Subscription, type SubscriptionDetail, type SubscriptionKind } from '@spendlight/shared';
import { asc, count, eq, gte, inArray, isNotNull, ne } from 'drizzle-orm';
import { TRANSFER_PATTERN } from '../categorize/known';
import type { AppContext } from '../context';
import { categories, categoryGroups, subscriptions, transactions, type SubscriptionRow } from '../db/schema';
import { addDays, addMonthsToDate, daysBetween, today } from '../dates';
import { catalogEntry, catalogMatch, type CatalogEntry } from './catalog';
// Finding subscriptions and bills. After every import, new charges are first
// matched to the subscriptions already known; whatever is left is searched for
// a merchant charging a similar amount on a steady rhythm. Every find waits for
// a person to say "yes, that's a subscription" before it counts.
export const CYCLE_DAYS: Record<Cadence, number> = { weekly: 7, monthly: 30.44, quarterly: 91.3, yearly: 365.25 };
const BAND: Record<Cadence, [number, number]> = { weekly: [5, 9], monthly: [25, 36], quarterly: [80, 100], yearly: [340, 390] };
/** How long after an expected charge before it counts as missing. */
export const GRACE_DAYS: Record<Cadence, number> = { weekly: 3, monthly: 7, quarterly: 12, yearly: 21 };
const BILL_CATEGORIES = new Set([
'mortgage or rent',
'home insurance',
'property tax',
'electric and gas',
'water and trash',
'internet',
'mobile phone',
'car payment',
'car insurance',
]);
/** Wording that marks a payment as a bill rather than a subscription. */
const BILL_WORDS = /MORTGAGE|\bMTG\b|\bLOAN\b|INSURANCE|\bINS\b|ENERGY|ELECTRIC|\bPOWER\b|WATER|SEWER|UTILIT|NATURAL GAS|\bGAS CO|TRASH|WASTE|PROPANE|\bHOA\b|\bRENT\b|TUITION|DAYCARE|CHILD ?CARE/i;
export function nextCharge(date: string, cadence: Cadence): string {
if (cadence === 'weekly') return addDays(date, 7);
return addMonthsToDate(date, cadence === 'monthly' ? 1 : cadence === 'quarterly' ? 3 : 12);
}
/** The rhythm a series of charge dates follows, allowing for one skipped cycle here and there. */
export function fitCadence(dates: string[]): Cadence | null {
if (dates.length < 2) return null;
const gaps = dates.slice(1).map((d, i) => daysBetween(dates[i]!, d));
for (const cadence of ['monthly', 'yearly', 'quarterly', 'weekly'] as Cadence[]) {
const [lo, hi] = BAND[cadence];
const exact = gaps.filter((g) => g >= lo && g <= hi).length;
const skipped = gaps.filter((g) => g >= lo * 2 && g <= hi * 2).length;
if (exact >= Math.ceil(gaps.length / 2) && (exact + skipped) / gaps.length >= 0.75) return cadence;
}
return null;
}
interface Charge {
id: number;
accountId: number;
date: string;
/** Positive cents. */
amount: number;
merchant: string;
description: string;
categoryId: number | null;
}
interface Candidate {
charges: Charge[];
cadence: Cadence;
kind: SubscriptionKind;
}
const median = (values: number[]) => {
const s = [...values].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid]! : Math.round((s[mid - 1]! + s[mid]!) / 2);
};
const namesOf = (s: SubscriptionRow) => new Set([s.merchant, ...(JSON.parse(s.aliases) as string[])].map((n) => n.toLowerCase()));
/** Charges of about the same amount, so a $2.99 and a $9.99 plan from Apple stay apart. */
function clusterByAmount(charges: Charge[]): Charge[][] {
const sorted = [...charges].sort((a, b) => a.amount - b.amount);
const clusters: Charge[][] = [];
for (const c of sorted) {
const current = clusters.at(-1);
if (current && c.amount - current[0]!.amount <= Math.max(current[0]!.amount * 0.03, 50)) current.push(c);
else clusters.push([c]);
}
return clusters.map((cl) => cl.sort((a, b) => a.date.localeCompare(b.date) || a.id - b.id));
}
function detectGroup(group: Charge[], catalog: CatalogEntry | undefined, billLike: (c: Charge) => boolean): Candidate[] {
const found: Candidate[] = [];
const latest = group.at(-1)!;
const isBill = catalog ? catalog.kind === 'bill' : billLike(latest) || BILL_WORDS.test(latest.description);
// Bills (electric, insurance, the mortgage) go by rhythm alone, since their
// amounts can change every month. Anything else needs a steady price too.
if (isBill) {
if (group.length < 2) return found;
const cadence = fitCadence(group.map((c) => c.date));
const mid = median(group.map((c) => c.amount));
if (cadence && cadence !== 'weekly' && group.every((c) => c.amount >= mid / 2.5 && c.amount <= mid * 2.5)) {
found.push({ charges: group, cadence, kind: 'bill' });
}
return found;
}
const used = new Set<number>();
// A large fixed monthly payment is a bill too (a car loan, rent), not something you'd cancel.
const kindFor = (charges: Charge[]): SubscriptionKind => (!catalog && charges.at(-1)!.amount >= 30_000 ? 'bill' : 'subscription');
for (const cluster of clusterByAmount(group)) {
if (cluster.length < 2) continue;
const cadence = fitCadence(cluster.map((c) => c.date));
if (!cadence) continue;
if (cadence === 'weekly' && cluster.length < 4) continue;
// Two charges of the same amount a month apart can be chance; ask only when they match to the cent.
if (cluster.length === 2 && !catalog && cluster[0]!.amount !== cluster[1]!.amount) continue;
found.push({ charges: cluster, cadence, kind: kindFor(cluster) });
cluster.forEach((c) => used.add(c.id));
}
// A price change splits one service into two runs of charges; join them back up.
found.sort((a, b) => a.charges[0]!.date.localeCompare(b.charges[0]!.date));
for (let i = 0; i < found.length - 1; i++) {
const a = found[i]!;
const b = found[i + 1]!;
const gap = daysBetween(a.charges.at(-1)!.date, b.charges[0]!.date);
const ratio = b.charges[0]!.amount / a.charges.at(-1)!.amount;
if (a.cadence === b.cadence && gap > 0 && gap <= CYCLE_DAYS[a.cadence] * 1.6 && ratio > 0.6 && ratio < 1.7) {
found.splice(i, 2, { ...b, charges: [...a.charges, ...b.charges] });
i--;
}
}
// Known services count from their very first charge.
if (!found.length && catalog && catalog.kind === 'subscription' && catalog.single !== false) {
const cadence = catalog.yearlyFrom && latest.amount >= catalog.yearlyFrom ? 'yearly' : catalog.cadence;
found.push({ charges: [latest], cadence, kind: 'subscription' });
}
return found;
}
/** Match new charges to known subscriptions, look for new ones, and bring every entry up to date. */
export function scanSubscriptions(ctx: AppContext): { linked: number; found: number } {
const now = new Date().toISOString();
const subs = ctx.db.select().from(subscriptions).all();
const catRows = ctx.db
.select({ id: categories.id, name: categories.name, kind: categories.kind, group: categoryGroups.name })
.from(categories)
.innerJoin(categoryGroups, eq(categoryGroups.id, categories.groupId))
.all();
const cats = new Map(catRows.map((c) => [c.id, c]));
const billLike = (c: Charge) => {
const cat = c.categoryId ? cats.get(c.categoryId) : undefined;
return !!cat && (BILL_CATEGORIES.has(cat.name.toLowerCase()) || cat.group.toLowerCase() === 'utilities');
};
const coverage = new Map(
(ctx.sqlite.prepare('SELECT account_id AS a, max(date) AS d FROM txn GROUP BY account_id').all() as { a: number; d: string }[]).map((r) => [r.a, r.d]),
);
const newest = [...coverage.values()].sort().at(-1) ?? today();
const since = addDays(newest, -550);
const rows = ctx.db
.select({
id: transactions.id,
accountId: transactions.accountId,
date: transactions.date,
amountCents: transactions.amountCents,
merchant: transactions.merchant,
description: transactions.description,
categoryId: transactions.categoryId,
subscriptionId: transactions.subscriptionId,
transferPeerId: transactions.transferPeerId,
isSplit: transactions.isSplit,
})
.from(transactions)
.where(gte(transactions.date, since))
.orderBy(asc(transactions.date), asc(transactions.id))
.all();
const linkedBySub = new Map<number, { date: string; accountId: number }[]>();
for (const r of rows) if (r.subscriptionId) linkedBySub.set(r.subscriptionId, [...(linkedBySub.get(r.subscriptionId) ?? []), r]);
const candidates: Charge[] = rows
.filter(
(r) =>
!r.subscriptionId &&
r.amountCents <= -100 &&
!r.isSplit &&
!r.transferPeerId &&
// Card payments and transfers never count. Bills paid by "autopay" from
// checking read like transfers, so wording alone rules out only unfiled ones.
(r.categoryId === null
? !TRANSFER_PATTERN.test(r.description) || !!catalogMatch(r.merchant, r.description)
: cats.get(r.categoryId)?.kind === 'expense'),
)
.map((r) => ({ id: r.id, accountId: r.accountId, date: r.date, amount: -r.amountCents, merchant: r.merchant, description: r.description, categoryId: r.categoryId }));
const link = (subscriptionId: number, charges: Charge[]) => {
if (!charges.length) return;
ctx.db.update(transactions).set({ subscriptionId }).where(inArray(transactions.id, charges.map((c) => c.id))).run();
linkedBySub.set(subscriptionId, [...(linkedBySub.get(subscriptionId) ?? []), ...charges]);
};
let linked = 0;
let found = 0;
ctx.sqlite.transaction(() => {
// 1. Charges from subscriptions already known.
const byName = new Map<string, SubscriptionRow[]>();
for (const s of subs) for (const n of namesOf(s)) byName.set(n, [...(byName.get(n) ?? []), s]);
const remaining: Charge[] = [];
for (const c of candidates) {
let best: { s: SubscriptionRow; diff: number } | null = null;
const known = catalogMatch(c.merchant, c.description)?.key;
for (const s of byName.get(c.merchant.toLowerCase()) ?? []) {
// A membership from the catalog takes only charges that read like it:
// Costco's yearly renewal, not the weekly shop at Costco.
if (s.catalogKey && known !== s.catalogKey) continue;
const diff = Math.abs(c.amount - s.expectedCents);
const fits =
s.status === 'dismissed'
? diff <= Math.max(s.expectedCents * 0.05, 50)
: s.kind === 'bill'
? c.amount >= s.expectedCents * 0.25 && c.amount <= s.expectedCents * 4
: diff <= Math.max(s.expectedCents * 0.25, 100);
if (!fits) continue;
// One charge per cycle on each card; a second one is a separate purchase.
const sameCycle = (linkedBySub.get(s.id) ?? []).some(
(x) => x.accountId === c.accountId && Math.abs(daysBetween(x.date, c.date)) < CYCLE_DAYS[s.cadence] * 0.5,
);
if (sameCycle) continue;
if (!best || diff < best.diff) best = { s, diff };
}
if (best) {
link(best.s.id, [c]);
linked++;
} else remaining.push(c);
}
// 2. New subscriptions among the rest, one group per merchant (charges
// that match a catalog service form their own group).
const groups = new Map<string, { key: string; catalog: CatalogEntry | undefined; charges: Charge[] }>();
for (const c of remaining) {
const catalog = catalogMatch(c.merchant, c.description);
const id = `${c.merchant.toLowerCase()}|${catalog?.key ?? ''}`;
const g = groups.get(id) ?? { key: c.merchant.toLowerCase(), catalog, charges: [] };
g.charges.push(c);
groups.set(id, g);
}
for (const { key, catalog, charges: group } of groups.values()) {
for (const cand of detectGroup(group, catalog, billLike)) {
const first = cand.charges[0]!;
const last = cand.charges.at(-1)!;
// A new price for a subscription already known continues it.
const continued = subs.find(
(s) =>
namesOf(s).has(key) &&
s.catalogKey === (catalog?.key ?? null) &&
s.status !== 'dismissed' &&
s.cadence === cand.cadence &&
s.lastChargeOn &&
first.date > s.lastChargeOn &&
daysBetween(s.lastChargeOn, first.date) <= CYCLE_DAYS[s.cadence] * 1.6,
);
if (continued) {
link(continued.id, cand.charges);
linked += cand.charges.length;
continue;
}
// Charges that stopped long ago belong to something already ended.
const reach = coverage.get(last.accountId) ?? newest;
if (daysBetween(last.date, reach) > CYCLE_DAYS[cand.cadence] * 1.5 + 7) continue;
const category = last.categoryId && cats.get(last.categoryId)?.kind === 'expense' ? last.categoryId : null;
const row = ctx.db
.insert(subscriptions)
.values({
name: catalog?.name ?? last.merchant,
kind: cand.kind,
merchant: last.merchant,
catalogKey: catalog?.key ?? null,
cadence: cand.cadence,
expectedCents: last.amount,
accountId: last.accountId,
categoryId: category,
status: 'suggested',
confidence: cand.charges.length >= 3 ? 'confident' : 'likely',
source: catalog ? 'catalog' : 'detected',
manageUrl: catalog?.manageUrl ?? null,
createdAt: now,
updatedAt: now,
})
.returning()
.get();
subs.push(row);
link(row.id, cand.charges);
found++;
}
}
// 3. Bring every entry up to date with its charges.
for (const s of ctx.db.select().from(subscriptions).all()) recompute(ctx, s, now);
})();
return { linked, found };
}
function recompute(ctx: AppContext, s: SubscriptionRow, now: string) {
const charges = ctx.db
.select({ date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId, categoryId: transactions.categoryId })
.from(transactions)
.where(eq(transactions.subscriptionId, s.id))
.orderBy(asc(transactions.date), asc(transactions.id))
.all();
if (!charges.length || (s.status === 'suggested' && s.source === 'detected' && charges.length < 2)) {
if (s.status === 'suggested' && s.source !== 'manual') {
ctx.db.update(transactions).set({ subscriptionId: null }).where(eq(transactions.subscriptionId, s.id)).run();
ctx.db.delete(subscriptions).where(eq(subscriptions.id, s.id)).run();
} else if (s.lastChargeOn && !charges.length) {
ctx.db.update(subscriptions).set({ firstChargeOn: null, lastChargeOn: null, updatedAt: now }).where(eq(subscriptions.id, s.id)).run();
}
return;
}
const last = charges.at(-1)!;
const recent = charges.slice(-3).map((c) => -c.amountCents);
const expected = s.kind === 'bill' ? Math.round(recent.reduce((a, b) => a + b, 0) / recent.length) : -last.amountCents;
const status = s.status === 'trial' && s.trialEndsOn && last.date > s.trialEndsOn ? 'active' : s.status;
const set: Partial<SubscriptionRow> = {
firstChargeOn: charges[0]!.date,
lastChargeOn: last.date,
nextExpectedOn: nextCharge(last.date, s.cadence),
accountId: last.accountId,
expectedCents: expected,
status,
confidence: charges.length >= 3 ? 'confident' : s.confidence,
};
const changed = (Object.keys(set) as (keyof SubscriptionRow)[]).some((k) => set[k] !== s[k]);
if (changed) ctx.db.update(subscriptions).set({ ...set, updatedAt: now }).where(eq(subscriptions.id, s.id)).run();
}
// --- Reading -------------------------------------------------------------------
export function toSubscription(s: SubscriptionRow, chargeCount: number): Subscription {
return {
id: s.id,
name: s.name,
kind: s.kind,
merchant: s.merchant,
aliases: JSON.parse(s.aliases) as string[],
cadence: s.cadence,
expectedCents: s.expectedCents,
monthlyCents: monthlyCost(s.expectedCents, s.cadence),
accountId: s.accountId,
categoryId: s.categoryId,
status: s.status,
confidence: s.confidence,
source: s.source,
trialEndsOn: s.trialEndsOn,
cancelledOn: s.cancelledOn,
firstChargeOn: s.firstChargeOn,
lastChargeOn: s.lastChargeOn,
nextExpectedOn: s.nextExpectedOn,
manageUrl: s.manageUrl ?? catalogEntry(s.catalogKey)?.manageUrl ?? null,
note: s.note,
alertsOn: s.alertsOn,
chargeCount,
};
}
export function chargeCounts(ctx: AppContext): Map<number, number> {
return new Map(
ctx.db
.select({ id: transactions.subscriptionId, n: count() })
.from(transactions)
.where(isNotNull(transactions.subscriptionId))
.groupBy(transactions.subscriptionId)
.all()
.map((r) => [r.id!, r.n]),
);
}
export function listSubscriptions(ctx: AppContext, includeDismissed = false): Subscription[] {
const counts = chargeCounts(ctx);
return ctx.db
.select()
.from(subscriptions)
.where(includeDismissed ? undefined : ne(subscriptions.status, 'dismissed'))
.orderBy(asc(subscriptions.name))
.all()
.map((s) => toSubscription(s, counts.get(s.id) ?? 0));
}
export function subscriptionDetail(ctx: AppContext, s: SubscriptionRow): SubscriptionDetail {
const charges = ctx.db
.select({ id: transactions.id, date: transactions.date, amountCents: transactions.amountCents, accountId: transactions.accountId })
.from(transactions)
.where(eq(transactions.subscriptionId, s.id))
.orderBy(asc(transactions.date), asc(transactions.id))
.all();
const priceHistory: SubscriptionDetail['priceHistory'] = [];
if (s.kind === 'subscription') {
for (const c of charges) {
const amount = -c.amountCents;
const step = priceHistory.at(-1);
if (!step || Math.abs(amount - step.amountCents) > Math.max(step.amountCents * 0.01, 5)) priceHistory.push({ from: c.date, amountCents: amount });
}
}
return { ...toSubscription(s, charges.length), charges: charges.reverse(), priceHistory };
}
/** Charges expected in the next few days, for "Coming up". */
export function upcomingCharges(ctx: AppContext, days = 30) {
const start = today();
const end = addDays(start, days);
const out: { subscriptionId: number; name: string; kind: SubscriptionKind; date: string; amountCents: number; accountId: number | null; trial: boolean }[] = [];
for (const s of ctx.db.select().from(subscriptions).where(inArray(subscriptions.status, ['active', 'trial'])).all()) {
if (s.status === 'trial') {
if (s.trialEndsOn && s.trialEndsOn >= start && s.trialEndsOn <= end) {
out.push({ subscriptionId: s.id, name: s.name, kind: s.kind, date: s.trialEndsOn, amountCents: s.expectedCents, accountId: s.accountId, trial: true });
}
continue;
}
let d = s.nextExpectedOn;
if (!d) continue;
for (let guard = 0; d < start && guard < 600; guard++) d = nextCharge(d, s.cadence);
for (let guard = 0; d <= end && guard < 10; guard++) {
out.push({ subscriptionId: s.id, name: s.name, kind: s.kind, date: d, amountCents: s.expectedCents, accountId: s.accountId, trial: false });
d = nextCharge(d, s.cadence);
}
}
return out.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
}

View File

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

View File

@@ -0,0 +1,54 @@
import { z } from '@hono/zod-openapi';
import { and, eq, exists, gte, isNull, like, lte, or, sql, type SQL } from 'drizzle-orm';
import type { AppContext } from '../context';
import { splits, tags, transactions, transactionTags } from '../db/schema';
import { DateString } from '../schemas';
/** The search and filters shared by the transaction list and its exports. */
export const TransactionQuery = z.object({
q: z.string().max(100).optional(),
accountId: z.coerce.number().int().optional(),
categoryId: z.union([z.coerce.number().int(), z.literal('none')]).optional(),
tag: z.string().max(40).optional(),
from: DateString.optional(),
to: DateString.optional(),
review: z.enum(['true', 'false']).optional(),
subscriptionId: z.coerce.number().int().optional(),
});
export type TransactionFilter = z.infer<typeof TransactionQuery>;
export function transactionWhere(ctx: AppContext, q: TransactionFilter): SQL | undefined {
const where: SQL[] = [];
if (q.q) {
const term = `%${q.q.replace(/[%_]/g, '')}%`;
where.push(or(like(transactions.merchant, term), like(transactions.description, term), like(transactions.note, term))!);
}
if (q.accountId) where.push(eq(transactions.accountId, q.accountId));
if (q.categoryId === 'none') where.push(and(isNull(transactions.categoryId), eq(transactions.isSplit, false))!);
else if (q.categoryId !== undefined) {
const cid = q.categoryId;
where.push(
or(
eq(transactions.categoryId, cid),
exists(ctx.db.select().from(splits).where(and(eq(splits.transactionId, transactions.id), eq(splits.categoryId, cid)))),
)!,
);
}
if (q.tag) {
where.push(
exists(
ctx.db
.select()
.from(transactionTags)
.innerJoin(tags, eq(tags.id, transactionTags.tagId))
.where(and(eq(transactionTags.transactionId, transactions.id), sql`lower(${tags.name}) = ${q.tag.toLowerCase()}`)),
),
);
}
if (q.from) where.push(gte(transactions.date, q.from));
if (q.to) where.push(lte(transactions.date, q.to));
if (q.review) where.push(eq(transactions.needsReview, q.review === 'true'));
if (q.subscriptionId) where.push(eq(transactions.subscriptionId, q.subscriptionId));
return where.length ? and(...where) : undefined;
}

View File

@@ -0,0 +1,415 @@
import { inflateRawSync } from 'node:zlib';
import type { AlertItem, BudgetYear, MonthsReport, Overview, Subscription, SubscriptionDetail } from '@spendlight/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { today } from '../src/dates';
import { buildCsv } from '../src/export/xlsx';
import { fitCadence } from '../src/subscriptions/engine';
import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers';
import { chaseCsv, checkingCsv, discoverCsv } from './synthetic';
// Milestone 2: budgets, reports, subscriptions, alerts, management and export,
// all on made-up statements (see synthetic.ts).
let t: TestApp;
let admin: Client;
let ids: { checking: number; discover: number; chase: number };
let imports: { discover: number; chase: number; checking: number };
const groups = async () => (await admin.get('/api/v1/categories?includeArchived=true')).json as { id: number; name: string; categories: { id: number; name: string; archived: boolean }[] }[];
const category = async (name: string) => (await groups()).flatMap((g) => g.categories).find((c) => c.name === name)!.id;
const subs = async () => (await admin.get('/api/v1/subscriptions')).json as Subscription[];
const sub = async (name: string) => (await subs()).find((s) => s.name === name)!;
const alerts = async (area?: string) => (await admin.get(`/api/v1/alerts${area ? `?area=${area}` : ''}`)).json as AlertItem[];
async function importText(name: string, text: string, accountId: number) {
const preview = await admin.upload('/api/v1/imports/files', name, text);
expect(preview.status).toBe(200);
const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId });
expect(done.status).toBe(200);
return done.json.id as number;
}
beforeEach(async () => {
t = await makeApp();
admin = await setUpAdmin(t);
const add = async (body: Record<string, unknown>) => (await admin.post('/api/v1/accounts', body)).json.id as number;
ids = {
checking: await add({ name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' }),
discover: await add({ name: 'Discover', kind: 'credit_card', institution: 'Discover' }),
chase: await add({ name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }),
};
// Filed by hand once, so the big Best Buy purchase has a typical Electronics purchase to compare with.
await admin.post('/api/v1/rules', { merchant: 'Micro Center', categoryId: await category('Electronics') });
imports = {
discover: await importText('Discover-2026.csv', discoverCsv(), ids.discover),
chase: await importText('Chase9876_Activity.csv', chaseCsv(), ids.chase),
checking: await importText('stmt.csv', checkingCsv(), ids.checking),
};
});
afterEach(() => t.cleanup());
describe('budgets', () => {
it('sets a monthly amount, a one-month exception, and "from this month on"', async () => {
const gifts = await category('Gifts');
let year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 10000, scope: 'year' })).json as BudgetYear;
expect(year.items).toEqual([{ categoryId: gifts, monthly: 10000, months: Array(12).fill(null) }]);
year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 12, amountCents: 80000, scope: 'month' })).json;
expect(year.items[0]!.months[11]).toBe(80000);
// October onward becomes $150; January to September keep $100. December's own amount gives way.
year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 10, amountCents: 15000, scope: 'rest' })).json;
const line = year.items[0]!;
const effective = line.months.map((m) => m ?? line.monthly);
expect(effective).toEqual([...Array(9).fill(10000), 15000, 15000, 15000]);
// Setting a month to the yearly amount leaves no needless exception behind.
year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 3, amountCents: 15000, scope: 'month' })).json;
expect(year.items[0]!.months[2]).toBeNull();
// Zero for every month removes the budget.
year = (await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 0, scope: 'year' })).json;
expect(year.items).toEqual([]);
});
it('fills empty categories from a 3-month average and copies a year forward', async () => {
const groceries = await category('Groceries');
const mine = await category('Dining Out');
await admin.put('/api/v1/budgets', { categoryId: mine, year: 2026, month: 1, amountCents: 12345, scope: 'year' });
const filled = await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' });
expect(filled.status).toBe(200);
expect(filled.json.filled).toBeGreaterThan(3);
const lines = new Map((filled.json.budgets as BudgetYear).items.map((l) => [l.categoryId, l]));
// Categories already budgeted are left alone; the rest round to $5 steps.
expect(lines.get(mine)!.monthly).toBe(12345);
const report = (await admin.get('/api/v1/reports/months?from=2026-07&to=2026-09')).json as MonthsReport;
const actual = report.categories.find((c) => c.categoryId === groceries)!.actual;
const average = actual.reduce((a, b) => a + b, 0) / 3;
expect(lines.get(groceries)!.monthly).toBe(Math.ceil(average / 500) * 500);
// Transfers never get a budget.
expect(lines.has(await category('Credit Card Payment'))).toBe(false);
const copied = await admin.post('/api/v1/budgets/copy-year', { from: 2026, to: 2027 });
expect(copied.json.copied).toBe(filled.json.filled + 1);
expect((await admin.put('/api/v1/budgets', { categoryId: await category('Credit Card Payment'), year: 2026, month: 1, amountCents: 100, scope: 'year' })).status).toBe(400);
});
});
describe('reports', () => {
it('totals each month by category: refunds reduce spending, transfers never count, splits count by part', async () => {
const report = (await admin.get('/api/v1/reports/months?from=2026-01&to=2026-09')).json as MonthsReport;
expect(report.months).toHaveLength(9);
expect(report.dataThrough).toBe('2026-09-29');
// Income: 19 or 20 paychecks of $2,450 across nine months.
const income = report.totals.income.reduce((a, b) => a + b, 0);
expect(income % 245000).toBe(0);
// Card payments are paired transfers, so no month counts them as spending.
const spentSep = report.totals.spent[8]!;
const page = (await admin.get('/api/v1/transactions?from=2026-09-01&to=2026-09-30&limit=500')).json;
expect(spentSep).toBe(-page.sums.outCents);
// Split a September Kroger trip: $20 of it was Household Supplies.
const kroger = page.items.find((x: { merchant: string }) => x.merchant === 'Kroger');
const groceries = await category('Groceries');
const household = await category('Household Supplies');
const before = report.categories.find((c) => c.categoryId === groceries)!.actual[8]!;
await admin.call('PUT', `/api/v1/transactions/${kroger.id}/split`, {
parts: [
{ categoryId: groceries, amountCents: kroger.amountCents + 2000 },
{ categoryId: household, amountCents: -2000 },
],
});
const after = (await admin.get('/api/v1/reports/months?from=2026-09&to=2026-09')).json as MonthsReport;
expect(after.categories.find((c) => c.categoryId === groceries)!.actual[0]).toBe(before - 2000);
expect(after.categories.find((c) => c.categoryId === household)!.actual[0]).toBe(2000);
expect(after.totals.spent[0]).toBe(spentSep);
});
it('shows the latest month with data on Home while statements catch up, and paces the year', async () => {
const gifts = await category('Groceries');
await admin.put('/api/v1/budgets', { categoryId: gifts, year: 2026, month: 1, amountCents: 30000, scope: 'year' });
const o = (await admin.get('/api/v1/reports/overview')).json as Overview;
if (today() > '2026-09-30') {
expect(o.month).toBe('2026-09');
expect(o.isCurrent).toBe(false);
}
expect(o.hasBudgets).toBe(true);
expect(o.budgetSpend).toBe(30000);
expect(o.year.budgetYear).toBe(360000);
expect(o.year.budgetSoFar).toBe(270000);
// Spending is far above this one small budget, so the projection runs over it.
expect(o.year.projected).toBeGreaterThan(o.year.budgetYear);
expect(o.categories[0]!.spent).toBeGreaterThanOrEqual(o.categories.at(-1)!.spent);
});
it('lists merchants, balances and tags', async () => {
const merchants = (await admin.get('/api/v1/reports/merchants?from=2026-01-01&to=2026-09-30')).json as { merchant: string; spent: number; count: number }[];
expect(merchants[0]!.merchant).toBe('Rocket Mortgage');
expect(merchants.find((m) => m.merchant === 'Starbucks')!.count).toBeGreaterThan(60);
expect(merchants.some((m) => /payment/i.test(m.merchant))).toBe(false);
const balances = (await admin.get('/api/v1/reports/balances?months=12')).json as { months: string[]; accounts: { accountId: number; balances: (number | null)[] }[] };
const checking = balances.accounts.find((a) => a.accountId === ids.checking)!;
const sep = balances.months.indexOf('2026-09');
expect(sep).toBeGreaterThan(-1);
// The statement's running balance anchors checking; the cards have no known balance yet.
expect(checking.balances[sep]).toBeTypeOf('number');
expect(balances.accounts.find((a) => a.accountId === ids.discover)!.balances.every((b) => b === null)).toBe(true);
const page = (await admin.get('/api/v1/transactions?q=Joe&limit=3')).json;
for (const x of page.items) await admin.patch(`/api/v1/transactions/${x.id}`, { tags: ['Birthday'] });
const tags = (await admin.get('/api/v1/reports/tags')).json as { tag: string; spent: number; count: number }[];
expect(tags[0]).toMatchObject({ tag: 'Birthday', count: 3 });
expect(tags[0]!.spent).toBe(-page.items.reduce((a: number, x: { amountCents: number }) => a + x.amountCents, 0));
});
});
describe('subscriptions', () => {
it('finds subscriptions and bills, one entry per service even across cards', async () => {
const list = await subs();
const byName = new Map(list.map((s) => [s.name, s]));
expect([...byName.keys()].sort()).toEqual(['Costco membership', 'Disney+', 'Duke Energy', 'Hulu', 'Netflix', 'Rocket Mortgage', 'Spotify']);
expect(byName.get('Netflix')).toMatchObject({ kind: 'subscription', cadence: 'monthly', expectedCents: 1799, chargeCount: 10, status: 'suggested', confidence: 'confident' });
expect(byName.get('Costco membership')).toMatchObject({ cadence: 'yearly', expectedCents: 6500, chargeCount: 1, monthlyCents: 542 });
expect(byName.get('Duke Energy')).toMatchObject({ kind: 'bill', chargeCount: 9 });
expect(byName.get('Rocket Mortgage')).toMatchObject({ kind: 'bill', expectedCents: 185000 });
expect(byName.get('Disney+')).toMatchObject({ confidence: 'likely', chargeCount: 2, manageUrl: 'https://www.disneyplus.com/account' });
const netflix = (await admin.get(`/api/v1/subscriptions/${byName.get('Netflix')!.id}`)).json as SubscriptionDetail;
expect(netflix.priceHistory).toEqual([
{ from: '2026-01-12', amountCents: 1549 },
{ from: '2026-07-12', amountCents: 1799 },
]);
expect(new Set(netflix.charges.map((c) => c.accountId))).toEqual(new Set([ids.discover, ids.chase]));
// Each find asks once.
expect((await alerts('subscriptions')).filter((a) => a.kind === 'sub_found')).toHaveLength(7);
});
it('raises alerts once confirmed: price change, two cards, charged after cancelling', async () => {
const netflix = await sub('Netflix');
await admin.patch(`/api/v1/subscriptions/${netflix.id}`, { status: 'active' });
const hulu = await sub('Hulu');
await admin.patch(`/api/v1/subscriptions/${hulu.id}`, { status: 'cancelled', cancelledOn: '2026-08-25' });
const list = await alerts('subscriptions');
const kinds = list.map((a) => a.kind);
expect(kinds).toContain('sub_two_cards');
expect(kinds).toContain('sub_after_cancel');
expect(list.find((a) => a.kind === 'sub_after_cancel')!.title).toBe('Hulu charged Discover after you marked it cancelled');
// The most urgent comes first.
expect(list[0]!.kind).toBe('sub_after_cancel');
// The July price rise is old news by September; recent rises raise an alert.
expect(kinds).not.toContain('sub_price');
const counts = (await admin.get('/api/v1/alerts/counts')).json;
expect(counts.subscriptions).toBe(list.length);
await admin.post('/api/v1/alerts/read', { area: 'subscriptions' });
expect((await admin.get('/api/v1/alerts/counts')).json.subscriptions).toBe(0);
const dismissed = list.find((a) => a.kind === 'sub_two_cards')!;
await admin.post(`/api/v1/alerts/${dismissed.id}/dismiss`);
expect((await alerts('subscriptions')).some((a) => a.id === dismissed.id)).toBe(false);
});
it('remembers "not a subscription", adds by hand, merges, and follows an undone import', async () => {
const disney = await sub('Disney+');
await admin.patch(`/api/v1/subscriptions/${disney.id}`, { status: 'dismissed' });
await admin.post('/api/v1/subscriptions/scan');
expect((await subs()).some((s) => s.name === 'Disney+')).toBe(false);
const added = await admin.post('/api/v1/subscriptions', { name: 'Car wash club', cadence: 'monthly', expectedCents: 2999, nextExpectedOn: '2026-10-15' });
expect(added.status).toBe(200);
expect(added.json).toMatchObject({ source: 'manual', status: 'active', chargeCount: 0 });
const upcoming = (await admin.get('/api/v1/subscriptions/upcoming?days=120')).json as { name: string }[];
expect(upcoming.some((u) => u.name === 'Car wash club')).toBe(true);
const spotify = await sub('Spotify');
const merged = await admin.post(`/api/v1/subscriptions/${added.json.id}/merge`, { intoId: spotify.id });
expect(merged.json.aliases).toEqual(['Car wash club']);
expect((await subs()).some((s) => s.name === 'Car wash club')).toBe(false);
// Undo the Chase import: Disney+'s charges go with it and the second Netflix card disappears.
await admin.del(`/api/v1/imports/${imports.chase}`);
const netflix = (await admin.get(`/api/v1/subscriptions/${(await sub('Netflix')).id}`)).json as SubscriptionDetail;
expect(netflix.charges.every((c) => c.accountId === ids.discover)).toBe(true);
expect(netflix.chargeCount).toBe(9);
});
it('files every charge when a subscription’s category changes', async () => {
const spotify = await sub('Spotify');
const streaming = await category('Streaming and Subscriptions');
await admin.patch(`/api/v1/subscriptions/${spotify.id}`, { status: 'active', categoryId: streaming });
const charges = (await admin.get(`/api/v1/transactions?subscriptionId=${spotify.id}&limit=50`)).json;
expect(charges.total).toBe(9);
expect(charges.items.every((x: { categoryId: number; needsReview: boolean }) => x.categoryId === streaming && !x.needsReview)).toBe(true);
});
it('reads rhythms, allowing a skipped month', () => {
expect(fitCadence(['2026-01-05', '2026-02-05', '2026-03-06', '2026-05-05'])).toBe('monthly');
expect(fitCadence(['2025-03-10', '2026-03-09'])).toBe('yearly');
expect(fitCadence(['2026-01-05', '2026-01-09', '2026-01-20'])).toBeNull();
});
});
describe('money alerts', () => {
it('flags an unusual purchase and a month over budget, set against the year', async () => {
const list = await alerts('money');
const unusual = list.find((a) => a.kind === 'unusual')!;
expect(unusual.title).toBe('Unusual: $480.00 at Best Buy');
expect(unusual.body).toMatch(/times your typical Electronics purchase/);
const dining = await category('Dining Out');
await admin.put('/api/v1/budgets', { categoryId: dining, year: 2026, month: 1, amountCents: 1000, scope: 'year' });
const after = await alerts('money');
const over = after.find((a) => a.kind === 'month_category_over');
if (today() > '2026-09-30') {
expect(over!.title).toMatch(/^Dining Out ran \$\d+(\.\d\d)? over in September$/);
expect(over!.body).toMatch(/over budget for the year/);
expect(after.find((a) => a.kind === 'year_pace')!.title).toMatch(/^On pace to finish 2026 .* over budget$/);
}
});
it('warns while a month is running when a category nears its budget', async () => {
const now = today();
const coffee = await category('Coffee and Snacks');
const add = await admin.post('/api/v1/transactions', { accountId: ids.checking, date: now, amountCents: -9000, merchant: 'Corner Cafe', categoryId: coffee });
expect(add.status).toBe(200);
await admin.put('/api/v1/budgets', { categoryId: coffee, year: Number(now.slice(0, 4)), month: Number(now.slice(5, 7)), amountCents: 10000, scope: 'month' });
const list = await alerts('money');
const left = new Date(Number(now.slice(0, 4)), Number(now.slice(5, 7)), 0).getDate() - Number(now.slice(8, 10));
const close = list.find((a) => a.kind === 'budget_close' || a.kind === 'budget_over');
if (left > 0) expect(close?.title).toMatch(/^Coffee and Snacks is at \d+% of budget with \d+ days? left$/);
});
});
describe('managing categories, rules and tags', () => {
it('adds, renames, archives and merges categories, carrying budgets along', async () => {
const home = (await groups()).find((g) => g.name === 'Home')!;
const added = await admin.post('/api/v1/categories', { groupId: home.id, name: 'Lawn Care', icon: 'wrench', color: 'emerald' });
expect(added.json).toMatchObject({ name: 'Lawn Care', kind: 'expense', groupName: 'Home' });
expect((await admin.post('/api/v1/categories', { groupId: home.id, name: 'lawn care' })).status).toBe(400);
const repairs = await category('Repairs and Maintenance');
await admin.put('/api/v1/budgets', { categoryId: added.json.id, year: 2026, month: 1, amountCents: 5000, scope: 'year' });
await admin.put('/api/v1/budgets', { categoryId: repairs, year: 2026, month: 1, amountCents: 10000, scope: 'year' });
const page = (await admin.get('/api/v1/transactions?q=Joe&limit=1')).json;
await admin.patch(`/api/v1/transactions/${page.items[0].id}`, { categoryId: added.json.id });
const merged = await admin.post(`/api/v1/categories/${added.json.id}/merge`, { intoId: repairs });
expect(merged.json.moved).toBe(1);
const budgets = (await admin.get('/api/v1/budgets?year=2026')).json as BudgetYear;
expect(budgets.items.find((l) => l.categoryId === repairs)!.monthly).toBe(15000);
expect((await admin.get(`/api/v1/transactions/${page.items[0].id}`)).json.categoryId).toBe(repairs);
// Merging spending into income makes no sense.
expect((await admin.post(`/api/v1/categories/${repairs}/merge`, { intoId: await category('Paycheck') })).status).toBe(400);
await admin.patch(`/api/v1/categories/${repairs}`, { name: 'Home Repairs', archived: true });
const active = (await admin.get('/api/v1/categories')).json as { categories: { name: string }[] }[];
expect(active.flatMap((g) => g.categories).some((c) => c.name === 'Home Repairs')).toBe(false);
expect((await groups()).flatMap((g) => g.categories).find((c) => c.id === repairs)).toMatchObject({ name: 'Home Repairs', archived: true });
expect((await admin.del(`/api/v1/category-groups/${home.id}`)).status).toBe(400);
const empty = await admin.post('/api/v1/category-groups', { name: 'Side business' });
expect((await admin.del(`/api/v1/category-groups/${empty.json.id}`)).status).toBe(200);
});
it('counts rule use on import, edits rules, and merges tags on rename', async () => {
const rules = (await admin.get('/api/v1/rules')).json as { id: number; merchant: string; hits: number; lastUsedAt: string | null }[];
const micro = rules.find((r) => r.merchant === 'Micro Center')!;
expect(micro.hits).toBe(1);
expect(micro.lastUsedAt).not.toBeNull();
const edited = await admin.patch(`/api/v1/rules/${micro.id}`, { renameTo: 'Micro Center Electronics' });
expect(edited.json.renameTo).toBe('Micro Center Electronics');
expect((await admin.patch(`/api/v1/rules/${micro.id}`, { categoryId: null, renameTo: null })).status).toBe(400);
const page = (await admin.get('/api/v1/transactions?q=Kroger&limit=2')).json;
await admin.patch(`/api/v1/transactions/${page.items[0].id}`, { tags: ['Party'] });
await admin.patch(`/api/v1/transactions/${page.items[1].id}`, { tags: ['Parties'] });
let tags = (await admin.get('/api/v1/tags')).json as { id: number; name: string; count: number }[];
const party = tags.find((x) => x.name === 'Parties')!;
await admin.patch(`/api/v1/tags/${party.id}`, { name: 'party' });
tags = (await admin.get('/api/v1/tags')).json;
expect(tags).toEqual([{ id: expect.any(Number), name: 'Party', count: 2 }]);
});
});
describe('export', () => {
it('writes CSV that defuses formulas and splits rows by part', async () => {
expect(buildCsv(['A', 'B'], [['=HYPERLINK("x")', { money: -1234 }], ['a,b', 5]])).toBe('\uFEFFA,B\r\n"\'=HYPERLINK(""x"")",-12.34\r\n"a,b",5\r\n');
const res = await t.app.request('http://localhost/api/v1/export/transactions.csv?q=Netflix', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
expect(res.status).toBe(200);
expect(res.headers.get('content-disposition')).toMatch(/attachment; filename="spendlight-transactions-\d{4}-\d\d-\d\d\.csv"/);
const bytes = Buffer.from(await res.arrayBuffer());
// Excel needs the byte-order mark to read accents and symbols correctly.
expect([...bytes.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf]);
const lines = bytes.subarray(3).toString('utf8').trim().split('\r\n');
expect(lines[0]).toBe('Date,Account,Merchant,Category,Group,Amount,Split of,Transfer with,Tags,Note,Subscription,Status,Bank description');
expect(lines).toHaveLength(11);
});
it('writes a real Excel workbook with every part Excel needs', async () => {
const res = await t.app.request('http://localhost/api/v1/export/everything.xlsx', { headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
expect(res.status).toBe(200);
const zip = Buffer.from(await res.arrayBuffer());
const files = unzip(zip);
expect([...files.keys()].sort()).toEqual([
'[Content_Types].xml',
'_rels/.rels',
'docProps/app.xml',
'docProps/core.xml',
'xl/_rels/workbook.xml.rels',
'xl/styles.xml',
'xl/workbook.xml',
'xl/worksheets/sheet1.xml',
'xl/worksheets/sheet2.xml',
'xl/worksheets/sheet3.xml',
'xl/worksheets/sheet4.xml',
'xl/worksheets/sheet5.xml',
'xl/worksheets/sheet6.xml',
]);
expect(files.get('xl/workbook.xml')).toContain('<sheet name="Subscriptions" sheetId="5" r:id="rId5"/>');
const sheet = files.get('xl/worksheets/sheet1.xml')!;
expect(sheet).toContain('<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>');
expect(sheet).toMatch(/<c r="F2" s="2"><v>-?\d+\.\d\d<\/v><\/c>/);
expect(sheet).toMatch(/<c r="A2" s="3"><v>46\d\d\d<\/v><\/c>/);
});
});
describe('roles and AI', () => {
it('keeps budgets read-only for read-only people and AI off without a provider', async () => {
await admin.post('/api/v1/people', { username: 'reader', displayName: 'Reader', access: 'read', isAdmin: false, password: 'reader-password-1' });
const { Client } = await import('./helpers');
const reader = new Client(t);
await reader.post('/api/v1/auth/login', { username: 'reader', password: 'reader-password-1' });
expect((await reader.get('/api/v1/reports/overview')).status).toBe(200);
expect((await reader.put('/api/v1/budgets', { categoryId: 1, year: 2026, month: 1, amountCents: 100, scope: 'year' })).status).toBe(403);
expect((await reader.get('/api/v1/ai')).status).toBe(403);
const status = (await admin.get('/api/v1/ai')).json;
expect(status.providerConfigured).toBe(false);
expect(status.features.every((f: { enabled: boolean }) => !f.enabled)).toBe(true);
const refused = await admin.patch('/api/v1/ai', { feature: 'categorize', enabled: true });
expect(refused.status).toBe(400);
expect(refused.json.error.code).toBe('no_provider');
});
});
/** Read a zip's entries, enough to check what buildXlsx wrote. */
function unzip(buf: Buffer): Map<string, string> {
const out = new Map<string, string>();
const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
const entries = buf.readUInt16LE(end + 10);
let p = buf.readUInt32LE(end + 16);
for (let i = 0; i < entries; i++) {
const nameLength = buf.readUInt16LE(p + 28);
const extra = buf.readUInt16LE(p + 30);
const comment = buf.readUInt16LE(p + 32);
const offset = buf.readUInt32LE(p + 42);
const name = buf.subarray(p + 46, p + 46 + nameLength).toString('utf8');
const size = buf.readUInt32LE(p + 20);
const local = offset + 30 + buf.readUInt16LE(offset + 26) + buf.readUInt16LE(offset + 28);
out.set(name, inflateRawSync(buf.subarray(local, local + size)).toString('utf8'));
p += 46 + nameLength + extra + comment;
}
return out;
}

View File

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

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

@@ -0,0 +1,125 @@
// Made-up statements for nine months of 2026, shaped like the real exports
// (Discover, Chase and Bank of America checking). Nothing here is real data.
// They carry the patterns milestone 2 has to find: subscriptions with a price
// change, a cancelled service that charges again, one billed on two cards, a
// yearly membership, variable bills, and an unusually large purchase.
function random(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const pad = (n: number) => String(n).padStart(2, '0');
const us = (iso: string) => `${iso.slice(5, 7)}/${iso.slice(8, 10)}/${iso.slice(0, 4)}`;
const day = (month: number, d: number) => `2026-${pad(month)}-${pad(d)}`;
const money = (cents: number) => (cents / 100).toFixed(2);
const quoted = (cents: number) => `"${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}"`;
interface Row {
date: string;
description: string;
/** Money out negative. */
cents: number;
category?: string;
}
function everyDay(months: number[], fn: (date: string, weekday: number) => void) {
for (const m of months) {
const last = new Date(2026, m, 0).getDate();
for (let d = 1; d <= last; d++) fn(day(m, d), new Date(2026, m - 1, d).getDay());
}
}
const MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
export function discoverRows(): Row[] {
const r = random(7);
const rows: Row[] = [];
for (const m of MONTHS) {
rows.push({ date: day(m, 12), description: 'NETFLIX.COM 866-579-7172 CA', cents: m <= 6 ? -1549 : -1799, category: 'Services' });
rows.push({ date: day(m, 3), description: 'SPOTIFY USA 877-778-1161 NY', cents: -1199, category: 'Services' });
rows.push({ date: day(m, 20), description: 'HULU 877-8244858 CA', cents: -799, category: 'Services' });
rows.push({ date: day(m, 25), description: 'MICRO CENTER #045 CHARLOTTE NC', cents: -Math.round(4000 + r() * 4000), category: 'Merchandise' });
rows.push({ date: day(m, 8), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
rows.push({ date: day(m, 22), description: 'JOES DINER #12 CHARLOTTE NC', cents: -Math.round(2500 + r() * 3500), category: 'Restaurants' });
rows.push({ date: day(m, 28), description: 'INTERNET PAYMENT - THANK YOU', cents: 60000, category: 'Payments and Credits' });
}
everyDay(MONTHS, (date, weekday) => {
if (weekday === 6) rows.push({ date, description: 'KROGER #123 CHARLOTTE NC', cents: -Math.round(4000 + r() * 10000), category: 'Supermarkets' });
if (weekday === 2 || weekday === 4) rows.push({ date, description: 'STARBUCKS #4567 CHARLOTTE NC', cents: -545, category: 'Restaurants' });
});
rows.push({ date: day(3, 10), description: 'COSTCO *ANNUAL RENEWAL 800-774-2678 WA', cents: -6500, category: 'Warehouse Clubs' });
rows.push({ date: day(9, 15), description: 'BEST BUY #321 CHARLOTTE NC', cents: -48000, category: 'Merchandise' });
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
export function chaseRows(): Row[] {
const r = random(11);
const rows: Row[] = [];
everyDay(MONTHS, (date, weekday) => {
if (weekday === 1) rows.push({ date, description: 'SHELL OIL 57444 CHARLOTTE NC', cents: -Math.round(3000 + r() * 2500), category: 'Gas' });
if (weekday === 5 && r() < 0.6) rows.push({ date, description: `AMAZON MKTPL*${Math.floor(r() * 1e9).toString(36).toUpperCase()}`, cents: -Math.round(1500 + r() * 6000), category: 'Shopping' });
});
for (const m of MONTHS) rows.push({ date: day(m, 27), description: 'AUTOMATIC PAYMENT - THANK', cents: 40000, category: '' });
rows.push({ date: day(8, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
rows.push({ date: day(9, 5), description: 'DISNEY PLUS 888-905-7888 CA', cents: -1599, category: 'Entertainment' });
// The same Netflix plan, accidentally on a second card.
rows.push({ date: day(9, 14), description: 'NETFLIX.COM 866-579-7172 CA', cents: -1799, category: 'Entertainment' });
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
export function checkingRows(): Row[] {
const r = random(23);
const rows: Row[] = [];
for (let d = new Date(2026, 0, 9); d.getMonth() < 9; d.setDate(d.getDate() + 14)) {
rows.push({ date: d.toLocaleDateString('en-CA'), description: 'ACME CORP DES:PAYROLL ID:XXXXX12345 INDN:DOE,JANE CO ID:XXXXX12345 PPD', cents: 245000 });
}
for (const m of MONTHS) {
rows.push({ date: day(m, 1), description: 'ROCKET MORTGAGE DES:MTG PYMT ID:XXXXX77777 INDN:JANE DOE CO ID:XXXXX77777 PPD', cents: -185000 });
rows.push({ date: day(m, 22), description: 'DUKE ENERGY DES:DUKEENERGY ID:XXXXX33333 INDN:JANE DOE CO ID:XXXXX33333 WEB', cents: -Math.round(9500 + r() * 8500) });
rows.push({ date: day(m, 28), description: 'DISCOVER DES:E-PAYMENT ID:XXXXX99999 INDN:JANE DOE CO ID:XXXXX99999 WEB', cents: -60000 });
rows.push({ date: day(m, 27), description: 'CHASE CREDIT CRD DES:EPAY ID:XXXXX55555 INDN:JANE DOE CO ID:XXXXX55555 WEB', cents: -40000 });
}
return rows.sort((a, b) => a.date.localeCompare(b.date));
}
export function discoverCsv(rows = discoverRows()): string {
return ['Trans. Date,Post Date,Description,Amount,Category', ...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${money(-x.cents)},${x.category ?? ''}`)].join('\n') + '\n';
}
export function chaseCsv(rows = chaseRows()): string {
return (
[
'Transaction Date,Post Date,Description,Category,Type,Amount,Memo',
...rows.map((x) => `${us(x.date)},${us(x.date)},${x.description},${x.category ?? ''},${x.cents > 0 ? 'Payment' : 'Sale'},${money(x.cents)},`),
].join('\n') + '\n'
);
}
export function checkingCsv(rows = checkingRows(), opening = 500000): string {
let balance = opening;
const lines = rows.map((x) => {
balance += x.cents;
return `${us(x.date)},"${x.description}",${quoted(x.cents)},${quoted(balance)}`;
});
const credits = rows.filter((x) => x.cents > 0).reduce((a, x) => a + x.cents, 0);
const debits = rows.filter((x) => x.cents < 0).reduce((a, x) => a + x.cents, 0);
return (
[
'Description,,Summary Amt.',
`Beginning balance as of 01/01/2026,,${quoted(opening)}`,
`Total credits,,${quoted(credits)}`,
`Total debits,,${quoted(debits)}`,
`Ending balance as of 09/30/2026,,${quoted(balance)}`,
'',
'Date,Description,Amount,Running Bal.',
`01/01/2026,Beginning balance as of 01/01/2026,,${quoted(opening)}`,
...lines,
].join('\n') + '\n'
);
}