---
title: "Fraud Detection and Hold Rules"
description: "Fraud Detection and Hold Rules provide a comprehensive risk-mitigation framework designed to safeguard partner programs against suspicious activity, duplicate accounts, and fraudulent referral traf..."
last_updated: "2026-10-05T05:07:35.16912+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/fraud-detection-and-hold-rules"
---

<details>
<summary>Relevant source files</summary>

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

- [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts)
- [apps/web/scripts/migrations/backfill-hold-pending-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-pending-commissions.ts)
- [apps/web/lib/api/fraud/release-hold-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts)
- [apps/web/lib/api/fraud/hold-processed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts)
- [apps/web/app/ee/api/workflows/create-partner-commission/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts)
- [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts)
- [apps/web/lib/api/fraud/hold-pending-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts)
- [apps/web/app/ee/api/cron/fraud/release-all-hold-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/fraud/release-all-hold-commissions/route.ts)
- [apps/web/lib/api/fraud/release-all-hold-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-all-hold-commissions.ts)
- [apps/web/app/ee/api/cron/fraud/release-hold-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/fraud/release-hold-commissions/route.ts)
- [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts)
- [apps/web/lib/partner-referrals/create-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-events-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/risk-events-table.tsx)
- [apps/web/lib/partner-referrals/create-network-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts)
- [apps/web/app/ee/api/fraud/rules/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts)
- [apps/web/app/ee/api/cron/commissions/referrals/queue/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts)
- [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx)
- [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx)
- [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/lib/api/fraud/report-network-level-ban.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts)
- [apps/web/lib/api/fraud/resolve-fraud-groups.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/resolve-fraud-groups.ts)
- [apps/web/scripts/misc/cleanup-generic-email-fraud-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/misc/cleanup-generic-email-fraud-events.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts)
- [apps/web/scripts/customers/beehiiv/fraud-checks.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fraud-checks.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/layout.tsx)
</details>

## Overview

Fraud Detection and Hold Rules provide a comprehensive risk-mitigation framework designed to safeguard partner programs against suspicious activity, duplicate accounts, and fraudulent referral traffic. By integrating real-time fraud monitoring directly into partner commission workflows, the system automatically detects policy violations—such as duplicate identities, matching customer emails, or network-level bans—and intercepts commission creation to place vulnerable earnings on hold. This proactive mechanism prevents premature payouts, recalculates balances, and manages automated hold releases or expiration schedules through orchestrated cron jobs, ensuring robust financial integrity across workspaces. 

Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:19-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L19-L34)

## Configurable Fraud Rules and Program Controls

### Overview

Public and workspace fraud rule configuration and validation manage how individual partner programs enforce detection mechanisms. Workspace-level settings allow authorized users to query existing rules or update rule configurations and active statuses via dedicated API endpoints. When fetching rules via `GET /api/fraud/rules`, the system retrieves stored configurations from the database and merges them with default overrides—such as platform settings for paid traffic detection—ensuring every rule has a valid initial state. 

Sources: [apps/web/app/ee/api/fraud/rules/route.ts:31-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L31-L69)

```mermaid
sequenceDiagram
    participant Client
    participant API as PATCH /api/fraud/rules
    participant DB as Prisma DB
    participant Resolve as resolveFraudGroups

    Client->>API: Send update payload
    API->>API: Parse via updateFraudRuleSettingsSchema
    loop For each rule
        API->>DB: Upsert fraud rule (config & disabledAt)
    end
    alt Rule disabled with resolvePendingEvents=true
        API->>Resolve: Background execution via waitUntil()
    end
    API->>Client: Return { success: true }
```

Sources: [apps/web/app/ee/api/fraud/rules/route.ts:75-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L75-L144)

### Rule Modification and Automatic Resolution

The `PATCH /api/fraud/rules` endpoint processes settings updates validated by `updateFraudRuleSettingsSchema`. Rules are iterated, and their configurations are persisted via database upserts, mapping enabled states to `disabledAt` timestamps. If a rule is disabled with `resolvePendingEvents` set to true, a background task executes `resolveFraudGroups` via Vercel's `waitUntil()` utility, automatically releasing held commissions with the resolution reason `"Resolved automatically because the fraud rule was disabled."`. 

Sources: [apps/web/app/ee/api/fraud/rules/route.ts:75-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L75-L141)

> [!WARNING]
> Disabling a fraud rule with `resolvePendingEvents: true` immediately triggers automatic group resolution and commission release in the background, which cannot be rolled back directly via the PATCH response. 

Sources: [apps/web/app/ee/api/fraud/rules/route.ts:116-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L116-L141)

### Referral Source Banning Rules

The `checkReferralSourceBanned` rule evaluates click event contexts against a list of banned domains. It parses raw configuration using a Zod schema requiring an optional string array of domains, defaulting to an empty list. 

Sources: [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts:7-13](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts#L7-L13)

```typescript
export const checkReferralSourceBanned = defineFraudRule({
  type: "referralSourceBanned",
  evaluate: async ({ click }: FraudEventContext, rawConfig) => {
    const parsedConfig = configSchema.safeParse(rawConfig ?? defaultConfig);

    if (!parsedConfig.success) {
      console.error(
        `[checkReferralSourceBanned] Invalid config:`,
        parsedConfig.error,
      );

      return {
        triggered: false,
      };
    }

    const config = parsedConfig.data;

    // Normalize banned domains by extracting domains and removing www. prefix
    const normalizedBannedDomains = config.domains
      .map((domain) => getDomainWithoutWWW(domain))
      .filter((domain): domain is string => Boolean(domain));

    if (normalizedBannedDomains.length === 0 || !click) {
      return {
        triggered: false,
      };
    }

    // Return early if both referer and referer_url are null/empty
    if (!click.referer && !click.referer_url) {
      return {
        triggered: false,
      };
    }

    // Check both referer and referer_url against banned sources
    // Normalize referrers by extracting domains and removing www. prefix
    const referrerCandidates = [click.referer, click.referer_url]
      .filter((value): value is string => Boolean(value))
      .map((referrer) => getDomainWithoutWWW(referrer))
      .filter((domain): domain is string => Boolean(domain));

    for (const referrer of referrerCandidates) {
      for (const source of normalizedBannedDomains) {
        if (minimatch(referrer, source, { nocase: true })) {
          return {
            triggered: true,
            metadata: {
              source,
            },
          };
        }
      }
    }

    return {
      triggered: false,
    };
  },
});
```

Sources: [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts:15-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts#L15-L75)

## Identity and Payout Fraud Detection

### Overview

Identity and payout fraud detection mechanisms identify colluding partners by cross-referencing shared verification sessions, payment method hashes, crypto wallet addresses, and network-level bans. When overlapping identifiers or bans are detected, the system generates fraud events across active program enrollments and initiates commission holds. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:15-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L15-L16)

### Duplicate Identity Verification

The `detectDuplicateIdentityFraud` function accepts a Veriff session identifier and risk labels. It extracts session IDs associated with valid risk labels, appends the current session ID, deduplicates the array, and queries database program enrollments where the associated partner matches any collected Veriff session ID. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L67)

Enrollments are filtered to ensure the `partnerDuplicateAccount` fraud rule is enabled in the program settings. Partners are grouped by `programId`, and groups containing fewer than two partners are filtered out. For every eligible partner within a multi-partner group, fraud events of type `partnerDuplicateAccount` are created and dispatched to `createFraudEvents`. Finally, affected pending and processed commissions are placed on hold via settlement promises. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:74-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L74-L144)

> [!NOTE]
> Partners whose enrollment status is included in `INACTIVE_ENROLLMENT_STATUSES` or who have `riskMonitoringDisabledAt` populated are skipped during event generation, even if they share a Veriff session ID with active partners. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:114-119](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L114-L119)

### Payment Method Collisions

The `detectDuplicatePayoutMethodFraud` function evaluates either a `payoutMethodHash` or a `cryptoWalletAddress` using mutually exclusive options. It searches for program enrollments linked to partners sharing the specified payout hash or wallet address. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:10-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L10-L44)

```typescript
type DetectDuplicatePayoutMethodFraudOptions =
  | { payoutMethodHash: string; cryptoWalletAddress?: never }
  | { cryptoWalletAddress: string; payoutMethodHash?: never };

export async function detectDuplicatePayoutMethodFraud({
  payoutMethodHash,
  cryptoWalletAddress,
}: DetectDuplicatePayoutMethodFraudOptions) {
  if (!payoutMethodHash && !cryptoWalletAddress) {
    return;
  }

  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,
        },
      },
    },
  });
...
```

Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:10-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L10-L48)

After fetching matching enrollments, the function validates that `partnerDuplicateAccount` is enabled, groups partners by program, excludes single-partner groups, and records fraud events containing either `payoutMethodHash` or `cryptoWalletAddress` in their metadata. 

Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:50-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L50-L109)

### Network-Level Bans

The `reportNetworkLevelBan` function alerts other programs when a partner is banned within a specific program. It queries all active program enrollments for the partner excluding the issuing program ID and ensuring risk monitoring is active. 

Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:12-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L12-L43)

```typescript
export async function reportNetworkLevelBan({
  partnerId,
  programId,
  bannedReason,
  bannedAt,
}: {
  partnerId: string;
  programId: string;
  bannedReason: PartnerBannedReason | null;
  bannedAt: Date | null;
}) {
  let affectedProgramEnrollments = await prisma.programEnrollment.findMany({
    where: {
      partnerId,
      programId: {
        not: programId,
      },
      status: {
        notIn: INACTIVE_ENROLLMENT_STATUSES,
      },
      riskMonitoringDisabledAt: null,
    },
    select: {
      programId: true,
      partnerId: true,
      program: {
        select: {
          fraudRules: true,
        },
      },
    },
  });
...
```

Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:12-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L12-L43)

Enrollments are filtered against the `partnerCrossProgramBan` fraud rule. Eligible enrollments trigger `partnerCrossProgramBan` fraud events with `sourceProgramId` set to the banning program and metadata containing `bannedReason` and `bannedAt`. Pending and processed commissions are then held for all affected groups. 

Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:52-91](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L52-L91)

## Commission Creation and Fraud Interception

### Overview

Partner commission creation workflows evaluate fraud risks inline to determine whether newly recorded commissions must be placed on hold immediately upon creation. The interception mechanism operates during side-effect execution following commission creation, inspecting both real-time customer risk events and partner-level pending fraud groups. 

Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:534-651](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L534-L651)

### Fraud Evaluation and Interception Walkthrough

When a commission is created, the workflow executes `stepRunSideEffects()`, which inspects workspace plan capabilities and verifies if risk monitoring applies to the transaction. The call chain proceeds through evaluation and status modification functions:

`stepRunSideEffects()` → evaluates `shouldRunRiskMonitoring` (checking for customer, event ID, and click event) → `detectAndRecordFraudEvent()` → checks `canManageFraudEvents` from workspace plan capabilities → `prisma.fraudEventGroup.findFirst()` (evaluating partner-level scope) → `prisma.commission.update()` (transitioning status to `hold` if flagged). 

Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:602-677](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L602-L677)

If risk rules trigger or pending risk groups exist, and the commission is not a clawback (`commission.earnings > 0`) with an explicit status input, the commission record transitions from `pending` to `hold`. 

Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:630-675](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L630-L675)

> [!WARNING]
> Clawback commissions (where earnings are less than or equal to zero) are never held, even if risk monitoring rules or pending partner-level fraud groups are triggered. 

Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:601-657](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L601-L657)

### Network Referral Commission Interception and Duplicate Handling

For network-level referrals, `createNetworkReferralCommission()` validates referrer program enrollment, active duration limits, and payout fee earnings before creating a referral commission record. 

Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:33-242](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L33-L242)

```typescript
  try {
    commission = await prisma.commission.create({
      data: commissionData,
    });

    console.log("Network referral commission created", commission);
  } catch (error) {
    // Don't retry on unique constraint violation – the commission already exists
    // (likely a race between the dedup check and the create)
    if (error.code === "P2002") {
      console.log(
        `Referral commission already exists for invoiceId ${commissionData.invoiceId}, skipping creation.`,
      );
      return null;
    }

    console.error(
      "Error creating network referral commission",
      error,
      commissionData,
    );

    await log({
      message: `[createNetworkReferralCommission] Error creating referral commission - ${error.message}`,
      type: "errors",
      mention: true,
    });

    throw error;
  }
```

Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:238-267](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L238-L267)

> [!NOTE]
> Unique constraint violations (`P2002`) during network referral commission creation are caught and ignored rather than retried, preventing duplicate insertion race conditions when an invoice ID already exists. 

Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:244-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L244-L252)

### Commission Interception Parameters and Constants

| Parameter / Constant | Target Scope | Purpose |
| :--- | :--- | :--- |
| `PARTNER_LEVEL_FRAUD_RULES` | Partner-level | Filters fraud event groups for partner-scope rule violations (`type: { in: PARTNER_LEVEL_FRAUD_RULES }`). Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:641-644](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L641-L644) |
| `FraudEventStatus.pending` | Fraud Event Group | Queries unresolving risk groups requiring commission interception. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:640](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L640) |
| `CommissionStatus.hold` | Commission Record | Target status assigned to intercepted commissions when risk rules or pending groups trigger. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:674](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L674) |
| `P2002` | Database Error Code | Identifies unique constraint collisions to prevent duplicate commission insertion retries. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:516](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L516) |

## Commission Hold and Payout Retallying

### Overview

When partner-level fraud triggers violations such as duplicate identities, matching payout methods, or network-level bans, bulk commission hold routines execute against both pending and processed commission records. 

Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:14-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L14-L18)

### Pending Commission Batching and Updates

`holdPendingCommissions()` processes program enrollments by mapping unique partner-program pairs and chunking them into batches of 50. 

Sources: [apps/web/lib/api/fraud/hold-pending-commissions.ts:10-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L10-L24)

```typescript
export async function holdPendingCommissions(
  programEnrollments: Pick<ProgramEnrollment, "programId" | "partnerId">[],
) {
  if (programEnrollments.length === 0) {
    console.log("No program enrollments to hold pending commissions for");
    return;
  }

  const uniquePairs = [
    ...new Map(
      programEnrollments.map((e) => [`${e.programId}:${e.partnerId}`, e]),
    ).values(),
  ];

  const chunks = chunk(uniquePairs, 50);

  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:10-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L10-L38)

> [!WARNING]
> Only commissions belonging to `enterprise` or `advanced` workspace plans with positive earnings (`earnings > 0`) are eligible for holding. 

Sources: [apps/web/lib/api/fraud/hold-pending-commissions.ts:28-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L28-L37)

### Processed Commission Handling and Payout Retallying

`holdProcessedCommissions()` queries processed commissions attached to pending payouts, updates their status to `hold`, sets their `payoutId` to `null`, and collects affected payout IDs into a tracking set. 

Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:32-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L32-L101)

```typescript
  // need to retally payouts to ensure the payout amount is correct
  await retallyPayoutsAmount(Array.from(payoutIdsToRetallySet));
```

Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:168-170](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L168-L170)

> [!NOTE]
> Because updating processed commissions detaches them from their pending payouts (`payoutId: null`), `retallyPayoutsAmount()` is invoked at the end of execution to recalculate correct payout totals. 

Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:98-100](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L98-L100)

### Commission Hold Parameters and Configuration

| Parameter / Constant | Target Scope | Purpose |
| :--- | :--- | :--- |
| `PRISMA_UPDATEMANY_LIMIT` | Query Batch Size | Limits the maximum number of commission rows retrieved per iteration. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L76) |
| `CommissionStatus.processed` | Commission Status | Initial state required for processed commissions before being transitioned to hold. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L33) |
| `PayoutStatus.pending` | Payout Status | Required payout status restriction when querying processed commissions for holding. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L34-L36) |
| `syncTotalCommissions` | Partner Totals | Recalculates total partner commissions for affected partner-program pairs after status updates. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:157-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L157-L164) |

## Automated Hold Releases and Expiration

### Automated Hold Releases and Expiration

Scheduled cron jobs and cleanup handlers manage the lifecycle of fraud groups and release held commissions back to a pending state once risks are resolved or expiration thresholds are met. 

Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:1-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L1-L18)

### Fraud Group Expiration and Cron Orchestration

The expiration cleanup cron route (`POST /api/cron/cleanup/expired-fraud-groups`) executes once every day at 02:30:00 AM UTC (`30 2 * * *`). It queries pending fraud event groups that have exceeded their time-to-live threshold and transitions their status to expired.

```typescript
const groupsToExpire = await prisma.fraudEventGroup.findMany({
  where: {
    status: "pending",
    type: {
      notIn: NON_EXPIRING_FRAUD_RULE_TYPES,
    },
    lastEventAt: {
      lt: subDays(new Date(), FRAUD_GROUP_EXPIRY_DAYS),
    },
  },
  select: {
    id: true,
  },
  take: BATCH_SIZE,
});
```

Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:21-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L21-L36)

> [!NOTE]
> `FRAUD_GROUP_EXPIRY_DAYS` defines the inactivity window (defaulting to 30 days based on `lastEventAt`), and rule types specified in `NON_EXPIRING_FRAUD_RULE_TYPES` are explicitly excluded from automatic expiration. 

Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:1-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L1-L30)

### Commission Release Logic and Safety Checks

When fraud groups are resolved via `resolveFraudGroups()` or marked expired during cleanup, `queueReleaseHoldCommissions()` triggers downstream hold releases. The core release function `releaseHoldCommissions()` performs strict verification before altering any commission states:

1. **Partner-Level Check:** It queries remaining pending fraud groups for the partner. If any active partner-level fraud rules remain, all hold commissions are kept on hold.
2. **Customer-Level Filtering:** If other pending groups exist, customer IDs tied to customer-level fraud rules are collected into a `blockedCustomerIds` set.
3. **Selective Release:** Commissions tied to blocked customer IDs remain in `CommissionStatus.hold`, whereas custom commissions (`customerId: null`) and commissions linked to unblocked customers transition from `hold` to `pending`.

```typescript
export async function releaseHoldCommissions({
  programId,
  partnerId,
  resolvedGroupIds,
}: {
  programId: string;
  partnerId: string;
  resolvedGroupIds: string[];
}) {
  if (resolvedGroupIds.length === 0) {
    return 0;
  }
...
```

Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:35-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L35-L48)

> [!WARNING]
> If a partner maintains any active partner-level pending fraud group across the program, the entire commission release batch for that partner is skipped, regardless of whether specific conversion groups have been resolved. 

Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:68-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L68-L77)

### Downstream Orchestration Steps

Once commissions are successfully moved to `CommissionStatus.pending`, `releaseHoldCommissions()` executes concurrent post-update procedures via `Promise.allSettled()`:

```typescript
const results = await Promise.allSettled([
  trackCommissionStatusUpdate({
    workspaceId: program.workspaceId,
    programId,
    commissions: releasedCommissions,
    newStatus: CommissionStatus.pending,
  }),
  syncTotalCommissions({
    partnerId,
    programId,
  }),
  triggerAggregateDueCommissionsCronJob(programId),
  releasedEarnings > 0 &&
    executeWorkflows({
      event: "commissionRecorded",
      identity: {
        workspaceId: program.workspaceId,
        programId,
        partnerId,
      },
      metrics: {
        current: {
          commissions: releasedEarnings,
        },
      },
    }),
]);
```

Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:191-218](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L191-L218)

Additionally, program downgrades or manual overrides can execute `releaseAllHoldCommissions()`, which bypasses customer-level filtering and releases all hold commissions belonging to a specific program. 

Sources: [apps/web/lib/api/fraud/release-all-hold-commissions.ts:8-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-all-hold-commissions.ts#L8-L28)

## Risk Center and Audit Operations

### Overview

The Risk Center interface and its associated audit operations provide workspace administrators with tooling to review flagged fraud event groups, examine associated commissions on hold, and execute backfill migrations for retroactively applying hold rules. Access to fraud risk layouts is governed by workspace plan capabilities via `getPlanCapabilities()`, which evaluates `canManageFraudEvents` and displays a `RiskCenterUpsell` component when capabilities are unavailable.

Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/layout.tsx:1-24](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/layout.tsx#L1-L24)

### Risk Events Table and Associated Commissions Audit

The `RiskEventsTable` component retrieves pending fraud groups using `useFraudGroups` and integrates filters, batch action modals, and review sheets. When investigating a specific risk group, the `AssociatedCommissionsTable` queries held commissions through the `/api/commissions` endpoint with a designated `fraudEventGroupId` and `status: "hold"`.

```typescript
  const query = {
    workspaceId: workspaceId!,
    status: "hold",
    fraudEventGroupId: fraudGroup.id,
    partnerId: fraudGroup.partner.id,
  };
```

Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:39-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L39-L44)

The table configures columns for creation date, associated customer row items, commission types via `CommissionTypeBadge`, currency-formatted amounts, and action menus, with row auxiliary click handlers linking directly to individual commission detail views.

Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:70-159](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L70-L159)

> [!NOTE]
> Associated commission queries utilize `keepPreviousData: true` alongside SWR fetchers to maintain UI stability during pagination across hold records. 

Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:57-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L57-L67)

### Backfill and Maintenance Scripts

When hold-on-fraud rules are introduced or updated, backfill scripts such as `backfill-hold-pending-commissions.ts` and `backfill-hold-processed-commissions.ts` scan pending `FraudEventGroup` records in batches of 50 and transition matching eligible commissions to `CommissionStatus.hold`.

| Script File | Target Status | Batch Size | Post-Update Operations |
| :--- | :--- | :--- | :--- |
| `backfill-hold-pending-commissions.ts` | `CommissionStatus.pending` | 50 | Tracks status updates, syncs total commissions |
| `backfill-hold-processed-commissions.ts` | `CommissionStatus.processed` | 50 | Tracks updates, syncs totals, retalls payouts |

Sources: [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:19-240](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts#L19-L240)

> [!WARNING]
> Because `updateMany` re-checks eligibility, a commission's status can change between `findMany` and `updateMany` (e.g., if a payout is updated). The scripts explicitly re-verify held IDs before tracking activity logs or syncing partner totals. 

Sources: [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:177-199](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts#L177-L199)

## Related

- [Commission Rules and Rewards](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/commission-rules-and-rewards)
- [Identity Verification](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/identity-verification)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
