import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { assertOwned, requireUserId } from "./lib/helpers"; import { buildSchedule, computePaymentFromTerm, computeTermFromPayment, } 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(), 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()), }); function normalizeLoanFields(args: { principal: number; annualInterestRate: number; monthlyPayment?: number; termMonths?: number; }) { 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) { 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(), 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()), }, 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, 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, }); }, }); 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()), 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")), ), 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 = { monthlyPayment: normalized.monthlyPayment, termMonths: normalized.termMonths, }; for (const key of [ "name", "lender", "accountId", "categoryId", "principal", "annualInterestRate", "effectiveAnnualRate", "startDate", "currentBalance", "totalInterest", "totalAmount", "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(), totalAmount: v.number(), remainingMonths: v.number(), paidMonths: 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, currentBalance: loan.currentBalance ?? undefined, }); const balance = scheduleResult.currentBalance; 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, totalAmount: scheduleResult.totalAmount, remainingMonths, paidMonths: scheduleResult.paidMonths, }; }, });