initial commit

This commit is contained in:
Matthias
2026-06-15 11:33:23 +02:00
commit fc0a6fb975
155 changed files with 24526 additions and 0 deletions

191
convex/lib/amortization.ts Normal file
View 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
View 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;
}

View 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
View 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
View 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);
}

View 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,
},
};