376 lines
12 KiB
TypeScript
376 lines
12 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"))),
|
|
withoutCategory: v.optional(v.boolean()),
|
|
accountId: v.optional(v.id("accounts")),
|
|
basis: v.optional(v.union(v.literal("effective"), v.literal("booking"))),
|
|
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);
|
|
const basis = args.basis ?? "booking";
|
|
const fromMonth = args.from?.slice(0, 7);
|
|
const toMonth = args.to?.slice(0, 7);
|
|
|
|
let q;
|
|
if (args.search) {
|
|
q = ctx.db
|
|
.query("transactions")
|
|
.withSearchIndex("search_description", (sq) =>
|
|
sq.search("description", args.search!).eq("userId", userId),
|
|
);
|
|
} else {
|
|
if (basis === "effective") {
|
|
q = ctx.db
|
|
.query("transactions")
|
|
.withIndex("by_user_effmonth", (iq) => {
|
|
if (fromMonth && toMonth) {
|
|
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
|
|
}
|
|
if (fromMonth) {
|
|
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth);
|
|
}
|
|
if (toMonth) {
|
|
return iq.eq("userId", userId).lte("effectiveMonth", toMonth);
|
|
}
|
|
return iq.eq("userId", userId);
|
|
})
|
|
.order("desc");
|
|
} else {
|
|
q = ctx.db
|
|
.query("transactions")
|
|
.withIndex("by_user_booking", (iq) => {
|
|
if (args.from && args.to) {
|
|
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
|
}
|
|
if (args.from) {
|
|
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
|
}
|
|
if (args.to) {
|
|
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
|
}
|
|
return iq.eq("userId", userId);
|
|
})
|
|
.order("desc");
|
|
}
|
|
}
|
|
|
|
if (args.search) {
|
|
if (basis === "effective") {
|
|
if (fromMonth) {
|
|
const fallbackFrom = `${fromMonth}-01`;
|
|
q = q.filter((f) =>
|
|
f.or(
|
|
f.gte(f.field("effectiveMonth"), fromMonth),
|
|
f.and(
|
|
f.eq(f.field("effectiveMonth"), undefined),
|
|
f.gte(f.field("bookingDate"), fallbackFrom),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (toMonth) {
|
|
const fallbackTo = `${toMonth}-31`;
|
|
q = q.filter((f) =>
|
|
f.or(
|
|
f.lte(f.field("effectiveMonth"), toMonth),
|
|
f.and(
|
|
f.eq(f.field("effectiveMonth"), undefined),
|
|
f.lte(f.field("bookingDate"), fallbackTo),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
if (args.from) {
|
|
const from = args.from;
|
|
q = q.filter((f) => f.gte(f.field("bookingDate"), from));
|
|
}
|
|
if (args.to) {
|
|
const to = args.to;
|
|
q = q.filter((f) => f.lte(f.field("bookingDate"), to));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (args.pendingOnly) {
|
|
q = q.filter((f) => f.eq(f.field("isPending"), true));
|
|
}
|
|
if (args.accountId) {
|
|
q = q.filter((f) => f.eq(f.field("accountId"), args.accountId));
|
|
}
|
|
if (args.type === "einnahme") {
|
|
q = q.filter((f) => f.gt(f.field("amount"), 0));
|
|
}
|
|
if (args.type === "ausgabe") {
|
|
q = q.filter((f) => f.lt(f.field("amount"), 0));
|
|
}
|
|
if (args.categoryIds && args.categoryIds.length > 0) {
|
|
q = q.filter((f) =>
|
|
f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))),
|
|
);
|
|
}
|
|
if (args.withoutCategory) {
|
|
q = q.filter((f) => f.eq(f.field("categoryId"), undefined));
|
|
}
|
|
|
|
const result = await q.paginate(args.paginationOpts);
|
|
|
|
return {
|
|
page: result.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 };
|
|
},
|
|
});
|