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

@@ -27,6 +27,7 @@ export function AmortizationSchedule({
startDate: new Date(loan.startDate),
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
currentBalance: loan.currentBalance ?? undefined,
});
}, [loan]);
@@ -71,8 +72,15 @@ export function AmortizationSchedule({
</TableBody>
</Table>
<p className="text-sm text-muted-foreground">
Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Enddatum:{" "}
{formatDate(scheduleResult.payoffDate.toISOString().slice(0, 10))}
Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Gesamtbetrag:{" "}
{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>
</DialogContent>
</Dialog>

View File

@@ -29,10 +29,13 @@ export function LoanFormDialog({
lender: "",
principal: 10000,
annualInterestRate: 3.5,
effectiveAnnualRate: undefined as number | undefined,
monthlyPayment: undefined as number | undefined,
termMonths: undefined as number | undefined,
startDate: new Date().toISOString().slice(0, 10),
currentBalance: undefined as number | undefined,
totalInterest: undefined as number | undefined,
totalAmount: undefined as number | undefined,
status: "aktiv" as "aktiv" | "abbezahlt" | "pausiert",
notes: "",
},
@@ -45,10 +48,13 @@ export function LoanFormDialog({
lender: loan.lender ?? "",
principal: loan.principal,
annualInterestRate: loan.annualInterestRate,
effectiveAnnualRate: loan.effectiveAnnualRate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
startDate: loan.startDate,
currentBalance: loan.currentBalance,
totalInterest: loan.totalInterest,
totalAmount: loan.totalAmount,
status: loan.status,
notes: loan.notes ?? "",
});
@@ -56,16 +62,30 @@ export function LoanFormDialog({
}, [loan, form]);
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 {
const payload = {
name: values.name,
lender: values.lender || undefined,
principal: values.principal,
annualInterestRate: values.annualInterestRate,
monthlyPayment: values.monthlyPayment,
termMonths: values.termMonths,
effectiveAnnualRate,
monthlyPayment,
termMonths,
startDate: values.startDate,
currentBalance: values.currentBalance,
currentBalance,
totalInterest,
totalAmount,
status: values.status,
notes: values.notes || undefined,
};
@@ -118,13 +138,19 @@ export function LoanFormDialog({
<Label>Jahreszins (%)</Label>
<Input type="number" step="0.01" {...form.register("annualInterestRate", { valueAsNumber: true })} />
</div>
<div>
<Label>Effektiver Jahreszins (%)</Label>
<Input type="number" step="0.01" {...form.register("effectiveAnnualRate", { valueAsNumber: true })} />
</div>
<div>
<Label>Monatsrate</Label>
<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>
<Label>Laufzeit (Monate)</Label>
<Input type="number" {...form.register("termMonths", { valueAsNumber: true })} />
<p className="text-xs text-muted-foreground mt-1">Rate oder Laufzeit angeben</p>
</div>
<div>
<Label>Startdatum</Label>
@@ -134,6 +160,14 @@ export function LoanFormDialog({
<Label>Aktuelle Restschuld (optional)</Label>
<Input type="number" step="0.01" {...form.register("currentBalance", { valueAsNumber: true })} />
</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">
<Label>Notiz</Label>
<Input {...form.register("notes")} />

View File

@@ -8,7 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { formatAmount } from "@/lib/format";
import { LoanFormDialog } from "@/components/loans/LoanFormDialog";
import { AmortizationSchedule } from "@/components/loans/AmortizationSchedule";
import { buildSchedule, currentBalanceFromSchedule } from "@convex-lib/amortization";
import { buildSchedule } from "@convex-lib/amortization";
export function LoansPage() {
const loans = useQuery(api.loans.list);
@@ -25,9 +25,9 @@ export function LoansPage() {
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
currentBalance: loan.currentBalance ?? undefined,
});
const balance =
loan.currentBalance ?? currentBalanceFromSchedule(schedule.schedule, startDate);
const balance = schedule.currentBalance;
return { loan, schedule, balance };
});
}, [loans]);
@@ -47,21 +47,27 @@ export function LoansPage() {
<TableHead>Gläubiger</TableHead>
<TableHead>Summe</TableHead>
<TableHead>Zins</TableHead>
<TableHead>Eff. Zins</TableHead>
<TableHead>Rate</TableHead>
<TableHead>Restschuld</TableHead>
<TableHead>Gesamtzinsen</TableHead>
<TableHead>Gesamtbetrag</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enriched.map(({ loan, balance }) => (
{enriched.map(({ loan, schedule, balance }) => (
<TableRow key={loan._id}>
<TableCell>{loan.name}</TableCell>
<TableCell>{loan.lender ?? ""}</TableCell>
<TableCell>{formatAmount(loan.principal)}</TableCell>
<TableCell>{loan.annualInterestRate.toFixed(2)} %</TableCell>
<TableCell>{loan.effectiveAnnualRate ? `${loan.effectiveAnnualRate.toFixed(2)} %` : ""}</TableCell>
<TableCell>{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : ""}</TableCell>
<TableCell>{formatAmount(balance)}</TableCell>
<TableCell>{formatAmount(schedule.totalInterest)}</TableCell>
<TableCell>{formatAmount(schedule.totalAmount)}</TableCell>
<TableCell>{loan.status}</TableCell>
<TableCell className="space-x-1">
<Button size="sm" variant="outline" onClick={() => setEditLoan(loan)}>