From 1db68644d71b8402b4bee321c5f431aa72d71a18 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:44:42 +0530 Subject: [PATCH 1/6] Refactor fraud event handling to include artifactKey for improved grouping and deduplication. --- .../app/(ee)/api/fraud/events/raw/route.ts | 28 ---------- .../stripe/connect/webhook/account-updated.ts | 5 +- apps/web/lib/api/fraud/create-fraud-events.ts | 22 ++++---- .../api/fraud/detect-record-fraud-event.ts | 2 +- .../web/lib/api/fraud/resolve-fraud-events.ts | 2 +- apps/web/lib/api/fraud/utils.ts | 52 +++++++++++-------- 6 files changed, 48 insertions(+), 63 deletions(-) diff --git a/apps/web/app/(ee)/api/fraud/events/raw/route.ts b/apps/web/app/(ee)/api/fraud/events/raw/route.ts index 6cf5f123e92..714c714f216 100644 --- a/apps/web/app/(ee)/api/fraud/events/raw/route.ts +++ b/apps/web/app/(ee)/api/fraud/events/raw/route.ts @@ -62,34 +62,6 @@ export const GET = withWorkspace( ); } - if (type === "partnerDuplicatePayoutMethod") { - if (!partner.payoutMethodHash) { - return NextResponse.json([]); - } - - const duplicatePartners = await prisma.programEnrollment.findMany({ - where: { - programId, - partner: { - payoutMethodHash: partner.payoutMethodHash, - }, - }, - select: { - createdAt: true, - partner: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }); - - return NextResponse.json(z.array(zodSchema).parse(duplicatePartners)); - } - return NextResponse.json(z.array(zodSchema).parse(fraudEvents)); }, { diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts index dc9e139f5ba..d9c4ad0d06b 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts @@ -2,7 +2,7 @@ import { createFraudEvents } from "@/lib/api/fraud/create-fraud-events"; import { qstash } from "@/lib/cron"; import { stripe } from "@/lib/stripe"; import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, log, nanoid } from "@dub/utils"; import Stripe from "stripe"; const queue = qstash.queue({ @@ -105,11 +105,14 @@ export async function accountUpdated(event: Stripe.Event) { ({ programs }) => programs, ); + const artifactKey = nanoid(10); + await createFraudEvents( programEnrollments.map(({ partnerId, programId }) => ({ programId, partnerId, type: "partnerDuplicatePayoutMethod", + artifactKey, })), ); } diff --git a/apps/web/lib/api/fraud/create-fraud-events.ts b/apps/web/lib/api/fraud/create-fraud-events.ts index 4e68eb8b762..54183dc9a35 100644 --- a/apps/web/lib/api/fraud/create-fraud-events.ts +++ b/apps/web/lib/api/fraud/create-fraud-events.ts @@ -1,26 +1,28 @@ import { prisma } from "@dub/prisma"; -import { Prisma } from "@prisma/client"; +import { FraudRuleType } from "@prisma/client"; import { createId } from "../create-id"; import { createFraudEventGroupKey } from "./utils"; -export async function createFraudEvents( - fraudEvents: Pick< - Prisma.FraudEventCreateManyInput, - "programId" | "partnerId" | "type" - >[], -) { +interface CreateFraudEventsInput { + programId: string; + partnerId: string; + type: FraudRuleType; + artifactKey?: string; // if not provided, partnerId will be used +} + +export async function createFraudEvents(fraudEvents: CreateFraudEventsInput[]) { if (fraudEvents.length === 0) { return; } await prisma.fraudEvent.createMany({ - data: fraudEvents.map((evt) => { - const { programId, partnerId, type } = evt; + data: fraudEvents.map((event) => { + const { programId, partnerId, type, artifactKey } = event; const groupKey = createFraudEventGroupKey({ programId, - partnerId, type, + artifactKey: artifactKey ?? partnerId, }); return { diff --git a/apps/web/lib/api/fraud/detect-record-fraud-event.ts b/apps/web/lib/api/fraud/detect-record-fraud-event.ts index 62aaccf96c0..9bad47eb9fa 100644 --- a/apps/web/lib/api/fraud/detect-record-fraud-event.ts +++ b/apps/web/lib/api/fraud/detect-record-fraud-event.ts @@ -101,7 +101,7 @@ export async function detectAndRecordFraudEvent(context: FraudEventContext) { metadata: event.metadata as Prisma.InputJsonValue, groupKey: createFraudEventGroupKey({ programId: validatedContext.program.id, - partnerId: validatedContext.partner.id, + artifactKey: validatedContext.partner.id, type: event.type, }), })), diff --git a/apps/web/lib/api/fraud/resolve-fraud-events.ts b/apps/web/lib/api/fraud/resolve-fraud-events.ts index 6a904ccce5a..e9e395af217 100644 --- a/apps/web/lib/api/fraud/resolve-fraud-events.ts +++ b/apps/web/lib/api/fraud/resolve-fraud-events.ts @@ -49,7 +49,7 @@ export async function resolveFraudEvents({ const firstEvent = events[0]; const newGroupKey = createFraudEventGroupKey({ programId: firstEvent.programId, - partnerId: firstEvent.partnerId, + artifactKey: firstEvent.partnerId, type: firstEvent.type, batchId: nanoid(10), }); diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index aa4d8171ebe..b4b25398a2d 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -26,28 +26,36 @@ export function normalizeEmail(email: string): string { return `${username}@${domain}`; } -// Create a unique group key to identify and deduplicate fraud events of the same type -// for the same partner-program combination. -// batchId is used when resolving fraud events to create a new unique group key, -// breaking the grouping so resolved events are no longer grouped with -// pending events that share the same programId, partnerId, and type -export function createFraudEventGroupKey({ - programId, - partnerId, - type, - batchId, -}: { - programId: string; - partnerId: string; +function createHashKey(value: string): string { + return createHash("sha256").update(value).digest("base64url").slice(0, 24); +} + +interface CreateGroupKeyInput { type: FraudRuleType; + programId: string; + + /** + * The batch ID used to group fraud events. This is used when resolving fraud events + * to break grouping so resolved events are no longer grouped with pending events + * that share the same programId, partnerId, and type. + */ batchId?: string; -}): string { - const parts = [programId, partnerId, type, batchId].map((part) => - part?.toLowerCase(), - ); - - return createHash("sha256") - .update(parts.join("|")) - .digest("base64url") - .slice(0, 24); + + /** + * The artifact key used to group fraud events. It can be: + * - partnerId: for partner-specific grouping + * - payoutMethodHash: for cross-partner grouping by payout method + * - Any other identifier relevant to the fraud rule type + */ + artifactKey: string; +} + +// Create a unique group key to identify and deduplicate fraud events of the same type +// based on programId and artifactKey (e.g., partnerId, payoutMethodHash, or other identifiers). +export function createFraudEventGroupKey(input: CreateGroupKeyInput): string { + const parts = [input.programId, input.type, input.artifactKey, input.batchId] + .filter(Boolean) + .map((p) => p!.toLowerCase()); + + return createHashKey(parts.join("|")); } From d2d36adf47bb27c3c92b455f410f63f31a8d9dc2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:48:26 +0530 Subject: [PATCH 2/6] Update utils.ts --- apps/web/lib/api/fraud/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index b4b25398a2d..f09b14ccc12 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -44,7 +44,6 @@ interface CreateGroupKeyInput { /** * The artifact key used to group fraud events. It can be: * - partnerId: for partner-specific grouping - * - payoutMethodHash: for cross-partner grouping by payout method * - Any other identifier relevant to the fraud rule type */ artifactKey: string; From ddd001ae48a34290703dd6683ee95e7cd37e4294 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:51:23 +0530 Subject: [PATCH 3/6] Remove batchId --- apps/web/lib/api/fraud/resolve-fraud-events.ts | 3 +-- apps/web/lib/api/fraud/utils.ts | 9 +-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/api/fraud/resolve-fraud-events.ts b/apps/web/lib/api/fraud/resolve-fraud-events.ts index e9e395af217..eab4f993d73 100644 --- a/apps/web/lib/api/fraud/resolve-fraud-events.ts +++ b/apps/web/lib/api/fraud/resolve-fraud-events.ts @@ -49,9 +49,8 @@ export async function resolveFraudEvents({ const firstEvent = events[0]; const newGroupKey = createFraudEventGroupKey({ programId: firstEvent.programId, - artifactKey: firstEvent.partnerId, type: firstEvent.type, - batchId: nanoid(10), + artifactKey: nanoid(10), }); await prisma.fraudEvent.updateMany({ diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index f09b14ccc12..70f7b4c2102 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -34,13 +34,6 @@ interface CreateGroupKeyInput { type: FraudRuleType; programId: string; - /** - * The batch ID used to group fraud events. This is used when resolving fraud events - * to break grouping so resolved events are no longer grouped with pending events - * that share the same programId, partnerId, and type. - */ - batchId?: string; - /** * The artifact key used to group fraud events. It can be: * - partnerId: for partner-specific grouping @@ -52,7 +45,7 @@ interface CreateGroupKeyInput { // Create a unique group key to identify and deduplicate fraud events of the same type // based on programId and artifactKey (e.g., partnerId, payoutMethodHash, or other identifiers). export function createFraudEventGroupKey(input: CreateGroupKeyInput): string { - const parts = [input.programId, input.type, input.artifactKey, input.batchId] + const parts = [input.programId, input.type, input.artifactKey] .filter(Boolean) .map((p) => p!.toLowerCase()); From 1b172f6cf73564055bb96e88e8850fb76c5c9d84 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:52:33 +0530 Subject: [PATCH 4/6] Update utils.ts --- apps/web/lib/api/fraud/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index 70f7b4c2102..4e2b8fbaa73 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -43,7 +43,7 @@ interface CreateGroupKeyInput { } // Create a unique group key to identify and deduplicate fraud events of the same type -// based on programId and artifactKey (e.g., partnerId, payoutMethodHash, or other identifiers). +// based on programId and artifactKey export function createFraudEventGroupKey(input: CreateGroupKeyInput): string { const parts = [input.programId, input.type, input.artifactKey] .filter(Boolean) From d606b7226a69d0d6a5868e1cd46c451fbfb201c1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:56:32 +0530 Subject: [PATCH 5/6] Rename to groupingKey --- .../(ee)/api/stripe/connect/webhook/account-updated.ts | 4 ++-- apps/web/lib/api/fraud/create-fraud-events.ts | 6 +++--- apps/web/lib/api/fraud/detect-record-fraud-event.ts | 2 +- apps/web/lib/api/fraud/resolve-fraud-events.ts | 2 +- apps/web/lib/api/fraud/utils.ts | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts index d9c4ad0d06b..c8e50431f47 100644 --- a/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts +++ b/apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts @@ -105,14 +105,14 @@ export async function accountUpdated(event: Stripe.Event) { ({ programs }) => programs, ); - const artifactKey = nanoid(10); + const groupingKey = nanoid(10); await createFraudEvents( programEnrollments.map(({ partnerId, programId }) => ({ programId, partnerId, type: "partnerDuplicatePayoutMethod", - artifactKey, + groupingKey, })), ); } diff --git a/apps/web/lib/api/fraud/create-fraud-events.ts b/apps/web/lib/api/fraud/create-fraud-events.ts index 54183dc9a35..13e6f3293b8 100644 --- a/apps/web/lib/api/fraud/create-fraud-events.ts +++ b/apps/web/lib/api/fraud/create-fraud-events.ts @@ -7,7 +7,7 @@ interface CreateFraudEventsInput { programId: string; partnerId: string; type: FraudRuleType; - artifactKey?: string; // if not provided, partnerId will be used + groupingKey?: string; // if not provided, partnerId will be used } export async function createFraudEvents(fraudEvents: CreateFraudEventsInput[]) { @@ -17,12 +17,12 @@ export async function createFraudEvents(fraudEvents: CreateFraudEventsInput[]) { await prisma.fraudEvent.createMany({ data: fraudEvents.map((event) => { - const { programId, partnerId, type, artifactKey } = event; + const { programId, partnerId, type, groupingKey } = event; const groupKey = createFraudEventGroupKey({ programId, type, - artifactKey: artifactKey ?? partnerId, + groupingKey: groupingKey ?? partnerId, }); return { diff --git a/apps/web/lib/api/fraud/detect-record-fraud-event.ts b/apps/web/lib/api/fraud/detect-record-fraud-event.ts index 9bad47eb9fa..e0c40f2ac5e 100644 --- a/apps/web/lib/api/fraud/detect-record-fraud-event.ts +++ b/apps/web/lib/api/fraud/detect-record-fraud-event.ts @@ -101,7 +101,7 @@ export async function detectAndRecordFraudEvent(context: FraudEventContext) { metadata: event.metadata as Prisma.InputJsonValue, groupKey: createFraudEventGroupKey({ programId: validatedContext.program.id, - artifactKey: validatedContext.partner.id, + groupingKey: validatedContext.partner.id, type: event.type, }), })), diff --git a/apps/web/lib/api/fraud/resolve-fraud-events.ts b/apps/web/lib/api/fraud/resolve-fraud-events.ts index eab4f993d73..fd63e48053d 100644 --- a/apps/web/lib/api/fraud/resolve-fraud-events.ts +++ b/apps/web/lib/api/fraud/resolve-fraud-events.ts @@ -50,7 +50,7 @@ export async function resolveFraudEvents({ const newGroupKey = createFraudEventGroupKey({ programId: firstEvent.programId, type: firstEvent.type, - artifactKey: nanoid(10), + groupingKey: nanoid(10), }); await prisma.fraudEvent.updateMany({ diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index 4e2b8fbaa73..0a6ae20ecc3 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -35,17 +35,17 @@ interface CreateGroupKeyInput { programId: string; /** - * The artifact key used to group fraud events. It can be: + * The grouping key used to group fraud events. It can be: * - partnerId: for partner-specific grouping * - Any other identifier relevant to the fraud rule type */ - artifactKey: string; + groupingKey: string; } // Create a unique group key to identify and deduplicate fraud events of the same type -// based on programId and artifactKey +// based on programId and groupingKey export function createFraudEventGroupKey(input: CreateGroupKeyInput): string { - const parts = [input.programId, input.type, input.artifactKey] + const parts = [input.programId, input.type, input.groupingKey] .filter(Boolean) .map((p) => p!.toLowerCase()); From ea4f8cf7b1d6e28df47600a5b93de8e28eeecfe9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 27 Nov 2025 17:59:09 +0530 Subject: [PATCH 6/6] Rename to groupingKey --- apps/web/lib/api/fraud/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index 0a6ae20ecc3..4a839451574 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -45,9 +45,9 @@ interface CreateGroupKeyInput { // Create a unique group key to identify and deduplicate fraud events of the same type // based on programId and groupingKey export function createFraudEventGroupKey(input: CreateGroupKeyInput): string { - const parts = [input.programId, input.type, input.groupingKey] - .filter(Boolean) - .map((p) => p!.toLowerCase()); + const parts = [input.programId, input.type, input.groupingKey].map((p) => + p!.toLowerCase(), + ); return createHashKey(parts.join("|")); }