Fix FinTS transaction deduplication

This commit is contained in:
Matthias
2026-06-23 12:37:41 +02:00
parent 993616c2f9
commit 6e687dc9b5
8 changed files with 451 additions and 2 deletions

198
convex/imports.test.ts Normal file
View 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);
});
});