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