214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
});
|