initial commit
This commit is contained in:
191
convex/lib/amortization.ts
Normal file
191
convex/lib/amortization.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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;
|
||||
payoffDate: Date;
|
||||
};
|
||||
|
||||
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 buildSchedule(input: {
|
||||
principal: number;
|
||||
annualRate: number;
|
||||
startDate: Date;
|
||||
monthlyPayment?: number;
|
||||
termMonths?: number;
|
||||
}): ScheduleResult {
|
||||
const P = input.principal;
|
||||
const i = input.annualRate / 100 / 12;
|
||||
|
||||
let payment = input.monthlyPayment;
|
||||
let termMonths = 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;
|
||||
|
||||
for (let month = 1; month <= termMonths; month++) {
|
||||
const interest = round2(i > 0 ? balance * i : 0);
|
||||
let principalPart = round2(payment - interest);
|
||||
if (month === termMonths || principalPart > balance) {
|
||||
principalPart = round2(balance);
|
||||
payment = round2(interest + principalPart);
|
||||
}
|
||||
balance = round2(balance - principalPart);
|
||||
const date = formatDate(addMonths(input.startDate, month - 1));
|
||||
schedule.push({
|
||||
month,
|
||||
date,
|
||||
payment,
|
||||
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),
|
||||
payoffDate,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user