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({ +
+ + +
+

Rate oder Laufzeit angeben

+

Rate oder Laufzeit angeben

@@ -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}