import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { paginationOptsValidator } from "convex/server"; import { assertOwned, enrichTransactionFields, getAppSettings, recomputeEffectiveMonth, requireUserId, } from "./lib/helpers"; import { applySalaryShiftRule } from "./lib/month"; const transactionValidator = v.object({ _id: v.id("transactions"), _creationTime: v.number(), userId: v.id("users"), accountId: v.optional(v.id("accounts")), categoryId: v.optional(v.id("categories")), bookingDate: v.optional(v.string()), valueDate: v.optional(v.string()), description: v.string(), counterparty: v.optional(v.string()), amount: v.number(), vorgang: v.optional(v.string()), isPending: v.boolean(), notes: v.optional(v.string()), rawText: v.optional(v.string()), importId: v.optional(v.id("imports")), assignedMonth: v.optional(v.string()), effectiveMonth: v.optional(v.string()), dedupHash: v.optional(v.string()), externalRef: v.optional(v.string()), }); export const list = query({ args: { paginationOpts: paginationOptsValidator, search: v.optional(v.string()), from: v.optional(v.string()), to: v.optional(v.string()), categoryIds: v.optional(v.array(v.id("categories"))), accountId: v.optional(v.id("accounts")), type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))), pendingOnly: v.optional(v.boolean()), }, returns: v.object({ page: v.array(transactionValidator), isDone: v.boolean(), continueCursor: v.union(v.string(), v.null()), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); let q = ctx.db .query("transactions") .withIndex("by_user_booking", (q) => q.eq("userId", userId)) .order("desc"); const result = await q.paginate(args.paginationOpts); let page = result.page; if (args.from) { page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!); } if (args.to) { page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!); } if (args.accountId) { page = page.filter((tx) => tx.accountId === args.accountId); } if (args.pendingOnly) { page = page.filter((tx) => tx.isPending); } if (args.type === "einnahme") { page = page.filter((tx) => tx.amount > 0); } if (args.type === "ausgabe") { page = page.filter((tx) => tx.amount < 0); } if (args.categoryIds && args.categoryIds.length > 0) { const set = new Set(args.categoryIds); page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId)); } if (args.search) { const s = args.search.toLowerCase(); page = page.filter( (tx) => tx.description.toLowerCase().includes(s) || (tx.counterparty?.toLowerCase().includes(s) ?? false) || (tx.rawText?.toLowerCase().includes(s) ?? false), ); } return { page, isDone: result.isDone, continueCursor: result.continueCursor, }; }, }); export const create = mutation({ args: { accountId: v.optional(v.id("accounts")), categoryId: v.optional(v.id("categories")), bookingDate: v.optional(v.string()), valueDate: v.optional(v.string()), description: v.string(), counterparty: v.optional(v.string()), amount: v.number(), vorgang: v.optional(v.string()), isPending: v.boolean(), notes: v.optional(v.string()), rawText: v.optional(v.string()), assignedMonth: v.optional(v.string()), }, returns: v.id("transactions"), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const enriched = await enrichTransactionFields(ctx, userId, args); const existingDedup = await ctx.db .query("transactions") .withIndex("by_user_dedup", (q) => q.eq("userId", userId).eq("dedupHash", enriched.dedupHash), ) .unique(); if (existingDedup) throw new Error("Duplikat erkannt"); return await ctx.db.insert("transactions", { userId, accountId: args.accountId, categoryId: enriched.categoryId, bookingDate: args.isPending ? undefined : args.bookingDate, valueDate: args.valueDate, description: args.description, counterparty: args.counterparty, amount: enriched.amount, vorgang: args.vorgang, isPending: args.isPending, notes: args.notes, rawText: args.rawText, assignedMonth: enriched.assignedMonth, effectiveMonth: enriched.effectiveMonth, dedupHash: enriched.dedupHash, }); }, }); export const update = mutation({ args: { id: v.id("transactions"), accountId: v.optional(v.id("accounts")), categoryId: v.optional(v.id("categories")), bookingDate: v.optional(v.string()), valueDate: v.optional(v.string()), description: v.optional(v.string()), counterparty: v.optional(v.string()), amount: v.optional(v.number()), vorgang: v.optional(v.string()), isPending: v.optional(v.boolean()), notes: v.optional(v.string()), assignedMonth: v.optional(v.union(v.string(), v.null())), }, returns: v.null(), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const tx = await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion"); const patch: Record = {}; for (const key of [ "accountId", "categoryId", "bookingDate", "valueDate", "description", "counterparty", "amount", "vorgang", "isPending", "notes", ] as const) { if (args[key] !== undefined) patch[key] = args[key]; } if (args.assignedMonth !== undefined) { patch.assignedMonth = args.assignedMonth === null ? undefined : args.assignedMonth; } const merged = { ...tx, ...patch }; if (merged.isPending) { patch.bookingDate = undefined; } const enriched = await enrichTransactionFields(ctx, userId, { accountId: merged.accountId, categoryId: merged.categoryId, bookingDate: merged.isPending ? undefined : merged.bookingDate, valueDate: merged.valueDate, description: merged.description, counterparty: merged.counterparty, amount: merged.amount, vorgang: merged.vorgang, isPending: merged.isPending, notes: merged.notes, rawText: merged.rawText, assignedMonth: merged.assignedMonth, externalRef: merged.externalRef, }); patch.categoryId = enriched.categoryId; patch.assignedMonth = enriched.assignedMonth; patch.effectiveMonth = enriched.effectiveMonth; patch.dedupHash = enriched.dedupHash; patch.amount = enriched.amount; await ctx.db.patch(args.id, patch); return null; }, }); export const remove = mutation({ args: { id: v.id("transactions") }, returns: v.null(), handler: async (ctx, args) => { const userId = await requireUserId(ctx); await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion"); await ctx.db.delete(args.id); return null; }, }); export const bulkSetCategory = mutation({ args: { ids: v.array(v.id("transactions")), categoryId: v.id("categories"), }, returns: v.null(), handler: async (ctx, args) => { const userId = await requireUserId(ctx); await assertOwned(await ctx.db.get("categories", args.categoryId), userId, "Kategorie"); for (const id of args.ids) { const tx = await assertOwned(await ctx.db.get("transactions", id), userId, "Transaktion"); await ctx.db.patch(id, { categoryId: args.categoryId }); const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, tx.assignedMonth); if (effectiveMonth !== tx.effectiveMonth) { await ctx.db.patch(id, { effectiveMonth }); } } return null; }, }); export const setAssignedMonth = mutation({ args: { id: v.id("transactions"), month: v.union(v.string(), v.null()), }, returns: v.null(), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const tx = await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion"); const assignedMonth = args.month === null ? undefined : args.month; const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, assignedMonth); await ctx.db.patch(args.id, { assignedMonth, effectiveMonth }); return null; }, }); export const applySalaryShift = mutation({ args: {}, returns: v.object({ updated: v.number() }), handler: async (ctx) => { const userId = await requireUserId(ctx); const settings = await getAppSettings(ctx, userId); if (!settings) throw new Error("Einstellungen nicht gefunden"); const categories = await ctx.db .query("categories") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); const nameById = new Map(categories.map((c) => [c._id, c.name])); const txs = await ctx.db .query("transactions") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); let updated = 0; for (const tx of txs) { if (tx.assignedMonth) continue; const categoryName = tx.categoryId ? nameById.get(tx.categoryId) : undefined; const assignedMonth = applySalaryShiftRule( tx.bookingDate, tx.amount, categoryName, settings.salaryShift, ); if (!assignedMonth) continue; const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, assignedMonth); await ctx.db.patch(tx._id, { assignedMonth, effectiveMonth }); updated++; } return { updated }; }, });