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

@@ -5,7 +5,6 @@ import {
buildSchedule,
computePaymentFromTerm,
computeTermFromPayment,
currentBalanceFromSchedule,
} from "./lib/amortization";
const loanValidator = v.object({
@@ -18,10 +17,13 @@ const loanValidator = v.object({
categoryId: v.optional(v.id("categories")),
principal: v.number(),
annualInterestRate: v.number(),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.string(),
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")),
notes: v.optional(v.string()),
});
@@ -32,7 +34,8 @@ function normalizeLoanFields(args: {
monthlyPayment?: 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) {
termMonths = computeTermFromPayment(args.principal, args.annualInterestRate, monthlyPayment);
} else if (termMonths !== undefined && monthlyPayment === undefined) {
@@ -63,10 +66,13 @@ export const create = mutation({
categoryId: v.optional(v.id("categories")),
principal: v.number(),
annualInterestRate: v.number(),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.string(),
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")),
notes: v.optional(v.string()),
},
@@ -82,10 +88,13 @@ export const create = mutation({
categoryId: args.categoryId,
principal: args.principal,
annualInterestRate: args.annualInterestRate,
effectiveAnnualRate: args.effectiveAnnualRate,
monthlyPayment: normalized.monthlyPayment,
termMonths: normalized.termMonths,
startDate: args.startDate,
currentBalance: args.currentBalance,
totalInterest: args.totalInterest,
totalAmount: args.totalAmount,
status: args.status,
notes: args.notes,
});
@@ -101,10 +110,13 @@ export const update = mutation({
categoryId: v.optional(v.id("categories")),
principal: v.optional(v.number()),
annualInterestRate: v.optional(v.number()),
effectiveAnnualRate: v.optional(v.number()),
monthlyPayment: v.optional(v.number()),
termMonths: v.optional(v.number()),
startDate: v.optional(v.string()),
currentBalance: v.optional(v.number()),
totalInterest: v.optional(v.number()),
totalAmount: v.optional(v.number()),
status: v.optional(
v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
),
@@ -133,8 +145,11 @@ export const update = mutation({
"categoryId",
"principal",
"annualInterestRate",
"effectiveAnnualRate",
"startDate",
"currentBalance",
"totalInterest",
"totalAmount",
"status",
"notes",
] as const) {
@@ -163,7 +178,9 @@ export const computeSummary = query({
currentBalance: v.number(),
payoffDate: v.string(),
totalInterest: v.number(),
totalAmount: v.number(),
remainingMonths: v.number(),
paidMonths: v.number(),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -175,10 +192,9 @@ export const computeSummary = query({
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
currentBalance: loan.currentBalance ?? undefined,
});
const balance =
loan.currentBalance ??
currentBalanceFromSchedule(scheduleResult.schedule, startDate);
const balance = scheduleResult.currentBalance;
const remainingMonths = scheduleResult.schedule.filter(
(_, idx) =>
idx >=
@@ -192,7 +208,9 @@ export const computeSummary = query({
currentBalance: balance,
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
totalInterest: scheduleResult.totalInterest,
totalAmount: scheduleResult.totalAmount,
remainingMonths,
paidMonths: scheduleResult.paidMonths,
};
},
});