Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.
Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).
Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.
Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
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, splitTags, 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);
|
||
// Uses: transactions tagged, plus purchases with a tagged part.
|
||
const rows = ctx.sqlite
|
||
.prepare(
|
||
`SELECT t.id, t.name, (SELECT count(*) FROM txn_tag tt WHERE tt.tag_id = t.id)
|
||
+ (SELECT count(DISTINCT s.transaction_id) FROM split_tag st JOIN split s ON s.id = st.split_id WHERE st.tag_id = t.id) AS count
|
||
FROM tag t ORDER BY count DESC, t.name`,
|
||
)
|
||
.all() as { id: number; name: string; count: number }[];
|
||
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();
|
||
}
|
||
for (const link of ctx.db.select().from(splitTags).where(eq(splitTags.tagId, id)).all()) {
|
||
ctx.db.insert(splitTags).values({ splitId: link.splitId, 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;
|
||
}
|