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:
135
convex/lib/amortization.test.ts
Normal file
135
convex/lib/amortization.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user