199 lines
6.1 KiB
TypeScript
199 lines
6.1 KiB
TypeScript
import { query, mutation } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import { assertOwned, requireUserId } from "./lib/helpers";
|
|
import {
|
|
buildSchedule,
|
|
computePaymentFromTerm,
|
|
computeTermFromPayment,
|
|
currentBalanceFromSchedule,
|
|
} from "./lib/amortization";
|
|
|
|
const loanValidator = v.object({
|
|
_id: v.id("loans"),
|
|
_creationTime: v.number(),
|
|
userId: v.id("users"),
|
|
name: v.string(),
|
|
lender: v.optional(v.string()),
|
|
accountId: v.optional(v.id("accounts")),
|
|
categoryId: v.optional(v.id("categories")),
|
|
principal: v.number(),
|
|
annualInterestRate: v.number(),
|
|
monthlyPayment: v.optional(v.number()),
|
|
termMonths: v.optional(v.number()),
|
|
startDate: v.string(),
|
|
currentBalance: v.optional(v.number()),
|
|
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
|
notes: v.optional(v.string()),
|
|
});
|
|
|
|
function normalizeLoanFields(args: {
|
|
principal: number;
|
|
annualInterestRate: number;
|
|
monthlyPayment?: number;
|
|
termMonths?: number;
|
|
}) {
|
|
let { monthlyPayment, termMonths } = args;
|
|
if (monthlyPayment !== undefined && termMonths === undefined) {
|
|
termMonths = computeTermFromPayment(args.principal, args.annualInterestRate, monthlyPayment);
|
|
} else if (termMonths !== undefined && monthlyPayment === undefined) {
|
|
monthlyPayment = computePaymentFromTerm(args.principal, args.annualInterestRate, termMonths);
|
|
} else if (monthlyPayment === undefined && termMonths === undefined) {
|
|
throw new Error("Entweder Rate oder Laufzeit angeben");
|
|
}
|
|
return { monthlyPayment, termMonths };
|
|
}
|
|
|
|
export const list = query({
|
|
args: {},
|
|
returns: v.array(loanValidator),
|
|
handler: async (ctx) => {
|
|
const userId = await requireUserId(ctx);
|
|
return await ctx.db
|
|
.query("loans")
|
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
},
|
|
});
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
name: v.string(),
|
|
lender: v.optional(v.string()),
|
|
accountId: v.optional(v.id("accounts")),
|
|
categoryId: v.optional(v.id("categories")),
|
|
principal: v.number(),
|
|
annualInterestRate: v.number(),
|
|
monthlyPayment: v.optional(v.number()),
|
|
termMonths: v.optional(v.number()),
|
|
startDate: v.string(),
|
|
currentBalance: v.optional(v.number()),
|
|
status: v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
|
notes: v.optional(v.string()),
|
|
},
|
|
returns: v.id("loans"),
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireUserId(ctx);
|
|
const normalized = normalizeLoanFields(args);
|
|
return await ctx.db.insert("loans", {
|
|
userId,
|
|
name: args.name,
|
|
lender: args.lender,
|
|
accountId: args.accountId,
|
|
categoryId: args.categoryId,
|
|
principal: args.principal,
|
|
annualInterestRate: args.annualInterestRate,
|
|
monthlyPayment: normalized.monthlyPayment,
|
|
termMonths: normalized.termMonths,
|
|
startDate: args.startDate,
|
|
currentBalance: args.currentBalance,
|
|
status: args.status,
|
|
notes: args.notes,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const update = mutation({
|
|
args: {
|
|
id: v.id("loans"),
|
|
name: v.optional(v.string()),
|
|
lender: v.optional(v.string()),
|
|
accountId: v.optional(v.id("accounts")),
|
|
categoryId: v.optional(v.id("categories")),
|
|
principal: v.optional(v.number()),
|
|
annualInterestRate: v.optional(v.number()),
|
|
monthlyPayment: v.optional(v.number()),
|
|
termMonths: v.optional(v.number()),
|
|
startDate: v.optional(v.string()),
|
|
currentBalance: v.optional(v.number()),
|
|
status: v.optional(
|
|
v.union(v.literal("aktiv"), v.literal("abbezahlt"), v.literal("pausiert")),
|
|
),
|
|
notes: v.optional(v.string()),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireUserId(ctx);
|
|
const loan = await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
|
|
const merged = {
|
|
principal: args.principal ?? loan.principal,
|
|
annualInterestRate: args.annualInterestRate ?? loan.annualInterestRate,
|
|
monthlyPayment: args.monthlyPayment ?? loan.monthlyPayment,
|
|
termMonths: args.termMonths ?? loan.termMonths,
|
|
};
|
|
const normalized = normalizeLoanFields(merged);
|
|
|
|
const patch: Record<string, unknown> = {
|
|
monthlyPayment: normalized.monthlyPayment,
|
|
termMonths: normalized.termMonths,
|
|
};
|
|
for (const key of [
|
|
"name",
|
|
"lender",
|
|
"accountId",
|
|
"categoryId",
|
|
"principal",
|
|
"annualInterestRate",
|
|
"startDate",
|
|
"currentBalance",
|
|
"status",
|
|
"notes",
|
|
] as const) {
|
|
if (args[key] !== undefined) patch[key] = args[key];
|
|
}
|
|
|
|
await ctx.db.patch(args.id, patch);
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const remove = mutation({
|
|
args: { id: v.id("loans") },
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireUserId(ctx);
|
|
await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
|
|
await ctx.db.delete(args.id);
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const computeSummary = query({
|
|
args: { id: v.id("loans") },
|
|
returns: v.object({
|
|
currentBalance: v.number(),
|
|
payoffDate: v.string(),
|
|
totalInterest: v.number(),
|
|
remainingMonths: v.number(),
|
|
}),
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireUserId(ctx);
|
|
const loan = await assertOwned(await ctx.db.get("loans", args.id), userId, "Kredit");
|
|
const startDate = new Date(loan.startDate);
|
|
const scheduleResult = buildSchedule({
|
|
principal: loan.principal,
|
|
annualRate: loan.annualInterestRate,
|
|
startDate,
|
|
monthlyPayment: loan.monthlyPayment,
|
|
termMonths: loan.termMonths,
|
|
});
|
|
const balance =
|
|
loan.currentBalance ??
|
|
currentBalanceFromSchedule(scheduleResult.schedule, startDate);
|
|
const remainingMonths = scheduleResult.schedule.filter(
|
|
(_, idx) =>
|
|
idx >=
|
|
Math.max(
|
|
0,
|
|
(new Date().getFullYear() - startDate.getFullYear()) * 12 +
|
|
(new Date().getMonth() - startDate.getMonth()),
|
|
),
|
|
).length;
|
|
return {
|
|
currentBalance: balance,
|
|
payoffDate: scheduleResult.payoffDate.toISOString().slice(0, 10),
|
|
totalInterest: scheduleResult.totalInterest,
|
|
remainingMonths,
|
|
};
|
|
},
|
|
});
|