Files
finanzen/convex/transactions.ts

306 lines
9.5 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
import {
assertOwned,
enrichTransactionFields,
getAppSettings,
recomputeEffectiveMonth,
requireUserId,
} from "./lib/helpers";
import { applySalaryShiftRule } from "./lib/month";
const transactionValidator = v.object({
_id: v.id("transactions"),
_creationTime: v.number(),
userId: v.id("users"),
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
bookingDate: v.optional(v.string()),
valueDate: v.optional(v.string()),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
vorgang: v.optional(v.string()),
isPending: v.boolean(),
notes: v.optional(v.string()),
rawText: v.optional(v.string()),
importId: v.optional(v.id("imports")),
assignedMonth: v.optional(v.string()),
effectiveMonth: v.optional(v.string()),
dedupHash: v.optional(v.string()),
externalRef: v.optional(v.string()),
});
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
search: v.optional(v.string()),
from: v.optional(v.string()),
to: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
accountId: v.optional(v.id("accounts")),
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
pendingOnly: v.optional(v.boolean()),
},
returns: v.object({
page: v.array(transactionValidator),
isDone: v.boolean(),
continueCursor: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
let q = ctx.db
.query("transactions")
.withIndex("by_user_booking", (q) => q.eq("userId", userId))
.order("desc");
const result = await q.paginate(args.paginationOpts);
let page = result.page;
if (args.from) {
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!);
}
if (args.to) {
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!);
}
if (args.accountId) {
page = page.filter((tx) => tx.accountId === args.accountId);
}
if (args.pendingOnly) {
page = page.filter((tx) => tx.isPending);
}
if (args.type === "einnahme") {
page = page.filter((tx) => tx.amount > 0);
}
if (args.type === "ausgabe") {
page = page.filter((tx) => tx.amount < 0);
}
if (args.categoryIds && args.categoryIds.length > 0) {
const set = new Set(args.categoryIds);
page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId));
}
if (args.search) {
const s = args.search.toLowerCase();
page = page.filter(
(tx) =>
tx.description.toLowerCase().includes(s) ||
(tx.counterparty?.toLowerCase().includes(s) ?? false) ||
(tx.rawText?.toLowerCase().includes(s) ?? false),
);
}
return {
page,
isDone: result.isDone,
continueCursor: result.continueCursor,
};
},
});
export const create = mutation({
args: {
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
bookingDate: v.optional(v.string()),
valueDate: v.optional(v.string()),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
vorgang: v.optional(v.string()),
isPending: v.boolean(),
notes: v.optional(v.string()),
rawText: v.optional(v.string()),
assignedMonth: v.optional(v.string()),
},
returns: v.id("transactions"),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const enriched = await enrichTransactionFields(ctx, userId, args);
const existingDedup = await ctx.db
.query("transactions")
.withIndex("by_user_dedup", (q) =>
q.eq("userId", userId).eq("dedupHash", enriched.dedupHash),
)
.unique();
if (existingDedup) throw new Error("Duplikat erkannt");
return await ctx.db.insert("transactions", {
userId,
accountId: args.accountId,
categoryId: enriched.categoryId,
bookingDate: args.isPending ? undefined : args.bookingDate,
valueDate: args.valueDate,
description: args.description,
counterparty: args.counterparty,
amount: enriched.amount,
vorgang: args.vorgang,
isPending: args.isPending,
notes: args.notes,
rawText: args.rawText,
assignedMonth: enriched.assignedMonth,
effectiveMonth: enriched.effectiveMonth,
dedupHash: enriched.dedupHash,
});
},
});
export const update = mutation({
args: {
id: v.id("transactions"),
accountId: v.optional(v.id("accounts")),
categoryId: v.optional(v.id("categories")),
bookingDate: v.optional(v.string()),
valueDate: v.optional(v.string()),
description: v.optional(v.string()),
counterparty: v.optional(v.string()),
amount: v.optional(v.number()),
vorgang: v.optional(v.string()),
isPending: v.optional(v.boolean()),
notes: v.optional(v.string()),
assignedMonth: v.optional(v.union(v.string(), v.null())),
},
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const tx = await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion");
const patch: Record<string, unknown> = {};
for (const key of [
"accountId",
"categoryId",
"bookingDate",
"valueDate",
"description",
"counterparty",
"amount",
"vorgang",
"isPending",
"notes",
] as const) {
if (args[key] !== undefined) patch[key] = args[key];
}
if (args.assignedMonth !== undefined) {
patch.assignedMonth = args.assignedMonth === null ? undefined : args.assignedMonth;
}
const merged = { ...tx, ...patch };
if (merged.isPending) {
patch.bookingDate = undefined;
}
const enriched = await enrichTransactionFields(ctx, userId, {
accountId: merged.accountId,
categoryId: merged.categoryId,
bookingDate: merged.isPending ? undefined : merged.bookingDate,
valueDate: merged.valueDate,
description: merged.description,
counterparty: merged.counterparty,
amount: merged.amount,
vorgang: merged.vorgang,
isPending: merged.isPending,
notes: merged.notes,
rawText: merged.rawText,
assignedMonth: merged.assignedMonth,
externalRef: merged.externalRef,
});
patch.categoryId = enriched.categoryId;
patch.assignedMonth = enriched.assignedMonth;
patch.effectiveMonth = enriched.effectiveMonth;
patch.dedupHash = enriched.dedupHash;
patch.amount = enriched.amount;
await ctx.db.patch(args.id, patch);
return null;
},
});
export const remove = mutation({
args: { id: v.id("transactions") },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion");
await ctx.db.delete(args.id);
return null;
},
});
export const bulkSetCategory = mutation({
args: {
ids: v.array(v.id("transactions")),
categoryId: v.id("categories"),
},
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await assertOwned(await ctx.db.get("categories", args.categoryId), userId, "Kategorie");
for (const id of args.ids) {
const tx = await assertOwned(await ctx.db.get("transactions", id), userId, "Transaktion");
await ctx.db.patch(id, { categoryId: args.categoryId });
const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, tx.assignedMonth);
if (effectiveMonth !== tx.effectiveMonth) {
await ctx.db.patch(id, { effectiveMonth });
}
}
return null;
},
});
export const setAssignedMonth = mutation({
args: {
id: v.id("transactions"),
month: v.union(v.string(), v.null()),
},
returns: v.null(),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const tx = await assertOwned(await ctx.db.get("transactions", args.id), userId, "Transaktion");
const assignedMonth = args.month === null ? undefined : args.month;
const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, assignedMonth);
await ctx.db.patch(args.id, { assignedMonth, effectiveMonth });
return null;
},
});
export const applySalaryShift = mutation({
args: {},
returns: v.object({ updated: v.number() }),
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const settings = await getAppSettings(ctx, userId);
if (!settings) throw new Error("Einstellungen nicht gefunden");
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const nameById = new Map(categories.map((c) => [c._id, c.name]));
const txs = await ctx.db
.query("transactions")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
let updated = 0;
for (const tx of txs) {
if (tx.assignedMonth) continue;
const categoryName = tx.categoryId ? nameById.get(tx.categoryId) : undefined;
const assignedMonth = applySalaryShiftRule(
tx.bookingDate,
tx.amount,
categoryName,
settings.salaryShift,
);
if (!assignedMonth) continue;
const effectiveMonth = recomputeEffectiveMonth(tx.bookingDate, assignedMonth);
await ctx.db.patch(tx._id, { assignedMonth, effectiveMonth });
updated++;
}
return { updated };
},
});