initial commit

This commit is contained in:
Matthias
2026-06-15 11:33:23 +02:00
commit fc0a6fb975
155 changed files with 24526 additions and 0 deletions

213
convex/dashboard.ts Normal file
View File

@@ -0,0 +1,213 @@
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<string, { income: number; expenses: number }>();
const categoryMap = new Map<string, { categoryId?: typeof categories[0]["_id"]; name: string; amount: number; block?: typeof categories[0]["block"]; color: string }>();
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,
};
},
});