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