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:
@@ -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>
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user