initial commit
This commit is contained in:
191
convex/lib/amortization.ts
Normal file
191
convex/lib/amortization.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
export type AmortRow = {
|
||||
month: number;
|
||||
date: string;
|
||||
payment: number;
|
||||
interest: number;
|
||||
principal: number;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export type ScheduleResult = {
|
||||
schedule: AmortRow[];
|
||||
payment: number;
|
||||
termMonths: number;
|
||||
totalInterest: number;
|
||||
payoffDate: Date;
|
||||
};
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setMonth(d.getMonth() + months);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function computeTermFromPayment(
|
||||
principal: number,
|
||||
annualRate: number,
|
||||
monthlyPayment: number,
|
||||
): number {
|
||||
const i = annualRate / 100 / 12;
|
||||
if (i <= 0) {
|
||||
return Math.ceil(principal / monthlyPayment);
|
||||
}
|
||||
const n = -Math.log(1 - (i * principal) / monthlyPayment) / Math.log(1 + i);
|
||||
return Math.ceil(n);
|
||||
}
|
||||
|
||||
export function computePaymentFromTerm(
|
||||
principal: number,
|
||||
annualRate: number,
|
||||
termMonths: number,
|
||||
): number {
|
||||
const i = annualRate / 100 / 12;
|
||||
if (i <= 0) {
|
||||
return round2(principal / termMonths);
|
||||
}
|
||||
const payment = (principal * i) / (1 - Math.pow(1 + i, -termMonths));
|
||||
return round2(payment);
|
||||
}
|
||||
|
||||
export function buildSchedule(input: {
|
||||
principal: number;
|
||||
annualRate: number;
|
||||
startDate: Date;
|
||||
monthlyPayment?: number;
|
||||
termMonths?: number;
|
||||
}): ScheduleResult {
|
||||
const P = input.principal;
|
||||
const i = input.annualRate / 100 / 12;
|
||||
|
||||
let payment = input.monthlyPayment;
|
||||
let termMonths = input.termMonths;
|
||||
|
||||
if (payment !== undefined && termMonths === undefined) {
|
||||
termMonths = computeTermFromPayment(P, input.annualRate, payment);
|
||||
} else if (termMonths !== undefined && payment === undefined) {
|
||||
payment = computePaymentFromTerm(P, input.annualRate, termMonths);
|
||||
} else if (payment === undefined && termMonths === undefined) {
|
||||
throw new Error("Entweder monthlyPayment oder termMonths erforderlich");
|
||||
}
|
||||
|
||||
payment = round2(payment!);
|
||||
termMonths = termMonths!;
|
||||
|
||||
const schedule: AmortRow[] = [];
|
||||
let balance = P;
|
||||
|
||||
for (let month = 1; month <= termMonths; month++) {
|
||||
const interest = round2(i > 0 ? balance * i : 0);
|
||||
let principalPart = round2(payment - interest);
|
||||
if (month === termMonths || principalPart > balance) {
|
||||
principalPart = round2(balance);
|
||||
payment = round2(interest + principalPart);
|
||||
}
|
||||
balance = round2(balance - principalPart);
|
||||
const date = formatDate(addMonths(input.startDate, month - 1));
|
||||
schedule.push({
|
||||
month,
|
||||
date,
|
||||
payment,
|
||||
interest,
|
||||
principal: principalPart,
|
||||
balance: Math.max(0, balance),
|
||||
});
|
||||
if (balance <= 0) break;
|
||||
}
|
||||
|
||||
const actualTerm = schedule.length;
|
||||
const totalPaid = schedule.reduce((sum, row) => sum + row.payment, 0);
|
||||
const payoffDate = addMonths(input.startDate, actualTerm);
|
||||
|
||||
return {
|
||||
schedule,
|
||||
payment,
|
||||
termMonths: actualTerm,
|
||||
totalInterest: round2(totalPaid - P),
|
||||
payoffDate,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentBalanceFromSchedule(
|
||||
schedule: AmortRow[],
|
||||
startDate: Date,
|
||||
asOf: Date = new Date(),
|
||||
): number {
|
||||
if (schedule.length === 0) return 0;
|
||||
const monthsElapsed = Math.max(
|
||||
0,
|
||||
(asOf.getFullYear() - startDate.getFullYear()) * 12 +
|
||||
(asOf.getMonth() - startDate.getMonth()),
|
||||
);
|
||||
if (monthsElapsed >= schedule.length) return 0;
|
||||
if (monthsElapsed <= 0) return schedule[0].balance + schedule[0].principal;
|
||||
return schedule[monthsElapsed - 1]?.balance ?? 0;
|
||||
}
|
||||
|
||||
export function remainingMonthsFromSchedule(
|
||||
schedule: AmortRow[],
|
||||
startDate: Date,
|
||||
asOf: Date = new Date(),
|
||||
): number {
|
||||
const balance = currentBalanceFromSchedule(schedule, startDate, asOf);
|
||||
if (balance <= 0) return 0;
|
||||
const monthsElapsed = Math.max(
|
||||
0,
|
||||
(asOf.getFullYear() - startDate.getFullYear()) * 12 +
|
||||
(asOf.getMonth() - startDate.getMonth()),
|
||||
);
|
||||
return Math.max(0, schedule.length - monthsElapsed);
|
||||
}
|
||||
|
||||
export function totalMonthlyPaymentActiveLoans(
|
||||
loans: Array<{ status: string; monthlyPayment?: number }>,
|
||||
): number {
|
||||
return round2(
|
||||
loans
|
||||
.filter((l) => l.status === "aktiv")
|
||||
.reduce((sum, l) => sum + (l.monthlyPayment ?? 0), 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function totalRemainingDebt(
|
||||
loans: Array<{
|
||||
status: string;
|
||||
principal: number;
|
||||
annualInterestRate: number;
|
||||
startDate: string;
|
||||
monthlyPayment?: number;
|
||||
termMonths?: number;
|
||||
currentBalance?: number;
|
||||
}>,
|
||||
): number {
|
||||
return round2(
|
||||
loans
|
||||
.filter((l) => l.status === "aktiv")
|
||||
.reduce((sum, loan) => {
|
||||
if (loan.currentBalance !== undefined) {
|
||||
return sum + loan.currentBalance;
|
||||
}
|
||||
const start = new Date(loan.startDate);
|
||||
const schedule = buildSchedule({
|
||||
principal: loan.principal,
|
||||
annualRate: loan.annualInterestRate,
|
||||
startDate: start,
|
||||
monthlyPayment: loan.monthlyPayment,
|
||||
termMonths: loan.termMonths,
|
||||
});
|
||||
return sum + currentBalanceFromSchedule(schedule.schedule, start);
|
||||
}, 0),
|
||||
);
|
||||
}
|
||||
123
convex/lib/categorize.ts
Normal file
123
convex/lib/categorize.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
const EXPENSE_RULES: Array<{ category: string; keywords: string[] }> = [
|
||||
{ category: "Haustier (Tierarzt & -bedarf)", keywords: ["FRESSNAPF", "KLEINTIERPRAXIS", "FUTTERKRIPPE", "TIERARZT", "TIERHEIM"] },
|
||||
{ category: "Gesundheit & Apotheke", keywords: ["APOTHEKE", "ZAHNARZT", "MVZ", "LABOR DR", "KLINIK", "PRAXIS DR"] },
|
||||
{ category: "Miete & Wohnen", keywords: ["MIETE", "BETRIEBSKOSTEN", "KAUTIONSKASSE", "KAUTION"] },
|
||||
{ category: "Wasser & Abwasser", keywords: ["WASSERWERKE", "VERBANDSGEMEINDEKASSE", "ABWASSER"] },
|
||||
{ category: "Rundfunkbeitrag", keywords: ["RUNDFUNK"] },
|
||||
{ category: "Kredite & Finanzierung", keywords: ["ING-DIBA", "RATENKREDIT", "KASHRES", "KASH RES"] },
|
||||
{ category: "Versicherung & Vorsorge", keywords: ["VERSICHER", "WUERTT", "KRANKENVERS", "RIESTER", "FONDS-RENTE", "DAK", "HAFTPFLICHT"] },
|
||||
{ category: "KFZ-Steuer", keywords: ["KFZ-STEUER", "KFZ STEUER"] },
|
||||
{ category: "Energie (Strom/Heizung)", keywords: ["EPRIMO", "STADTWERKE", "MONTANA", "GASVERSORG"] },
|
||||
{ category: "Telekom, Internet & Hosting", keywords: ["O2", "TELEFONICA", "VODAFONE", "TELEKOM", "1&1", "1UND1", "CONGSTAR", "NETCUP", "CLOUDFLARE", "GOOGLE CLOUD"] },
|
||||
{ category: "Abos & Streaming", keywords: ["YOUTUBE", "NETFLIX", "SPOTIFY", "DISNEY", "AMAZON PRIME", "GOOGLE *", "APPLE.COM", "ITUNES", "C2 CIRCLE"] },
|
||||
{ category: "Beiträge & Mitgliedschaften", keywords: ["OFFIZIERGESELLSCHAFT", "CASINO JULIUS", "MITGLIEDSBEITR", "ADAC", "TCG ", "E.V", "E. V", "SCHUFA"] },
|
||||
{ category: "Soziales, Kita & Bildung", keywords: ["AWO KREISVERBAND", "STADTBIBLIOTHEK", "BILDUNGSTICKET"] },
|
||||
{ category: "Tanken", keywords: ["KAUFL SERVICE STATION", "SHELL", "ESSO", "ARAL", "JET ", "STAR ", "STAR T", "TANKSTELLE", "AVIA", "TOTAL", "HEM "] },
|
||||
{ category: "Auto & Werkstatt", keywords: ["AUTO TELEMANN", "AUTOHAUS", "WERKSTATT", "REIFEN"] },
|
||||
{ category: "Lebensmittel & Supermarkt", keywords: ["KAUFLAND", "LIDL", "ALDI", "NETTO", "PENNY", "REWE", "EDEKA", "NORMA", "KAUFPARK", "GETRAENKE", "GETRÄNKE", "E-CENTER", "E CENTER", "SPAR-LAND", "DISKA", "GLOBUS", "TRANSGOURMET", "PHILIPPS", "SPAR LAND"] },
|
||||
{ category: "Drogerie & Körperpflege", keywords: ["ROSSMANN", "DM-DROGERIE", "DM DROGERIE", "DM FIL", "MUELLER", "BUDNI", "HAARSCHARF", "FRISEUR"] },
|
||||
{ category: "Baumarkt, Garten & Möbel", keywords: ["OBI", "SONDERPREIS BAUMARKT", "BAUZENTRUM", "DEHNER", "BAUMARKT", "HORNBACH", "IKEA", "XXXLUTZ", "WOKO", "SUEDWEST"] },
|
||||
{ category: "Haushalt & Discounter", keywords: ["ACTION", "TEDI", "WOOLWORTH", "NKD"] },
|
||||
{ category: "Kleidung & Schuhe", keywords: ["TAKKO", "RENO", "DEICHMANN", "KIK", "C&A", "H&M", "ZALANDO", "FASHION"] },
|
||||
{ category: "Restaurant, Lieferdienst & Gastro", keywords: ["MCDONALD", "BURGER", "LIEFERANDO", "DOMINO", "KFC", "SUBWAY", "BÄCKER", "BAECKER", "RESTAURANT", "CAFE", "IMBISS", "VIELFALTMENU", "VIELFALTMENUE", "SYSTEMGASTRONOMIE", "BRAUHAUS", "SUN HOUSE", "SANIFAIR", "PANUSSIS", "LEITERMANN", "NYX*", "VENDING"] },
|
||||
{ category: "Parken", keywords: ["EASYPARK", "PARKHAUS", "APCOA", "PARK "] },
|
||||
{ category: "Bahn, ÖPNV & Mobilität", keywords: ["DB FERNVERKEHR", "DB VERTRIEB", "BAHN", "FLIXBUS", "FLIX", "NAHVERKEHR", "STAEDTISCHE VERKEHRS", "MILES MOBILITY", "SVZ "] },
|
||||
{ category: "Freizeit & Kultur", keywords: ["FILMPALAST", "KINO", "SCHWIMM", "THEATER"] },
|
||||
{ category: "Shopping & Online", keywords: ["AMAZON", "PAYPAL", "ETSY", "EBAY", "TEMU", "OTTO "] },
|
||||
{ category: "Porto & Versand", keywords: ["DEUTSCHE POST", "DHL"] },
|
||||
];
|
||||
|
||||
const INCOME_RULES: Array<{ category: string; keywords: string[] }> = [
|
||||
{ category: "Trennungsgeld / Mietzuschuss", keywords: ["TG-MIETE", "VZ TG"] },
|
||||
{ category: "Gehalt & Besoldung", keywords: ["BESOLDUNG", "LOHN", "GEHALT"] },
|
||||
{ category: "Kindergeld", keywords: ["FAMILIENKASSE", "KINDERGELD"] },
|
||||
{ category: "Eigene Überträge / Einzahlungen", keywords: ["ÜBERTRAG", "UEBERTRAG"] },
|
||||
{ category: "Zinsen", keywords: ["ZINSEN", "ABSCHLUSS"] },
|
||||
];
|
||||
|
||||
function matchesKeywords(text: string, keywords: string[]): boolean {
|
||||
return keywords.some((kw) => text.includes(kw));
|
||||
}
|
||||
|
||||
function matchesOwnNameTransfer(text: string, ownNames: string[]): boolean {
|
||||
for (const name of ownNames) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) continue;
|
||||
const pattern = new RegExp(`EMPFÄNGER:\\s*${trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
|
||||
if (pattern.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function categorize(
|
||||
rawText: string,
|
||||
amount: number,
|
||||
vorgang: string,
|
||||
ownNames: string[],
|
||||
): string {
|
||||
const text = rawText.toUpperCase();
|
||||
const vorgangUpper = (vorgang ?? "").toUpperCase();
|
||||
|
||||
if (amount < 0) {
|
||||
if (matchesOwnNameTransfer(text, ownNames)) {
|
||||
return "Interne & private Überträge";
|
||||
}
|
||||
for (const rule of EXPENSE_RULES) {
|
||||
if (matchesKeywords(text, rule.keywords)) {
|
||||
return rule.category;
|
||||
}
|
||||
}
|
||||
if (vorgangUpper === "AUSZAHLUNG GAA" || vorgangUpper === "AUSZAHLUNG") {
|
||||
return "Bargeldauszahlung";
|
||||
}
|
||||
if (
|
||||
vorgangUpper === "KONTOFÜHRUNGSENTGELT" ||
|
||||
["ENTGELT", "GEBÜHR", "GEBUEHR", "ABSCHLUSS ZINSEN"].some((k) => vorgangUpper.includes(k))
|
||||
) {
|
||||
return "Bankgebühren & Zinsen";
|
||||
}
|
||||
return "Sonstiges";
|
||||
}
|
||||
|
||||
for (const rule of INCOME_RULES) {
|
||||
if (matchesKeywords(text, rule.keywords)) {
|
||||
return rule.category;
|
||||
}
|
||||
}
|
||||
return "Sonstige Einnahmen";
|
||||
}
|
||||
|
||||
export function parseCounterpartyFromBuchungstext(buchungstext: string): {
|
||||
counterparty?: string;
|
||||
description: string;
|
||||
rawText: string;
|
||||
} {
|
||||
const rawText = buchungstext.trim();
|
||||
const normalized = rawText.replace(/\s+/g, " ");
|
||||
|
||||
const auftraggeber = normalized.match(/Auftraggeber:\s*([^]+?)(?=\s*(?:Empfänger:|Buchungstext:|$))/i);
|
||||
if (auftraggeber?.[1]) {
|
||||
return { counterparty: auftraggeber[1].trim(), description: auftraggeber[1].trim(), rawText };
|
||||
}
|
||||
|
||||
const empfaenger = normalized.match(/Empfänger:\s*([^]+?)(?=\s*(?:Buchungstext:|$))/i);
|
||||
if (empfaenger?.[1]) {
|
||||
return { counterparty: empfaenger[1].trim(), description: empfaenger[1].trim(), rawText };
|
||||
}
|
||||
|
||||
const buchungstextMatch = normalized.match(/Buchungstext:\s*([^]+)$/i);
|
||||
if (buchungstextMatch?.[1]) {
|
||||
return { description: buchungstextMatch[1].trim(), rawText };
|
||||
}
|
||||
|
||||
return { description: normalized, rawText };
|
||||
}
|
||||
|
||||
export function parseGermanAmount(value: string): number {
|
||||
const cleaned = value.trim().replace(/\./g, "").replace(",", ".");
|
||||
return Math.round(parseFloat(cleaned) * 100) / 100;
|
||||
}
|
||||
|
||||
export function roundEur(amount: number): number {
|
||||
return Math.round(amount * 100) / 100;
|
||||
}
|
||||
98
convex/lib/comdirectMap.ts
Normal file
98
convex/lib/comdirectMap.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { categorize } from "./categorize";
|
||||
import { resolveAssignedAndEffective, type SalaryShiftSettings } from "./month";
|
||||
import { roundEur } from "./categorize";
|
||||
|
||||
export type ComdirectTransaction = {
|
||||
bookingStatus?: string;
|
||||
bookingDate?: string;
|
||||
valueDate?: string;
|
||||
amount?: { value?: string };
|
||||
remittanceInfo?: string;
|
||||
remitter?: { holderName?: string };
|
||||
creditor?: { holderName?: string };
|
||||
deptor?: { holderName?: string };
|
||||
transactionType?: { text?: string };
|
||||
reference?: string;
|
||||
};
|
||||
|
||||
export function parseRemittanceInfo(remittanceInfo?: string): string {
|
||||
if (!remittanceInfo) return "";
|
||||
const blocks = remittanceInfo.split(/\s(?=\d{2})/);
|
||||
return blocks
|
||||
.map((block) => block.replace(/^\d{2}/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type MappedComdirectRow = {
|
||||
bookingDate?: string;
|
||||
valueDate?: string;
|
||||
description: string;
|
||||
counterparty?: string;
|
||||
amount: number;
|
||||
vorgang?: string;
|
||||
isPending: boolean;
|
||||
rawText?: string;
|
||||
externalRef?: string;
|
||||
categoryName: string;
|
||||
assignedMonth?: string;
|
||||
effectiveMonth?: string;
|
||||
};
|
||||
|
||||
export function mapComdirectTransaction(
|
||||
tx: ComdirectTransaction,
|
||||
ownNames: string[],
|
||||
salaryShift: SalaryShiftSettings,
|
||||
): MappedComdirectRow {
|
||||
const isPending = tx.bookingStatus === "NOTBOOKED";
|
||||
const amount = roundEur(Number(tx.amount?.value ?? 0));
|
||||
const rawText = parseRemittanceInfo(tx.remittanceInfo);
|
||||
const counterparty =
|
||||
tx.remitter?.holderName ?? tx.creditor?.holderName ?? tx.deptor?.holderName;
|
||||
const vorgang = tx.transactionType?.text;
|
||||
const description = counterparty ?? rawText.slice(0, 80) ?? "Umsatz";
|
||||
const categoryName = categorize(rawText, amount, vorgang ?? "", ownNames);
|
||||
const { assignedMonth, effectiveMonth } = resolveAssignedAndEffective(
|
||||
isPending ? undefined : tx.bookingDate,
|
||||
amount,
|
||||
categoryName,
|
||||
salaryShift,
|
||||
);
|
||||
|
||||
return {
|
||||
bookingDate: isPending ? undefined : tx.bookingDate,
|
||||
valueDate: tx.valueDate,
|
||||
description,
|
||||
counterparty,
|
||||
amount,
|
||||
vorgang,
|
||||
isPending,
|
||||
rawText: rawText || undefined,
|
||||
externalRef: tx.reference,
|
||||
categoryName,
|
||||
assignedMonth,
|
||||
effectiveMonth,
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeDedupHash(input: {
|
||||
accountId?: string;
|
||||
bookingDate?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
vorgang?: string;
|
||||
}): Promise<string> {
|
||||
const payload = [
|
||||
input.accountId ?? "",
|
||||
input.bookingDate ?? "pending",
|
||||
input.amount.toFixed(2),
|
||||
input.description.trim().toLowerCase(),
|
||||
(input.vorgang ?? "").trim().toLowerCase(),
|
||||
].join("|");
|
||||
const data = new TextEncoder().encode(payload);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
134
convex/lib/helpers.ts
Normal file
134
convex/lib/helpers.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import { categorize, roundEur } from "./categorize";
|
||||
import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month";
|
||||
import { computeDedupHash } from "./comdirectMap";
|
||||
|
||||
export async function requireUserId(ctx: QueryCtx | MutationCtx): Promise<Id<"users">> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Nicht angemeldet");
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function getAppSettings(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
|
||||
return await ctx.db
|
||||
.query("appSettings")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.unique();
|
||||
}
|
||||
|
||||
export async function getCategoryMap(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
|
||||
const categories = await ctx.db
|
||||
.query("categories")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const byName = new Map<string, Id<"categories">>();
|
||||
for (const cat of categories) {
|
||||
byName.set(cat.name, cat._id);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
export async function resolveCategoryId(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
userId: Id<"users">,
|
||||
categoryName: string,
|
||||
): Promise<Id<"categories"> | undefined> {
|
||||
const cat = await ctx.db
|
||||
.query("categories")
|
||||
.withIndex("by_user_name", (q) => q.eq("userId", userId).eq("name", categoryName))
|
||||
.unique();
|
||||
return cat?._id;
|
||||
}
|
||||
|
||||
export type TransactionInput = {
|
||||
accountId?: Id<"accounts">;
|
||||
categoryId?: Id<"categories">;
|
||||
categoryName?: string;
|
||||
bookingDate?: string;
|
||||
valueDate?: string;
|
||||
description: string;
|
||||
counterparty?: string;
|
||||
amount: number;
|
||||
vorgang?: string;
|
||||
isPending: boolean;
|
||||
notes?: string;
|
||||
rawText?: string;
|
||||
importId?: Id<"imports">;
|
||||
assignedMonth?: string;
|
||||
externalRef?: string;
|
||||
};
|
||||
|
||||
export async function enrichTransactionFields(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
input: TransactionInput,
|
||||
) {
|
||||
const settings = await getAppSettings(ctx, userId);
|
||||
const salaryShift = settings?.salaryShift ?? {
|
||||
enabled: true,
|
||||
categoryNames: ["Gehalt & Besoldung"],
|
||||
dayThreshold: 25,
|
||||
};
|
||||
const ownNames = settings?.ownNames ?? [];
|
||||
|
||||
let categoryId = input.categoryId;
|
||||
let categoryName = input.categoryName;
|
||||
if (!categoryId && !categoryName && input.rawText) {
|
||||
categoryName = categorize(
|
||||
input.rawText,
|
||||
input.amount,
|
||||
input.vorgang ?? "",
|
||||
ownNames,
|
||||
);
|
||||
}
|
||||
if (!categoryId && categoryName) {
|
||||
categoryId = await resolveCategoryId(ctx, userId, categoryName);
|
||||
}
|
||||
if (categoryId && !categoryName) {
|
||||
const cat = await ctx.db.get("categories", categoryId);
|
||||
categoryName = cat?.name;
|
||||
}
|
||||
|
||||
const { assignedMonth, effectiveMonth } = resolveAssignedAndEffective(
|
||||
input.bookingDate,
|
||||
input.amount,
|
||||
categoryName,
|
||||
salaryShift,
|
||||
input.assignedMonth,
|
||||
);
|
||||
|
||||
const dedupHash = await computeDedupHash({
|
||||
accountId: input.accountId,
|
||||
bookingDate: input.bookingDate,
|
||||
amount: roundEur(input.amount),
|
||||
description: input.description,
|
||||
vorgang: input.vorgang,
|
||||
});
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
assignedMonth,
|
||||
effectiveMonth,
|
||||
dedupHash,
|
||||
amount: roundEur(input.amount),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertOwned<T extends { userId: Id<"users"> }>(
|
||||
doc: T | null,
|
||||
userId: Id<"users">,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
if (!doc) throw new Error(`${label} nicht gefunden`);
|
||||
if (doc.userId !== userId) throw new Error("Nicht autorisiert");
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function recomputeEffectiveMonth(
|
||||
bookingDate: string | undefined,
|
||||
assignedMonth: string | undefined,
|
||||
) {
|
||||
return computeEffectiveMonth(bookingDate, assignedMonth);
|
||||
}
|
||||
77
convex/lib/month.ts
Normal file
77
convex/lib/month.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type SalaryShiftSettings = {
|
||||
enabled: boolean;
|
||||
categoryNames: string[];
|
||||
dayThreshold: number;
|
||||
};
|
||||
|
||||
export function bookingMonth(bookingDate?: string): string | undefined {
|
||||
if (!bookingDate) return undefined;
|
||||
return bookingDate.slice(0, 7);
|
||||
}
|
||||
|
||||
export function computeEffectiveMonth(
|
||||
bookingDate: string | undefined,
|
||||
assignedMonth: string | undefined,
|
||||
): string | undefined {
|
||||
if (assignedMonth) return assignedMonth;
|
||||
return bookingMonth(bookingDate);
|
||||
}
|
||||
|
||||
export function addMonthsToMonthKey(monthKey: string, months: number): string {
|
||||
const [yearStr, monthStr] = monthKey.split("-");
|
||||
const date = new Date(Number(yearStr), Number(monthStr) - 1 + months, 1);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
export function getDayFromDate(dateStr: string): number {
|
||||
return Number(dateStr.slice(8, 10));
|
||||
}
|
||||
|
||||
export function applySalaryShiftRule(
|
||||
bookingDate: string | undefined,
|
||||
amount: number,
|
||||
categoryName: string | undefined,
|
||||
salaryShift: SalaryShiftSettings,
|
||||
existingAssignedMonth?: string,
|
||||
): string | undefined {
|
||||
if (existingAssignedMonth) return existingAssignedMonth;
|
||||
if (!salaryShift.enabled || !bookingDate || amount <= 0 || !categoryName) {
|
||||
return undefined;
|
||||
}
|
||||
if (!salaryShift.categoryNames.includes(categoryName)) {
|
||||
return undefined;
|
||||
}
|
||||
const day = getDayFromDate(bookingDate);
|
||||
if (day < salaryShift.dayThreshold) {
|
||||
return undefined;
|
||||
}
|
||||
const booking = bookingMonth(bookingDate);
|
||||
if (!booking) return undefined;
|
||||
return addMonthsToMonthKey(booking, 1);
|
||||
}
|
||||
|
||||
export function resolveAssignedAndEffective(
|
||||
bookingDate: string | undefined,
|
||||
amount: number,
|
||||
categoryName: string | undefined,
|
||||
salaryShift: SalaryShiftSettings,
|
||||
manualAssignedMonth?: string,
|
||||
): { assignedMonth?: string; effectiveMonth?: string } {
|
||||
const assignedMonth =
|
||||
manualAssignedMonth ??
|
||||
applySalaryShiftRule(bookingDate, amount, categoryName, salaryShift);
|
||||
const effectiveMonth = computeEffectiveMonth(bookingDate, assignedMonth);
|
||||
return { assignedMonth, effectiveMonth };
|
||||
}
|
||||
|
||||
export function monthKeyFromBasis(
|
||||
tx: { bookingDate?: string; effectiveMonth?: string },
|
||||
basis: "effective" | "booking",
|
||||
): string | undefined {
|
||||
if (basis === "effective") {
|
||||
return tx.effectiveMonth ?? bookingMonth(tx.bookingDate);
|
||||
}
|
||||
return bookingMonth(tx.bookingDate);
|
||||
}
|
||||
63
convex/lib/seedCategories.ts
Normal file
63
convex/lib/seedCategories.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type CategoryKind = "einnahme" | "ausgabe";
|
||||
export type ExpenseBlock = "wiederkehrend" | "variabel";
|
||||
|
||||
export type DefaultCategory = {
|
||||
name: string;
|
||||
kind: CategoryKind;
|
||||
block?: ExpenseBlock;
|
||||
color: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_CATEGORIES: DefaultCategory[] = [
|
||||
// Einnahmen
|
||||
{ name: "Gehalt & Besoldung", kind: "einnahme", color: "#22c55e", icon: "Briefcase", sortOrder: 1 },
|
||||
{ name: "Trennungsgeld / Mietzuschuss", kind: "einnahme", color: "#16a34a", icon: "Home", sortOrder: 2 },
|
||||
{ name: "Kindergeld", kind: "einnahme", color: "#4ade80", icon: "Baby", sortOrder: 3 },
|
||||
{ name: "Eigene Überträge / Einzahlungen", kind: "einnahme", color: "#86efac", icon: "ArrowDownLeft", sortOrder: 4 },
|
||||
{ name: "Zinsen", kind: "einnahme", color: "#059669", icon: "Percent", sortOrder: 5 },
|
||||
{ name: "Sonstige Einnahmen", kind: "einnahme", color: "#6ee7b7", icon: "CircleDollarSign", sortOrder: 6 },
|
||||
// Ausgaben wiederkehrend
|
||||
{ name: "Miete & Wohnen", kind: "ausgabe", block: "wiederkehrend", color: "#6366f1", icon: "Building2", sortOrder: 10 },
|
||||
{ name: "Kredite & Finanzierung", kind: "ausgabe", block: "wiederkehrend", color: "#4f46e5", icon: "Landmark", sortOrder: 11 },
|
||||
{ name: "Versicherung & Vorsorge", kind: "ausgabe", block: "wiederkehrend", color: "#4338ca", icon: "Shield", sortOrder: 12 },
|
||||
{ name: "Energie (Strom/Heizung)", kind: "ausgabe", block: "wiederkehrend", color: "#f59e0b", icon: "Zap", sortOrder: 13 },
|
||||
{ name: "Wasser & Abwasser", kind: "ausgabe", block: "wiederkehrend", color: "#0ea5e9", icon: "Droplets", sortOrder: 14 },
|
||||
{ name: "Telekom, Internet & Hosting", kind: "ausgabe", block: "wiederkehrend", color: "#8b5cf6", icon: "Wifi", sortOrder: 15 },
|
||||
{ name: "Abos & Streaming", kind: "ausgabe", block: "wiederkehrend", color: "#a855f7", icon: "Tv", sortOrder: 16 },
|
||||
{ name: "Rundfunkbeitrag", kind: "ausgabe", block: "wiederkehrend", color: "#7c3aed", icon: "Radio", sortOrder: 17 },
|
||||
{ name: "KFZ-Steuer", kind: "ausgabe", block: "wiederkehrend", color: "#64748b", icon: "Car", sortOrder: 18 },
|
||||
{ name: "Beiträge & Mitgliedschaften", kind: "ausgabe", block: "wiederkehrend", color: "#475569", icon: "Users", sortOrder: 19 },
|
||||
{ name: "Soziales, Kita & Bildung", kind: "ausgabe", block: "wiederkehrend", color: "#ec4899", icon: "GraduationCap", sortOrder: 20 },
|
||||
// Ausgaben variabel
|
||||
{ name: "Lebensmittel & Supermarkt", kind: "ausgabe", block: "variabel", color: "#ef4444", icon: "ShoppingCart", sortOrder: 30 },
|
||||
{ name: "Drogerie & Körperpflege", kind: "ausgabe", block: "variabel", color: "#f97316", icon: "Sparkles", sortOrder: 31 },
|
||||
{ name: "Haushalt & Discounter", kind: "ausgabe", block: "variabel", color: "#fb923c", icon: "Store", sortOrder: 32 },
|
||||
{ name: "Tanken", kind: "ausgabe", block: "variabel", color: "#eab308", icon: "Fuel", sortOrder: 33 },
|
||||
{ name: "Auto & Werkstatt", kind: "ausgabe", block: "variabel", color: "#ca8a04", icon: "Wrench", sortOrder: 34 },
|
||||
{ name: "Bahn, ÖPNV & Mobilität", kind: "ausgabe", block: "variabel", color: "#0284c7", icon: "Train", sortOrder: 35 },
|
||||
{ name: "Parken", kind: "ausgabe", block: "variabel", color: "#0369a1", icon: "ParkingCircle", sortOrder: 36 },
|
||||
{ name: "Restaurant, Lieferdienst & Gastro", kind: "ausgabe", block: "variabel", color: "#dc2626", icon: "Utensils", sortOrder: 37 },
|
||||
{ name: "Haustier (Tierarzt & -bedarf)", kind: "ausgabe", block: "variabel", color: "#d97706", icon: "PawPrint", sortOrder: 38 },
|
||||
{ name: "Gesundheit & Apotheke", kind: "ausgabe", block: "variabel", color: "#e11d48", icon: "HeartPulse", sortOrder: 39 },
|
||||
{ name: "Kleidung & Schuhe", kind: "ausgabe", block: "variabel", color: "#db2777", icon: "Shirt", sortOrder: 40 },
|
||||
{ name: "Baumarkt, Garten & Möbel", kind: "ausgabe", block: "variabel", color: "#65a30d", icon: "Hammer", sortOrder: 41 },
|
||||
{ name: "Shopping & Online", kind: "ausgabe", block: "variabel", color: "#9333ea", icon: "ShoppingBag", sortOrder: 42 },
|
||||
{ name: "Freizeit & Kultur", kind: "ausgabe", block: "variabel", color: "#7e22ce", icon: "Ticket", sortOrder: 43 },
|
||||
{ name: "Porto & Versand", kind: "ausgabe", block: "variabel", color: "#57534e", icon: "Package", sortOrder: 44 },
|
||||
{ name: "Bargeldauszahlung", kind: "ausgabe", block: "variabel", color: "#78716c", icon: "Banknote", sortOrder: 45 },
|
||||
{ name: "Bankgebühren & Zinsen", kind: "ausgabe", block: "variabel", color: "#44403c", icon: "Receipt", sortOrder: 46 },
|
||||
{ name: "Interne & private Überträge", kind: "ausgabe", block: "variabel", color: "#334155", icon: "ArrowLeftRight", sortOrder: 47 },
|
||||
{ name: "Sonstiges", kind: "ausgabe", block: "variabel", color: "#94a3b8", icon: "MoreHorizontal", sortOrder: 48 },
|
||||
];
|
||||
|
||||
export const DEFAULT_APP_SETTINGS = {
|
||||
ownNames: [] as string[],
|
||||
monthBasis: "effective" as const,
|
||||
salaryShift: {
|
||||
enabled: true,
|
||||
categoryNames: ["Gehalt & Besoldung"],
|
||||
dayThreshold: 25,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user