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"))), withoutCategory: v.optional(v.boolean()), accountId: v.optional(v.id("accounts")), basis: v.optional(v.union(v.literal("effective"), v.literal("booking"))), 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); const basis = args.basis ?? "booking"; const fromMonth = args.from?.slice(0, 7); const toMonth = args.to?.slice(0, 7); let q; if (args.search) { q = ctx.db .query("transactions") .withSearchIndex("search_description", (sq) => sq.search("description", args.search!).eq("userId", userId), ); } else { if (basis === "effective") { q = ctx.db .query("transactions") .withIndex("by_user_effmonth", (iq) => { if (fromMonth && toMonth) { return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth); } if (fromMonth) { return iq.eq("userId", userId).gte("effectiveMonth", fromMonth); } if (toMonth) { return iq.eq("userId", userId).lte("effectiveMonth", toMonth); } return iq.eq("userId", userId); }) .order("desc"); } else { q = ctx.db .query("transactions") .withIndex("by_user_booking", (iq) => { if (args.from && args.to) { return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to); } if (args.from) { return iq.eq("userId", userId).gte("bookingDate", args.from); } if (args.to) { return iq.eq("userId", userId).lte("bookingDate", args.to); } return iq.eq("userId", userId); }) .order("desc"); } } if (args.search) { if (basis === "effective") { if (fromMonth) { const fallbackFrom = `${fromMonth}-01`; q = q.filter((f) => f.or( f.gte(f.field("effectiveMonth"), fromMonth), f.and( f.eq(f.field("effectiveMonth"), undefined), f.gte(f.field("bookingDate"), fallbackFrom), ), ), ); } if (toMonth) { const fallbackTo = `${toMonth}-31`; q = q.filter((f) => f.or( f.lte(f.field("effectiveMonth"), toMonth), f.and( f.eq(f.field("effectiveMonth"), undefined), f.lte(f.field("bookingDate"), fallbackTo), ), ), ); } } else { if (args.from) { const from = args.from; q = q.filter((f) => f.gte(f.field("bookingDate"), from)); } if (args.to) { const to = args.to; q = q.filter((f) => f.lte(f.field("bookingDate"), to)); } } } if (args.pendingOnly) { q = q.filter((f) => f.eq(f.field("isPending"), true)); } if (args.accountId) { q = q.filter((f) => f.eq(f.field("accountId"), args.accountId)); } if (args.type === "einnahme") { q = q.filter((f) => f.gt(f.field("amount"), 0)); } if (args.type === "ausgabe") { q = q.filter((f) => f.lt(f.field("amount"), 0)); } if (args.categoryIds && args.categoryIds.length > 0) { q = q.filter((f) => f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))), ); } if (args.withoutCategory) { q = q.filter((f) => f.eq(f.field("categoryId"), undefined)); } const result = await q.paginate(args.paginationOpts); return { page: result.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 }; }, });