Skip to main content
doc0blade47/dubdocumentation home
Sign in

Fraud Prevention

Last updated Oct 12, 202692% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Fraud Prevention protects partner programs by combining automated edge bot filtering, heuristic application risk analysis, backend duplicate detection, and third-party identity verification with Veriff. It mitigates financial exposure through automated commission freezes, gives administrators centralized tooling to review risk alerts, and manages partner bans across workspaces.

Sources: apps/web/lib/middleware/link.ts:32-32

Sources: apps/web/lib/api/fraud/get-program-application-risks.ts:11-17

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23

Sources: apps/web/app/ee/admin.dub.co/dashboard/partners/fraud/page.tsx:41-67

Sources: apps/web/lib/api/fraud/hold-pending-commissions.ts:10-16

Edge Bot Detection and Clicks

Bot Filtering and Identity Hashing

The edge redirection middleware inspects incoming requests before redirection or analytics recording occurs. To handle automated scrapers and bot traffic, links ending with .php extensions are systematically intercepted via isUnsupportedKey and redirected to the root domain with a dub-no-track=1 query parameter and an X-Robots-Tag: googlebot: noindex header to prevent tracking pollution.

// we don't support .php links (too much bot traffic)
// hence we redirect to the root domain and add `dub-no-track` header to avoid tracking bot traffic
if (isUnsupportedKey(key)) {
  return NextResponse.redirect(new URL("/?dub-no-track=1", req.url), {
    headers: {
      ...DUB_HEADERS,
      "X-Robots-Tag": "googlebot: noindex",
    },
    status: 302,
  });
}

Sources: apps/web/lib/middleware/link.ts:77-87

For general traffic, unique visitor identification relies on getIdentityHash to compute request signatures. When conversion tracking or partner attribution is active, the middleware checks recordClickCache using this identity hash and domain-key pair to deduplicate or correlate incoming request sessions.

// if we need to cache the clickId, check if clickId is cached in Redis
if (shouldCacheClickId) {
  const identityHash = await getIdentityHash(req);
  clickId =
    (await recordClickCache
      .get({ domain, key, identityHash })
      .catch(() => undefined)) || undefined;
}

Sources: apps/web/lib/middleware/link.ts:198-205

Note

Unsupported .php links bypass standard database lookups entirely, immediately redirecting to the root domain with dub-no-track=1 to prevent bot traffic from skewing click analytics.

Sources: apps/web/lib/middleware/link.ts:77-87

Warning

During Redis failover states (redisFailOver active), click tracking jobs and identity hash lookups are skipped entirely to avoid edge execution timeouts, generating a local fallback nanoid(16) click identifier when necessary.

Sources: apps/web/lib/middleware/link.ts:93-98

Sources: apps/web/lib/middleware/link.ts:207-210

Program Application Risk Evaluation

Heuristic Checks and Application Risk Analysis

Partner applications are evaluated through heuristic rules to detect suspicious profile attributes during program enrollment. The risk evaluation engine analyzes partner attributes such as email format, domain consistency, and linked social profiles to calculate risk levels and identify potential fraud indicators.

export async function getProgramApplicationRisks({
  program,
  partner,
}: {
  program: Pick<Program, "id">;
  partner: Pick<PartnerProps, "id" | "email" | "country" | "platforms">;
}) {
  const fraudGroups = await prisma.fraudEventGroup.findMany({
    where: {
      programId: program.id,
      partnerId: partner.id,
      status: "pending",
      type: {
        in: ["partnerCrossProgramBan", "partnerDuplicateAccount"],
      },
    },
  });
 
  const hasCrossProgramBan = fraudGroups.some(
    (group) => group.type === "partnerCrossProgramBan",
  );
 
  const hasDuplicateAccounts = fraudGroups.some(
    (group) => group.type === "partnerDuplicateAccount",
  );
 
  const risksDetected: Partial<Record<ExtendedFraudRuleType, boolean>> = {
    partnerCrossProgramBan: hasCrossProgramBan,
    partnerDuplicateAccount: hasDuplicateAccounts,
    partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner),
    partnerEmailMasked: checkPartnerEmailMasked(partner),
    partnerNoSocialLinks: checkPartnerNoSocialLinks(partner),
    partnerNoVerifiedSocialLinks: checkPartnerNoVerifiedSocialLinks(partner),
  };

Sources: apps/web/lib/api/fraud/get-program-application-risks.ts:11-44

The risk assessment workflow checks pending fraud event groups for cross-program bans and duplicate accounts alongside four specific heuristic checks. The results populate a structured risks dictionary and determine the overall risk severity using highest-severity aggregation.

Rule TypeDetection FunctionTarget PropertyPurpose
partnerCrossProgramBanDatabase query (fraudEventGroup)partner.id, program.idChecks for active cross-program ban events in pending status.
partnerDuplicateAccountDatabase query (fraudEventGroup)partner.id, program.idChecks for active duplicate account events in pending status.
partnerEmailDomainMismatchcheckPartnerEmailDomainMismatchpartnerEvaluates discrepancies between email domains and profile data.
partnerEmailMaskedcheckPartnerEmailMaskedpartnerIdentifies masked or disposable email address formats.
partnerNoSocialLinkscheckPartnerNoSocialLinkspartnerDetects applications lacking any connected social platforms.
partnerNoVerifiedSocialLinkscheckPartnerNoVerifiedSocialLinkspartnerFlags partner profiles with unverified social links.

Sources: apps/web/lib/api/fraud/get-program-application-risks.ts:18-44

Note

Application risk evaluation runs dynamically when fetching partner details through getProgramApplicationRisks, querying pending fraudEventGroup entries for partnerCrossProgramBan and partnerDuplicateAccount alongside runtime heuristic functions.

Sources: apps/web/lib/api/fraud/get-program-application-risks.ts:18-44

UI Integration and Plan Capabilities

The frontend renders application risk summaries via ProgramApplicationRiskSummary, which retrieves risk status using SWR immutable hooks linked to workspace settings.

export function useProgramApplicationRisks({
  filters,
  enabled = true,
}: {
  filters: { partnerId: string | null | undefined };
  enabled?: boolean;
}) {
  const { id: workspaceId } = useWorkspace();
  const { partnerId } = filters;
 
  const { data, isLoading, error } = useSWRImmutable<FraudRisksResponse>(
    enabled && partnerId && workspaceId
      ? `/api/partners/${partnerId}/application-risks?workspaceId=${workspaceId}`
      : null,
    fetcher,
  );
 
  const { risksDetected, riskSeverity } = data || {};
 
  const triggeredFraudRules = useMemo(() => {
    if (!risksDetected) return [];
 
    return FRAUD_RULES.filter((rule) => {
      return risksDetected[rule.type] === true;
    });
  }, [risksDetected]);
 
  return {
    risks: risksDetected,
    triggeredFraudRules,
    severity: riskSeverity,
    isLoading,
    error,
  };

Sources: apps/web/lib/swr/use-program-application-risks.ts:13-44

When plan capabilities permit fraud event management (canManageFraudEvents), the component displays triggered fraud rules with colored severity indicators. If the workspace is on a lower tier, it renders an upsell component featuring blurred risk items and an upgrade prompt for the Advanced plan.

  const { canManageFraudEvents } = getPlanCapabilities(plan);
 
  if (isLoading || triggeredFraudRules.length === 0) {
    return null;
  }
 
  if (!canManageFraudEvents) {
    return <ProgramApplicationRiskSummaryUpsell />;
  }
 
  return (
    <>
      <div className="flex flex-col gap-4 p-4">
        <div className="flex items-center justify-between">
          <h3 className="text-content-emphasis text-sm font-semibold">
            Risk analysis
          </h3>
 
          <Button
            type="button"
            text="View"
            variant="secondary"
            onClick={() => setShowModal(true)}
            className="h-6 w-fit px-1.5 py-2"
          />
        </div>
 
        <ProgramApplicationFraudSeverityIndicator severity={severity} />
 
        <ul className="space-y-2">
          {triggeredFraudRules.map((rule) => {
            if (!rule.severity) return null;
 
            return (
              <li key={rule.type} className="flex items-center gap-2">
                <div
                  className={cn(
                    "size-2 shrink-0 rounded-full",
                    FRAUD_SEVERITY_CONFIG[rule.severity].bg,
                  )}
                />
                <span className="text-xs font-medium leading-4 text-neutral-700">
                  {rule.name}
                </span>
              </li>
            );
          })}
        </ul>
      </div>
 
      <ProgramApplicationRiskSummaryModal />
    </>
  );
}

Sources: apps/web/ui/partners/fraud-risks/program-application-risk-summary.tsx:39-91

Warning

Workspaces lacking fraud management privileges do not receive raw risk data; ProgramApplicationRiskSummaryUpsell displays placeholder risk entries with a backdrop blur and an upgrade banner.

Sources: apps/web/ui/partners/fraud-risks/program-application-risk-summary.tsx:39-47

Sources: apps/web/ui/partners/fraud-risks/program-application-risk-summary.tsx:112-192

Duplicate Identity and Payout Detection

Identity Verification and Session Aggregation

The backend identity fraud workflow evaluates incoming Veriff verification decisions to uncover shared partner identities. When a verification session completes, the system filters risk labels against the approved Veriff risk categories and extracts all associated session identifiers linked to those labels.

let veriffSessionIds = riskLabels
  .filter(({ label }) => veriffRiskLabels.includes(label as VeriffRiskLabel))
  .map(({ sessionIds }) => sessionIds)
  .flat();
 
// Add the current veriff session id to the list
veriffSessionIds.push(veriffSessionId);
 
// Remove duplicates
veriffSessionIds = [...new Set(veriffSessionIds)];
 
if (veriffSessionIds.length === 0) {
  console.log(
    "[detectDuplicateIdentityFraud] No veriff session ids provided.",
  );
  return;
}

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:30-46

The function queries prisma.programEnrollment to find all program enrollments where the associated partner matches any collected Veriff session IDs. Enrollments where the partnerDuplicateAccount fraud rule is disabled via the program's fraud rules configuration are filtered out.

let programEnrollments = await prisma.programEnrollment.findMany({
  where: {
    partner: {
      veriffSessionId: {
        in: veriffSessionIds,
      },
    },
  },
  select: {
    programId: true,
    partnerId: true,
    status: true,
    riskMonitoringDisabledAt: true,
    program: {
      select: {
        fraudRules: true,
      },
    },
  },
});
 
if (programEnrollments.length === 0) {
  console.log("[detectDuplicateIdentityFraud] No program enrollments found.");
  return;
}
 
// Filter out program enrollments where the partnerDuplicateAccount rule is disabled
programEnrollments = programEnrollments.filter((enrollment) =>
  isFraudRuleEnabled({
    fraudRules: enrollment.program.fraudRules,
    ruleType: FraudRuleType.partnerDuplicateAccount,
  }),
);

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:48-80

Note

During duplicate identity detection, the current veriffSessionId is explicitly appended to the extracted session list and deduplicated via a Set before querying the database.

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:35-39

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:48-55

Payout Method and Crypto Wallet Matching

Duplicate payout method detection correlates partners who register identical payout destinations or cryptocurrency wallet addresses. The detection accepts an options object containing either a payoutMethodHash or a cryptoWalletAddress.

type DetectDuplicatePayoutMethodFraudOptions =
  | { payoutMethodHash: string; cryptoWalletAddress?: never }
  | { cryptoWalletAddress: string; payoutMethodHash?: never };
 
// Check for duplicate payout methods: if multiple partners share the same payout method hash,
// create fraud events for all their active program enrollments to flag potential fraud
export async function detectDuplicatePayoutMethodFraud({
  payoutMethodHash,
  cryptoWalletAddress,
}: DetectDuplicatePayoutMethodFraudOptions) {
  if (!payoutMethodHash && !cryptoWalletAddress) {
    return;
  }

Sources: apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:10-22

The system queries program enrollments whose partner records match the provided hash or wallet address using an OR condition, then verifies that the partnerDuplicateAccount rule remains enabled for each program.

let programEnrollments = await prisma.programEnrollment.findMany({
  where: {
    partner: {
      OR: [
        ...(payoutMethodHash ? [{ payoutMethodHash }] : []),
        ...(cryptoWalletAddress ? [{ cryptoWalletAddress }] : []),
      ],
    },
  },
  select: {
    programId: true,
    partnerId: true,
    status: true,
    riskMonitoringDisabledAt: true,
    program: {
      select: {
        fraudRules: true,
      },
    },
  },
});
 
if (programEnrollments.length === 0) {
  return;
}
 
// Filter out program enrollments where the partnerDuplicateAccount rule is disabled
programEnrollments = programEnrollments.filter((enrollment) =>
  isFraudRuleEnabled({
    fraudRules: enrollment.program.fraudRules,
    ruleType: FraudRuleType.partnerDuplicateAccount,
  }),
);

Sources: apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:24-56

Important

Both duplicate identity and payout detection workflows group matching partners by program ID and discard any programs with fewer than two participating partners before generating fraud events.

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:82-107

Sources: apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:58-82

Fraud Event Creation and Commission Freezes

Once multiple partners are identified within a program, the workflows iterate through each active, risk-monitored partner to construct duplicate account fraud event inputs. Inactive enrollment statuses and partners with disabled risk monitoring are skipped.

for (const [programId, partners] of partnersByProgram.entries()) {
  for (const sourcePartner of partners) {
    // Skip if the partner is inactive or risk detection is disabled
    if (
      INACTIVE_ENROLLMENT_STATUSES.includes(sourcePartner.status) ||
      sourcePartner.riskMonitoringDisabledAt
    ) {
      continue;
    }
 
    for (const enrolledPartner of partners) {
      fraudEvents.push({
        programId,
        partnerId: sourcePartner.partnerId,
        type: FraudRuleType.partnerDuplicateAccount,
        metadata: {
          duplicatePartnerId: enrolledPartner.partnerId,
          riskLabels,
        },
      });
    }
  }
}

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:111-133

The resulting fraud events are persisted via createFraudEvents, returning affected groups that trigger batch commission freezes for both pending and processed earnings.

const { affectedGroups } = await createFraudEvents(fraudEvents);
 
const results = await Promise.allSettled([
  holdPendingCommissions(affectedGroups),
  holdProcessedCommissions(affectedGroups),
]);
results
  .filter((r): r is PromiseRejectedResult => r.status === "rejected")
  .forEach((r) => console.error("Failed to hold commissions:", r.reason));

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:135-143

Detection WorkflowInput ParametersTarget Entity PropertyAssociated Fraud Rule
detectDuplicateIdentityFraudveriffSessionId, riskLabelspartner.veriffSessionIdFraudRuleType.partnerDuplicateAccount
detectDuplicatePayoutMethodFraudpayoutMethodHash, cryptoWalletAddresspartner.payoutMethodHash, partner.cryptoWalletAddressFraudRuleType.partnerDuplicateAccount

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23

Sources: apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:125

Sources: apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:16-19

Sources: apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:100

Veriff Identity Verification Workflow

Session Creation and Action Validation

The identity verification workflow begins when a partner initiates verification through the startIdentityVerificationAction server action. The action verifies that the user possesses the partner_profile.update permission before evaluating the partner's current identityVerificationStatus.

export const startIdentityVerificationAction = authPartnerActionClient.action(
  async ({ ctx }) => {
    const { partner, partnerUser } = ctx;
 
    throwIfNoPermission({
      role: partnerUser.role,
      permission: "partner_profile.update",
    });
 
    if (partner.identityVerificationStatus) {
      switch (partner.identityVerificationStatus) {
        case "approved":
          throw new Error(
            "Your identity has already been verified. No further action is required.",
          );
        case "submitted":
        case "review":
          throw new Error(
            "A verification attempt is already in progress. Please wait for it to complete or resubmit.",
          );
      }
    }

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:16-37

If the status is approved, the system rejects the request with an error indicating no further action is required. If the status is submitted or review, the system blocks duplicate attempts while an active review is in progress. Additionally, the system checks veriffMetadata.attemptCount against MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS.

const veriffMetadata = parseVeriffMetadata(partner.veriffMetadata);
 
if (
  veriffMetadata.attemptCount >= MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS
) {
  throw new Error(
    "You've reached the maximum number of identity verification attempts. Please contact support: https://dub.co/support",
  );
}

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:39-47

Warning

If an unexpired session already exists (sessionExpiresAt > new Date()), the action returns the existing sessionUrl instead of generating a duplicate Veriff session.

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:51-60

Partner Dashboard and Admin UI Integration

When a new session is generated, rate limiting is enforced via RATELIMIT_POLICIES.identityVerificationStart keyed on the partner ID. The backend invokes createVeriffSession, updates the partner record with the new veriffSessionId and merged Veriff metadata featuring a 7-day expiration via addDays(new Date(), 7), and returns the session URL.

await assertRateLimit({
  policy: RATELIMIT_POLICIES.identityVerificationStart,
  identifier: partner.id,
});
 
// Create a new session
const { verification } = await createVeriffSession({
  partner,
});
 
await prisma.partner.update({
  where: {
    id: partner.id,
  },
  data: {
    veriffSessionId: verification.id,
    veriffMetadata: mergeVeriffMetadata(partner.veriffMetadata, {
      sessionUrl: verification.url,
      sessionExpiresAt: addDays(new Date(), 7),
    }),
  },
});
 
return {
  sessionUrl: verification.url,
};

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:62-87

In the partner profile UI, IdentityVerificationSection renders status badges and manages verification actions. Upon successful action execution, the client dynamically imports createVeriffFrame from @veriff/incontext-sdk to render the verification modal in-context, triggering profile revalidation when the MESSAGES.FINISHED event occurs.

const { executeAsync, isPending } = useAction(
  startIdentityVerificationAction,
  {
    onError: ({ error }) => {
      toast.error(
        parseActionError(error, "Failed to start identity verification."),
      );
    },
    onSuccess: async ({ data }) => {
      const { createVeriffFrame, MESSAGES } = await import(
        "@veriff/incontext-sdk"
      );
 
      createVeriffFrame({
        url: data.sessionUrl,
        onEvent: (msg) => {
          if (msg === MESSAGES.FINISHED) {
            toast.success(
              "Verification submitted. We'll update your status shortly.",
            );
            mutate();
          }
        },
      });
 
      mutate();
    },
  },
);

Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:30-58

Administrators can also manage verifications directly from the network management dashboard using NetworkIdentityVerification, which supports generating Veriff session URLs or manually verifying partner identity.

const handleGenerateSession = async () => {
  const requestPartnerId = partner.id;
  setIsGeneratingSession(true);
 
  try {
    const response = await fetch(
      `/api/admin/partners/${requestPartnerId}/generate-veriff-session`,
      {
        method: "POST",
      },
    );
 
    if (!response.ok) {
      throw new Error(
        (await response.text()) || "Failed to generate Veriff session.",
      );
    }
 
    const { sessionUrl: url } = (await response.json()) as {
      sessionUrl: string;
    };
 
    if (partnerIdRef.current !== requestPartnerId) {
      return;
    }
 
    setSessionUrl(url);
    toast.success("Veriff session generated.");
  } catch (error) {
    if (partnerIdRef.current !== requestPartnerId) {
      return;
    }
 
    toast.error(
      error instanceof Error
        ? error.message
        : "Failed to generate Veriff session.",
    );
  } finally {
    if (partnerIdRef.current === requestPartnerId) {
      setIsGeneratingSession(false);
    }
  }
};
 
const handleVerifyIdentity = async () => {
  const requestPartnerId = partner.id;
  setIsVerifyingIdentity(true);
 
  try {
    const response = await fetch(
      `/api/admin/partners/${requestPartnerId}/verify-identity`,
      {
        method: "POST",
      },
    );
 
    if (!response.ok) {
      throw new Error(
        (await response.text()) || "Failed to verify partner identity.",
      );
    }
 
    if (partnerIdRef.current !== requestPartnerId) {
      return;
    }
 
    toast.success("Partner identity verified.");
  } catch (error) {
    if (partnerIdRef.current !== requestPartnerId) {
      return;
    }
 
    toast.error(
      error instanceof Error
        ? error.message
        : "Failed to verify partner identity.",
    );
  } finally {
    if (partnerIdRef.current === requestPartnerId) {
      setIsVerifyingIdentity(false);
    }
  }
};

Sources: apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:27-110

Component / ActionTarget Entity / EndpointPrimary Status States / ActionsPermission Required
startIdentityVerificationActionServer Actionapproved, submitted, review, started, declinedpartner_profile.update
IdentityVerificationSection/profile#identity-verificationapproved, submitted, review, started, declined, resubmissionRequested, expired, abandonedpartner_profile.update (enforced via hasPermission)
NetworkIdentityVerification/api/admin/partners/{id}/generate-veriff-sessionGenerate Veriff Session, Verify identityAdmin Role

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:20-23

Sources: apps/web/lib/actions/partners/start-identity-verification.ts:72-83

Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:30-57

Sources: apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:32-82

Commission Holding and Settlement Freezes

Pending and Processed Commission Batch Holds

When fraud rules or risk triggers are activated, the system executes bulk-hold mechanisms for both pending and processed commissions. For pending commissions, holdPendingCommissions targets partner-program enrollments flagged by partner-level fraud criteria, such as duplicate identities, duplicate payout methods, or network-level bans. The engine evaluates eligibility using a standard filter across enterprise and advanced workspace plans.

const holdEligibleWhere: Prisma.CommissionWhereInput = {
  status: CommissionStatus.pending,
  earnings: {
    gt: 0,
  },
  program: {
    workspace: {
      plan: {
        in: ["enterprise", "advanced"],
      },
    },
  },
};

Sources: apps/web/lib/api/fraud/hold-pending-commissions.ts:26-38

For processed commissions that are already associated with a pending payout, holdProcessedCommissions applies a parallel batch hold mechanism. In addition to updating the commission status to hold, this operation resets the payoutId field to null and tracks affected payout IDs for subsequent amount recalculation via retallyPayoutsAmount.

const holdEligibleWhere: Prisma.CommissionWhereInput = {
  status: CommissionStatus.processed,
  payout: {
    status: PayoutStatus.pending,
  },
  earnings: {
    gt: 0,
  },
  program: {
    workspace: {
      plan: {
        in: ["enterprise", "advanced"],
      },
    },
  },
};

Sources: apps/web/lib/api/fraud/hold-processed-commissions.ts:32-47

Warning

Because commission statuses can change between the initial query and the execution of updateMany (for example, if a payout is modified concurrently), updateMany re-checks eligibility, and the system verifies updated row counts against database state before logging activity or syncing totals.

Sources: apps/web/lib/api/fraud/hold-pending-commissions.ts:92-96

Sources: apps/web/lib/api/fraud/hold-processed-commissions.ts:110-114

Migration Backfills and Settlement Synchronization

Historical risk groups are remediated via dedicated migration scripts (backfill-hold-pending-commissions.ts and backfill-hold-processed-commissions.ts). These routines iterate through pending fraud event groups in batches of 50, dividing rules into partner-level and customer-level categories to construct targeted Prisma queries.

const BATCH_SIZE = 50;
 
type PartnerLevelFraudRule = (typeof PARTNER_LEVEL_FRAUD_RULES)[number];
type CustomerLevelFraudRule = (typeof CUSTOMER_LEVEL_FRAUD_RULES)[number];
 
const holdEligibleWhere: Prisma.CommissionWhereInput = {
  status: CommissionStatus.pending,
  earnings: {
    gt: 0,
  },
  program: {
    workspace: {
      plan: {
        in: ["enterprise", "advanced"],
      },
    },
  },
};

Sources: apps/web/scripts/migrations/backfill-hold-pending-commissions.ts:13-30

Migration ScriptTarget StatusPayout AssociationAdditional Post-Processing
backfill-hold-pending-commissions.tsCommissionStatus.pendingUnrestricted by payout statustrackCommissionStatusUpdate, syncTotalCommissions
backfill-hold-processed-commissions.tsCommissionStatus.processedPayoutStatus.pending (sets payoutId: null)retallyPayoutsAmount, activity logging, total sync

Sources: apps/web/scripts/migrations/backfill-hold-pending-commissions.ts:18-30

Sources: apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:24-39

Sources: apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:240

When commissions are transitioned to hold status, the system groups them by program identifier to record entries via trackCommissionStatusUpdate, and recalculates partner totals using syncTotalCommissions across all affected program-partner pairs.

// Activity logs are scoped to a workspace + program, so batch by program.
const commissionsByProgram = groupBy(heldCommissions, (c) => c.programId);
 
for (const commissions of Object.values(commissionsByProgram)) {
  const { id: programId, workspaceId } = commissions[0].program;
 
  await trackCommissionStatusUpdate({
    workspaceId,
    programId,
    commissions,
    newStatus: CommissionStatus.hold,
  });
}
 
const affectedPairs = [
  ...new Map(
    heldCommissions.map((c) => [
      `${c.programId}:${c.partnerId}`,
      { programId: c.programId, partnerId: c.partnerId },
    ]),
  ).values(),
];
 
await Promise.all(
  affectedPairs.map(({ programId, partnerId }) =>
    syncTotalCommissions({
      programId,
      partnerId,
    }),
  ),
);

Sources: apps/web/lib/api/fraud/hold-pending-commissions.ts:116-146

Sources: apps/web/lib/api/fraud/hold-processed-commissions.ts:134-165

Program Risk Management Interface

Risk Center Layout and Feature Gating

The Risk Center page is available in enterprise and advanced workspaces under /[slug]/program/risks, delivering workspace-level visibility into partner risk events and automated monitoring rules. Access to these risk management features is governed by plan capabilities; workspaces lacking the required tier are intercepted by FraudRiskLayout, which renders RiskCenterUpsell instead of loading the dashboard components.

export default function FraudRiskLayout({ children }: { children: ReactNode }) {
  const { plan, loading } = useWorkspace();
 
  if (loading) {
    return <LayoutLoader />;
  }
 
  const { canManageFraudEvents } = getPlanCapabilities(plan);
 
  if (!canManageFraudEvents) {
    return <RiskCenterUpsell />;
  }
 
  return children;
}

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/layout.tsx:9-22

The Risk Center view wraps its data layer inside RiskEventsTable, providing automatic background filtering, sorting via query parameters (sortBy and sortOrder), and expiration disclaimers. Unresolved risk events automatically expire after 30 days unless categorized as confirmed network-level bans.

export function RiskDisclaimerBanner({ className }: { className?: string }) {
  return (
    <Callout variant="warn" size={2} className={className}>
      We recommend reviewing the risk events thoroughly before taking action.
      Unresolved events expire after 30 days, except confirmed network-level
      bans.{" "}
      <a
        href="https://dub.co/help/article/risk-monitoring"
        target="_blank"
        className="font-medium underline underline-offset-2 transition-colors hover:text-neutral-800"
      >
        Learn more
      </a>
    </Callout>
  );
}

Sources: apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx:3-17

Warning

While standard risk events carry a 30-day expiration window, confirmed network-level bans bypass automatic expiration and remain enforced across the entire partner network.

Sources: apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx:3-16

Risk Rules Configuration Sheet

Workspace administrators configure monitoring criteria using RiskRulesSheet, which interfaces with /api/fraud/rules. The form state distinguishes between complex configurable fraud rules with dedicated subcomponents and toggle-only rules that use standard switches.

// Rules that have dedicated settings components with complex config UI
const RULES_WITH_CUSTOM_UI = new Set([
  "paidTrafficDetected",
  "referralSourceBanned",
]);
 
// Toggle-only rules rendered via the generic RiskRuleToggleSettings component
const TOGGLE_ONLY_RULES = CONFIGURABLE_FRAUD_RULES.filter(
  (rule): rule is FraudRuleInfo & { type: keyof UpdateFraudRuleSettings } =>
    !RULES_WITH_CUSTOM_UI.has(rule.type),
);
 
interface RiskRulesSheetProps {
  setIsOpen: Dispatch<SetStateAction<boolean>>;
}
 
function RiskRulesSheetContent({ setIsOpen }: RiskRulesSheetProps) {
  const { id: workspaceId } = useWorkspace();
  const { isSubmitting, makeRequest } = useApiMutation();
 
  const { data: fraudRules, isLoading } = useSWR<FraudRuleProps[]>(
    workspaceId ? `/api/fraud/rules?workspaceId=${workspaceId}` : null,
    fetcher,
  );
 
  const { setShowDisableModal, DisableRiskRulesModal } =
    useDisableRiskRulesModal({ setIsOpen });
 
  const form = useForm<UpdateFraudRuleSettings>({
    defaultValues: {
      referralSourceBanned: {
        enabled: false,
        config: { domains: [] },
      },
      paidTrafficDetected: {
        enabled: false,
        config: { platforms: [], google: { whitelistedCampaignIds: [] } },
      },
      ...Object.fromEntries(
        TOGGLE_ONLY_RULES.map((rule) => [rule.type, { enabled: true }]),
      ),
    },
  });

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-rules-sheet.tsx:32-73

Rule TypeConfiguration ComponentDefault StateConfig Structure
referralSourceBannedRiskReferralSourceSettingsDisabled (false){ domains: string[] }
paidTrafficDetectedRiskPaidTrafficSettingsDisabled (false){ platforms: PaidTrafficPlatform[], google: { whitelistedCampaignIds: string[] } }
Toggle-only rulesRiskRuleToggleSettingsEnabled (true){ enabled: boolean }

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-rules-sheet.tsx:32-73

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-rules-sheet.tsx:185-195

When a user attempts to disable active rules, getRulesBeingDisabled evaluates the configuration delta against previous settings. If any active rule is deactivated, the form halts direct submission and opens the disable confirmation modal before dispatching a PATCH request to the API.

  // Handle form submission
  const onSubmit = async (body: UpdateFraudRuleSettings) => {
    if (isLoading) {
      return;
    }
 
    // First submit if no previous data exists
    if (!fraudRules) {
      await submitForm(body);
      return;
    }
 
    // Detect rule disable transitions
    const rulesBeingDisabled = getRulesBeingDisabled({
      previousFraudRules: fraudRules,
      nextFraudRules: body,
    });
 
    // If any rules are being disabled, show confirmation modal
    if (rulesBeingDisabled.length > 0) {
      setShowDisableModal(true);
      return;
    }
 
    // Otherwise, submit directly
    await submitForm(body);
  };

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-rules-sheet.tsx:132-157

Was this page helpful?