diff --git a/convex/lib/amortization.test.ts b/convex/lib/amortization.test.ts
new file mode 100644
index 0000000..b1c017c
--- /dev/null
+++ b/convex/lib/amortization.test.ts
@@ -0,0 +1,135 @@
+///
+
+import { describe, expect, test } from "vitest";
+import { buildSchedule, computePaymentFromTerm, paymentsMade } from "./amortization";
+
+describe("amortization", () => {
+ test("builds schedule without currentBalance from start", () => {
+ const result = buildSchedule({
+ principal: 10000,
+ annualRate: 3.5,
+ startDate: new Date("2024-01-15"),
+ termMonths: 24,
+ });
+
+ expect(result.schedule).toHaveLength(24);
+ expect(result.payment).toBeGreaterThan(0);
+ expect(result.totalAmount).toBeGreaterThan(10000);
+ expect(result.totalInterest).toBeGreaterThan(0);
+ expect(result.paidMonths).toBe(0);
+ expect(result.currentBalance).toBe(result.schedule[result.schedule.length - 1].balance);
+ });
+
+ test("computes monthly payment for a 60 month loan", () => {
+ const payment = computePaymentFromTerm(10000, 3.5, 60);
+ expect(payment).toBeCloseTo(181.92, 2);
+ });
+
+ test("calculates paid months from currentBalance", () => {
+ const principal = 10000;
+ const annualRate = 3.5;
+ const termMonths = 60;
+ const monthlyPayment = computePaymentFromTerm(principal, annualRate, termMonths);
+
+ const paidMonths = paymentsMade(principal, annualRate, monthlyPayment, 8135.81);
+ expect(paidMonths).toBe(12);
+ });
+
+ test("reconstructs plan from currentBalance and keeps total deviation small", () => {
+ const principal = 10000;
+ const annualRate = 3.5;
+ const termMonths = 60;
+ const monthlyPayment = computePaymentFromTerm(principal, annualRate, termMonths);
+
+ const result = buildSchedule({
+ principal,
+ annualRate,
+ startDate: new Date("2024-01-15"),
+ monthlyPayment,
+ currentBalance: 8135.81,
+ });
+
+ expect(result.paidMonths).toBe(12);
+ expect(result.schedule[11].balance).toBeCloseTo(8135.81, 2);
+ expect(result.schedule).toHaveLength(60);
+ expect(result.totalAmount).toBeCloseTo(principal + result.totalInterest, 0);
+ expect(result.totalAmount).toBeGreaterThanOrEqual(principal);
+ });
+
+ test("builds schedule from termMonths when monthlyPayment is omitted", () => {
+ const result = buildSchedule({
+ principal: 12000,
+ annualRate: 4.5,
+ startDate: new Date("2024-01-15"),
+ termMonths: 48,
+ });
+
+ expect(result.payment).toBeGreaterThan(0);
+ expect(result.schedule).toHaveLength(48);
+ expect(result.totalInterest).toBeGreaterThan(0);
+ });
+
+ test("reconstructs plan from termMonths and currentBalance without monthlyPayment", () => {
+ const principal = 12000;
+ const annualRate = 4.5;
+ const termMonths = 48;
+
+ const result = buildSchedule({
+ principal,
+ annualRate,
+ startDate: new Date("2024-01-15"),
+ termMonths,
+ currentBalance: 7634.12,
+ });
+
+ expect(result.payment).toBeGreaterThan(0);
+ expect(result.paidMonths).toBeGreaterThan(0);
+ expect(result.paidMonths).toBeLessThan(48);
+ expect(result.schedule[result.paidMonths - 1].balance).toBeCloseTo(7634.12, 2);
+ expect(result.schedule).toHaveLength(48);
+ });
+
+ test("throws when neither monthlyPayment nor termMonths is provided", () => {
+ expect(() =>
+ buildSchedule({
+ principal: 10000,
+ annualRate: 3.5,
+ startDate: new Date("2024-01-15"),
+ }),
+ ).toThrow("Entweder monthlyPayment oder termMonths erforderlich");
+ });
+
+ test("handles zero interest schedule", () => {
+ const result = buildSchedule({
+ principal: 12000,
+ annualRate: 0,
+ startDate: new Date("2024-01-15"),
+ monthlyPayment: 200,
+ });
+
+ expect(result.schedule).toHaveLength(60);
+ expect(result.totalInterest).toBe(0);
+ expect(result.totalAmount).toBe(12000);
+ });
+
+ test("returns realistic plan for user scenario with currentBalance", () => {
+ const principal = 1860;
+ const annualRate = 5.99;
+ const termMonths = 24;
+ const monthlyPayment = computePaymentFromTerm(principal, annualRate, termMonths);
+
+ const result = buildSchedule({
+ principal,
+ annualRate,
+ startDate: new Date("2024-01-01"),
+ monthlyPayment,
+ currentBalance: 775.25,
+ asOf: new Date("2025-06-01"),
+ });
+
+ expect(result.paidMonths).toBeGreaterThan(0);
+ expect(result.paidMonths).toBeLessThan(24);
+ expect(result.schedule[result.paidMonths - 1].balance).toBeCloseTo(775.25, 2);
+ expect(result.schedule).toHaveLength(24);
+ });
+});
diff --git a/convex/lib/amortization.ts b/convex/lib/amortization.ts
index 11d20a3..f2339ef 100644
--- a/convex/lib/amortization.ts
+++ b/convex/lib/amortization.ts
@@ -12,7 +12,10 @@ export type ScheduleResult = {
payment: number;
termMonths: number;
totalInterest: number;
+ totalAmount: number;
payoffDate: Date;
+ paidMonths: number;
+ currentBalance: number;
};
function round2(n: number): number {
@@ -58,18 +61,100 @@ export function computePaymentFromTerm(
return round2(payment);
}
+export function paymentsMade(
+ principal: number,
+ annualRate: number,
+ monthlyPayment: number,
+ currentBalance: number,
+): number {
+ const i = annualRate / 100 / 12;
+ const B = Math.max(0, currentBalance);
+
+ if (Number.isNaN(B) || Number.isNaN(monthlyPayment) || B <= 0 || monthlyPayment <= 0) {
+ return 0;
+ }
+
+ if (i <= 0) {
+ const paidPrincipal = principal - B;
+ return Math.max(0, Math.floor(paidPrincipal / monthlyPayment));
+ }
+
+ const ratio = (monthlyPayment - i * B) / (monthlyPayment - i * principal);
+
+ if (ratio <= 0 || Number.isNaN(ratio)) {
+ return 0;
+ }
+
+ const n = Math.log(ratio) / Math.log(1 + i);
+ return Math.max(0, Math.floor(n));
+}
+
+function buildFutureSchedule(
+ startingBalance: number,
+ annualRate: number,
+ monthlyPayment: number,
+ startDate: Date,
+ startMonthIndex: number,
+): AmortRow[] {
+ const i = annualRate / 100 / 12;
+ const schedule: AmortRow[] = [];
+ let balance = round2(startingBalance);
+
+ if (Number.isNaN(balance) || balance <= 0 || Number.isNaN(monthlyPayment) || monthlyPayment <= 0) {
+ return schedule;
+ }
+
+ let month = startMonthIndex;
+ while (balance > 0) {
+ const interest = round2(i > 0 ? balance * i : 0);
+ let principalPart = round2(monthlyPayment - interest);
+ let payment = monthlyPayment;
+
+ if (principalPart >= balance) {
+ principalPart = round2(balance);
+ payment = round2(interest + principalPart);
+ }
+
+ balance = round2(balance - principalPart);
+
+ if (balance < 0.01) {
+ balance = 0;
+ }
+
+ schedule.push({
+ month,
+ date: formatDate(addMonths(startDate, month - 1)),
+ payment,
+ interest,
+ principal: principalPart,
+ balance: Math.max(0, balance),
+ });
+
+ if (balance <= 0) break;
+ month++;
+
+ if (month > startMonthIndex + 1200) {
+ throw new Error("Tilgungsplan kann nicht berechnet werden: Laufzeit zu lang");
+ }
+ }
+
+ return schedule;
+}
+
export function buildSchedule(input: {
principal: number;
annualRate: number;
startDate: Date;
monthlyPayment?: number;
termMonths?: number;
+ currentBalance?: number;
+ asOf?: Date;
}): ScheduleResult {
const P = input.principal;
const i = input.annualRate / 100 / 12;
- let payment = input.monthlyPayment;
- let termMonths = input.termMonths;
+ let payment = Number.isNaN(input.monthlyPayment ?? 0) ? undefined : input.monthlyPayment;
+ let termMonths = Number.isNaN(input.termMonths ?? 0) ? undefined : input.termMonths;
if (payment !== undefined && termMonths === undefined) {
termMonths = computeTermFromPayment(P, input.annualRate, payment);
@@ -85,19 +170,66 @@ export function buildSchedule(input: {
const schedule: AmortRow[] = [];
let balance = P;
- for (let month = 1; month <= termMonths; month++) {
+ const currentBalance = Number.isNaN(input.currentBalance ?? 0) ? undefined : input.currentBalance;
+
+ let paidMonths = 0;
+
+ if (currentBalance !== undefined) {
+ paidMonths = paymentsMade(P, input.annualRate, payment, currentBalance);
+ }
+
+ if (currentBalance === undefined || paidMonths === 0) {
+ for (let month = 1; month <= termMonths; month++) {
+ const interest = round2(i > 0 ? balance * i : 0);
+ let principalPart = round2(payment - interest);
+ let actualPayment = payment;
+ if (month === termMonths || principalPart > balance) {
+ principalPart = round2(balance);
+ actualPayment = round2(interest + principalPart);
+ }
+ balance = round2(balance - principalPart);
+ const date = formatDate(addMonths(input.startDate, month - 1));
+ schedule.push({
+ month,
+ date,
+ payment: actualPayment,
+ interest,
+ principal: principalPart,
+ balance: Math.max(0, balance),
+ });
+ if (balance <= 0) break;
+ }
+
+ const actualTerm = schedule.length;
+ const totalPaid = schedule.reduce((sum, row) => sum + row.payment, 0);
+ const payoffDate = addMonths(input.startDate, actualTerm);
+
+ return {
+ schedule,
+ payment,
+ termMonths: actualTerm,
+ totalInterest: round2(totalPaid - P),
+ totalAmount: round2(totalPaid),
+ payoffDate,
+ paidMonths: 0,
+ currentBalance: schedule[schedule.length - 1]?.balance ?? 0,
+ };
+ }
+
+ for (let month = 1; month <= paidMonths; month++) {
const interest = round2(i > 0 ? balance * i : 0);
let principalPart = round2(payment - interest);
- if (month === termMonths || principalPart > balance) {
+ let actualPayment = payment;
+ if (principalPart > balance) {
principalPart = round2(balance);
- payment = round2(interest + principalPart);
+ actualPayment = round2(interest + principalPart);
}
balance = round2(balance - principalPart);
const date = formatDate(addMonths(input.startDate, month - 1));
schedule.push({
month,
date,
- payment,
+ payment: actualPayment,
interest,
principal: principalPart,
balance: Math.max(0, balance),
@@ -105,6 +237,27 @@ export function buildSchedule(input: {
if (balance <= 0) break;
}
+ const lastHistoricalRow = schedule[schedule.length - 1];
+ const projectedBalance = currentBalance;
+
+ if (lastHistoricalRow) {
+ const adjustment = round2(projectedBalance - lastHistoricalRow.balance);
+ if (Math.abs(adjustment) > 0) {
+ lastHistoricalRow.balance = projectedBalance;
+ lastHistoricalRow.principal = round2(lastHistoricalRow.principal + adjustment);
+ }
+ }
+
+ const future = buildFutureSchedule(
+ projectedBalance,
+ input.annualRate,
+ payment,
+ input.startDate,
+ paidMonths + 1,
+ );
+
+ schedule.push(...future);
+
const actualTerm = schedule.length;
const totalPaid = schedule.reduce((sum, row) => sum + row.payment, 0);
const payoffDate = addMonths(input.startDate, actualTerm);
@@ -114,7 +267,10 @@ export function buildSchedule(input: {
payment,
termMonths: actualTerm,
totalInterest: round2(totalPaid - P),
+ totalAmount: round2(totalPaid),
payoffDate,
+ paidMonths,
+ currentBalance: projectedBalance,
};
}
diff --git a/convex/loans.ts b/convex/loans.ts
index ba1e627..c982f8f 100644
--- a/convex/loans.ts
+++ b/convex/loans.ts
@@ -5,7 +5,6 @@ import {
buildSchedule,
computePaymentFromTerm,
computeTermFromPayment,
- currentBalanceFromSchedule,
} from "./lib/amortization";
const loanValidator = v.object({
@@ -18,10 +17,13 @@ const loanValidator = v.object({
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()),
});
@@ -32,7 +34,8 @@ function normalizeLoanFields(args: {
monthlyPayment?: number;
termMonths?: number;
}) {
- let { monthlyPayment, termMonths } = args;
+ 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) {
@@ -63,10 +66,13 @@ export const create = mutation({
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()),
},
@@ -82,10 +88,13 @@ export const create = mutation({
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,
});
@@ -101,10 +110,13 @@ export const update = mutation({
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")),
),
@@ -133,8 +145,11 @@ export const update = mutation({
"categoryId",
"principal",
"annualInterestRate",
+ "effectiveAnnualRate",
"startDate",
"currentBalance",
+ "totalInterest",
+ "totalAmount",
"status",
"notes",
] as const) {
@@ -163,7 +178,9 @@ export const computeSummary = query({
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);
@@ -175,10 +192,9 @@ export const computeSummary = query({
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
+ currentBalance: loan.currentBalance ?? undefined,
});
- const balance =
- loan.currentBalance ??
- currentBalanceFromSchedule(scheduleResult.schedule, startDate);
+ const balance = scheduleResult.currentBalance;
const remainingMonths = scheduleResult.schedule.filter(
(_, idx) =>
idx >=
@@ -192,7 +208,9 @@ export const computeSummary = query({
currentBalance: balance,
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
totalInterest: scheduleResult.totalInterest,
+ totalAmount: scheduleResult.totalAmount,
remainingMonths,
+ paidMonths: scheduleResult.paidMonths,
};
},
});
diff --git a/convex/schema.ts b/convex/schema.ts
index 39daf7e..e1177a0 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -81,10 +81,13 @@ export default defineSchema({
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: loanStatus,
notes: v.optional(v.string()),
})
diff --git a/src/components/loans/AmortizationSchedule.tsx b/src/components/loans/AmortizationSchedule.tsx
index ab4edc1..c8edcf5 100644
--- a/src/components/loans/AmortizationSchedule.tsx
+++ b/src/components/loans/AmortizationSchedule.tsx
@@ -27,6 +27,7 @@ export function AmortizationSchedule({
startDate: new Date(loan.startDate),
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
+ currentBalance: loan.currentBalance ?? undefined,
});
}, [loan]);
@@ -71,8 +72,15 @@ export function AmortizationSchedule({
- Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Enddatum:{" "}
- {formatDate(scheduleResult.payoffDate.toISOString().slice(0, 10))}
+ Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Gesamtbetrag:{" "}
+ {formatAmount(scheduleResult.totalAmount)} · Enddatum:{" "}
+ {formatDate(
+ Number.isNaN(scheduleResult.payoffDate.getTime())
+ ? undefined
+ : scheduleResult.payoffDate.toISOString().slice(0, 10),
+ )}
+ {loan.effectiveAnnualRate !== undefined &&
+ ` · Effektivzins: ${loan.effectiveAnnualRate.toFixed(2)} %`}
diff --git a/src/components/loans/LoanFormDialog.tsx b/src/components/loans/LoanFormDialog.tsx
index 805c33d..8f34363 100644
--- a/src/components/loans/LoanFormDialog.tsx
+++ b/src/components/loans/LoanFormDialog.tsx
@@ -29,10 +29,13 @@ export function LoanFormDialog({
lender: "",
principal: 10000,
annualInterestRate: 3.5,
+ effectiveAnnualRate: undefined as number | undefined,
monthlyPayment: undefined as number | undefined,
termMonths: undefined as number | undefined,
startDate: new Date().toISOString().slice(0, 10),
currentBalance: undefined as number | undefined,
+ totalInterest: undefined as number | undefined,
+ totalAmount: undefined as number | undefined,
status: "aktiv" as "aktiv" | "abbezahlt" | "pausiert",
notes: "",
},
@@ -45,10 +48,13 @@ export function LoanFormDialog({
lender: loan.lender ?? "",
principal: loan.principal,
annualInterestRate: loan.annualInterestRate,
+ effectiveAnnualRate: loan.effectiveAnnualRate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
startDate: loan.startDate,
currentBalance: loan.currentBalance,
+ totalInterest: loan.totalInterest,
+ totalAmount: loan.totalAmount,
status: loan.status,
notes: loan.notes ?? "",
});
@@ -56,16 +62,30 @@ export function LoanFormDialog({
}, [loan, form]);
const onSubmit = form.handleSubmit(async (values) => {
+ const monthlyPayment = Number.isNaN(values.monthlyPayment ?? 0) ? undefined : values.monthlyPayment;
+ const termMonths = Number.isNaN(values.termMonths ?? 0) ? undefined : values.termMonths;
+ const currentBalance = Number.isNaN(values.currentBalance ?? 0) ? undefined : values.currentBalance;
+ const effectiveAnnualRate = Number.isNaN(values.effectiveAnnualRate ?? 0) ? undefined : values.effectiveAnnualRate;
+ const totalInterest = Number.isNaN(values.totalInterest ?? 0) ? undefined : values.totalInterest;
+ const totalAmount = Number.isNaN(values.totalAmount ?? 0) ? undefined : values.totalAmount;
+
+ if (monthlyPayment === undefined && termMonths === undefined) {
+ toast.error("Bitte Monatsrate oder Laufzeit angeben");
+ return;
+ }
try {
const payload = {
name: values.name,
lender: values.lender || undefined,
principal: values.principal,
annualInterestRate: values.annualInterestRate,
- monthlyPayment: values.monthlyPayment,
- termMonths: values.termMonths,
+ effectiveAnnualRate,
+ monthlyPayment,
+ termMonths,
startDate: values.startDate,
- currentBalance: values.currentBalance,
+ currentBalance,
+ totalInterest,
+ totalAmount,
status: values.status,
notes: values.notes || undefined,
};
@@ -118,13 +138,19 @@ export function LoanFormDialog({
+
+
+
+
@@ -134,6 +160,14 @@ export function LoanFormDialog({
+
+
+
+
+
+
+
+
diff --git a/src/pages/LoansPage.tsx b/src/pages/LoansPage.tsx
index 9749cf9..06af414 100644
--- a/src/pages/LoansPage.tsx
+++ b/src/pages/LoansPage.tsx
@@ -8,7 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { formatAmount } from "@/lib/format";
import { LoanFormDialog } from "@/components/loans/LoanFormDialog";
import { AmortizationSchedule } from "@/components/loans/AmortizationSchedule";
-import { buildSchedule, currentBalanceFromSchedule } from "@convex-lib/amortization";
+import { buildSchedule } from "@convex-lib/amortization";
export function LoansPage() {
const loans = useQuery(api.loans.list);
@@ -25,9 +25,9 @@ export function LoansPage() {
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
+ currentBalance: loan.currentBalance ?? undefined,
});
- const balance =
- loan.currentBalance ?? currentBalanceFromSchedule(schedule.schedule, startDate);
+ const balance = schedule.currentBalance;
return { loan, schedule, balance };
});
}, [loans]);
@@ -47,21 +47,27 @@ export function LoansPage() {
Gläubiger
Summe
Zins
+
Eff. Zins
Rate
Restschuld
+
Gesamtzinsen
+
Gesamtbetrag
Status
- {enriched.map(({ loan, balance }) => (
+ {enriched.map(({ loan, schedule, balance }) => (
{loan.name}
{loan.lender ?? "–"}
{formatAmount(loan.principal)}
{loan.annualInterestRate.toFixed(2)} %
+ {loan.effectiveAnnualRate ? `${loan.effectiveAnnualRate.toFixed(2)} %` : "–"}
{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : "–"}
{formatAmount(balance)}
+ {formatAmount(schedule.totalInterest)}
+ {formatAmount(schedule.totalAmount)}
{loan.status}