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
This commit is contained in:
Matthias
2026-06-15 20:02:44 +02:00
parent 4869402d45
commit 4a1cbd105b
7 changed files with 380 additions and 20 deletions

View File

@@ -0,0 +1,135 @@
/// <reference types="vite/client" />
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);
});
});

View File

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

View File

@@ -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,
};
},
});

View File

@@ -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()),
})