Skip to main content
doc0blade47/dubdocumentation home
Sign in

Commission Engine

Last updated Oct 12, 2026100% grounded
Relevant source files

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

Overview

The Commission Engine powers Dub's partner programs by tracking, calculating, and managing financial rewards across affiliate click, lead, sale, referral, and custom commission types. It processes asynchronous event pipelines, evaluates complex reward rules, handles multi-currency conversions, and executes historical batch migrations from platforms such as Lemon Squeezy, PartnerStack, Tolt, Tapfiliate, FirstPromoter, and Rewardful. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:182-218

Sources: apps/web/prisma/schema/commission.prisma:37-62

Sources: apps/web/lib/lemonsqueezy/import-commissions.ts:336-375

Data model and commission states

Commission Enumerations and Status Lifecycle

The commission engine relies on a relational Prisma schema centered around the Commission model, backed by enums for lifecycle status (CommissionStatus), event classification (CommissionType), and data ingestion origin (CommissionSource). Sources: apps/web/prisma/schema/commission.prisma:1-35

enum CommissionStatus {
  pending
  processed
  paid
  refunded
  duplicate
  fraud
  canceled
  hold
}

enum CommissionType {
  click
  lead
  sale
  referral
  custom
}

enum CommissionSource {
  api // track lead/sale API
  user // manual creates with userId (manual commissions, bounty submission approvals, submitted leads/sales)
  stripe
  shopify
  hubspot
  appsflyer
  singular
  rewardful
  partnerstack
  firstpromoter
  tolt
  tapfiliate
  lemonsqueezy
  affiliatewp
}

Sources: apps/web/prisma/schema/commission.prisma:1-35

The Commission model records financial metrics, idempotency identifiers, external references, and relations to programs, partners, payouts, invoices, and customers. Sources: apps/web/prisma/schema/commission.prisma:37-87

model Commission {
  id                 String            @id
  programId          String
  partnerId          String
  rewardId           String?
  linkId             String?
  payoutId           String?
  invoiceId          String? // only for sales (idempotency key, each sale event is associated with a unique invoice)
  customerId         String? // only for leads and sales
  eventId            String?           @unique // only for leads and sales
  description        String?           @db.Text
  type               CommissionType
  amount             Int // only for sales (amount of the sale event)
  quantity           Int // only for clicks/leads (quantity of the event)
  earnings           Int               @default(0) // amount earned by the partner
  currency           String            @default("usd")
  status             CommissionStatus  @default(pending)
  userId             String? // user who created the manual commission
  source             CommissionSource?
  sourceCommissionId String?
  sourcePartnerId    String?
  bountySubmissionId String?
  metadata           Json?             @db.Json
  createdAt          DateTime          @default(now())
  updatedAt          DateTime          @updatedAt

  program           Program           @relation(fields: [programId], references: [id])
  partner           Partner           @relation(fields: [partnerId], references: [id])
  programEnrollment ProgramEnrollment @relation(fields: [programId, partnerId], references: [programId, partnerId])
  payout            Payout?           @relation(fields: [payoutId], references: [id])
  link              Link?             @relation(fields: [linkId], references: [id])
  customer          Customer?         @relation(fields: [customerId], references: [id])
  reward            Reward?           @relation(fields: [rewardId], references: [id])
  bountySubmission  BountySubmission? @relation(fields: [bountySubmissionId], references: [id], onDelete: SetNull)
  user              User?             @relation(fields: [userId], references: [id])

  @@unique([invoiceId, programId])
  @@index([earnings, programId, partnerId, status]) // old index
  @@index([programId, partnerId]) // for programEnrollment relation (used in cron/payouts/aggregate-due-commissions)
  @@index([programId, status, createdAt, amount, earnings]) // for program/commissions (scoped by programId)
  @@index([createdAt, status, programId, earnings]) // for admin/commissions
  @@index([partnerId, customerId, type, createdAt]) // for finding the firstCommission in create-partner-commission workflow
  @@index(payoutId)
  @@index(customerId)
  @@index(linkId)
  @@index(type)
  @@index(status)
  @@index(rewardId)
  @@index(userId)
  @@index(bountySubmissionId)
}

Sources: apps/web/prisma/schema/commission.prisma:37-87

Commissions move through distinct lifecycle states managed by background processing crons and administrative scripts. The table below outlines the available enums for status, type, and source. Sources: apps/web/prisma/schema/commission.prisma:1-35

Enum CategoryAllowed ValuesSources
CommissionStatuspending, processed, paid, refunded, duplicate, fraud, canceled, holdapps/web/prisma/schema/commission.prisma:1-10
CommissionTypeclick, lead, sale, referral, customapps/web/prisma/schema/commission.prisma:12-18
CommissionSourceapi, user, stripe, shopify, hubspot, appsflyer, singular, rewardful, partnerstack, firstpromoter, tolt, tapfiliate, lemonsqueezy, affiliatewpapps/web/prisma/schema/commission.prisma:20-35

Warning

When updating commission statuses from pending or processed to canceled via maintenance scripts, associated payout references (payoutId) must be explicitly nulled and payout balances must be retallied using retallyPayoutsAmount to prevent accounting discrepancies. Sources: apps/web/scripts/programs/update-commissions-canceled.ts:42-58

Partner commission creation workflow

Execution Pipeline and Earnings Calculation

The step-based commission creation workflow (stepCreateCommission) evaluates incoming events through a structured validation and calculation pipeline. Click and referral events are explicitly skipped as they are not processed by this workflow. For lead and sale events, the engine validates that the partner's program enrollment has an eligible status before proceeding. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:212-230

// Skip invalid reward events (should never happen, but just in case)
if (event === "click" || event === "referral") {
  return logAndReturn({
    commission: null,
    outputLog: `"${event}" commissions are not created via this workflow. Skipping commission creation...`,
  });
}
 
// for lead / sale rewards, we need to check if the partner is eligible for commissions
if (
  ["lead", "sale"].includes(event) &&
  !COMMISSION_ELIGIBLE_ENROLLMENT_STATUSES.includes(programEnrollment.status)
) {
  return logAndReturn({
    commission: null,
    outputLog: `Partner ${partnerId} is not eligible for commissions (status: ${programEnrollment.status}) in program ${programId}, skipping ${event} commission creation...`,
  });
}

Sources: apps/web/app/ee/api/workflows/create-partner-commission/route.ts:212-230

When a customer identifier is present, the pipeline queries historical records to locate the initial commission for that partner-customer pair. This establishes the subscription start date and duration months, classifying sales as either new or recurring. The engine then determines applicable partner rewards and calculates sale earnings based on reward type and sale quantity or amount. Sources: apps/web/lib/api/sales/calculate-sale-earnings.ts:8-28

export const calculateSaleEarnings = ({
  reward,
  sale,
}: {
  reward: Pick<RewardProps, "type" | "amountInCents" | "amountInPercentage">;
  sale: Pick<Commission, "quantity" | "amount">;
}) => {
  if (!reward) {
    return 0;
  }
 
  const amount = getRewardAmount(reward);
 
  if (reward.type === "flat") {
    return sale.quantity * amount;
  } else if (reward.type === "percentage") {
    return Math.round((sale.amount * amount) / 100);
  }
 
  return 0;
};

Sources: apps/web/lib/api/sales/calculate-sale-earnings.ts:8-28

Caution

If a prior commission for the customer is marked as fraud or canceled, the execution pipeline terminates immediately and skips commission creation entirely, preventing fraudulent or revoked transactions from generating new payouts. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:312-319

Deduplication and Spend Limit Windows

The workflow enforces strict deduplication and duration boundaries for lead and sale events. If a lead reward has already been issued for a customer, duplicate lead commissions are dropped. For recurring sales, the pipeline evaluates maxDuration constraints against subscription duration months to ensure commissions are only created within allowed windows. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:321-384

// for lead events, we need to check if the partner has already been issued a lead reward for this customer
if (event === "lead") {
  return logAndReturn({
    commission: null,
    outputLog: `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`,
  });
 
  // for sale rewards, we need to check if partner's reward was updated and different from the first commission's reward
  // we need to make sure it wasn't changed from one-time to recurring so we don't create a new commission
} else {
  if (
    firstCommission.rewardId &&
    firstCommission.rewardId !== reward.id
  ) {
    const originalReward = await prisma.reward.findUnique({
      where: {
        id: firstCommission.rewardId,
      },
      select: {
        id: true,
        maxDuration: true,
        modifiers: true,
      },
    });
 
    const originalMaxDuration = originalReward
      ? getRewardMaxDurationForContext({
          reward: originalReward,
          context,
        })
      : null;
 
    if (
      typeof originalMaxDuration === "number" &&
      originalMaxDuration === 0
    ) {
      return logAndReturn({
        commission: null,
        outputLog: `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward?.id}, skipping commission creation...`,
      });
    }
  }
 
  // for sale rewards with a max duration, we need to check if the first commission is within the max duration
  // if it's beyond the max duration, we should not create a new commission
  if (typeof reward?.maxDuration === "number") {
    // One-time sale reward (maxDuration === 0)
    if (reward.maxDuration === 0) {
      return logAndReturn({
        commission: null,
        outputLog: `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`,
      });
    }
 
    // Recurring sale reward (maxDuration > 0)
    else {
      const subscriptionDurationMonths = differenceInMonths(
        createdAt ?? new Date(), // account for custom commission creation date
        firstCommission.createdAt,
      );
 
      if (subscriptionDurationMonths >= reward.maxDuration) {
        return logAndReturn({
          commission: null,

Sources: apps/web/app/ee/api/workflows/create-partner-commission/route.ts:321-384

Note

Custom commission types bypass standard reward evaluation entirely, directly assigning the input amount as earnings while zeroing out the base amount. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:239-242

Manual and batch commission ingestion

Workspace users with owner or member roles on business, advanced, or enterprise plans can create manual commissions through the REST API endpoint at /api/commissions. The endpoint parses incoming payloads using Zod validation schemas and executes manual creation handlers. When the request specifies a custom commission with a negative amount, the response designates the transaction as a clawback queued for the partner with a 202 status code. Sources: apps/web/app/(ee)/api/commissions/route.ts:74-105

export const POST = withWorkspace(
  async ({ workspace, session, req }) => {
    const programId = getDefaultProgramIdOrThrow(workspace);
 
    const body = createManualCommissionBodySchema.parse(
      await parseRequestBody(req),
    );
 
    console.time("createManualCommissions");
 
    await createManualCommissions({
      ...body,
      workspace,
      programId,
      user: session.user,
    });
 
    console.timeEnd("createManualCommissions");
 
    const isClawback = body.type === "custom" && body.amount < 0;
 
    const response = createCommissionResponseSchema.parse({
      success: true,
      message: isClawback
        ? "A clawback has been queued for the partner!"
        : "Your commissions are being created and will appear shortly.",
    });
 
    return NextResponse.json(response, {
      status: 202,
    });
  },

Sources: apps/web/app/ee/api/commissions/route.ts:74-105

The ingestion logic handles custom, lead, and sale event types. For custom commissions, it queues partner commission creation directly with user source attribution and aggregate trigger flags. For sale events, if importStripeInvoices is enabled, the handler validates that the workspace maintains an active Stripe Connect integration and that the target customer has an associated Stripe Customer ID. It also performs invoice binding checks to verify whether a commission for the given invoice identifier already exists within the program. Sources: apps/web/lib/api/commissions/create-manual-commissions.ts:123-164

if (type === "sale") {
  const { importStripeInvoices, sale } = args;
 
  if (importStripeInvoices) {
    if (!workspace.stripeConnectId) {
      throw new DubApiError({
        code: "bad_request",
        message: `Your workspace isn't connected to Stripe yet. Please install the Stripe integration to continue: https://app.dub.co/${workspace.slug}/settings/integrations/stripe`,
      });
    }
 
    if (targetCustomer && !targetCustomer.stripeCustomerId) {
      throw new DubApiError({
        code: "bad_request",
        message: `Customer ${targetCustomer.id} does not have a Stripe Customer ID configured. Please update the customer record at https://app.dub.co/${workspace.slug}/program/customers/${targetCustomer.id}`,
      });
    }
  }
 
  const invoiceId = sale?.invoiceId ?? args.invoiceId;
 
  if (invoiceId) {
    const commission = await prisma.commission.findUnique({
      where: {
        invoiceId_programId: {
          invoiceId,
          programId,
        },
      },
      select: {
        id: true,
      },
    });
 
    if (commission) {
      throw new DubApiError({
        code: "conflict",
        message: `There is already a commission for the invoice ${invoiceId}.`,
      });
    }
  }
}

Sources: apps/web/lib/api/commissions/create-manual-commissions.ts:123-164

Warning

Attempting to ingest a manual sale commission with an invoiceId that already exists for the program throws a conflict API error (409), preventing duplicate payouts for the same invoice. Sources: apps/web/lib/api/commissions/create-manual-commissions.ts:144-163

Batch ingestion scripts can also synchronize paid invoices directly from Stripe for enrolled customers. The synchronization script queries paid invoices, filters out zero-amount invoices, and checks for existing commission records by matching either the invoiceId or checking if the amount matches within a one-hour timestamp window of the invoice creation time. Sources: apps/web/scripts/customers/upheal/sync-stripe-invoices.ts:69-84

const invoicesToProcess = invoices.data.filter((invoice) => {
  // skip $0 invoices
  if (invoice.amount_paid === 0) {
    return false;
  }
  const existingCommission = existingCommissions.find(
    (commission) =>
      commission.invoiceId === invoice.id ||
      (commission.amount === invoice.amount_paid &&
        new Date(commission.createdAt).getTime() >=
          new Date(invoice.created * 1000).getTime() - 60 * 60 * 1000 &&
        new Date(commission.createdAt).getTime() <=
          new Date(invoice.created * 1000).getTime() + 60 * 60 * 1000),
  );
  return !existingCommission;
});

Sources: apps/web/scripts/customers/upheal/sync-stripe-invoices.ts:69-84

Tip

Stripe invoice synchronization scripts match existing commissions by checking invoice IDs or by verifying amount equality within a 60-minute window of the Stripe invoice creation timestamp to prevent duplicate batch creations. Sources: apps/web/scripts/customers/upheal/sync-stripe-invoices.ts:74-82

Partner and network referral commissions

Scheduled Cron Pipelines and Backfill Mechanics

Referral commissions evaluate multi-tier partner relationships and platform network payouts through automated cron jobs. The backfill endpoint at /api/cron/commissions/referrals/backfill inspects program application events to locate a referring partner (referredByPartnerId) and evaluates the referrer's referralReward configuration triggers. Depending on the trigger configuration, the cron dispatches batch jobs to the creation handler. Sources: apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts:75-130

Trigger TypeEligible Source EventEnqueued Job TargetSources
commissionThreshold / partnerApprovedProgram application or partner threshold approval/api/cron/commissions/referrals/create with programId and partnerIdapps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts:75-91
saleRecorded / commissionEarnedExisting sale commissions with pending, processed, or paid status/api/cron/commissions/referrals/create chunked in batches of 50 with sourceCommissionIdapps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts:93-130

The execution endpoint at /api/cron/commissions/referrals/create receives the payload and delegates execution to createReferralCommission. This function blocks self-referrals by checking whether the target partnerId matches the referredByPartnerId. Sources: apps/web/lib/partner-referrals/create-referral-commission.ts:30-35

if (partnerId && referredByPartnerId && partnerId === referredByPartnerId) {
  console.log(
    `Skipping referral commission creation for self-referral (partner ${partnerId}).`,
  );
  return null;
}

Sources: apps/web/lib/partner-referrals/create-referral-commission.ts:30-35

Warning

If a partner's ID matches their referredByPartnerId, the referral commission pipeline halts immediately and skips creation to prevent circular self-referral payouts. Sources: apps/web/lib/partner-referrals/create-referral-commission.ts:30-35

Network Program Payout Fees and Duration Limits

Network referral commissions governed by createNetworkReferralCommission calculate earnings based on a partner's payout event within the Dub network workspace (NETWORK_WORKSPACE_ID). The function validates that the payout does not originate from internal platform programs (NETWORK_PROGRAM_ID, ACME_PROGRAM_ID, or DEMO_PROGRAM_ID) and estimates a 3% average payout fee (payoutFeeEarned) from the total payout amount. Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:44-55

if (
  [NETWORK_PROGRAM_ID, ACME_PROGRAM_ID, DEMO_PROGRAM_ID].includes(
    payout.programId,
  )
) {
  console.error(
    `Payout ${payout.id} is from Network, Acme, or Demo program, skipping...`,
  );
  return null;
}
 
const payoutFeeEarned = Math.floor(payout.amount * 0.03); // assumes 3% average payout fee for all payouts

Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:44-55

The pipeline checks if the referring partner is actively enrolled in the network program and inspects prior commission history against maximum duration bounds. Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:199-211

if (firstCommission) {
  const durationMonths = differenceInMonths(
    new Date(),
    firstCommission.createdAt,
  );
 
  if (durationMonths >= NETWORK_REFERRAL_REWARD.maxDuration) {
    console.log(
      `Referrer partner ${partner.referredByPartnerId} has reached max duration for network bonus.`,
    );
    return null;
  }
}

Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:199-211

Note

Network referral bonuses enforce a strict maximum duration (NETWORK_REFERRAL_REWARD.maxDuration) calculated from the creation date of the very first network commission (firstCommission.createdAt). Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:199-211

When database unique constraint violations (P2002) occur during commission insertion due to race conditions on the invoiceId, the handler catches the error, logs the skip event, and prevents duplicate commission creation. Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:247-252

if (error.code === "P2002") {
  console.log(
    `Referral commission already exists for invoiceId ${commissionData.invoiceId}, skipping creation.`,
  );
  return null;
}

Sources: apps/web/lib/partner-referrals/create-network-referral-commission.ts:247-252

Legacy platform commission migration

Platform Importers and Currency Conversion

Migrating historical affiliate programs into Dub requires dedicated batch importers that process external payloads from Lemon Squeezy, PartnerStack, Tolt, Tapfiliate, and FirstPromoter. Each platform importer normalizes source-specific records into unified Prisma commission schemas while resolving associated partner links, customer identifiers, and lead events. Sources: apps/web/lib/lemonsqueezy/import-commissions.ts:263-281

Importers retrieve historical conversions, invoices, or commissions in paginated chunks and evaluate currency formats. Non-USD values are converted into standard amounts using real-time foreign exchange rates via convertCurrencyWithFxRates. Lemon Squeezy imports map affiliate invoices to saleEvents, falling back to firstOrderItemPrice when initial order subtotals are zero. PartnerStack, Tolt, Tapfiliate, and FirstPromoter handle manual line items or transactional sales by querying matching customer records and verifying deduplication windows. Sources: apps/web/lib/partnerstack/import-commissions.ts:177-185

Platform Importer SourceEntity IdentifierCore Deduplication Key / FallbackCurrency Handling & ConversionSources
Lemon SqueezyinvoiceId (ls_invoice_${id})invoiceId_programId unique indexResolved via resolveAmountUsd or fx ratesapps/web/lib/lemonsqueezy/import-commissions.ts:265-274
PartnerStackcommission.keyinvoiceId_programId or $\pm 1$ hour window checkconvertCurrencyWithFxRates if not USDapps/web/lib/partnerstack/import-commissions.ts:156-166
Tapfiliatecommission.idinvoiceId_programId using conversion IDconvertCurrencyWithFxRates for sub-amounts and earningsapps/web/lib/tapfiliate/import-commissions.ts:167-176
Toltcommission.idtransaction_id or $\pm 1$ hour window checkConverted via convertCurrencyWithFxRates against program currencyapps/web/lib/tolt/import-commissions.ts:178-199
Rewardfulcommission.idsale.id or Stripe customer ID $\pm 1$ hour window checkConverted via convertCurrencyWithFxRates for cents and earningsapps/web/lib/rewardful/import-commissions.ts:163-197
FirstPromoter${commission.id}${commission.id} or customer $\pm 1$ hour window checkconvertCurrencyWithFxRates for original sale amountapps/web/lib/firstpromoter/import-commissions.ts:178-209

Warning

Platforms like PartnerStack, Tolt, and Rewardful that omit native Stripe invoice identifiers during migration rely on a fallback window query checking for matching customer IDs and amounts within a $\pm 1$ hour timeframe of chargedAt to prevent duplicate imports during transition periods. Sources: apps/web/lib/partnerstack/import-commissions.ts:290-306

Sources: apps/web/lib/tolt/import-commissions.ts:251-264

Sources: apps/web/lib/rewardful/import-commissions.ts:237-252

Customer reattribution and clawback reconciliation

Transfer execution and adjustments

When a customer is reattributed between partners, the engine manages clawback adjustments and replacement commission generation. If paid commissions exist on the old customer record, their eventId and invoiceId fields are cleared to prevent constraint violations, and a negative adjustment commission with the type custom, description tracking_error, and earnings set to negative paid earnings is queued for the old partner using a unique execution key. Sources: apps/web/lib/api/customers/reattribute-customer.ts:729-771

if (plan.shouldClearIdentifiers && plan.paidCommissionIds.length > 0) {
  await prisma.commission.updateMany({
    where: {
      id: { in: plan.paidCommissionIds },
    },
    data: {
      eventId: null,
      invoiceId: null,
    },
  });
}
 
await runOnce({
  key: `reattribute-customer:${oldCustomerId}:clawback`,
  fn: async () => {
    const existingClawback = await prisma.commission.findFirst({
      where: {
        customerId: oldCustomerId,
        partnerId: oldPartnerId,
        programId,
        type: "custom",
        description: "tracking_error",
        earnings: { lt: 0 },
      },
      select: { id: true },
    });
 
    if (existingClawback) {
      return;
    }
 
    await queuePartnerCommissionCreation({
      event: "custom",
      partnerId: oldPartnerId,
      programId,
      customerId: oldCustomerId,
      amount: -plan.paidEarnings,
      quantity: 1,
      description: "tracking_error",
      skipWorkflow: true,
    });
  },
});

Sources: apps/web/lib/api/customers/reattribute-customer.ts:729-771

Replacement commission routing

New lead and sale events are queried for the incoming customer and filtered against existing commissions to prevent duplicates. Lead reattribution evaluates whether the old customer generated a paid lead while the new partner has no existing lead. Sale events match paid invoice identifiers or supplement invoiceless sales up to the prior paid count. Sources: apps/web/lib/api/customers/reattribute-customer.ts:773-821

const [newEvents, existingNewCommissions] = await Promise.all([
  getCustomerReattributeEvents(newCustomerId),
  prisma.commission.findMany({
    where: {
      customerId: newCustomerId,
      partnerId: newPartnerId,
      type: { in: ["lead", "sale"] },
    },
    select: {
      type: true,
      invoiceId: true,
    },
  }),
]);
 
const hasExistingLead = existingNewCommissions.some(
  (commission) => commission.type === "lead",
);
 
const leadEvent =
  plan.hasPaidLead && !hasExistingLead
    ? newEvents.find((event) => event.event === "lead") ?? null
    : null;
 
const paidInvoiceIds = new Set(plan.paidInvoiceIds);
const existingInvoiceIds = new Set(
  existingNewCommissions
    .map((commission) => commission.invoiceId)
    .filter((invoiceId): invoiceId is string => Boolean(invoiceId)),
);
const existingInvoicelessCount = existingNewCommissions.filter(
  (commission) => commission.type === "sale" && !commission.invoiceId,
).length;
 
const saleEvents = [
  ...newEvents.filter(
    (event) =>
      event.event === "sale" &&
      event.invoice_id &&
      paidInvoiceIds.has(event.invoice_id) &&
      !existingInvoiceIds.has(event.invoice_id),
  ),
  ...newEvents
    .filter((event) => event.event === "sale" && !event.invoice_id)
    .slice(
      0,
      Math.max(0, plan.paidInvoicelessCount - existingInvoicelessCount),
    ),
];

Sources: apps/web/lib/api/customers/reattribute-customer.ts:773-821

Caution

Customer reattribution requires running synchronization across both old and old/new partner totals via syncTotalCommissions immediately after queueing replacement events to prevent payout imbalances. Sources: apps/web/lib/api/customers/reattribute-customer.ts:864-874

Status tracking and balance aggregation

Activity log auditing

Dub tracks commission updates and status transitions by parsing activity change sets and recording structured snapshots. The trackCommissionStatusUpdate utility captures bulk operations such as aggregation, bans, and payouts by mapping commission records and generating activity logs through trackCommissionActivityLog with amount, earnings, and status fields. Sources: apps/web/lib/api/commissions/track-commission-update-activity-log.ts:79-99

export async function trackCommissionStatusUpdate({
  commissions,
  newStatus,
  ...baseInput
}: {
  commissions: Pick<Commission, "id" | "amount" | "earnings" | "status">[];
  newStatus: CommissionStatus;
} & Pick<TrackActivityLogInput, "workspaceId" | "programId" | "userId">) {
  if (commissions.length === 0) {
    return;
  }
 
  return trackCommissionActivityLog({
    ...baseInput,
    old: commissions,
    new: commissions.map((commission) => ({
      ...commission,
      status: newStatus,
    })),
  });
}

Sources: apps/web/lib/api/commissions/track-commission-update-activity-log.ts:79-99

Note

When processing payouts spanning multiple programs, trackCommissionStatusUpdatesByProgram groups commissions by program ID and resolves the correct workspace context before invoking individual status updates. Sources: apps/web/lib/api/commissions/track-commission-update-activity-log.ts:103-142

Redis stream events and synchronization

Partner commission totals are synchronized using Upstash Redis streams via syncTotalCommissions. When an event is published, the system attempts to dispatch through the Redis stream infrastructure. If a failure occurs, it falls back to a direct database transaction that aggregates earnings across non-zero, active commission states (pending, processed, and paid) to update the corresponding ProgramEnrollment record. Sources: apps/web/lib/api/partners/sync-total-commissions.ts:37-59

export const syncTotalCommissions = async ({
  partnerId,
  programId,
}: {
  partnerId: string;
  programId: string;
}) => {
  try {
    return await publishPartnerActivityEvent({
      programId,
      partnerId,
      eventType: "commission",
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    console.error(
      `[syncTotalCommissionsError]: Failed to sync total commissions for partner ${partnerId} in program ${programId}, falling back to direct database update...`,
      error,
    );
 
    return await aggregateAndUpdateTotalCommissions({ partnerId, programId });
  }
};

Sources: apps/web/lib/api/partners/sync-total-commissions.ts:37-59

Caution

Direct database aggregation inside aggregateAndUpdateTotalCommissions executes within a Prisma transaction (tx) to prevent race conditions when concurrent payouts or status updates modify enrollment totals. Sources: apps/web/lib/api/partners/sync-total-commissions.ts:11-34

Commission querying and analytics reporting

Filtering and pagination parameters

The commission retrieval engine processes requests through getCommissions by evaluating filters across identifiers, metadata queries, and pagination constraints. When an invoiceId is supplied, the query searches uniquely within the specified program and metadata parameters. Otherwise, pagination queries use cursor-based navigation validated against startingAfter or endingBefore parameters. Sources: apps/web/lib/api/commissions/get-commissions.ts:35-89

export async function getCommissions(filters: CommissionsFilters) {
  const {
    invoiceId,
    programId,
    partnerId,
    status,
    type,
    customerId,
    payoutId,
    bountySubmissionId,
    groupId,
    partnerTagId,
    fraudEventGroupId,
    start,
    end,
    interval,
    timezone,
    startingAfter,
    endingBefore,
    query,
  } = filters;
 
  // Metadata filter
  const parsedMetadataQuery = parseCommissionMetadataQuery(query);
  const metadataWhere = buildCommissionMetadataWhere(parsedMetadataQuery);
 
  // InvoiceId is unique within a program
  if (invoiceId) {
    return await prisma.commission.findMany({
      where: {
        invoiceId,
        programId,
        ...metadataWhere,
      },
      include: {
        ...commissionIncludes,
      },
    });
  }
 
  const paginationQuery = buildPaginationQuery(filters);
 
  // Validate the provided cursor ID
  const cursorId = startingAfter || endingBefore;
 
  if (cursorId) {
    const commission = await prisma.commission.findUnique({
      where: {
        id: cursorId,
      },
      select: {
        id: true,
        programId: true,
      },
    });

Sources: apps/web/lib/api/commissions/get-commissions.ts:35-89

Caution

If a cursor ID is provided in commission queries, the engine performs a validation lookup to ensure the record exists and belongs to the active programId, throwing a unprocessable_entity API error if the check fails. Sources: apps/web/lib/api/commissions/get-commissions.ts:80-97

Time-series aggregation endpoints

Time-series metrics are compiled via raw SQL queries in getCommissionsTimeseries using granularity mappings from sqlGranularityMap. The pipeline aggregates earnings grouped by time intervals, excluding duplicate, fraudulent, or canceled commission states while filtering out internal programs (ACME_PROGRAM_ID and DEMO_PROGRAM_ID) when no specific program is requested. Sources: apps/web/app/(ee)/api/admin/commissions/get-commissions-timeseries.ts:13-41

export async function getCommissionsTimeseries({
  programId,
  startDate,
  endDate,
  granularity,
  timezone,
}: {
  programId?: string;
  startDate: Date;
  endDate: Date;
  granularity: string;
  timezone: string;
}) {
  const { dateFormat, dateIncrement, startFunction, formatString } =
    sqlGranularityMap[granularity];
 
  const commissions = await prisma.$queryRaw<Commission[]>`
        SELECT 
          DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start, 
          SUM(earnings) AS commissions
        FROM Commission
        WHERE 
          createdAt >= ${startDate}
          AND createdAt < ${endDate}
          AND status IN ("pending", "processed", "paid")
          AND ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId NOT IN (${ACME_PROGRAM_ID}, ${DEMO_PROGRAM_ID})`}
        GROUP BY start
        ORDER BY start ASC;`;

Sources: apps/web/app/ee/api/admin/commissions/get-commissions-timeseries.ts:13-41

Was this page helpful?