Files
finanzen/convex/loans.ts
Matthias 4a1cbd105b Add realistic loan amortization with currentBalance back-calculation
- Extend schema with effectiveAnnualRate, totalInterest, totalAmount
- Back-calculate paid months from currentBalance and rebuild schedule
- Allow schedule calculation from termMonths without monthlyPayment
- Handle NaN form values gracefully
- Show effective rate, total interest and total amount in UI
- Add amortization unit tests
2026-06-15 20:02:44 +02:00

217 lines
6.9 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { assertOwned, requireUserId } from "./lib/helpers";
import {
buildSchedule,
computePaymentFromTerm,
computeTermFromPayment,
} from "./lib/amortization";
const loanValidator = v.object({
_id: v.id("loans"),
_creationTime: v.number(),
userId: v.id("users"),
name: v.string(),
lender: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
principal: v.number(),
annualInterestRate: v.number(),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.string(),
currentBalance: v.optional(v.number()),
totalInterest: v.optional(v.number()),
totalAmount: v.optional(v.number()),
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
notes: v.optional(v.string()),
});
function normalizeLoanFields(args: {
principal: number;
annualInterestRate: number;
monthlyPayment?: number;
termMonths?: number;
}) {
let monthlyPayment = Number.isNaN(args.monthlyPayment ?? 0) ? undefined : args.monthlyPayment;
let termMonths = Number.isNaN(args.termMonths ?? 0) ? undefined : args.termMonths;
if (monthlyPayment !== undefined && termMonths === undefined) {
termMonths = computeTermFromPayment(args.principal, args.annualInterestRate, monthlyPayment);
} else if (termMonths !== undefined && monthlyPayment === undefined) {
monthlyPayment = computePaymentFromTerm(args.principal, args.annualInterestRate, termMonths);
} else if (monthlyPayment === undefined && termMonths === undefined) {
throw new Error("Entweder Rate oder Laufzeit angeben");
}
return { monthlyPayment, termMonths };
}
export const list = query({
args: {},
returns: v.array(loanValidator),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
return await ctx.db
.query("loans")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
},
});
export const create = mutation({
args: {
name: v.string(),
lender: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
principal: v.number(),
annualInterestRate: v.number(),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.string(),
currentBalance: v.optional(v.number()),
totalInterest: v.optional(v.number()),
totalAmount: v.optional(v.number()),
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
notes: v.optional(v.string()),
},
returns: v.id("loans"),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const normalized = normalizeLoanFields(args);
return await ctx.db.insert("loans", {
userId,
name: args.name,
lender: args.lender,
accountId: args.accountId,
categoryId: args.categoryId,
principal: args.principal,
annualInterestRate: args.annualInterestRate,
effectiveAnnualRate: args.effectiveAnnualRate,
monthlyPayment: normalized.monthlyPayment,
termMonths: normalized.termMonths,
startDate: args.startDate,
currentBalance: args.currentBalance,
totalInterest: args.totalInterest,
totalAmount: args.totalAmount,
status: args.status,
notes: args.notes,
});
},
});
export const update = mutation({
args: {
id: v.id("loans"),
name: v.optional(v.string()),
lender: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
principal: v.optional(v.number()),
annualInterestRate: v.optional(v.number()),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.optional(v.string()),
currentBalance: v.optional(v.number()),
totalInterest: v.optional(v.number()),
totalAmount: v.optional(v.number()),
status: v.optional(
v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
),
notes: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const loan = await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
const merged = {
principal: args.principal ?? loan.principal,
annualInterestRate: args.annualInterestRate ?? loan.annualInterestRate,
monthlyPayment: args.monthlyPayment ?? loan.monthlyPayment,
termMonths: args.termMonths ?? loan.termMonths,
};
const normalized = normalizeLoanFields(merged);
const patch: Record<string, unknown> = {
monthlyPayment: normalized.monthlyPayment,
termMonths: normalized.termMonths,
};
for (const key of [
"name",
"lender",
"accountId",
"categoryId",
"principal",
"annualInterestRate",
"effectiveAnnualRate",
"startDate",
"currentBalance",
"totalInterest",
"totalAmount",
"status",
"notes",
] as const) {
if (args[key] !== undefined) patch[key] = args[key];
}
await ctx.db.patch(args.id, patch);
return null;
},
});
export const remove = mutation({
args: { id: v.id("loans") },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
await ctx.db.delete(args.id);
return null;
},
});
export const computeSummary = query({
args: { id: v.id("loans") },
returns: v.object({
currentBalance: v.number(),
payoffDate: v.string(),
totalInterest: v.number(),
totalAmount: v.number(),
remainingMonths: v.number(),
paidMonths: v.number(),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const loan = await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
const startDate = new Date(loan.startDate);
const scheduleResult = buildSchedule({
principal: loan.principal,
annualRate: loan.annualInterestRate,
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
currentBalance: loan.currentBalance ?? undefined,
});
const balance = scheduleResult.currentBalance;
const remainingMonths = scheduleResult.schedule.filter(
(_, idx) =>
idx >=
Math.max(
0,
(new Date().getFullYear() - startDate.getFullYear()) * 12 +
(new Date().getMonth() - startDate.getMonth()),
),
).length;
return {
currentBalance: balance,
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
totalInterest: scheduleResult.totalInterest,
totalAmount: scheduleResult.totalAmount,
remainingMonths,
paidMonths: scheduleResult.paidMonths,
};
},
});