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:
Matthias
2026-06-15 20:02:44 +02:00
parent 4869402d45
commit 4a1cbd105b
7 changed files with 380 additions and 20 deletions

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

View File

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

View File

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

View File

@@ -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()),
}) })

View File

@@ -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>

View File

@@ -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")} />

View File

@@ -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)}>