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