initial commit
This commit is contained in:
198
convex/loans.ts
Normal file
198
convex/loans.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user