From c961d6160e3a731ef9df4630ca874523be92d357 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 10:44:53 +0530 Subject: [PATCH 1/6] Consolidate partnerDuplicatePayoutMethod into partnerDuplicateAccount fraud rule --- .../api/cron/partners/merge-accounts/route.ts | 4 +- apps/web/lib/api/fraud/constants.ts | 12 +----- .../fraud/detect-duplicate-identity-fraud.ts | 2 +- .../detect-duplicate-payout-method-fraud.ts | 6 +-- .../fraud/detect-record-fraud-application.ts | 4 +- .../fraud/get-partner-application-risks.ts | 8 ++-- apps/web/lib/api/fraud/utils.ts | 9 ++-- apps/web/lib/zod/schemas/fraud.ts | 3 -- .../consolidate-duplicate-account-fraud.ts | 41 +++++++++++++++++++ .../fraud-partner-info-table.tsx | 2 +- .../fraud-risks/fraud-events-tables/index.tsx | 1 - 11 files changed, 59 insertions(+), 33 deletions(-) create mode 100644 apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts diff --git a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts index b64ff737343..9f9aea291a7 100644 --- a/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts @@ -346,7 +346,7 @@ export async function POST(req: Request) { where: { partnerId: sourcePartnerId, fraudEventGroup: { - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, }, }, include: { @@ -395,7 +395,7 @@ export async function POST(req: Request) { ] : []), ], - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, }, resolutionReason: "Automatically resolved because partners with duplicate payout methods were merged. No other partners share this payout method.", diff --git a/apps/web/lib/api/fraud/constants.ts b/apps/web/lib/api/fraud/constants.ts index 891f859d88b..f9e92d3d27b 100644 --- a/apps/web/lib/api/fraud/constants.ts +++ b/apps/web/lib/api/fraud/constants.ts @@ -48,7 +48,7 @@ export const FRAUD_RULES: FraudRuleInfo[] = [ configurable: true, }, { - type: "partnerDuplicatePayoutMethod", + type: "partnerDuplicateAccount", name: "Duplicate account detected", description: "This partner was flagged by our system for having 2 or more Dub accounts. Please review to prevent abuse of program restrictions, caps, or bonuses.", @@ -56,16 +56,6 @@ export const FRAUD_RULES: FraudRuleInfo[] = [ severity: "high", configurable: true, }, - // Not visible in the UI - { - type: "partnerDuplicateAccount", - name: "Duplicate account detected", - description: - "This partner was flagged by our system for having 2 or more Dub accounts. Please review to prevent abuse of program restrictions, caps, or bonuses.", - scope: "partner", - severity: "low", - configurable: false, - }, { type: "partnerEmailDomainMismatch", name: "Email domain mismatch with website", diff --git a/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts b/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts index 977ecc5aac4..3af9bc6ba7e 100644 --- a/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts +++ b/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts @@ -72,7 +72,7 @@ export async function detectDuplicateIdentityFraud({ programEnrollments = programEnrollments.filter((enrollment) => isFraudRuleEnabled({ fraudRules: enrollment.program.fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, // TODO: Change to partnerDuplicateAccount + ruleType: FraudRuleType.partnerDuplicateAccount, }), ); diff --git a/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts b/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts index 7019814a23b..28f8ad7e3a5 100644 --- a/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts +++ b/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts @@ -44,11 +44,11 @@ export async function detectDuplicatePayoutMethodFraud({ return; } - // Filter out program enrollments where the partnerDuplicatePayoutMethod rule is disabled + // Filter out program enrollments where the partnerDuplicateAccount rule is disabled programEnrollments = programEnrollments.filter((enrollment) => isFraudRuleEnabled({ fraudRules: enrollment.program.fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, + ruleType: FraudRuleType.partnerDuplicateAccount, }), ); @@ -89,7 +89,7 @@ export async function detectDuplicatePayoutMethodFraud({ fraudEvents.push({ programId, partnerId: sourcePartner.partnerId, - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, metadata: { ...(payoutMethodHash ? { payoutMethodHash } : {}), ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}), diff --git a/apps/web/lib/api/fraud/detect-record-fraud-application.ts b/apps/web/lib/api/fraud/detect-record-fraud-application.ts index 3b73376b5ae..38af7cd10fb 100644 --- a/apps/web/lib/api/fraud/detect-record-fraud-application.ts +++ b/apps/web/lib/api/fraud/detect-record-fraud-application.ts @@ -33,7 +33,7 @@ export async function detectAndRecordFraudApplication({ if ( isFraudRuleEnabled({ fraudRules, - ruleType: FraudRuleType.partnerDuplicatePayoutMethod, + ruleType: FraudRuleType.partnerDuplicateAccount, }) ) { const { payoutMethodHash, cryptoWalletAddress } = partner; @@ -81,7 +81,7 @@ export async function detectAndRecordFraudApplication({ fraudEvents.push({ programId: program.id, partnerId: sourcePartner.id, - type: FraudRuleType.partnerDuplicatePayoutMethod, + type: FraudRuleType.partnerDuplicateAccount, metadata: { ...(payoutMethodHash ? { payoutMethodHash } : {}), ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}), diff --git a/apps/web/lib/api/fraud/get-partner-application-risks.ts b/apps/web/lib/api/fraud/get-partner-application-risks.ts index 2108f352d8b..6918be64cdc 100644 --- a/apps/web/lib/api/fraud/get-partner-application-risks.ts +++ b/apps/web/lib/api/fraud/get-partner-application-risks.ts @@ -21,7 +21,7 @@ export async function getPartnerApplicationRisks({ partnerId: partner.id, status: "pending", type: { - in: ["partnerCrossProgramBan", "partnerDuplicatePayoutMethod"], + in: ["partnerCrossProgramBan", "partnerDuplicateAccount"], }, }, }); @@ -30,13 +30,13 @@ export async function getPartnerApplicationRisks({ (group) => group.type === "partnerCrossProgramBan", ); - const hasDuplicatePayoutMethod = fraudGroups.some( - (group) => group.type === "partnerDuplicatePayoutMethod", + const hasDuplicateAccounts = fraudGroups.some( + (group) => group.type === "partnerDuplicateAccount", ); const risksDetected: Partial> = { partnerCrossProgramBan: hasCrossProgramBan, - partnerDuplicatePayoutMethod: hasDuplicatePayoutMethod, + partnerDuplicateAccount: hasDuplicateAccounts, partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner), partnerEmailMasked: checkPartnerEmailMasked(partner), partnerNoSocialLinks: checkPartnerNoSocialLinks(partner), diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index f9233b5dcbd..0fa023e00cf 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -89,12 +89,14 @@ function getIdentityFieldsForFraudEvent({ customerId, }; - case "partnerDuplicatePayoutMethod": case "partnerDuplicateAccount": return { duplicatePartnerId: eventMetadata?.duplicatePartnerId, }; + case "partnerDuplicatePayoutMethod": + throw new Error("Fraud rule is no longer supported."); + case "partnerCrossProgramBan": if (!sourceProgramId) { throw new Error(`sourceProgramId is required for ${type} fraud rule.`); @@ -136,10 +138,7 @@ export function getPartnerIdForFraudEvent( ) { const metadata = event.metadata as Record | undefined; - if ( - event.type === "partnerDuplicatePayoutMethod" || - event.type === "partnerDuplicateAccount" - ) { + if (event.type === "partnerDuplicateAccount") { return metadata?.duplicatePartnerId ?? event.partnerId; } diff --git a/apps/web/lib/zod/schemas/fraud.ts b/apps/web/lib/zod/schemas/fraud.ts index 100bb7f0263..e749344e53c 100644 --- a/apps/web/lib/zod/schemas/fraud.ts +++ b/apps/web/lib/zod/schemas/fraud.ts @@ -251,7 +251,6 @@ export const updateFraudRuleSettingsSchema = z.object({ customerEmailMatch: toggleOnlyFraudRuleSchema, customerEmailSuspiciousDomain: toggleOnlyFraudRuleSchema, partnerCrossProgramBan: toggleOnlyFraudRuleSchema, - partnerDuplicatePayoutMethod: toggleOnlyFraudRuleSchema, partnerDuplicateAccount: toggleOnlyFraudRuleSchema, }); @@ -326,8 +325,6 @@ export const fraudEventSchemas = { }), }), - partnerDuplicatePayoutMethod: baseFraudEventSchema, - partnerDuplicateAccount: baseFraudEventSchema, }; diff --git a/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts b/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts new file mode 100644 index 00000000000..f958f0235d2 --- /dev/null +++ b/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts @@ -0,0 +1,41 @@ +import { prisma } from "@dub/prisma"; +import "dotenv-flow/config"; + +async function main() { + // Migrate FraudRule + const { count } = await prisma.fraudRule.updateMany({ + where: { + type: "partnerDuplicatePayoutMethod", + }, + data: { + type: "partnerDuplicateAccount", + }, + }); + + console.log( + `Updated ${count} fraud rules from partnerDuplicatePayoutMethod to partnerDuplicateAccount`, + ); + + // Migrate FraudEventGroup + while (true) { + const { count } = await prisma.fraudEventGroup.updateMany({ + where: { + type: "partnerDuplicatePayoutMethod", + }, + data: { + type: "partnerDuplicateAccount", + }, + limit: 100, + }); + + console.log( + `Updated ${count} fraud event groups from partnerDuplicatePayoutMethod to partnerDuplicateAccount`, + ); + + if (count === 0) { + break; + } + } +} + +main(); diff --git a/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx b/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx index f3b7a7c6c2a..1e3ba8aff4e 100644 --- a/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx +++ b/apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx @@ -10,7 +10,7 @@ import Link from "next/link"; import * as z from "zod/v4"; type EventDataProps = z.infer< - (typeof fraudEventSchemas)["partnerDuplicatePayoutMethod"] + (typeof fraudEventSchemas)["partnerDuplicateAccount"] >; export function FraudPartnerInfoTable() { diff --git a/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx b/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx index 3ea29fd446f..f5bc9509ecd 100644 --- a/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx +++ b/apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx @@ -14,7 +14,6 @@ const FRAUD_EVENTS_TABLES: Partial> = referralSourceBanned: FraudReferralSourceBannedTable, paidTrafficDetected: FraudPaidTrafficDetectedTable, partnerCrossProgramBan: FraudCrossProgramBanTable, - partnerDuplicatePayoutMethod: FraudPartnerInfoTable, partnerDuplicateAccount: FraudPartnerInfoTable, }; From 5351cdcc3c22b9b332cdd138830b31cf46d52d1e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 10:52:30 +0530 Subject: [PATCH 2/6] Update utils.ts --- apps/web/lib/api/fraud/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index 0fa023e00cf..d2e66ee8fc9 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -94,6 +94,7 @@ function getIdentityFieldsForFraudEvent({ duplicatePartnerId: eventMetadata?.duplicatePartnerId, }; + // Note: This will be removed in the next PR case "partnerDuplicatePayoutMethod": throw new Error("Fraud rule is no longer supported."); From 12d94a78b5756733c8d95834889836c35d45fd3d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 11:38:54 +0530 Subject: [PATCH 3/6] Recompute FraudEvent hashes after partnerDuplicateAccount rename --- .../app/(ee)/api/fraud/groups/count/route.ts | 12 +-- .../consolidate-duplicate-account-fraud.ts | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/fraud/groups/count/route.ts b/apps/web/app/(ee)/api/fraud/groups/count/route.ts index 69ab8acd5d7..91160e66966 100644 --- a/apps/web/app/(ee)/api/fraud/groups/count/route.ts +++ b/apps/web/app/(ee)/api/fraud/groups/count/route.ts @@ -41,11 +41,13 @@ export const GET = withWorkspace( }, }); - Object.values(FraudRuleType).forEach((type) => { - if (!fraudGroups.some((e) => e.type === type)) { - fraudGroups.push({ _count: 0, type }); - } - }); + Object.values(FraudRuleType) + .filter((type) => type !== "partnerDuplicatePayoutMethod") + .forEach((type) => { + if (!fraudGroups.some((e) => e.type === type)) { + fraudGroups.push({ _count: 0, type }); + } + }); return NextResponse.json( z.array(fraudGroupCountSchema).parse(fraudGroups), diff --git a/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts b/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts index f958f0235d2..9d8431f4bf0 100644 --- a/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts +++ b/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts @@ -1,7 +1,9 @@ +import { createFraudEventHash } from "@/lib/api/fraud/utils"; import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; async function main() { + // Step 1 // Migrate FraudRule const { count } = await prisma.fraudRule.updateMany({ where: { @@ -16,6 +18,7 @@ async function main() { `Updated ${count} fraud rules from partnerDuplicatePayoutMethod to partnerDuplicateAccount`, ); + // Step 2 // Migrate FraudEventGroup while (true) { const { count } = await prisma.fraudEventGroup.updateMany({ @@ -36,6 +39,80 @@ async function main() { break; } } + + // Step 3 + // Recompute FraudEvent.hash for every event in a partnerDuplicateAccount + // group. The hash formula mixes `type` into the digest, so events created + // before the type rename still carry the old hash and would miss dedup in + // createFraudEvents. + let cursor: string | undefined; + let totalUpdated = 0; + + while (true) { + const events = await prisma.fraudEvent.findMany({ + where: { + fraudEventGroup: { + type: "partnerDuplicateAccount", + }, + }, + select: { + id: true, + programId: true, + partnerId: true, + hash: true, + fraudEventGroup: { + select: { + partnerId: true, + }, + }, + }, + take: 200, + orderBy: { id: "asc" }, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + + if (events.length === 0) { + break; + } + + const hashesToUpdate = events.flatMap((event) => { + const newHash = createFraudEventHash({ + type: "partnerDuplicateAccount", + programId: event.programId, + partnerId: event.fraudEventGroup.partnerId, + metadata: { duplicatePartnerId: event.partnerId }, + }); + + if (newHash === event.hash) { + return []; + } + + return [ + { + id: event.id, + hash: newHash, + }, + ]; + }); + + console.table(hashesToUpdate); + + await Promise.all( + hashesToUpdate.map(({ id, hash }) => + prisma.fraudEvent.update({ + where: { id }, + data: { hash }, + }), + ), + ); + + totalUpdated += hashesToUpdate.length; + + cursor = events[events.length - 1].id; + console.log(`Recomputed hashes: ${totalUpdated} updated so far`); + } + + console.log(`Done. Total FraudEvent hashes updated: ${totalUpdated}`); } main(); From f2c937d3198f3733284ec9b89c062572ea3344de Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 11:43:46 +0530 Subject: [PATCH 4/6] Remove partnerDuplicatePayoutMethod fraud rule type --- .../app/(ee)/api/fraud/groups/count/route.ts | 12 +- apps/web/lib/api/fraud/execute-fraud-rule.ts | 3 - apps/web/lib/api/fraud/utils.ts | 4 - .../consolidate-duplicate-account-fraud.ts | 118 ------------------ packages/prisma/schema/fraud.prisma | 1 - 5 files changed, 5 insertions(+), 133 deletions(-) delete mode 100644 apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts diff --git a/apps/web/app/(ee)/api/fraud/groups/count/route.ts b/apps/web/app/(ee)/api/fraud/groups/count/route.ts index 91160e66966..69ab8acd5d7 100644 --- a/apps/web/app/(ee)/api/fraud/groups/count/route.ts +++ b/apps/web/app/(ee)/api/fraud/groups/count/route.ts @@ -41,13 +41,11 @@ export const GET = withWorkspace( }, }); - Object.values(FraudRuleType) - .filter((type) => type !== "partnerDuplicatePayoutMethod") - .forEach((type) => { - if (!fraudGroups.some((e) => e.type === type)) { - fraudGroups.push({ _count: 0, type }); - } - }); + Object.values(FraudRuleType).forEach((type) => { + if (!fraudGroups.some((e) => e.type === type)) { + fraudGroups.push({ _count: 0, type }); + } + }); return NextResponse.json( z.array(fraudGroupCountSchema).parse(fraudGroups), diff --git a/apps/web/lib/api/fraud/execute-fraud-rule.ts b/apps/web/lib/api/fraud/execute-fraud-rule.ts index d8ef692a4da..a7ae12868a2 100644 --- a/apps/web/lib/api/fraud/execute-fraud-rule.ts +++ b/apps/web/lib/api/fraud/execute-fraud-rule.ts @@ -23,9 +23,6 @@ const FRAUD_RULES_REGISTRY: Record< referralSourceBanned: checkReferralSourceBanned, paidTrafficDetected: checkPaidTrafficDetected, partnerCrossProgramBan: defineFraudRuleStub("partnerCrossProgramBan"), - partnerDuplicatePayoutMethod: defineFraudRuleStub( - "partnerDuplicatePayoutMethod", - ), partnerDuplicateAccount: defineFraudRuleStub("partnerDuplicateAccount"), }; diff --git a/apps/web/lib/api/fraud/utils.ts b/apps/web/lib/api/fraud/utils.ts index d2e66ee8fc9..372f931a1d7 100644 --- a/apps/web/lib/api/fraud/utils.ts +++ b/apps/web/lib/api/fraud/utils.ts @@ -94,10 +94,6 @@ function getIdentityFieldsForFraudEvent({ duplicatePartnerId: eventMetadata?.duplicatePartnerId, }; - // Note: This will be removed in the next PR - case "partnerDuplicatePayoutMethod": - throw new Error("Fraud rule is no longer supported."); - case "partnerCrossProgramBan": if (!sourceProgramId) { throw new Error(`sourceProgramId is required for ${type} fraud rule.`); diff --git a/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts b/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts deleted file mode 100644 index 9d8431f4bf0..00000000000 --- a/apps/web/scripts/migrations/consolidate-duplicate-account-fraud.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createFraudEventHash } from "@/lib/api/fraud/utils"; -import { prisma } from "@dub/prisma"; -import "dotenv-flow/config"; - -async function main() { - // Step 1 - // Migrate FraudRule - const { count } = await prisma.fraudRule.updateMany({ - where: { - type: "partnerDuplicatePayoutMethod", - }, - data: { - type: "partnerDuplicateAccount", - }, - }); - - console.log( - `Updated ${count} fraud rules from partnerDuplicatePayoutMethod to partnerDuplicateAccount`, - ); - - // Step 2 - // Migrate FraudEventGroup - while (true) { - const { count } = await prisma.fraudEventGroup.updateMany({ - where: { - type: "partnerDuplicatePayoutMethod", - }, - data: { - type: "partnerDuplicateAccount", - }, - limit: 100, - }); - - console.log( - `Updated ${count} fraud event groups from partnerDuplicatePayoutMethod to partnerDuplicateAccount`, - ); - - if (count === 0) { - break; - } - } - - // Step 3 - // Recompute FraudEvent.hash for every event in a partnerDuplicateAccount - // group. The hash formula mixes `type` into the digest, so events created - // before the type rename still carry the old hash and would miss dedup in - // createFraudEvents. - let cursor: string | undefined; - let totalUpdated = 0; - - while (true) { - const events = await prisma.fraudEvent.findMany({ - where: { - fraudEventGroup: { - type: "partnerDuplicateAccount", - }, - }, - select: { - id: true, - programId: true, - partnerId: true, - hash: true, - fraudEventGroup: { - select: { - partnerId: true, - }, - }, - }, - take: 200, - orderBy: { id: "asc" }, - ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), - }); - - if (events.length === 0) { - break; - } - - const hashesToUpdate = events.flatMap((event) => { - const newHash = createFraudEventHash({ - type: "partnerDuplicateAccount", - programId: event.programId, - partnerId: event.fraudEventGroup.partnerId, - metadata: { duplicatePartnerId: event.partnerId }, - }); - - if (newHash === event.hash) { - return []; - } - - return [ - { - id: event.id, - hash: newHash, - }, - ]; - }); - - console.table(hashesToUpdate); - - await Promise.all( - hashesToUpdate.map(({ id, hash }) => - prisma.fraudEvent.update({ - where: { id }, - data: { hash }, - }), - ), - ); - - totalUpdated += hashesToUpdate.length; - - cursor = events[events.length - 1].id; - console.log(`Recomputed hashes: ${totalUpdated} updated so far`); - } - - console.log(`Done. Total FraudEvent hashes updated: ${totalUpdated}`); -} - -main(); diff --git a/packages/prisma/schema/fraud.prisma b/packages/prisma/schema/fraud.prisma index f8313776615..c8bfdcddddd 100644 --- a/packages/prisma/schema/fraud.prisma +++ b/packages/prisma/schema/fraud.prisma @@ -9,7 +9,6 @@ enum FraudRuleType { referralSourceBanned paidTrafficDetected partnerCrossProgramBan // Cross-program ban from other programs - partnerDuplicatePayoutMethod // Duplicate payout method with other partners partnerDuplicateAccount // Duplicate identity with other partners } From 7616e928a4d90727a2b3421d8a311815702a0470 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 11:56:58 +0530 Subject: [PATCH 5/6] Add backfill script for Veriff duplicate identity fraud events Re-fetches Veriff decisions for every partner with a veriffSessionId and replays detectDuplicateIdentityFraud when risk labels are present, so partners flagged before #3765 also get partnerDuplicateAccount fraud events. Idempotent via hash-based dedup in createFraudEvents. --- ...ackfill-veriff-duplicate-identity-fraud.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts diff --git a/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts b/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts new file mode 100644 index 00000000000..b49acde440c --- /dev/null +++ b/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts @@ -0,0 +1,95 @@ +import { detectDuplicateIdentityFraud } from "@/lib/api/fraud/detect-duplicate-identity-fraud"; +import { fetchVeriffSessionDecision } from "@/lib/veriff/fetch-veriff-session-decision"; +import { VeriffRiskLabel, veriffRiskLabels } from "@/lib/veriff/schema"; +import { prisma } from "@dub/prisma"; +import { chunk } from "@dub/utils"; +import "dotenv-flow/config"; + +// Backfills `partnerDuplicateAccount` fraud events for partners whose Veriff decisions +// predate https://github.com/dubinc/dub/pull/3765. Re-fetches each decision and replays +// `detectDuplicateIdentityFraud`, which is idempotent via hash-based dedup. +async function main() { + const partners = await prisma.partner.findMany({ + where: { + veriffSessionId: { + not: null, + }, + }, + select: { + id: true, + veriffSessionId: true, + }, + orderBy: { + createdAt: "asc", + }, + }); + + console.log(`Found ${partners.length} partners with a Veriff session.`); + + let scanned = 0; + let matched = 0; + let skipped = 0; + let errored = 0; + + const batches = chunk(partners, 10); + + for (const [batchIndex, batch] of batches.entries()) { + await Promise.all( + batch.map(async (partner) => { + const { id: partnerId, veriffSessionId } = partner; + + if (!veriffSessionId) { + return; + } + + try { + const decision = await fetchVeriffSessionDecision(veriffSessionId); + scanned += 1; + + const { riskLabels } = decision.verification; + + const hasDuplicateRiskLabel = + riskLabels && + riskLabels.length > 0 && + riskLabels.some(({ label }) => + veriffRiskLabels.includes(label as VeriffRiskLabel), + ); + + if (!hasDuplicateRiskLabel) { + skipped += 1; + return; + } + + matched += 1; + + console.log( + `[match] partner=${partnerId} session=${veriffSessionId} labels=${riskLabels + ?.map(({ label }) => label) + .join(",")}`, + ); + + await detectDuplicateIdentityFraud({ + veriffSessionId, + riskLabels, + }); + } catch (error) { + errored += 1; + console.error( + `[error] partner=${partnerId} session=${veriffSessionId}`, + error, + ); + } + }), + ); + + console.log( + `Processed batch ${batchIndex + 1}/${batches.length} (scanned=${scanned}, matched=${matched}, skipped=${skipped}, errored=${errored})`, + ); + } + + console.log( + `Backfill complete. total=${partners.length} scanned=${scanned} matched=${matched} skipped=${skipped} errored=${errored}`, + ); +} + +main(); From e361cfca0dd9367d9aeb653ee70ad646568ed862 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Apr 2026 12:24:34 +0530 Subject: [PATCH 6/6] Update backfill-veriff-duplicate-identity-fraud.ts --- .../backfill-veriff-duplicate-identity-fraud.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts b/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts index b49acde440c..9a2112f01a0 100644 --- a/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts +++ b/apps/web/scripts/migrations/backfill-veriff-duplicate-identity-fraud.ts @@ -35,18 +35,17 @@ async function main() { for (const [batchIndex, batch] of batches.entries()) { await Promise.all( - batch.map(async (partner) => { - const { id: partnerId, veriffSessionId } = partner; - + batch.map(async ({ id: partnerId, veriffSessionId }) => { if (!veriffSessionId) { return; } try { - const decision = await fetchVeriffSessionDecision(veriffSessionId); - scanned += 1; + const { + verification: { riskLabels }, + } = await fetchVeriffSessionDecision(veriffSessionId); - const { riskLabels } = decision.verification; + scanned += 1; const hasDuplicateRiskLabel = riskLabels &&