Add effective-date transaction filtering and bulk selection
This commit is contained in:
189
convex/transactions.test.ts
Normal file
189
convex/transactions.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { api } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./transactions.test.ts"];
|
||||
|
||||
describe("transactions.list", () => {
|
||||
test("combines search, date range, account, category, and type filters", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Filter User",
|
||||
email: "filter@example.com",
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const otherAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Depot",
|
||||
type: "investment",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const groceryCategoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel & Supermarkt",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#ef4444",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const restaurantCategoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Restaurant",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#f97316",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
const matchingId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-15",
|
||||
description: "LIDL SAGT DANKE",
|
||||
amount: -18.37,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-05-29",
|
||||
description: "LIDL OLD MONTH",
|
||||
amount: -21.92,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-05",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: otherAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-16",
|
||||
description: "LIDL OTHER ACCOUNT",
|
||||
amount: -99,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: restaurantCategoryId,
|
||||
bookingDate: "2026-06-17",
|
||||
description: "LIDL RESTAURANT",
|
||||
amount: -12,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-18",
|
||||
description: "LIDL REFUND",
|
||||
amount: 5,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
|
||||
return { userId, giroAccountId, groceryCategoryId, matchingId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
search: "LIDL",
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
categoryIds: [seeded.groceryCategoryId as Id<"categories">],
|
||||
type: "ausgabe",
|
||||
basis: "booking",
|
||||
});
|
||||
|
||||
expect(result.page.map((tx) => tx._id)).toEqual([seeded.matchingId]);
|
||||
});
|
||||
|
||||
test("filters by effective month when the global basis is assignment month", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Basis User",
|
||||
email: "basis@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const shiftedId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-05-29",
|
||||
description: "Salary shifted into June",
|
||||
amount: 2500,
|
||||
isPending: false,
|
||||
assignedMonth: "2026-06",
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-05-20",
|
||||
description: "May only",
|
||||
amount: -15,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-05",
|
||||
});
|
||||
|
||||
return { userId, shiftedId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const effectiveResult = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
basis: "effective",
|
||||
});
|
||||
const bookingResult = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
basis: "booking",
|
||||
});
|
||||
|
||||
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
|
||||
expect(bookingResult.page).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export const list = query({
|
||||
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()),
|
||||
},
|
||||
@@ -51,6 +52,9 @@ export const list = query({
|
||||
}),
|
||||
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) {
|
||||
@@ -60,21 +64,77 @@ export const list = query({
|
||||
sq.search("description", args.search!).eq("userId", userId),
|
||||
);
|
||||
} 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 (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) {
|
||||
|
||||
Reference in New Issue
Block a user