import { query } from "./_generated/server"; import { v } from "convex/values"; import { getAppSettings, requireUserId } from "./lib/helpers"; import { buildSchedule, currentBalanceFromSchedule, totalMonthlyPaymentActiveLoans, totalRemainingDebt, } from "./lib/amortization"; import { monthKeyFromBasis } from "./lib/month"; export const summary = query({ args: { from: v.string(), to: v.string(), accountId: v.optional(v.id("accounts")), basis: v.union(v.literal("effective"), v.literal("booking")), }, returns: v.object({ income: v.number(), expenses: v.number(), fixedCosts: v.number(), variableCosts: v.number(), balance: v.number(), savingsRate: v.union(v.number(), v.null()), totalLoanPayment: v.number(), totalRemainingDebt: v.number(), monthlyTrend: v.array( v.object({ month: v.string(), income: v.number(), expenses: v.number(), balance: v.number(), }), ), categoryBreakdown: v.array( v.object({ categoryId: v.optional(v.id("categories")), name: v.string(), amount: v.number(), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), color: v.string(), }), ), recentTransactions: v.array( v.object({ _id: v.id("transactions"), bookingDate: v.optional(v.string()), description: v.string(), amount: v.number(), categoryId: v.optional(v.id("categories")), isPending: v.boolean(), }), ), activeLoans: v.array( v.object({ _id: v.id("loans"), name: v.string(), monthlyPayment: v.optional(v.number()), currentBalance: v.number(), remainingMonths: v.number(), payoffDate: v.string(), }), ), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); await getAppSettings(ctx, userId); const categories = await ctx.db .query("categories") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); const categoryById = new Map(categories.map((c) => [c._id, c])); let transactions = await ctx.db .query("transactions") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); if (args.accountId) { transactions = transactions.filter((tx) => tx.accountId === args.accountId); } transactions = transactions.filter((tx) => { const month = monthKeyFromBasis(tx, args.basis); if (!month) return false; const monthStart = `${month}-01`; const monthEnd = `${month}-31`; return monthStart >= `${args.from.slice(0, 7)}-01` && monthEnd <= `${args.to.slice(0, 7)}-31`; }); // Filter by date range using month keys transactions = transactions.filter((tx) => { const month = monthKeyFromBasis(tx, args.basis); if (!month) return false; return month >= args.from.slice(0, 7) && month <= args.to.slice(0, 7); }); let income = 0; let expenses = 0; let fixedCosts = 0; let variableCosts = 0; const monthlyMap = new Map(); const categoryMap = new Map(); for (const tx of transactions) { const month = monthKeyFromBasis(tx, args.basis)!; const entry = monthlyMap.get(month) ?? { income: 0, expenses: 0 }; if (tx.amount > 0) { income += tx.amount; entry.income += tx.amount; } else { expenses += tx.amount; entry.expenses += tx.amount; const cat = tx.categoryId ? categoryById.get(tx.categoryId) : undefined; if (cat?.block === "wiederkehrend") fixedCosts += tx.amount; if (cat?.block === "variabel") variableCosts += tx.amount; const key = tx.categoryId ?? "none"; const catEntry = categoryMap.get(key) ?? { categoryId: tx.categoryId, name: cat?.name ?? "Ohne Kategorie", amount: 0, block: cat?.block, color: cat?.color ?? "#94a3b8", }; catEntry.amount += tx.amount; categoryMap.set(key, catEntry); } monthlyMap.set(month, entry); } const balance = income + expenses; const savingsRate = income > 0 ? balance / income : null; const monthlyTrend = [...monthlyMap.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([month, data]) => ({ month, income: Math.round(data.income * 100) / 100, expenses: Math.round(data.expenses * 100) / 100, balance: Math.round((data.income + data.expenses) * 100) / 100, })); const categoryBreakdown = [...categoryMap.values()] .sort((a, b) => a.amount - b.amount) .map((c) => ({ ...c, amount: Math.round(c.amount * 100) / 100 })); const recentTransactions = (await ctx.db .query("transactions") .withIndex("by_user_booking", (q) => q.eq("userId", userId)) .order("desc") .take(10)) .filter((tx) => !args.accountId || tx.accountId === args.accountId) .map((tx) => ({ _id: tx._id, bookingDate: tx.bookingDate, description: tx.description, amount: tx.amount, categoryId: tx.categoryId, isPending: tx.isPending, })); const loans = await ctx.db .query("loans") .withIndex("by_user_status", (q) => q.eq("userId", userId).eq("status", "aktiv")) .collect(); const activeLoans = loans.map((loan) => { const startDate = new Date(loan.startDate); const scheduleResult = buildSchedule({ principal: loan.principal, annualRate: loan.annualInterestRate, startDate, monthlyPayment: loan.monthlyPayment, termMonths: loan.termMonths, }); const currentBalance = loan.currentBalance ?? currentBalanceFromSchedule(scheduleResult.schedule, startDate); const monthsElapsed = Math.max( 0, (new Date().getFullYear() - startDate.getFullYear()) * 12 + (new Date().getMonth() - startDate.getMonth()), ); return { _id: loan._id, name: loan.name, monthlyPayment: loan.monthlyPayment, currentBalance, remainingMonths: Math.max(0, scheduleResult.termMonths - monthsElapsed), payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10), }; }); return { income: Math.round(income * 100) / 100, expenses: Math.round(expenses * 100) / 100, fixedCosts: Math.round(fixedCosts * 100) / 100, variableCosts: Math.round(variableCosts * 100) / 100, balance: Math.round(balance * 100) / 100, savingsRate: savingsRate === null ? null : Math.round(savingsRate * 1000) / 1000, totalLoanPayment: totalMonthlyPaymentActiveLoans(loans), totalRemainingDebt: totalRemainingDebt(loans), monthlyTrend, categoryBreakdown, recentTransactions, activeLoans, }; }, });