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;
|
payment: number;
|
||||||
termMonths: number;
|
termMonths: number;
|
||||||
totalInterest: number;
|
totalInterest: number;
|
||||||
|
totalAmount: number;
|
||||||
payoffDate: Date;
|
payoffDate: Date;
|
||||||
|
paidMonths: number;
|
||||||
|
currentBalance: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function round2(n: number): number {
|
function round2(n: number): number {
|
||||||
@@ -58,18 +61,100 @@ export function computePaymentFromTerm(
|
|||||||
return round2(payment);
|
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: {
|
export function buildSchedule(input: {
|
||||||
principal: number;
|
principal: number;
|
||||||
annualRate: number;
|
annualRate: number;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
monthlyPayment?: number;
|
monthlyPayment?: number;
|
||||||
termMonths?: number;
|
termMonths?: number;
|
||||||
|
currentBalance?: number;
|
||||||
|
asOf?: Date;
|
||||||
}): ScheduleResult {
|
}): ScheduleResult {
|
||||||
const P = input.principal;
|
const P = input.principal;
|
||||||
const i = input.annualRate / 100 / 12;
|
const i = input.annualRate / 100 / 12;
|
||||||
|
|
||||||
let payment = input.monthlyPayment;
|
let payment = Number.isNaN(input.monthlyPayment ?? 0) ? undefined : input.monthlyPayment;
|
||||||
let termMonths = input.termMonths;
|
let termMonths = Number.isNaN(input.termMonths ?? 0) ? undefined : input.termMonths;
|
||||||
|
|
||||||
if (payment !== undefined && termMonths === undefined) {
|
if (payment !== undefined && termMonths === undefined) {
|
||||||
termMonths = computeTermFromPayment(P, input.annualRate, payment);
|
termMonths = computeTermFromPayment(P, input.annualRate, payment);
|
||||||
@@ -85,19 +170,29 @@ export function buildSchedule(input: {
|
|||||||
const schedule: AmortRow[] = [];
|
const schedule: AmortRow[] = [];
|
||||||
let balance = P;
|
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++) {
|
for (let month = 1; month <= termMonths; month++) {
|
||||||
const interest = round2(i > 0 ? balance * i : 0);
|
const interest = round2(i > 0 ? balance * i : 0);
|
||||||
let principalPart = round2(payment - interest);
|
let principalPart = round2(payment - interest);
|
||||||
|
let actualPayment = payment;
|
||||||
if (month === termMonths || principalPart > balance) {
|
if (month === termMonths || principalPart > balance) {
|
||||||
principalPart = round2(balance);
|
principalPart = round2(balance);
|
||||||
payment = round2(interest + principalPart);
|
actualPayment = round2(interest + principalPart);
|
||||||
}
|
}
|
||||||
balance = round2(balance - principalPart);
|
balance = round2(balance - principalPart);
|
||||||
const date = formatDate(addMonths(input.startDate, month - 1));
|
const date = formatDate(addMonths(input.startDate, month - 1));
|
||||||
schedule.push({
|
schedule.push({
|
||||||
month,
|
month,
|
||||||
date,
|
date,
|
||||||
payment,
|
payment: actualPayment,
|
||||||
interest,
|
interest,
|
||||||
principal: principalPart,
|
principal: principalPart,
|
||||||
balance: Math.max(0, balance),
|
balance: Math.max(0, balance),
|
||||||
@@ -114,7 +209,68 @@ export function buildSchedule(input: {
|
|||||||
payment,
|
payment,
|
||||||
termMonths: actualTerm,
|
termMonths: actualTerm,
|
||||||
totalInterest: round2(totalPaid - P),
|
totalInterest: round2(totalPaid - P),
|
||||||
|
totalAmount: round2(totalPaid),
|
||||||
payoffDate,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
buildSchedule,
|
buildSchedule,
|
||||||
computePaymentFromTerm,
|
computePaymentFromTerm,
|
||||||
computeTermFromPayment,
|
computeTermFromPayment,
|
||||||
currentBalanceFromSchedule,
|
|
||||||
} from "./lib/amortization";
|
} from "./lib/amortization";
|
||||||
|
|
||||||
const loanValidator = v.object({
|
const loanValidator = v.object({
|
||||||
@@ -18,10 +17,13 @@ const loanValidator = v.object({
|
|||||||
categoryId: v.optional(v.id("categories")),
|
categoryId: v.optional(v.id("categories")),
|
||||||
principal: v.number(),
|
principal: v.number(),
|
||||||
annualInterestRate: v.number(),
|
annualInterestRate: v.number(),
|
||||||
|
effectiveAnnualRate: v.optional(v.number()),
|
||||||
monthlyPayment: v.optional(v.number()),
|
monthlyPayment: v.optional(v.number()),
|
||||||
termMonths: v.optional(v.number()),
|
termMonths: v.optional(v.number()),
|
||||||
startDate: v.string(),
|
startDate: v.string(),
|
||||||
currentBalance: v.optional(v.number()),
|
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")),
|
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
});
|
});
|
||||||
@@ -32,7 +34,8 @@ function normalizeLoanFields(args: {
|
|||||||
monthlyPayment?: number;
|
monthlyPayment?: number;
|
||||||
termMonths?: 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) {
|
if (monthlyPayment !== undefined && termMonths === undefined) {
|
||||||
termMonths = computeTermFromPayment(args.principal, args.annualInterestRate, monthlyPayment);
|
termMonths = computeTermFromPayment(args.principal, args.annualInterestRate, monthlyPayment);
|
||||||
} else if (termMonths !== undefined && monthlyPayment === undefined) {
|
} else if (termMonths !== undefined && monthlyPayment === undefined) {
|
||||||
@@ -63,10 +66,13 @@ export const create = mutation({
|
|||||||
categoryId: v.optional(v.id("categories")),
|
categoryId: v.optional(v.id("categories")),
|
||||||
principal: v.number(),
|
principal: v.number(),
|
||||||
annualInterestRate: v.number(),
|
annualInterestRate: v.number(),
|
||||||
|
effectiveAnnualRate: v.optional(v.number()),
|
||||||
monthlyPayment: v.optional(v.number()),
|
monthlyPayment: v.optional(v.number()),
|
||||||
termMonths: v.optional(v.number()),
|
termMonths: v.optional(v.number()),
|
||||||
startDate: v.string(),
|
startDate: v.string(),
|
||||||
currentBalance: v.optional(v.number()),
|
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")),
|
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
@@ -82,10 +88,13 @@ export const create = mutation({
|
|||||||
categoryId: args.categoryId,
|
categoryId: args.categoryId,
|
||||||
principal: args.principal,
|
principal: args.principal,
|
||||||
annualInterestRate: args.annualInterestRate,
|
annualInterestRate: args.annualInterestRate,
|
||||||
|
effectiveAnnualRate: args.effectiveAnnualRate,
|
||||||
monthlyPayment: normalized.monthlyPayment,
|
monthlyPayment: normalized.monthlyPayment,
|
||||||
termMonths: normalized.termMonths,
|
termMonths: normalized.termMonths,
|
||||||
startDate: args.startDate,
|
startDate: args.startDate,
|
||||||
currentBalance: args.currentBalance,
|
currentBalance: args.currentBalance,
|
||||||
|
totalInterest: args.totalInterest,
|
||||||
|
totalAmount: args.totalAmount,
|
||||||
status: args.status,
|
status: args.status,
|
||||||
notes: args.notes,
|
notes: args.notes,
|
||||||
});
|
});
|
||||||
@@ -101,10 +110,13 @@ export const update = mutation({
|
|||||||
categoryId: v.optional(v.id("categories")),
|
categoryId: v.optional(v.id("categories")),
|
||||||
principal: v.optional(v.number()),
|
principal: v.optional(v.number()),
|
||||||
annualInterestRate: v.optional(v.number()),
|
annualInterestRate: v.optional(v.number()),
|
||||||
|
effectiveAnnualRate: v.optional(v.number()),
|
||||||
monthlyPayment: v.optional(v.number()),
|
monthlyPayment: v.optional(v.number()),
|
||||||
termMonths: v.optional(v.number()),
|
termMonths: v.optional(v.number()),
|
||||||
startDate: v.optional(v.string()),
|
startDate: v.optional(v.string()),
|
||||||
currentBalance: v.optional(v.number()),
|
currentBalance: v.optional(v.number()),
|
||||||
|
totalInterest: v.optional(v.number()),
|
||||||
|
totalAmount: v.optional(v.number()),
|
||||||
status: v.optional(
|
status: v.optional(
|
||||||
v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
||||||
),
|
),
|
||||||
@@ -133,8 +145,11 @@ export const update = mutation({
|
|||||||
"categoryId",
|
"categoryId",
|
||||||
"principal",
|
"principal",
|
||||||
"annualInterestRate",
|
"annualInterestRate",
|
||||||
|
"effectiveAnnualRate",
|
||||||
"startDate",
|
"startDate",
|
||||||
"currentBalance",
|
"currentBalance",
|
||||||
|
"totalInterest",
|
||||||
|
"totalAmount",
|
||||||
"status",
|
"status",
|
||||||
"notes",
|
"notes",
|
||||||
] as const) {
|
] as const) {
|
||||||
@@ -163,7 +178,9 @@ export const computeSummary = query({
|
|||||||
currentBalance: v.number(),
|
currentBalance: v.number(),
|
||||||
payoffDate: v.string(),
|
payoffDate: v.string(),
|
||||||
totalInterest: v.number(),
|
totalInterest: v.number(),
|
||||||
|
totalAmount: v.number(),
|
||||||
remainingMonths: v.number(),
|
remainingMonths: v.number(),
|
||||||
|
paidMonths: v.number(),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await requireUserId(ctx);
|
const userId = await requireUserId(ctx);
|
||||||
@@ -175,10 +192,9 @@ export const computeSummary = query({
|
|||||||
startDate,
|
startDate,
|
||||||
monthlyPayment: loan.monthlyPayment,
|
monthlyPayment: loan.monthlyPayment,
|
||||||
termMonths: loan.termMonths,
|
termMonths: loan.termMonths,
|
||||||
|
currentBalance: loan.currentBalance ?? undefined,
|
||||||
});
|
});
|
||||||
const balance =
|
const balance = scheduleResult.currentBalance;
|
||||||
loan.currentBalance ??
|
|
||||||
currentBalanceFromSchedule(scheduleResult.schedule, startDate);
|
|
||||||
const remainingMonths = scheduleResult.schedule.filter(
|
const remainingMonths = scheduleResult.schedule.filter(
|
||||||
(_, idx) =>
|
(_, idx) =>
|
||||||
idx >=
|
idx >=
|
||||||
@@ -192,7 +208,9 @@ export const computeSummary = query({
|
|||||||
currentBalance: balance,
|
currentBalance: balance,
|
||||||
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
|
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
|
||||||
totalInterest: scheduleResult.totalInterest,
|
totalInterest: scheduleResult.totalInterest,
|
||||||
|
totalAmount: scheduleResult.totalAmount,
|
||||||
remainingMonths,
|
remainingMonths,
|
||||||
|
paidMonths: scheduleResult.paidMonths,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,10 +81,13 @@ export default defineSchema({
|
|||||||
categoryId: v.optional(v.id("categories")),
|
categoryId: v.optional(v.id("categories")),
|
||||||
principal: v.number(),
|
principal: v.number(),
|
||||||
annualInterestRate: v.number(),
|
annualInterestRate: v.number(),
|
||||||
|
effectiveAnnualRate: v.optional(v.number()),
|
||||||
monthlyPayment: v.optional(v.number()),
|
monthlyPayment: v.optional(v.number()),
|
||||||
termMonths: v.optional(v.number()),
|
termMonths: v.optional(v.number()),
|
||||||
startDate: v.string(),
|
startDate: v.string(),
|
||||||
currentBalance: v.optional(v.number()),
|
currentBalance: v.optional(v.number()),
|
||||||
|
totalInterest: v.optional(v.number()),
|
||||||
|
totalAmount: v.optional(v.number()),
|
||||||
status: loanStatus,
|
status: loanStatus,
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function AmortizationSchedule({
|
|||||||
startDate: new Date(loan.startDate),
|
startDate: new Date(loan.startDate),
|
||||||
monthlyPayment: loan.monthlyPayment,
|
monthlyPayment: loan.monthlyPayment,
|
||||||
termMonths: loan.termMonths,
|
termMonths: loan.termMonths,
|
||||||
|
currentBalance: loan.currentBalance ?? undefined,
|
||||||
});
|
});
|
||||||
}, [loan]);
|
}, [loan]);
|
||||||
|
|
||||||
@@ -71,8 +72,15 @@ export function AmortizationSchedule({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Enddatum:{" "}
|
Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Gesamtbetrag:{" "}
|
||||||
{formatDate(scheduleResult.payoffDate.toISOString().slice(0, 10))}
|
{formatAmount(scheduleResult.totalAmount)} · Enddatum:{" "}
|
||||||
|
{formatDate(
|
||||||
|
Number.isNaN(scheduleResult.payoffDate.getTime())
|
||||||
|
? undefined
|
||||||
|
: scheduleResult.payoffDate.toISOString().slice(0, 10),
|
||||||
|
)}
|
||||||
|
{loan.effectiveAnnualRate !== undefined &&
|
||||||
|
` · Effektivzins: ${loan.effectiveAnnualRate.toFixed(2)} %`}
|
||||||
</p>
|
</p>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ export function LoanFormDialog({
|
|||||||
lender: "",
|
lender: "",
|
||||||
principal: 10000,
|
principal: 10000,
|
||||||
annualInterestRate: 3.5,
|
annualInterestRate: 3.5,
|
||||||
|
effectiveAnnualRate: undefined as number | undefined,
|
||||||
monthlyPayment: undefined as number | undefined,
|
monthlyPayment: undefined as number | undefined,
|
||||||
termMonths: undefined as number | undefined,
|
termMonths: undefined as number | undefined,
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
currentBalance: undefined as number | undefined,
|
currentBalance: undefined as number | undefined,
|
||||||
|
totalInterest: undefined as number | undefined,
|
||||||
|
totalAmount: undefined as number | undefined,
|
||||||
status: "aktiv" as "aktiv" | "abbezahlt" | "pausiert",
|
status: "aktiv" as "aktiv" | "abbezahlt" | "pausiert",
|
||||||
notes: "",
|
notes: "",
|
||||||
},
|
},
|
||||||
@@ -45,10 +48,13 @@ export function LoanFormDialog({
|
|||||||
lender: loan.lender ?? "",
|
lender: loan.lender ?? "",
|
||||||
principal: loan.principal,
|
principal: loan.principal,
|
||||||
annualInterestRate: loan.annualInterestRate,
|
annualInterestRate: loan.annualInterestRate,
|
||||||
|
effectiveAnnualRate: loan.effectiveAnnualRate,
|
||||||
monthlyPayment: loan.monthlyPayment,
|
monthlyPayment: loan.monthlyPayment,
|
||||||
termMonths: loan.termMonths,
|
termMonths: loan.termMonths,
|
||||||
startDate: loan.startDate,
|
startDate: loan.startDate,
|
||||||
currentBalance: loan.currentBalance,
|
currentBalance: loan.currentBalance,
|
||||||
|
totalInterest: loan.totalInterest,
|
||||||
|
totalAmount: loan.totalAmount,
|
||||||
status: loan.status,
|
status: loan.status,
|
||||||
notes: loan.notes ?? "",
|
notes: loan.notes ?? "",
|
||||||
});
|
});
|
||||||
@@ -56,16 +62,30 @@ export function LoanFormDialog({
|
|||||||
}, [loan, form]);
|
}, [loan, form]);
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
const monthlyPayment = Number.isNaN(values.monthlyPayment ?? 0) ? undefined : values.monthlyPayment;
|
||||||
|
const termMonths = Number.isNaN(values.termMonths ?? 0) ? undefined : values.termMonths;
|
||||||
|
const currentBalance = Number.isNaN(values.currentBalance ?? 0) ? undefined : values.currentBalance;
|
||||||
|
const effectiveAnnualRate = Number.isNaN(values.effectiveAnnualRate ?? 0) ? undefined : values.effectiveAnnualRate;
|
||||||
|
const totalInterest = Number.isNaN(values.totalInterest ?? 0) ? undefined : values.totalInterest;
|
||||||
|
const totalAmount = Number.isNaN(values.totalAmount ?? 0) ? undefined : values.totalAmount;
|
||||||
|
|
||||||
|
if (monthlyPayment === undefined && termMonths === undefined) {
|
||||||
|
toast.error("Bitte Monatsrate oder Laufzeit angeben");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
lender: values.lender || undefined,
|
lender: values.lender || undefined,
|
||||||
principal: values.principal,
|
principal: values.principal,
|
||||||
annualInterestRate: values.annualInterestRate,
|
annualInterestRate: values.annualInterestRate,
|
||||||
monthlyPayment: values.monthlyPayment,
|
effectiveAnnualRate,
|
||||||
termMonths: values.termMonths,
|
monthlyPayment,
|
||||||
|
termMonths,
|
||||||
startDate: values.startDate,
|
startDate: values.startDate,
|
||||||
currentBalance: values.currentBalance,
|
currentBalance,
|
||||||
|
totalInterest,
|
||||||
|
totalAmount,
|
||||||
status: values.status,
|
status: values.status,
|
||||||
notes: values.notes || undefined,
|
notes: values.notes || undefined,
|
||||||
};
|
};
|
||||||
@@ -118,13 +138,19 @@ export function LoanFormDialog({
|
|||||||
<Label>Jahreszins (%)</Label>
|
<Label>Jahreszins (%)</Label>
|
||||||
<Input type="number" step="0.01" {...form.register("annualInterestRate", { valueAsNumber: true })} />
|
<Input type="number" step="0.01" {...form.register("annualInterestRate", { valueAsNumber: true })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Effektiver Jahreszins (%)</Label>
|
||||||
|
<Input type="number" step="0.01" {...form.register("effectiveAnnualRate", { valueAsNumber: true })} />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Monatsrate</Label>
|
<Label>Monatsrate</Label>
|
||||||
<Input type="number" step="0.01" {...form.register("monthlyPayment", { valueAsNumber: true })} />
|
<Input type="number" step="0.01" {...form.register("monthlyPayment", { valueAsNumber: true })} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Rate oder Laufzeit angeben</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Laufzeit (Monate)</Label>
|
<Label>Laufzeit (Monate)</Label>
|
||||||
<Input type="number" {...form.register("termMonths", { valueAsNumber: true })} />
|
<Input type="number" {...form.register("termMonths", { valueAsNumber: true })} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Rate oder Laufzeit angeben</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Startdatum</Label>
|
<Label>Startdatum</Label>
|
||||||
@@ -134,6 +160,14 @@ export function LoanFormDialog({
|
|||||||
<Label>Aktuelle Restschuld (optional)</Label>
|
<Label>Aktuelle Restschuld (optional)</Label>
|
||||||
<Input type="number" step="0.01" {...form.register("currentBalance", { valueAsNumber: true })} />
|
<Input type="number" step="0.01" {...form.register("currentBalance", { valueAsNumber: true })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Gesamtzinsen lt. Vertrag (optional)</Label>
|
||||||
|
<Input type="number" step="0.01" {...form.register("totalInterest", { valueAsNumber: true })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Gesamtbetrag lt. Vertrag (optional)</Label>
|
||||||
|
<Input type="number" step="0.01" {...form.register("totalAmount", { valueAsNumber: true })} />
|
||||||
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label>Notiz</Label>
|
<Label>Notiz</Label>
|
||||||
<Input {...form.register("notes")} />
|
<Input {...form.register("notes")} />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { formatAmount } from "@/lib/format";
|
import { formatAmount } from "@/lib/format";
|
||||||
import { LoanFormDialog } from "@/components/loans/LoanFormDialog";
|
import { LoanFormDialog } from "@/components/loans/LoanFormDialog";
|
||||||
import { AmortizationSchedule } from "@/components/loans/AmortizationSchedule";
|
import { AmortizationSchedule } from "@/components/loans/AmortizationSchedule";
|
||||||
import { buildSchedule, currentBalanceFromSchedule } from "@convex-lib/amortization";
|
import { buildSchedule } from "@convex-lib/amortization";
|
||||||
|
|
||||||
export function LoansPage() {
|
export function LoansPage() {
|
||||||
const loans = useQuery(api.loans.list);
|
const loans = useQuery(api.loans.list);
|
||||||
@@ -25,9 +25,9 @@ export function LoansPage() {
|
|||||||
startDate,
|
startDate,
|
||||||
monthlyPayment: loan.monthlyPayment,
|
monthlyPayment: loan.monthlyPayment,
|
||||||
termMonths: loan.termMonths,
|
termMonths: loan.termMonths,
|
||||||
|
currentBalance: loan.currentBalance ?? undefined,
|
||||||
});
|
});
|
||||||
const balance =
|
const balance = schedule.currentBalance;
|
||||||
loan.currentBalance ?? currentBalanceFromSchedule(schedule.schedule, startDate);
|
|
||||||
return { loan, schedule, balance };
|
return { loan, schedule, balance };
|
||||||
});
|
});
|
||||||
}, [loans]);
|
}, [loans]);
|
||||||
@@ -47,21 +47,27 @@ export function LoansPage() {
|
|||||||
<TableHead>Gläubiger</TableHead>
|
<TableHead>Gläubiger</TableHead>
|
||||||
<TableHead>Summe</TableHead>
|
<TableHead>Summe</TableHead>
|
||||||
<TableHead>Zins</TableHead>
|
<TableHead>Zins</TableHead>
|
||||||
|
<TableHead>Eff. Zins</TableHead>
|
||||||
<TableHead>Rate</TableHead>
|
<TableHead>Rate</TableHead>
|
||||||
<TableHead>Restschuld</TableHead>
|
<TableHead>Restschuld</TableHead>
|
||||||
|
<TableHead>Gesamtzinsen</TableHead>
|
||||||
|
<TableHead>Gesamtbetrag</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead></TableHead>
|
<TableHead></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{enriched.map(({ loan, balance }) => (
|
{enriched.map(({ loan, schedule, balance }) => (
|
||||||
<TableRow key={loan._id}>
|
<TableRow key={loan._id}>
|
||||||
<TableCell>{loan.name}</TableCell>
|
<TableCell>{loan.name}</TableCell>
|
||||||
<TableCell>{loan.lender ?? "–"}</TableCell>
|
<TableCell>{loan.lender ?? "–"}</TableCell>
|
||||||
<TableCell>{formatAmount(loan.principal)}</TableCell>
|
<TableCell>{formatAmount(loan.principal)}</TableCell>
|
||||||
<TableCell>{loan.annualInterestRate.toFixed(2)} %</TableCell>
|
<TableCell>{loan.annualInterestRate.toFixed(2)} %</TableCell>
|
||||||
|
<TableCell>{loan.effectiveAnnualRate ? `${loan.effectiveAnnualRate.toFixed(2)} %` : "–"}</TableCell>
|
||||||
<TableCell>{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : "–"}</TableCell>
|
<TableCell>{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : "–"}</TableCell>
|
||||||
<TableCell>{formatAmount(balance)}</TableCell>
|
<TableCell>{formatAmount(balance)}</TableCell>
|
||||||
|
<TableCell>{formatAmount(schedule.totalInterest)}</TableCell>
|
||||||
|
<TableCell>{formatAmount(schedule.totalAmount)}</TableCell>
|
||||||
<TableCell>{loan.status}</TableCell>
|
<TableCell>{loan.status}</TableCell>
|
||||||
<TableCell className="space-x-1">
|
<TableCell className="space-x-1">
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditLoan(loan)}>
|
<Button size="sm" variant="outline" onClick={() => setEditLoan(loan)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user