Files
finanzen/convex/lib/amortization.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

348 lines
9.2 KiB
TypeScript

export type AmortRow = {
month: number;
date: string;
payment: number;
interest: number;
principal: number;
balance: number;
};
export type ScheduleResult = {
schedule: AmortRow[];
payment: number;
termMonths: number;
totalInterest: number;
totalAmount: number;
payoffDate: Date;
paidMonths: number;
currentBalance: number;
};
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function addMonths(date: Date, months: number): Date {
const d = new Date(date);
d.setMonth(d.getMonth() + months);
return d;
}
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function computeTermFromPayment(
principal: number,
annualRate: number,
monthlyPayment: number,
): number {
const i = annualRate / 100 / 12;
if (i <= 0) {
return Math.ceil(principal / monthlyPayment);
}
const n = -Math.log(1 - (i * principal) / monthlyPayment) / Math.log(1 + i);
return Math.ceil(n);
}
export function computePaymentFromTerm(
principal: number,
annualRate: number,
termMonths: number,
): number {
const i = annualRate / 100 / 12;
if (i <= 0) {
return round2(principal / termMonths);
}
const payment = (principal * i) / (1 - Math.pow(1 + i, -termMonths));
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 = 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);
} else if (termMonths !== undefined && payment === undefined) {
payment = computePaymentFromTerm(P, input.annualRate, termMonths);
} else if (payment === undefined && termMonths === undefined) {
throw new Error("Entweder monthlyPayment oder termMonths erforderlich");
}
payment = round2(payment!);
termMonths = termMonths!;
const schedule: AmortRow[] = [];
let balance = P;
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);
let actualPayment = payment;
if (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 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);
return {
schedule,
payment,
termMonths: actualTerm,
totalInterest: round2(totalPaid - P),
totalAmount: round2(totalPaid),
payoffDate,
paidMonths,
currentBalance: projectedBalance,
};
}
export function currentBalanceFromSchedule(
schedule: AmortRow[],
startDate: Date,
asOf: Date = new Date(),
): number {
if (schedule.length === 0) return 0;
const monthsElapsed = Math.max(
0,
(asOf.getFullYear() - startDate.getFullYear()) * 12 +
(asOf.getMonth() - startDate.getMonth()),
);
if (monthsElapsed >= schedule.length) return 0;
if (monthsElapsed <= 0) return schedule[0].balance + schedule[0].principal;
return schedule[monthsElapsed - 1]?.balance ?? 0;
}
export function remainingMonthsFromSchedule(
schedule: AmortRow[],
startDate: Date,
asOf: Date = new Date(),
): number {
const balance = currentBalanceFromSchedule(schedule, startDate, asOf);
if (balance <= 0) return 0;
const monthsElapsed = Math.max(
0,
(asOf.getFullYear() - startDate.getFullYear()) * 12 +
(asOf.getMonth() - startDate.getMonth()),
);
return Math.max(0, schedule.length - monthsElapsed);
}
export function totalMonthlyPaymentActiveLoans(
loans: Array<{ status: string; monthlyPayment?: number }>,
): number {
return round2(
loans
.filter((l) => l.status === "aktiv")
.reduce((sum, l) => sum + (l.monthlyPayment ?? 0), 0),
);
}
export function totalRemainingDebt(
loans: Array<{
status: string;
principal: number;
annualInterestRate: number;
startDate: string;
monthlyPayment?: number;
termMonths?: number;
currentBalance?: number;
}>,
): number {
return round2(
loans
.filter((l) => l.status === "aktiv")
.reduce((sum, loan) => {
if (loan.currentBalance !== undefined) {
return sum + loan.currentBalance;
}
const start = new Date(loan.startDate);
const schedule = buildSchedule({
principal: loan.principal,
annualRate: loan.annualInterestRate,
startDate: start,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
});
return sum + currentBalanceFromSchedule(schedule.schedule, start);
}, 0),
);
}