Fix FinTS transaction deduplication
This commit is contained in:
198
convex/imports.test.ts
Normal file
198
convex/imports.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./imports.test.ts"];
|
||||
|
||||
const commitRowsInternal = makeFunctionReference<"mutation">(
|
||||
"imports:commitRowsInternal",
|
||||
);
|
||||
|
||||
async function seedImportFixture() {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Import User",
|
||||
email: "import@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "giro",
|
||||
iban: "DE40200411110592825400",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
externalId: "592825400",
|
||||
});
|
||||
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
return { t, seeded };
|
||||
}
|
||||
|
||||
describe("imports.commitRowsInternal", () => {
|
||||
test("imports multiple FinTS rows without external references when transaction content differs", async () => {
|
||||
const { t, seeded } = await seedImportFixture();
|
||||
|
||||
const result = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-2026-06-15-2026-06-23",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [
|
||||
{
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
bookingDate: "2026-06-22",
|
||||
valueDate: "2026-06-22",
|
||||
description: "REWE",
|
||||
amount: -12.34,
|
||||
vorgang: "NMSC",
|
||||
isPending: false,
|
||||
rawText: "REWE SAGT DANKE",
|
||||
},
|
||||
{
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
bookingDate: "2026-06-22",
|
||||
valueDate: "2026-06-22",
|
||||
description: "ALDI",
|
||||
amount: -23.45,
|
||||
vorgang: "NMSC",
|
||||
isPending: false,
|
||||
rawText: "ALDI SAGT DANKE",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rows = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_account", (q) =>
|
||||
q.eq("userId", seeded.userId).eq("accountId", seeded.accountId),
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
expect(result.importedCount).toBe(2);
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(rows.map((row) => row.description).sort()).toEqual(["ALDI", "REWE"]);
|
||||
expect(rows.every((row) => row.externalRef === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not let a legacy positional external reference block new FinTS rows without external references", async () => {
|
||||
const { t, seeded } = await seedImportFixture();
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId: seeded.userId,
|
||||
accountId: seeded.accountId,
|
||||
bookingDate: "2024-09-30",
|
||||
valueDate: "2024-09-30",
|
||||
description: "Old positional transaction",
|
||||
amount: -9.99,
|
||||
isPending: false,
|
||||
effectiveMonth: "2024-09",
|
||||
dedupHash: "legacy-dedup",
|
||||
externalRef: "592825400:POS 13",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-2026-06-15-2026-06-23",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [
|
||||
{
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
bookingDate: "2026-06-22",
|
||||
valueDate: "2026-06-22",
|
||||
description: "HEM TANKSTELLE",
|
||||
amount: -62.01,
|
||||
vorgang: "NMSC",
|
||||
isPending: false,
|
||||
rawText: "HEM TANKSTELLE",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.importedCount).toBe(1);
|
||||
expect(result.skippedCount).toBe(0);
|
||||
});
|
||||
|
||||
test("still skips real duplicate external references", async () => {
|
||||
const { t, seeded } = await seedImportFixture();
|
||||
const row = {
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
bookingDate: "2026-06-22",
|
||||
valueDate: "2026-06-22",
|
||||
description: "Kartenzahlung",
|
||||
amount: -12.34,
|
||||
vorgang: "NMSC",
|
||||
isPending: false,
|
||||
rawText: "Kartenzahlung",
|
||||
externalRef: "592825400:5J2C21XL0470L56V/39761",
|
||||
};
|
||||
|
||||
const first = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-first",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [row],
|
||||
});
|
||||
const second = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-second",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [{ ...row, description: "Different text same bank ref", amount: -99 }],
|
||||
});
|
||||
|
||||
expect(first.importedCount).toBe(1);
|
||||
expect(first.skippedCount).toBe(0);
|
||||
expect(second.importedCount).toBe(0);
|
||||
expect(second.skippedCount).toBe(1);
|
||||
});
|
||||
|
||||
test("skips repeated rows without external references via dedup hash", async () => {
|
||||
const { t, seeded } = await seedImportFixture();
|
||||
const row = {
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
bookingDate: "2026-06-22",
|
||||
valueDate: "2026-06-22",
|
||||
description: "REWE",
|
||||
amount: -12.34,
|
||||
vorgang: "NMSC",
|
||||
isPending: false,
|
||||
rawText: "REWE SAGT DANKE",
|
||||
};
|
||||
|
||||
const first = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-first",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [row],
|
||||
});
|
||||
const second = await t.mutation(commitRowsInternal, {
|
||||
userId: seeded.userId,
|
||||
filename: "fints-sync-second",
|
||||
source: "fints",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
rows: [row],
|
||||
});
|
||||
|
||||
expect(first.importedCount).toBe(1);
|
||||
expect(first.skippedCount).toBe(0);
|
||||
expect(second.importedCount).toBe(0);
|
||||
expect(second.skippedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user