From 3bbfb45928c52ebd3ef8750a5e1262c6dfbc4ec5 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 28 May 2026 22:08:37 -0700 Subject: [PATCH 01/34] Add workflows/create-partner-commission --- apps/web/lib/api/conversions/track-sale.ts | 69 +++++++++---------- .../lib/partners/create-partner-commission.ts | 50 +++++++------- 2 files changed, 57 insertions(+), 62 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 604e32d5aa7..6d268d8a8f9 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -20,6 +20,7 @@ import { WorkspaceProps, } from "@/lib/types"; import { redis } from "@/lib/upstash"; +import { publishWorkspaceClicksUsageEvent } from "@/lib/upstash/redis-streams/workspace-clicks-usage"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformLeadEventData, @@ -442,54 +443,34 @@ const _trackSale = async ({ waitUntil( (async () => { - const [_sale, link] = await Promise.all([ - // Record sale event - recordSale({ - ...saleData, - timestamp: undefined, - }), - - // Update link conversions, sales, and saleAmount - prisma.link.update({ - where: { - id: saleData.link_id, - }, - data: { - ...(firstConversionFlag && { - conversions: { - increment: 1, - }, - lastConversionAt: new Date(), - }), - sales: { + // Update link conversions, sales, and saleAmount + const link = await prisma.link.update({ + where: { + id: saleData.link_id, + }, + data: { + ...(firstConversionFlag && { + conversions: { increment: 1, }, - saleAmount: { - increment: amount, - }, - }, - include: includeTags, - }), - - // Update workspace events usage - prisma.project.update({ - where: { - id: workspace.id, + lastConversionAt: new Date(), + }), + sales: { + increment: 1, }, - data: { - usage: { - increment: 1, - }, + saleAmount: { + increment: amount, }, - }), - ]); + }, + include: includeTags, + }); let createdCommission: | Awaited> | undefined = undefined; - // Create partner commission and execute workflows if (link.programId && link.partnerId) { + // TODO: move to workflows/create-partner-commission createdCommission = await createPartnerCommission({ event: "sale", programId: link.programId, @@ -539,6 +520,7 @@ const _trackSale = async ({ eventType: "sale", }), + // TODO: move to workflows/create-partner-commission webhookPartner && detectAndRecordFraudEvent({ program: { id: link.programId }, @@ -556,6 +538,11 @@ const _trackSale = async ({ } await Promise.allSettled([ + recordSale({ + ...saleData, + timestamp: undefined, + }), + sendWorkspaceWebhook({ trigger: "sale.created", data: transformSaleEventData({ @@ -583,6 +570,12 @@ const _trackSale = async ({ }), ] : []), + + publishWorkspaceClicksUsageEvent({ + linkId: link.id, + workspaceId: workspace.id, + timestamp: new Date().toISOString(), + }), ]); // Update customer stats + program/partner associations diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index 268284d897a..a8c1c5c0be5 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -136,11 +136,11 @@ export const createPartnerCommission = async ({ // if there is no reward, skip commission creation if (!reward) { - console.log( - `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`, - ); + const outputLog = `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -158,11 +158,11 @@ export const createPartnerCommission = async ({ if (firstCommission) { // if first commission is fraud or canceled, skip commission creation if (["fraud", "canceled"].includes(firstCommission.status)) { - console.log( - `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`, - ); + const outputLog = `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -170,12 +170,11 @@ export const createPartnerCommission = async ({ // for lead events, we need to check if the partner has already been issued a lead reward for this customer if (event === "lead") { - console.log( - `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`, - ); - + const outputLog = `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -201,11 +200,11 @@ export const createPartnerCommission = async ({ typeof originalReward?.maxDuration === "number" && originalReward.maxDuration === 0 ) { - console.log( - `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`, - ); + const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -217,12 +216,12 @@ export const createPartnerCommission = async ({ if (typeof reward?.maxDuration === "number") { // One-time sale reward (maxDuration === 0) if (reward.maxDuration === 0) { - console.log( - `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`, - ); + const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -236,12 +235,12 @@ export const createPartnerCommission = async ({ ); if (subscriptionDurationMonths >= reward.maxDuration) { - console.log( - `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`, - ); + const outputLog = `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`; + console.log(outputLog); return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; @@ -310,9 +309,9 @@ export const createPartnerCommission = async ({ }, }); - console.log( - `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}: ${prettyPrint(commission)}`, - ); + const outputLog = `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`; + console.log(outputLog); + console.log(prettyPrint(commission)); const webhookPartner = constructWebhookPartner(programEnrollment, { // check links metrics @@ -432,16 +431,18 @@ export const createPartnerCommission = async ({ return { commission, + outputLog, programEnrollment, webhookPartner, }; } catch (error) { - console.error("Error creating commission", error); + const outputLog = `Error creating commission - ${error.message}`; + console.error(outputLog); // only log to Slack if the error is not a unique constraint violation if (error.code !== "P2002") { await log({ - message: `Error creating commission - ${error.message}`, + message: outputLog, type: "errors", mention: true, }); @@ -449,6 +450,7 @@ export const createPartnerCommission = async ({ return { commission: null, + outputLog, programEnrollment, webhookPartner: constructWebhookPartner(programEnrollment), }; From b43272aae589194b5ff877a542cf3bca733e2513 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 14:42:15 +0530 Subject: [PATCH 02/34] Improve QStash workflow triggering with retries, labels, and Axiom logging. --- .../api/partner-profile/referrals/route.ts | 7 +- .../create-partner-commission/route.ts | 54 ++++++++ .../api/workflows/partner-approved/route.ts | 119 +++++++--------- .../actions/partners/bulk-approve-partners.ts | 7 +- .../partners/applications/approve-partner.ts | 7 +- apps/web/lib/cron/qstash-workflow-logger.ts | 36 ----- apps/web/lib/cron/qstash-workflow.ts | 127 ++++++++++++------ 7 files changed, 195 insertions(+), 162 deletions(-) create mode 100644 apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts delete mode 100644 apps/web/lib/cron/qstash-workflow-logger.ts diff --git a/apps/web/app/(ee)/api/partner-profile/referrals/route.ts b/apps/web/app/(ee)/api/partner-profile/referrals/route.ts index 3f0e0722c67..a8800b09f36 100644 --- a/apps/web/app/(ee)/api/partner-profile/referrals/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/referrals/route.ts @@ -2,7 +2,7 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { triggerWorkflows } from "@/lib/cron/qstash-workflow"; +import { triggerQStashWorkflow } from "@/lib/cron/qstash-workflow"; import { getNetworkReferralsQuerySchema, networkReferralSchema, @@ -56,8 +56,9 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { console.log( "Program enrollment created in the last 1 min, most likely it's a new partner", ); - await triggerWorkflows({ - workflowId: "partner-approved", + await triggerQStashWorkflow({ + workflowType: "partner-approved", + workflowLabel: partner.id, body: { programId: NETWORK_PROGRAM_ID, partnerId: partner.id, diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts new file mode 100644 index 00000000000..84d841825ca --- /dev/null +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -0,0 +1,54 @@ +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { createWorkflowLogger } from "@/lib/cron/qstash-workflow-logger"; +import { ProgramPartnerLinkSchema } from "@/lib/zod/schemas/programs"; +import { serve } from "@upstash/workflow/nextjs"; +import * as z from "zod/v4"; + +const payloadSchema = z.object({ + programId: z.string(), + partnerId: z.string(), + userId: z.string(), +}); + +type Payload = z.infer; + +// POST /api/workflows/partner-approved +export const { POST } = serve( + async (context) => { + const input = context.requestPayload; + const { programId, partnerId, userId } = input; + + const logger = createWorkflowLogger({ + workflowId: "partner-approved", + workflowRunId: context.workflowRunId, + }); + + const { + program, + partner, + links: existingPartnerLinks, + ...programEnrollment + } = await getProgramEnrollmentOrThrow({ + programId, + partnerId, + include: { + program: true, + partner: true, + links: true, + }, + }); + + const { groupId } = programEnrollment; + + const allPartnerLinks = + ProgramPartnerLinkSchema.array().parse(existingPartnerLinks); + + // Step 1: Create partner default links + await context.run("create-default-links", async () => {}); + }, + { + initialPayloadParser: (requestPayload) => { + return payloadSchema.parse(JSON.parse(requestPayload)); + }, + }, +); diff --git a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts index 134570b8168..136c388d087 100644 --- a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts +++ b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts @@ -2,8 +2,9 @@ import { createPartnerDefaultLinks } from "@/lib/api/partners/create-partner-def import { getGroupRewardsAndBounties } from "@/lib/api/partners/get-group-rewards-and-bounties"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { logger } from "@/lib/axiom/server"; import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions"; -import { createWorkflowLogger } from "@/lib/cron/qstash-workflow-logger"; +import { getWorkflowConfig } from "@/lib/cron/qstash-workflow"; import { generateDiscountCodeForPartner } from "@/lib/discounts/generate-discount-code-for-partner"; import { createReferralCommission } from "@/lib/partner-referrals/create-referral-commission"; import { polyfillSocialMediaFields } from "@/lib/social-utils"; @@ -18,13 +19,13 @@ import { NETWORK_PROGRAM_ID } from "@dub/utils"; import { serve } from "@upstash/workflow/nextjs"; import * as z from "zod/v4"; -const payloadSchema = z.object({ +const inputSchema = z.object({ programId: z.string(), partnerId: z.string(), userId: z.string(), }); -type Payload = z.infer; +type Input = z.infer; /** * Partner Approved Workflow @@ -51,16 +52,11 @@ type Payload = z.infer; */ // POST /api/workflows/partner-approved -export const { POST } = serve( +export const { POST } = serve( async (context) => { const input = context.requestPayload; const { programId, partnerId, userId } = input; - const logger = createWorkflowLogger({ - workflowId: "partner-approved", - workflowRunId: context.workflowRunId, - }); - const { program, partner, @@ -83,15 +79,10 @@ export const { POST } = serve( // Step 1: Create partner default links await context.run("create-default-links", async () => { - logger.info({ - message: "Started executing workflow step 'create-default-links'.", - data: input, - }); - if (!groupId) { - logger.error({ - message: `The partner ${partnerId} is not associated with any group.`, - }); + console.error( + `The partner ${partnerId} is not associated with any group.`, + ); return; } @@ -107,9 +98,7 @@ export const { POST } = serve( }); if (partnerGroupDefaultLinks.length === 0) { - logger.error({ - message: `Group ${groupId} does not have any default links.`, - }); + console.error(`Group ${groupId} does not have any default links.`); return; } @@ -123,9 +112,9 @@ export const { POST } = serve( } if (partnerGroupDefaultLinks.length === 0) { - logger.error({ - message: `Already created default links for partner ${partnerId}.`, - }); + console.error( + `Already created default links for partner ${partnerId}.`, + ); return; } @@ -162,7 +151,7 @@ export const { POST } = serve( userId, }); - logger.info({ + console.info({ message: `Created ${partnerLinks.length} partner default links.`, data: partnerLinks.map(({ id, url, shortLink }) => ({ id, @@ -183,11 +172,6 @@ export const { POST } = serve( // Step 2: Auto-provision discount code if enabled await context.run("create-discount-codes", async () => { - logger.info({ - message: "Started executing workflow step 'create-discount-codes'.", - data: input, - }); - await generateDiscountCodeForPartner({ workspaceId: program.workspaceId, partner: { @@ -200,15 +184,10 @@ export const { POST } = serve( // Step 3: Send email to partner application approved await context.run("send-email", async () => { - logger.info({ - message: "Started executing workflow step 'send-email'.", - data: input, - }); - if (!groupId) { - logger.error({ - message: `The partner ${partnerId} is not associated with any group.`, - }); + console.error( + `The partner ${partnerId} is not associated with any group.`, + ); return; } @@ -236,17 +215,12 @@ export const { POST } = serve( }); if (partnerUsers.length === 0) { - logger.info({ - message: `No partner users found for partner ${partnerId} to send email notification.`, - }); + console.log( + `No partner users found for partner ${partnerId} to send email notification.`, + ); return; } - logger.info({ - message: `Sending email notification to ${partnerUsers.length} partner users.`, - data: partnerUsers, - }); - const rewardsAndBounties = await getGroupRewardsAndBounties({ programId, groupId: programEnrollment.groupId || program.defaultGroupId, @@ -279,7 +253,7 @@ export const { POST } = serve( ); if (data) { - logger.info({ + console.info({ message: `Sent emails to ${partnerUsers.length} partner users.`, data: data, }); @@ -292,11 +266,6 @@ export const { POST } = serve( // Step 4: Send webhook to workspace await context.run("send-webhook", async () => { - logger.info({ - message: "Started executing workflow step 'send-webhook'.", - data: input, - }); - const partnerPlatforms = await prisma.partnerPlatform.findMany({ where: { partnerId, @@ -326,20 +295,10 @@ export const { POST } = serve( trigger: "partner.enrolled", data: enrolledPartner, }); - - logger.info({ - message: `Sent "partner.enrolled" webhook to workspace ${workspace.id}.`, - }); }); // Step 5: Trigger draft bounty submission creation await context.run("trigger-draft-bounty-submission-creation", async () => { - logger.info({ - message: - "Started executing workflow step 'trigger-draft-bounty-submission-creation'.", - data: input, - }); - await triggerDraftBountySubmissionCreation({ programId, partnerIds: [partnerId], @@ -348,12 +307,6 @@ export const { POST } = serve( // Step 6: Execute Dub workflows using the “partnerEnrolled” trigger. await context.run("execute-workflows", async () => { - logger.info({ - message: - "Started executing workflow step 'execute-workflows' for the trigger 'partnerEnrolled'.", - data: input, - }); - await executeWorkflows({ trigger: "partnerEnrolled", identity: { @@ -366,12 +319,6 @@ export const { POST } = serve( // Step 7: Create referral commission if enabled await context.run("create-referral-commission", async () => { - logger.info({ - message: - "Started executing workflow step 'create-referral-commission'.", - data: input, - }); - await createReferralCommission({ partnerId, programId, @@ -380,7 +327,31 @@ export const { POST } = serve( }, { initialPayloadParser: (requestPayload) => { - return payloadSchema.parse(JSON.parse(requestPayload)); + return inputSchema.parse(JSON.parse(requestPayload)); + }, + failureFunction: async ({ + context, + failStatus, + failResponse, + failHeaders, + }) => { + const { correlation } = getWorkflowConfig({ + workflowType: "partner-approved", + body: context.requestPayload, + }); + + logger.error("workflow.failed", { + service: "qstash", + event: "workflow.failed", + workflowType: "partner-approved", + workflowRunId: context.workflowRunId, + failStatus, + failResponse, + failHeaders, + correlation, + }); + + await logger.flush(); }, }, ); diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts index e52dafae35d..0eb2e711651 100644 --- a/apps/web/lib/actions/partners/bulk-approve-partners.ts +++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts @@ -4,7 +4,7 @@ import { trackActivityLog } from "@/lib/api/activity-log/track-activity-log"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { trackApplicationEvents } from "@/lib/application-events/update-application-event"; -import { triggerWorkflows } from "@/lib/cron/qstash-workflow"; +import { triggerQStashWorkflow } from "@/lib/cron/qstash-workflow"; import { throwIfPartnersLimitExceeded } from "@/lib/partners/throw-if-partners-limit-exceeded"; import { bulkApprovePartnersSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; @@ -142,9 +142,10 @@ export const bulkApprovePartnersAction = authActionClient })), ), - triggerWorkflows( + triggerQStashWorkflow( updatedEnrollments.map(({ partnerId, programId }) => ({ - workflowId: "partner-approved", + workflowType: "partner-approved", + workflowLabel: partnerId, body: { programId, partnerId, diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts index a22d30ccf63..2cc40458a97 100644 --- a/apps/web/lib/api/partners/applications/approve-partner.ts +++ b/apps/web/lib/api/partners/applications/approve-partner.ts @@ -3,7 +3,7 @@ import { prisma } from "@dub/prisma"; import { ProgramEnrollmentStatus } from "@dub/prisma/client"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; -import { triggerWorkflows } from "../../../cron/qstash-workflow"; +import { triggerQStashWorkflow } from "../../../cron/qstash-workflow"; import { throwIfPartnersLimitExceeded } from "../../../partners/throw-if-partners-limit-exceeded"; import { approvePartnerSchema } from "../../../zod/schemas/partners"; import { trackActivityLog } from "../../activity-log/track-activity-log"; @@ -150,8 +150,9 @@ export async function approvePartner({ partnerIds: [partnerId], }), - triggerWorkflows({ - workflowId: "partner-approved", + triggerQStashWorkflow({ + workflowType: "partner-approved", + workflowLabel: partnerId, body: { programId, partnerId, diff --git a/apps/web/lib/cron/qstash-workflow-logger.ts b/apps/web/lib/cron/qstash-workflow-logger.ts deleted file mode 100644 index a88195c74ab..00000000000 --- a/apps/web/lib/cron/qstash-workflow-logger.ts +++ /dev/null @@ -1,36 +0,0 @@ -type LogData = { - message: string; - data?: Record; -}; - -type ErrorData = { - message: string; - error?: any; - data?: Record; -}; - -// Create a context-aware logger factory -export function createWorkflowLogger({ - workflowId, - workflowRunId, -}: { - workflowId: string; - workflowRunId: string; -}) { - return { - info: ({ message, data }: LogData) => { - console.info(`[Upstash Workflow:${workflowId}] ${message}`, { - workflowRunId, - ...data, - }); - }, - - error: ({ message, error, data }: ErrorData) => { - console.error(`[Upstash Workflow:${workflowId}] ${message}`, { - workflowRunId, - error: error?.message || error, - ...data, - }); - }, - }; -} diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index e012f736f90..36fea94fea4 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -1,63 +1,104 @@ -import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; +import { logger } from "@/lib/axiom/server"; +import { APP_DOMAIN } from "@dub/utils"; import { Client } from "@upstash/workflow"; const client = new Client({ - baseUrl: "https://qstash-us-east-1.upstash.io", + baseUrl: process.env.QSTASH_URL || "https://qstash-us-east-1.upstash.io", token: process.env.QSTASH_TOKEN || "", }); -const WORKFLOW_RETRIES = 3; -const WORKFLOW_PARALLELISM = 20; - -type WorkflowIds = "partner-approved"; +type WorkflowType = "partner-approved" | "create-partner-commission"; interface QStashWorkflow { - workflowId: WorkflowIds; - body?: Record; + workflowType: WorkflowType; + workflowLabel: string; + body: Record; } // Run workflows -export async function triggerWorkflows( +export async function triggerQStashWorkflow( input: QStashWorkflow | QStashWorkflow[], ) { - try { - const workflows = Array.isArray(input) ? input : [input]; - - const results = await client.trigger( - workflows.map((workflow) => ({ - url: `${APP_DOMAIN_WITH_NGROK}/api/workflows/${workflow.workflowId}`, - body: workflow.body, - retries: WORKFLOW_RETRIES, - flowControl: { - key: workflow.workflowId, - parallelism: WORKFLOW_PARALLELISM, - }, - })), - ); - - if (process.env.NODE_ENV === "development") { - console.debug("[Upstash] Workflows triggered", { - count: workflows.length, - ids: workflows.map((w) => w.workflowId), - results, - }); + const workflows = Array.isArray(input) ? input : [input]; + const maxRetries = 3; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await client.trigger( + workflows.map((workflow) => ({ + url: `${APP_DOMAIN}/api/workflows/${workflow.workflowType}`, + body: workflow.body, + label: workflow.workflowLabel, + retries: 5, + flowControl: { + key: workflow.workflowType, + parallelism: 15, + }, + })), + ); + + return response; + } catch (error) { + console.error("QStash workflow trigger failed", { error, workflows }); + + if (attempt < maxRetries) { + await new Promise((resolve) => + setTimeout(resolve, 1000 * Math.pow(2, attempt)), + ); + continue; + } + + for (const workflow of workflows) { + const { correlation } = getWorkflowConfig(workflow); + + logger.error("workflow.trigger_failed", { + service: "qstash", + event: "workflow.trigger_failed", + workflowType: workflow.workflowType, + errorName: error instanceof Error ? error.name : undefined, + errorStack: error instanceof Error ? error.stack : undefined, + correlation, + }); + } + + await logger.flush(); + + return null; } + } +} - return results; - } catch (error) { - const message = - error instanceof Error ? error.message : JSON.stringify(error); +export function getWorkflowConfig({ + workflowType, + body, +}: Omit): { + correlation: Record; +} { + switch (workflowType) { + case "partner-approved": + return { + correlation: { + programId: body.programId, + partnerId: body.partnerId, + userId: body.userId, + }, + }; - console.error("[Upstash] Failed to trigger workflows", { - error: message, - input, - }); + case "create-partner-commission": { + const saleEvent = body.saleEvent as Record; - await log({ - message: `[Upstash] Failed to trigger QStash workflows. ${message}`, - type: "errors", - }); + return { + correlation: { + linkId: saleEvent.link_id, + eventId: saleEvent.event_id, + customerId: saleEvent.customer_id, + }, + }; + } - return null; + default: + return { + correlation: {}, + }; } } From ce77e407b77dbcba5808e3bb13419ca011ce61df Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 16:18:42 +0530 Subject: [PATCH 03/34] Move partner commission creation into create-partner-commission workflow. --- apps/web/app/(ee)/api/cron/utils.ts | 5 + .../webhook/checkout-session-completed.ts | 47 +- .../integration/webhook/invoice-paid.ts | 40 +- .../create-partner-commission/route.ts | 540 +++++++++++- .../partners/create-manual-commission.ts | 10 +- apps/web/lib/api/conversions/track-lead.ts | 38 +- apps/web/lib/api/conversions/track-sale.ts | 38 +- .../bounty/api/approve-bounty-submission.ts | 3 + .../lib/integrations/shopify/create-sale.ts | 40 +- .../lib/integrations/shopify/process-order.ts | 6 +- .../lib/partners/create-partner-commission.ts | 793 ++++++++---------- apps/web/lib/zod/schemas/commissions.ts | 20 +- 12 files changed, 1000 insertions(+), 580 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/utils.ts b/apps/web/app/(ee)/api/cron/utils.ts index a3f24263c4d..fc5258504cf 100644 --- a/apps/web/app/(ee)/api/cron/utils.ts +++ b/apps/web/app/(ee)/api/cron/utils.ts @@ -11,3 +11,8 @@ export function logAndRespond( console[logLevel](message); return new Response(message, { status }); } + +export function logAndReturn(value: T): T { + console.log(value); + return value; +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 1627cd24778..a48829dae01 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -1,7 +1,6 @@ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { createId } from "@/lib/api/create-id"; -import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; @@ -24,7 +23,7 @@ import { } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { Customer, Project } from "@dub/prisma/client"; -import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils"; +import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; @@ -477,10 +476,8 @@ export async function checkoutSessionCompleted( }), ]); - // for program links - let createdCommission: - | Awaited> - | undefined = undefined; + let result: Awaited> | undefined = + undefined; if (link && link.programId && link.partnerId) { const productId = await getCheckoutSessionProductId({ @@ -489,7 +486,7 @@ export async function checkoutSessionCompleted( mode, }); - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, @@ -512,8 +509,6 @@ export async function checkoutSessionCompleted( }, }); - const { webhookPartner, programEnrollment } = createdCommission; - waitUntil( Promise.allSettled([ executeWorkflows({ @@ -538,19 +533,19 @@ export async function checkoutSessionCompleted( eventType: "sale", }), - webhookPartner && - detectAndRecordFraudEvent({ - program: { id: link.programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion: firstConversionFlag, - }, - link: pick(link, ["id"]), - click: pick(saleData, ["url", "referer"]), - event: { id: saleData.event_id }, - }), + // webhookPartner && + // detectAndRecordFraudEvent({ + // program: { id: link.programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: { + // ...pick(customer, ["id", "email", "name"]), + // isFirstConversion: firstConversionFlag, + // }, + // link: pick(link, ["id"]), + // click: pick(saleData, ["url", "referer"]), + // event: { id: saleData.event_id }, + // }), ]), ); } @@ -565,7 +560,7 @@ export async function checkoutSessionCompleted( clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata: null, }), }), @@ -703,12 +698,12 @@ async function attributeViaPromoCode({ (async () => { const linkUpdated = await incrementLinkLeads(link.id); - let createdCommission: + let result: | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "lead", programId: link.programId, partnerId: link.partnerId, @@ -756,7 +751,7 @@ async function attributeViaPromoCode({ eventName: "Checkout session completed", link: linkUpdated, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata: null, }), }), diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 64bf076b016..c58c405018c 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -1,6 +1,5 @@ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; -import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; @@ -12,7 +11,7 @@ import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformSaleEventData } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; -import { nanoid, pick } from "@dub/utils"; +import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; @@ -239,12 +238,11 @@ export async function invoicePaid( ]); // for program links - let createdCommission: - | Awaited> - | undefined = undefined; + let result: Awaited> | undefined = + undefined; if (link.programId && link.partnerId) { - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, @@ -267,8 +265,6 @@ export async function invoicePaid( }, }); - const { webhookPartner, programEnrollment } = createdCommission; - waitUntil( Promise.allSettled([ executeWorkflows({ @@ -293,19 +289,19 @@ export async function invoicePaid( eventType: "sale", }), - webhookPartner && - detectAndRecordFraudEvent({ - program: { id: link.programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion: firstConversionFlag, - }, - link: pick(link, ["id"]), - click: pick(saleData, ["url", "referer"]), - event: { id: saleData.event_id }, - }), + // webhookPartner && + // detectAndRecordFraudEvent({ + // program: { id: link.programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: { + // ...pick(customer, ["id", "email", "name"]), + // isFirstConversion: firstConversionFlag, + // }, + // link: pick(link, ["id"]), + // click: pick(saleData, ["url", "referer"]), + // event: { id: saleData.event_id }, + // }), ]), ); } @@ -320,7 +316,7 @@ export async function invoicePaid( clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata: null, }), }), diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 84d841825ca..55c7a1c06c3 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -1,54 +1,530 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { createId } from "@/lib/api/create-id"; +import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commission"; +import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { createWorkflowLogger } from "@/lib/cron/qstash-workflow-logger"; -import { ProgramPartnerLinkSchema } from "@/lib/zod/schemas/programs"; +import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; +import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { logger } from "@/lib/axiom/server"; +import { getWorkflowConfig } from "@/lib/cron/qstash-workflow"; +import { constructWebhookPartner } from "@/lib/partners/constuct-webhook-partner"; +import { determinePartnerReward } from "@/lib/partners/determine-partner-reward"; +import { getRewardAmount } from "@/lib/partners/get-reward-amount"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; +import { RewardProps } from "@/lib/types"; +import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; +import { + CommissionWebhookSchema, + createPartnerCommissionSchema, +} from "@/lib/zod/schemas/commissions"; +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { prisma } from "@dub/prisma"; +import { Commission, CommissionStatus } from "@dub/prisma/client"; +import { currencyFormatter, log, prettyPrint, toCentsNumber } from "@dub/utils"; +import { WorkflowRetryAfterError } from "@upstash/workflow"; import { serve } from "@upstash/workflow/nextjs"; +import { differenceInMonths } from "date-fns"; import * as z from "zod/v4"; +import { logAndReturn } from "../../cron/utils"; -const payloadSchema = z.object({ - programId: z.string(), - partnerId: z.string(), - userId: z.string(), -}); +type Input = z.infer; -type Payload = z.infer; +type StepFunctionInput = Input & { + programEnrollment: Awaited>; + isFirstCommission?: boolean; +}; -// POST /api/workflows/partner-approved -export const { POST } = serve( +type StepCreateCommissionOutput = { + commission: Pick | null; + outputLog: string; + isFirstCommission?: boolean; +}; + +// POST /api/workflows/create-partner-commission +export const { POST } = serve( async (context) => { const input = context.requestPayload; - const { programId, partnerId, userId } = input; - - const logger = createWorkflowLogger({ - workflowId: "partner-approved", - workflowRunId: context.workflowRunId, - }); + const { event, partnerId, programId } = input; - const { - program, - partner, - links: existingPartnerLinks, - ...programEnrollment - } = await getProgramEnrollmentOrThrow({ - programId, + const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, + programId, include: { - program: true, - partner: true, links: true, + partner: true, + partnerGroup: true, + ...(event === "click" && { clickReward: true }), + ...(event === "lead" && { leadReward: true }), + ...(event === "sale" && { saleReward: true }), }, }); - const { groupId } = programEnrollment; - - const allPartnerLinks = - ProgramPartnerLinkSchema.array().parse(existingPartnerLinks); + // Step 1: Create commission + const { commission, isFirstCommission } = await context.run( + "create-commission", + async () => { + return await stepCreateCommission({ + ...input, + // @ts-ignore // Fix this + programEnrollment, + }); + }, + ); - // Step 1: Create partner default links - await context.run("create-default-links", async () => {}); + if (commission) { + // Step 2: Run side effects + await context.run("run-side-effects", async () => { + return await stepRunSideEffects({ + ...input, + // @ts-ignore // Fix this + programEnrollment, + isFirstCommission, + }); + }); + } }, { initialPayloadParser: (requestPayload) => { - return payloadSchema.parse(JSON.parse(requestPayload)); + return createPartnerCommissionSchema.parse(JSON.parse(requestPayload)); + }, + failureFunction: async ({ + context, + failStatus, + failResponse, + failHeaders, + }) => { + const { correlation } = getWorkflowConfig({ + workflowType: "create-partner-commission", + body: context.requestPayload, + }); + + logger.error("workflow.failed", { + service: "qstash", + event: "workflow.failed", + workflowType: "create-partner-commission", + workflowRunId: context.workflowRunId, + failStatus, + failResponse, + failHeaders, + correlation, + }); + + await logger.flush(); }, }, ); + +async function stepCreateCommission( + input: StepFunctionInput, +): Promise { + let { + event, + partnerId, + programId, + linkId, + customerId, + eventId, + invoiceId, + amount, + quantity, + currency, + description, + createdAt, + userId, + context, + programEnrollment, + } = input; + + let earnings = 0; + let reward: RewardProps | null = null; + let status: CommissionStatus = "pending"; + let firstCommission: Pick< + Commission, + "rewardId" | "status" | "createdAt" + > | null = null; + + if (event === "custom") { + earnings = amount ?? 0; + amount = 0; + } else { + if (["lead", "sale"].includes(event) && customerId) { + firstCommission = await prisma.commission.findFirst({ + where: { + partnerId, + customerId, + type: event, + }, + orderBy: { + createdAt: "asc", + }, + select: { + rewardId: true, + status: true, + createdAt: true, + }, + }); + + const subscriptionStartDate = + event === "sale" ? firstCommission?.createdAt ?? new Date() : undefined; + + const subscriptionDurationMonths = subscriptionStartDate + ? differenceInMonths( + createdAt ?? new Date(), // account for custom commission creation date + subscriptionStartDate, + ) + : 0; + + context = { + ...context, + customer: { + ...context?.customer, + subscriptionStartDate, + subscriptionDurationMonths, + }, + ...(event === "sale" && { + sale: { + ...context?.sale, + type: firstCommission ? "recurring" : "new", + }, + }), + }; + } + + reward = determinePartnerReward({ + event, + programEnrollment, + ...(context ? { context } : {}), + }); + + // if there is no reward, skip commission creation + if (!reward) { + return logAndReturn({ + commission: null, + outputLog: `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`, + }); + } + + // for click events, it's super simple – just multiply the reward amount by the quantity + if (event === "click") { + earnings = getRewardAmount(reward) * quantity; + + // for lead and sale events, we need to check if this partner-customer combination was recorded already (for deduplication) + // for sale rewards specifically, we also need to check: + // 1. if the partner has reached the max duration for the reward (if applicable) + // 2. if the previous commission were marked as fraud or canceled + } else { + if (firstCommission) { + // if first commission is fraud or canceled, skip commission creation + if (["fraud", "canceled"].includes(firstCommission.status)) { + return logAndReturn({ + commission: null, + outputLog: `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`, + }); + } + + // 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, + }, + }); + + if ( + typeof originalReward?.maxDuration === "number" && + originalReward.maxDuration === 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, + outputLog: `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`, + }); + } + } + } + } + } + + // for lead events, we just multiply the reward amount by the quantity + if (event === "lead") { + earnings = getRewardAmount(reward) * quantity; + // for sale events, we need to calculate the earnings based on the sale amount + } else { + earnings = calculateSaleEarnings({ + reward, + sale: { quantity, amount: amount ?? 0 }, + }); + } + } + } + + // skip commission creation if the earnings is zero + if (earnings === 0) { + return logAndReturn({ + commission: null, + outputLog: `Partner ${partnerId} has zero earnings for ${event} event, skipping commission creation...`, + }); + } + + try { + const commission = await prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + programId, + partnerId, + rewardId: reward?.id, + customerId, + linkId, + eventId: eventId || null, // empty string should convert to null + invoiceId: invoiceId || null, // empty string should convert to null + userId, + quantity, + amount, + type: event, + currency, + earnings, + status, + description, + ...(createdAt && { createdAt }), // TODO: Check this + }, + }); + + console.log(prettyPrint(commission)); + + return logAndReturn({ + commission: { + id: commission.id, + }, + isFirstCommission: firstCommission === null, + outputLog: `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`, + }); + } catch (error) { + const outputLog = `Error creating commission - ${error.message}`; + + // only log to Slack if the error is not a unique constraint violation + if (error.code !== "P2002") { + await log({ + message: outputLog, + type: "errors", + mention: true, + }); + + // Retry after 5 seconds + throw new WorkflowRetryAfterError(error.message, "5s"); + } + + return logAndReturn({ + commission: null, + outputLog, + }); + } +} + +async function stepRunSideEffects( + input: StepFunctionInput & { commission: Pick }, +) { + const { + commission: _commission, + programEnrollment, + isFirstCommission, + programId, + partnerId, + userId, + linkId, + skipWorkflow, + } = input; + + const commission = await prisma.commission.findUnique({ + where: { + id: _commission.id, + }, + include: { + customer: true, + }, + }); + + if (!commission) { + return logAndReturn({ + commission: null, + outputLog: `Commission ${_commission.id} not found, skipping side effects...`, + }); + } + + const webhookPartner = constructWebhookPartner(programEnrollment, { + totalCommissions: + toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, + }); + + const program = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + select: { + id: true, + name: true, + slug: true, + logo: true, + supportEmail: true, + workspace: { + select: { + id: true, + slug: true, + name: true, + webhookEnabled: true, + }, + }, + // if no partner group is found, need to fetch default group to fallback to + ...(!programEnrollment.partnerGroup && { + groups: { + select: { + holdingPeriodDays: true, + }, + where: { + slug: DEFAULT_PARTNER_GROUP.slug, + }, + }, + }), + }, + }); + + const { workspace } = program; + const { customer } = commission; + const isClawback = commission.earnings < 0; + const shouldTriggerWorkflow = !isClawback && !skipWorkflow; + + await Promise.allSettled([ + sendWorkspaceWebhook({ + workspace, + trigger: "commission.created", + data: CommissionWebhookSchema.parse({ + ...commission, + partner: webhookPartner, + }), + }), + + sendPartnerPostback({ + partnerId, + event: "commission.created", + data: { + ...commission, + customer: commission.customer, + }, + }), + + syncTotalCommissions({ + partnerId, + programId, + }), + + !isClawback && + notifyPartnerCommission({ + program, + // fallback to default group if no partner group is found + group: programEnrollment.partnerGroup ?? program.groups[0], + workspace, + commission, + isFirstCommission, + }), + ]); + + const user = userId + ? await prisma.user.findUnique({ + where: { + id: userId, + }, + }) + : null; + + await Promise.allSettled([ + // We only capture audit logs for manual commissions + user && + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: isClawback ? "clawback.created" : "commission.created", + description: isClawback + ? `Clawback created for ${partnerId}` + : `Commission created for ${partnerId}`, + actor: user, + targets: [ + { + type: isClawback ? "clawback" : "commission", + id: commission.id, + metadata: commission, + }, + ], + }), + + shouldTriggerWorkflow && + executeWorkflows({ + trigger: "partnerMetricsUpdated", + reason: "commission", + identity: { + workspaceId: workspace.id, + programId, + partnerId, + }, + metrics: { + current: { + commissions: commission.earnings, + }, + }, + }), + + // Only run this for non-manual commissions + // customer && + // detectAndRecordFraudEvent({ + // program: { id: programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: { + // ...pick(customer, ["id", "email", "name"]), + // isFirstConversion: firstConversionFlag, + // }, + // link: { id: linkId }, + // click: pick(saleData, ["url", "referer"]), + // event: { id: saleData.event_id }, + // }), + ]); +} diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index 1fe49257ee4..478c0f06744 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -380,17 +380,15 @@ export const createManualCommissionAction = authActionClient const tbRes = await Promise.allSettled(tbEventsToRecord.map((fn) => fn())); console.log("Recorded events in Tinybird: ", prettyPrint(tbRes)); - let createdCommissions = 0; + let queuedCommissions = 0; // create partner commissions (use a for loop to make sure the commissions are created in the correct order) // TODO: migrate to use workflow to support bulk creation for (const c of commissionsToCreate) { - const { commission } = await createPartnerCommission(c); - if (commission) { - createdCommissions++; - } + await createPartnerCommission(c); + queuedCommissions++; } console.log( - `Created ${createdCommissions} commissions for partner ${partner.email} (${partner.id}) and customer ${customer.email} (${customer.id})`, + `Queued ${queuedCommissions} commissions for partner ${partner.email} (${partner.id}) and customer ${customer.email} (${customer.id})`, ); waitUntil( diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 518eab1e37e..992f663a9b0 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -1,6 +1,5 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; -import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; @@ -17,7 +16,7 @@ import { } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; import { Link } from "@dub/prisma/client"; -import { nanoid, pick, R2_URL } from "@dub/utils"; +import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; import { syncPartnerLinksStats } from "../partners/sync-partner-links-stats"; @@ -287,12 +286,12 @@ export const trackLead = async ({ ]); link = updatedLink; // update the link variable to the latest version - let createdCommission: - | Awaited> - | undefined = undefined; + let result: Awaited< + ReturnType + > | null = null; if (link.programId && link.partnerId && customer) { - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "lead", programId: link.programId, partnerId: link.partnerId, @@ -308,9 +307,6 @@ export const trackLead = async ({ }, }); - const { commission, webhookPartner, programEnrollment } = - createdCommission; - await Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", @@ -334,17 +330,17 @@ export const trackLead = async ({ }), // only run fraud checks if the commission was created - commission && - webhookPartner && - detectAndRecordFraudEvent({ - program: { id: link.programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: pick(customer, ["id", "email", "name"]), - link: pick(link, ["id"]), - click: pick(clickData, ["url", "referer"]), - event: { id: leadEventId }, - }), + // commission && + // webhookPartner && + // detectAndRecordFraudEvent({ + // program: { id: link.programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: pick(customer, ["id", "email", "name"]), + // link: pick(link, ["id"]), + // click: pick(clickData, ["url", "referer"]), + // event: { id: leadEventId }, + // }), ]); } @@ -356,7 +352,7 @@ export const trackLead = async ({ eventName, link, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata, }), workspace, diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 6d268d8a8f9..5b7a1ec95c9 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -1,7 +1,6 @@ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { DubApiError } from "@/lib/api/errors"; -import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; @@ -32,7 +31,7 @@ import { } from "@/lib/zod/schemas/sales"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; -import { nanoid, pick, R2_URL } from "@dub/utils"; +import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; import { createId } from "../create-id"; @@ -465,13 +464,12 @@ const _trackSale = async ({ include: includeTags, }); - let createdCommission: + let result: | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - // TODO: move to workflows/create-partner-commission - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, @@ -495,8 +493,6 @@ const _trackSale = async ({ }, }); - const { webhookPartner, programEnrollment } = createdCommission; - await Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", @@ -521,19 +517,19 @@ const _trackSale = async ({ }), // TODO: move to workflows/create-partner-commission - webhookPartner && - detectAndRecordFraudEvent({ - program: { id: link.programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion: firstConversionFlag, - }, - link: pick(link, ["id"]), - click: pick(saleData, ["url", "referer"]), - event: { id: saleData.event_id }, - }), + // webhookPartner && + // detectAndRecordFraudEvent({ + // program: { id: link.programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: { + // ...pick(customer, ["id", "email", "name"]), + // isFirstConversion: firstConversionFlag, + // }, + // link: pick(link, ["id"]), + // click: pick(saleData, ["url", "referer"]), + // event: { id: saleData.event_id }, + // }), ]); } @@ -550,7 +546,7 @@ const _trackSale = async ({ clickedAt: customer.clickedAt || customer.createdAt, link, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata, }), workspace, diff --git a/apps/web/lib/bounty/api/approve-bounty-submission.ts b/apps/web/lib/bounty/api/approve-bounty-submission.ts index 6326c46c35c..6df7ab275c5 100644 --- a/apps/web/lib/bounty/api/approve-bounty-submission.ts +++ b/apps/web/lib/bounty/api/approve-bounty-submission.ts @@ -106,6 +106,9 @@ export async function approveBountySubmission({ }); } + // TODO: + // Fix this + const { commission } = await createPartnerCommission({ event: "custom", partnerId: submission.partnerId, diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index 18a5c14b09f..54d5720f9b6 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -1,5 +1,4 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; -import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; @@ -11,7 +10,7 @@ import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformSaleEventData } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; -import { nanoid, pick } from "@dub/utils"; +import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { orderSchema } from "./schema"; @@ -132,12 +131,11 @@ export async function createShopifySale({ ]); // for program links - let createdCommission: - | Awaited> - | undefined = undefined; + let result: Awaited> | undefined = + undefined; if (link.programId && link.partnerId) { - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, @@ -159,8 +157,6 @@ export async function createShopifySale({ }, }); - const { webhookPartner, programEnrollment } = createdCommission; - waitUntil( Promise.allSettled([ executeWorkflows({ @@ -185,19 +181,19 @@ export async function createShopifySale({ eventType: "sale", }), - webhookPartner && - detectAndRecordFraudEvent({ - program: { id: link.programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion: firstConversionFlag, - }, - link: pick(link, ["id"]), - click: pick(saleData, ["url", "referer"]), - event: { id: saleData.event_id }, - }), + // webhookPartner && + // detectAndRecordFraudEvent({ + // program: { id: link.programId }, + // partner: pick(webhookPartner, ["id", "email", "name"]), + // programEnrollment: pick(programEnrollment, ["status"]), + // customer: { + // ...pick(customer, ["id", "email", "name"]), + // isFirstConversion: firstConversionFlag, + // }, + // link: pick(link, ["id"]), + // click: pick(saleData, ["url", "referer"]), + // event: { id: saleData.event_id }, + // }), ]), ); } @@ -212,7 +208,7 @@ export async function createShopifySale({ link, clickedAt: customer.clickedAt || customer.createdAt, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata: null, }), }), diff --git a/apps/web/lib/integrations/shopify/process-order.ts b/apps/web/lib/integrations/shopify/process-order.ts index c7ed6f81d3c..33e2d24c9af 100644 --- a/apps/web/lib/integrations/shopify/process-order.ts +++ b/apps/web/lib/integrations/shopify/process-order.ts @@ -149,12 +149,12 @@ export async function attributeViaDiscountCode({ include: includeTags, }); - let createdCommission: + let result: | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - createdCommission = await createPartnerCommission({ + result = await createPartnerCommission({ event: "lead", programId: link.programId, partnerId: link.partnerId, @@ -202,7 +202,7 @@ export async function attributeViaDiscountCode({ eventName: "Checkout with discount code", link: linkUpdated, customer, - partner: createdCommission?.webhookPartner, + partner: result?.webhookPartner, metadata: null, }), }), diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index a8c1c5c0be5..affc309827f 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -1,45 +1,13 @@ -import { prisma } from "@dub/prisma"; -import { - Commission, - CommissionStatus, - CommissionType, -} from "@dub/prisma/client"; -import { currencyFormatter, log, prettyPrint, toCentsNumber } from "@dub/utils"; -import { waitUntil } from "@vercel/functions"; -import { differenceInMonths } from "date-fns"; -import { recordAuditLog } from "../api/audit-logs/record-audit-log"; -import { createId } from "../api/create-id"; -import { notifyPartnerCommission } from "../api/partners/notify-partner-commission"; -import { syncTotalCommissions } from "../api/partners/sync-total-commissions"; +import * as z from "zod/v4"; import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; -import { calculateSaleEarnings } from "../api/sales/calculate-sale-earnings"; -import { executeWorkflows } from "../api/workflows/execute-workflows"; import { Session } from "../auth"; -import { sendPartnerPostback } from "../postback/send-partner-postback"; -import { RewardContext, RewardProps } from "../types"; -import { sendWorkspaceWebhook } from "../webhook/publish"; -import { CommissionWebhookSchema } from "../zod/schemas/commissions"; -import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; +import { createPartnerCommissionSchema } from "../zod/schemas/commissions"; import { constructWebhookPartner } from "./constuct-webhook-partner"; -import { determinePartnerReward } from "./determine-partner-reward"; -import { getRewardAmount } from "./get-reward-amount"; -export type CreatePartnerCommissionProps = { - event: CommissionType; - partnerId: string; - programId: string; - linkId?: string; - customerId?: string; - eventId?: string; - invoiceId?: string | null; - amount?: number; - quantity: number; - currency?: string; - description?: string | null; - createdAt?: Date; - user?: Session["user"]; // user who created the manual commission - context?: RewardContext; - skipWorkflow?: boolean; +export type CreatePartnerCommissionProps = z.infer< + typeof createPartnerCommissionSchema +> & { + user?: Session["user"]; }; export const createPartnerCommission = async ({ @@ -59,400 +27,373 @@ export const createPartnerCommission = async ({ context, skipWorkflow = false, }: CreatePartnerCommissionProps) => { - let earnings = 0; - let reward: RewardProps | null = null; - let status: CommissionStatus = "pending"; - - const programEnrollment = await getProgramEnrollmentOrThrow({ + const result = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { links: true, partner: true, - partnerGroup: true, - ...(event === "click" && { clickReward: true }), - ...(event === "lead" && { leadReward: true }), - ...(event === "sale" && { saleReward: true }), }, }); - let firstCommission: Pick< - Commission, - "rewardId" | "status" | "createdAt" - > | null = null; - - if (event === "custom") { - earnings = amount; - amount = 0; - } else { - if (["lead", "sale"].includes(event) && customerId) { - firstCommission = await prisma.commission.findFirst({ - where: { - partnerId, - customerId, - type: event, - }, - orderBy: { - createdAt: "asc", - }, - select: { - rewardId: true, - status: true, - createdAt: true, - }, - }); - - const subscriptionStartDate = - event === "sale" ? firstCommission?.createdAt ?? new Date() : undefined; - - const subscriptionDurationMonths = subscriptionStartDate - ? differenceInMonths( - createdAt ?? new Date(), // account for custom commission creation date - subscriptionStartDate, - ) - : 0; - - context = { - ...context, - customer: { - ...context?.customer, - subscriptionStartDate, - subscriptionDurationMonths, - }, - ...(event === "sale" && { - sale: { - ...context?.sale, - type: firstCommission ? "recurring" : "new", - }, - }), - }; - } - - reward = determinePartnerReward({ - event, - programEnrollment, - context, - }); - - // if there is no reward, skip commission creation - if (!reward) { - const outputLog = `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`; - console.log(outputLog); - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - - // for click events, it's super simple – just multiply the reward amount by the quantity - if (event === "click") { - earnings = getRewardAmount(reward) * quantity; - - // for lead and sale events, we need to check if this partner-customer combination was recorded already (for deduplication) - // for sale rewards specifically, we also need to check: - // 1. if the partner has reached the max duration for the reward (if applicable) - // 2. if the previous commission were marked as fraud or canceled - } else { - if (firstCommission) { - // if first commission is fraud or canceled, skip commission creation - if (["fraud", "canceled"].includes(firstCommission.status)) { - const outputLog = `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`; - console.log(outputLog); - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - - // for lead events, we need to check if the partner has already been issued a lead reward for this customer - if (event === "lead") { - const outputLog = `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`; - console.log(outputLog); - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - - // 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, - }, - }); - - if ( - typeof originalReward?.maxDuration === "number" && - originalReward.maxDuration === 0 - ) { - const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`; - console.log(outputLog); - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - } - - // 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) { - const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`; - console.log(outputLog); - - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - - // Recurring sale reward (maxDuration > 0) - else { - const subscriptionDurationMonths = differenceInMonths( - createdAt ?? new Date(), // account for custom commission creation date - firstCommission.createdAt, - ); - - if (subscriptionDurationMonths >= reward.maxDuration) { - const outputLog = `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`; - console.log(outputLog); - - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - } - } - } - } - - // for lead events, we just multiply the reward amount by the quantity - if (event === "lead") { - earnings = getRewardAmount(reward) * quantity; - // for sale events, we need to calculate the earnings based on the sale amount - } else { - earnings = calculateSaleEarnings({ - reward, - sale: { quantity, amount }, - }); - } - } - } - - // skip commission creation if the earnings is zero - if (earnings === 0) { - console.log( - `Partner ${partnerId} has zero earnings for ${event} event, skipping commission creation...`, - ); - return { - commission: null, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } - - try { - const commission = await prisma.commission.create({ - data: { - id: createId({ prefix: "cm_" }), - programId, - partnerId, - rewardId: reward?.id, - customerId, - linkId, - eventId: eventId || null, // empty string should convert to null - invoiceId: invoiceId || null, // empty string should convert to null - userId: user?.id, - quantity, - amount, - type: event, - currency, - earnings, - status, - description, - createdAt, - }, - include: { - customer: true, - link: { - select: { - id: true, - shortLink: true, - domain: true, - key: true, - }, - }, - }, - }); - - const outputLog = `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`; - console.log(outputLog); - console.log(prettyPrint(commission)); - - const webhookPartner = constructWebhookPartner(programEnrollment, { - // check links metrics - totalCommissions: - toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, - }); - - waitUntil( - (async () => { - const program = await prisma.program.findUniqueOrThrow({ - where: { - id: programId, - }, - select: { - id: true, - name: true, - slug: true, - logo: true, - supportEmail: true, - workspace: { - select: { - id: true, - slug: true, - name: true, - webhookEnabled: true, - }, - }, - // if no partner group is found, need to fetch default group to fallback to - ...(!programEnrollment.partnerGroup && { - groups: { - select: { - holdingPeriodDays: true, - }, - where: { - slug: DEFAULT_PARTNER_GROUP.slug, - }, - }, - }), - }, - }); - - const { workspace } = program; - const isClawback = commission.earnings < 0; - const shouldTriggerWorkflow = !isClawback && !skipWorkflow; - - await Promise.allSettled([ - sendWorkspaceWebhook({ - workspace, - trigger: "commission.created", - data: CommissionWebhookSchema.parse({ - ...commission, - partner: webhookPartner, - }), - }), - - sendPartnerPostback({ - partnerId, - event: "commission.created", - data: { - ...commission, - customer: commission.customer, - }, - }), - - syncTotalCommissions({ - partnerId, - programId, - }), - - !isClawback && - notifyPartnerCommission({ - program, - // fallback to default group if no partner group is found - group: programEnrollment.partnerGroup ?? program.groups[0], - workspace, - commission, - isFirstCommission: firstCommission === null, - }), - - // We only capture audit logs for manual commissions - user && - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: isClawback ? "clawback.created" : "commission.created", - description: isClawback - ? `Clawback created for ${partnerId}` - : `Commission created for ${partnerId}`, - actor: user, - targets: [ - { - type: isClawback ? "clawback" : "commission", - id: commission.id, - metadata: commission, - }, - ], - }), - - shouldTriggerWorkflow && - executeWorkflows({ - trigger: "partnerMetricsUpdated", - reason: "commission", - identity: { - workspaceId: workspace.id, - programId, - partnerId, - }, - metrics: { - current: { - commissions: commission.earnings, - }, - }, - }), - ]); - })(), - ); - - return { - commission, - outputLog, - programEnrollment, - webhookPartner, - }; - } catch (error) { - const outputLog = `Error creating commission - ${error.message}`; - console.error(outputLog); - - // only log to Slack if the error is not a unique constraint violation - if (error.code !== "P2002") { - await log({ - message: outputLog, - type: "errors", - mention: true, - }); - } - - return { - commission: null, - outputLog, - programEnrollment, - webhookPartner: constructWebhookPartner(programEnrollment), - }; - } + const { partner, links, ...programEnrollment } = result; + + return { + partner, + links, + programEnrollment, + webhookPartner: constructWebhookPartner(result), + }; + + // let earnings = 0; + // let reward: RewardProps | null = null; + // let status: CommissionStatus = "pending"; + + // let firstCommission: Pick< + // Commission, + // "rewardId" | "status" | "createdAt" + // > | null = null; + // if (event === "custom") { + // earnings = amount; + // amount = 0; + // } else { + // if (["lead", "sale"].includes(event) && customerId) { + // firstCommission = await prisma.commission.findFirst({ + // where: { + // partnerId, + // customerId, + // type: event, + // }, + // orderBy: { + // createdAt: "asc", + // }, + // select: { + // rewardId: true, + // status: true, + // createdAt: true, + // }, + // }); + // const subscriptionStartDate = + // event === "sale" ? firstCommission?.createdAt ?? new Date() : undefined; + // const subscriptionDurationMonths = subscriptionStartDate + // ? differenceInMonths( + // createdAt ?? new Date(), // account for custom commission creation date + // subscriptionStartDate, + // ) + // : 0; + // context = { + // ...context, + // customer: { + // ...context?.customer, + // subscriptionStartDate, + // subscriptionDurationMonths, + // }, + // ...(event === "sale" && { + // sale: { + // ...context?.sale, + // type: firstCommission ? "recurring" : "new", + // }, + // }), + // }; + // } + // reward = determinePartnerReward({ + // event, + // programEnrollment, + // context, + // }); + // // if there is no reward, skip commission creation + // if (!reward) { + // const outputLog = `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // // for click events, it's super simple – just multiply the reward amount by the quantity + // if (event === "click") { + // earnings = getRewardAmount(reward) * quantity; + // // for lead and sale events, we need to check if this partner-customer combination was recorded already (for deduplication) + // // for sale rewards specifically, we also need to check: + // // 1. if the partner has reached the max duration for the reward (if applicable) + // // 2. if the previous commission were marked as fraud or canceled + // } else { + // if (firstCommission) { + // // if first commission is fraud or canceled, skip commission creation + // if (["fraud", "canceled"].includes(firstCommission.status)) { + // const outputLog = `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // // for lead events, we need to check if the partner has already been issued a lead reward for this customer + // if (event === "lead") { + // const outputLog = `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // // 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, + // }, + // }); + // if ( + // typeof originalReward?.maxDuration === "number" && + // originalReward.maxDuration === 0 + // ) { + // const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // } + // // 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) { + // const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // // Recurring sale reward (maxDuration > 0) + // else { + // const subscriptionDurationMonths = differenceInMonths( + // createdAt ?? new Date(), // account for custom commission creation date + // firstCommission.createdAt, + // ); + // if (subscriptionDurationMonths >= reward.maxDuration) { + // const outputLog = `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`; + // console.log(outputLog); + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // } + // } + // } + // } + // // for lead events, we just multiply the reward amount by the quantity + // if (event === "lead") { + // earnings = getRewardAmount(reward) * quantity; + // // for sale events, we need to calculate the earnings based on the sale amount + // } else { + // earnings = calculateSaleEarnings({ + // reward, + // sale: { quantity, amount }, + // }); + // } + // } + // } + // // skip commission creation if the earnings is zero + // if (earnings === 0) { + // console.log( + // `Partner ${partnerId} has zero earnings for ${event} event, skipping commission creation...`, + // ); + // return { + // commission: null, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } + // try { + // const commission = await prisma.commission.create({ + // data: { + // id: createId({ prefix: "cm_" }), + // programId, + // partnerId, + // rewardId: reward?.id, + // customerId, + // linkId, + // eventId: eventId || null, // empty string should convert to null + // invoiceId: invoiceId || null, // empty string should convert to null + // userId: user?.id, + // quantity, + // amount, + // type: event, + // currency, + // earnings, + // status, + // description, + // createdAt, + // }, + // include: { + // customer: true, + // link: { + // select: { + // id: true, + // shortLink: true, + // domain: true, + // key: true, + // }, + // }, + // }, + // }); + // const outputLog = `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`; + // console.log(outputLog); + // console.log(prettyPrint(commission)); + // const webhookPartner = constructWebhookPartner(programEnrollment, { + // // check links metrics + // totalCommissions: + // toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, + // }); + // waitUntil( + // (async () => { + // const program = await prisma.program.findUniqueOrThrow({ + // where: { + // id: programId, + // }, + // select: { + // id: true, + // name: true, + // slug: true, + // logo: true, + // supportEmail: true, + // workspace: { + // select: { + // id: true, + // slug: true, + // name: true, + // webhookEnabled: true, + // }, + // }, + // // if no partner group is found, need to fetch default group to fallback to + // ...(!programEnrollment.partnerGroup && { + // groups: { + // select: { + // holdingPeriodDays: true, + // }, + // where: { + // slug: DEFAULT_PARTNER_GROUP.slug, + // }, + // }, + // }), + // }, + // }); + // const { workspace } = program; + // const isClawback = commission.earnings < 0; + // const shouldTriggerWorkflow = !isClawback && !skipWorkflow; + // await Promise.allSettled([ + // sendWorkspaceWebhook({ + // workspace, + // trigger: "commission.created", + // data: CommissionWebhookSchema.parse({ + // ...commission, + // partner: webhookPartner, + // }), + // }), + // sendPartnerPostback({ + // partnerId, + // event: "commission.created", + // data: { + // ...commission, + // customer: commission.customer, + // }, + // }), + // syncTotalCommissions({ + // partnerId, + // programId, + // }), + // !isClawback && + // notifyPartnerCommission({ + // program, + // // fallback to default group if no partner group is found + // group: programEnrollment.partnerGroup ?? program.groups[0], + // workspace, + // commission, + // isFirstCommission: firstCommission === null, + // }), + // // We only capture audit logs for manual commissions + // user && + // recordAuditLog({ + // workspaceId: workspace.id, + // programId, + // action: isClawback ? "clawback.created" : "commission.created", + // description: isClawback + // ? `Clawback created for ${partnerId}` + // : `Commission created for ${partnerId}`, + // actor: user, + // targets: [ + // { + // type: isClawback ? "clawback" : "commission", + // id: commission.id, + // metadata: commission, + // }, + // ], + // }), + // shouldTriggerWorkflow && + // executeWorkflows({ + // trigger: "partnerMetricsUpdated", + // reason: "commission", + // identity: { + // workspaceId: workspace.id, + // programId, + // partnerId, + // }, + // metrics: { + // current: { + // commissions: commission.earnings, + // }, + // }, + // }), + // ]); + // })(), + // ); + // return { + // commission, + // outputLog, + // programEnrollment, + // webhookPartner, + // }; + // } catch (error) { + // const outputLog = `Error creating commission - ${error.message}`; + // console.error(outputLog); + // // only log to Slack if the error is not a unique constraint violation + // if (error.code !== "P2002") { + // await log({ + // message: outputLog, + // type: "errors", + // mention: true, + // }); + // } + // return { + // commission: null, + // outputLog, + // programEnrollment, + // webhookPartner: constructWebhookPartner(programEnrollment), + // }; + // } }; diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index c3c264d8578..00e4c95c3d0 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -9,7 +9,7 @@ import { } from "./misc"; import { EnrolledPartnerSchema, WebhookPartnerSchema } from "./partners"; import { PayoutSchema } from "./payouts"; -import { RewardSchema } from "./rewards"; +import { rewardContextSchema, RewardSchema } from "./rewards"; import { UserSchema } from "./users"; import { centsSchema, parseDateSchema } from "./utils"; @@ -431,3 +431,21 @@ export const commissionsExportQuerySchema = getCommissionsQuerySchema }, ), }); + +export const createPartnerCommissionSchema = z.object({ + event: z.enum(CommissionType), + partnerId: z.string(), + programId: z.string(), + linkId: z.string().nullish(), + customerId: z.string().nullish(), + eventId: z.string().nullish(), + invoiceId: z.string().nullish(), + amount: z.number().default(0).optional(), + quantity: z.number().default(1), + currency: z.string().optional(), + description: z.string().nullish(), + createdAt: z.coerce.date().nullish(), + userId: z.string().nullish(), + context: rewardContextSchema.nullish(), + skipWorkflow: z.boolean().nullish(), +}); From 9af6848c88bb875efd81d37f1ddfd397ae2e56f7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 17:07:09 +0530 Subject: [PATCH 04/34] Queue partner commissions via QStash from createPartnerCommission. --- .../create-partner-commission/route.ts | 11 ++++-- .../lib/actions/partners/create-clawback.ts | 2 +- .../partners/create-manual-commission.ts | 6 ++-- .../lib/partners/create-partner-commission.ts | 34 +++++++------------ apps/web/lib/zod/schemas/commissions.ts | 16 ++++----- .../programs/backfill-reuse-commission.ts | 1 - 6 files changed, 34 insertions(+), 36 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 55c7a1c06c3..68d0439eb5c 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -135,6 +135,10 @@ async function stepCreateCommission( programEnrollment, } = input; + if (typeof amount !== "number") { + amount = 0; + } + let earnings = 0; let reward: RewardProps | null = null; let status: CommissionStatus = "pending"; @@ -144,7 +148,7 @@ async function stepCreateCommission( > | null = null; if (event === "custom") { - earnings = amount ?? 0; + earnings = amount; amount = 0; } else { if (["lead", "sale"].includes(event) && customerId) { @@ -293,7 +297,10 @@ async function stepCreateCommission( } else { earnings = calculateSaleEarnings({ reward, - sale: { quantity, amount: amount ?? 0 }, + sale: { + quantity, + amount, + }, }); } } diff --git a/apps/web/lib/actions/partners/create-clawback.ts b/apps/web/lib/actions/partners/create-clawback.ts index 9ee2a04e6e5..c1ea5c967d3 100644 --- a/apps/web/lib/actions/partners/create-clawback.ts +++ b/apps/web/lib/actions/partners/create-clawback.ts @@ -33,6 +33,6 @@ export const createClawbackAction = authActionClient description, amount: -amount, quantity: 1, - user, + userId: user.id, }); }); diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index 478c0f06744..71fc2b6d247 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -98,7 +98,7 @@ export const createManualCommissionAction = authActionClient amount: amount ?? 0, quantity: 1, createdAt: date ?? new Date(), - user, + userId: user.id, description, }); @@ -322,7 +322,7 @@ export const createManualCommissionAction = authActionClient eventId: leadEventData.event_id, quantity: 1, createdAt: new Date(leadEventData.timestamp), // we don't add the "Z" to the timestamp because it's already in UTC - user, + userId: user.id, context: { customer: { country: customer.country }, }, @@ -363,7 +363,7 @@ export const createManualCommissionAction = authActionClient currency: saleEventData.currency, invoiceId: saleEventData.invoice_id, createdAt: new Date(saleEventData.timestamp), // we don't add the "Z" to the timestamp because it's already in UTC - user, + userId: user.id, context: { customer: { country: customer.country }, sale: { productId }, diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index affc309827f..acd140891a4 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -1,32 +1,18 @@ import * as z from "zod/v4"; import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; -import { Session } from "../auth"; +import { triggerQStashWorkflow } from "../cron/qstash-workflow"; import { createPartnerCommissionSchema } from "../zod/schemas/commissions"; import { constructWebhookPartner } from "./constuct-webhook-partner"; export type CreatePartnerCommissionProps = z.infer< typeof createPartnerCommissionSchema -> & { - user?: Session["user"]; -}; +>; + +export const createPartnerCommission = async ( + params: CreatePartnerCommissionProps, +) => { + const { partnerId, programId, customerId } = params; -export const createPartnerCommission = async ({ - event, - partnerId, - programId, - linkId, - customerId, - eventId, - invoiceId, - amount = 0, - quantity, - currency, - description, - createdAt, - user, - context, - skipWorkflow = false, -}: CreatePartnerCommissionProps) => { const result = await getProgramEnrollmentOrThrow({ partnerId, programId, @@ -38,6 +24,12 @@ export const createPartnerCommission = async ({ const { partner, links, ...programEnrollment } = result; + await triggerQStashWorkflow({ + workflowType: "create-partner-commission", + workflowLabel: customerId ?? partnerId, + body: params, + }); + return { partner, links, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 00e4c95c3d0..e671de83214 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -436,16 +436,16 @@ export const createPartnerCommissionSchema = z.object({ event: z.enum(CommissionType), partnerId: z.string(), programId: z.string(), - linkId: z.string().nullish(), - customerId: z.string().nullish(), - eventId: z.string().nullish(), - invoiceId: z.string().nullish(), + linkId: z.string().optional(), + customerId: z.string().optional(), + eventId: z.string().optional(), + invoiceId: z.string().optional(), amount: z.number().default(0).optional(), quantity: z.number().default(1), currency: z.string().optional(), description: z.string().nullish(), - createdAt: z.coerce.date().nullish(), - userId: z.string().nullish(), - context: rewardContextSchema.nullish(), - skipWorkflow: z.boolean().nullish(), + createdAt: z.coerce.date().optional(), + userId: z.string().optional(), + context: rewardContextSchema.optional(), + skipWorkflow: z.boolean().default(false).optional(), }); diff --git a/apps/web/scripts/programs/backfill-reuse-commission.ts b/apps/web/scripts/programs/backfill-reuse-commission.ts index 8a609bd14ea..42b90f4fd62 100644 --- a/apps/web/scripts/programs/backfill-reuse-commission.ts +++ b/apps/web/scripts/programs/backfill-reuse-commission.ts @@ -130,7 +130,6 @@ async function main() { eventId: leadEventData.event_id, quantity: 1, createdAt: new Date(leadEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC - user, context: { customer: { country: customer.country }, }, From 2e9eef2f027d10c86c7508e29fa0369abb8d4846 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 17:20:45 +0530 Subject: [PATCH 05/34] Rename createPartnerCommission to queuePartnerCommissionCreation --- .../webhook/checkout-session-completed.ts | 13 +++++++------ .../integration/webhook/invoice-paid.ts | 9 +++++---- .../lib/actions/partners/create-clawback.ts | 4 ++-- .../partners/create-manual-commission.ts | 10 ++++------ apps/web/lib/api/conversions/track-lead.ts | 6 +++--- apps/web/lib/api/conversions/track-sale.ts | 6 +++--- .../bounty/api/approve-bounty-submission.ts | 19 +++++-------------- .../lib/integrations/shopify/create-sale.ts | 9 +++++---- .../lib/integrations/shopify/process-order.ts | 6 +++--- .../lib/partners/create-partner-commission.ts | 9 ++------- apps/web/lib/types.ts | 5 +++++ .../programs/backfill-reuse-commission.ts | 8 +++----- 12 files changed, 47 insertions(+), 57 deletions(-) diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index a48829dae01..09064a2e35f 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -5,7 +5,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getClickEvent, @@ -476,8 +476,9 @@ export async function checkoutSessionCompleted( }), ]); - let result: Awaited> | undefined = - undefined; + let result: + | Awaited> + | undefined = undefined; if (link && link.programId && link.partnerId) { const productId = await getCheckoutSessionProductId({ @@ -486,7 +487,7 @@ export async function checkoutSessionCompleted( mode, }); - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "sale", programId: link.programId, partnerId: link.partnerId, @@ -699,11 +700,11 @@ async function attributeViaPromoCode({ const linkUpdated = await incrementLinkLeads(link.id); let result: - | Awaited> + | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "lead", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index c58c405018c..3b0fe5e74e7 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -3,7 +3,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getLeadEvent, recordSale } from "@/lib/tinybird"; import { StripeMode } from "@/lib/types"; @@ -238,11 +238,12 @@ export async function invoicePaid( ]); // for program links - let result: Awaited> | undefined = - undefined; + let result: + | Awaited> + | undefined = undefined; if (link.programId && link.partnerId) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "sale", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/lib/actions/partners/create-clawback.ts b/apps/web/lib/actions/partners/create-clawback.ts index c1ea5c967d3..ec23f6352a9 100644 --- a/apps/web/lib/actions/partners/create-clawback.ts +++ b/apps/web/lib/actions/partners/create-clawback.ts @@ -2,7 +2,7 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { createClawbackSchema } from "@/lib/zod/schemas/commissions"; import { authActionClient } from "../safe-action"; import { throwIfNoPermission } from "../throw-if-no-permission"; @@ -26,7 +26,7 @@ export const createClawbackAction = authActionClient include: {}, }); - await createPartnerCommission({ + await queuePartnerCommissionCreation({ event: "custom", partnerId, programId, diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index 71fc2b6d247..3f3bc57b64e 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -7,16 +7,14 @@ import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-sta import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { - createPartnerCommission, - CreatePartnerCommissionProps, -} from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { recordClickZod, recordClickZodSchema, } from "@/lib/tinybird/record-click-zod"; import { recordLeadWithTimestamp } from "@/lib/tinybird/record-lead"; import { recordSaleWithTimestamp } from "@/lib/tinybird/record-sale"; +import { CreatePartnerCommissionProps } from "@/lib/types"; import { createCommissionSchema } from "@/lib/zod/schemas/commissions"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { saleEventSchemaTB } from "@/lib/zod/schemas/sales"; @@ -91,7 +89,7 @@ export const createManualCommissionAction = authActionClient // Create a custom commission if (commissionType === "custom") { - await createPartnerCommission({ + await queuePartnerCommissionCreation({ event: "custom", partnerId, programId, @@ -384,7 +382,7 @@ export const createManualCommissionAction = authActionClient // create partner commissions (use a for loop to make sure the commissions are created in the correct order) // TODO: migrate to use workflow to support bulk creation for (const c of commissionsToCreate) { - await createPartnerCommission(c); + await queuePartnerCommissionCreation(c); queuedCommissions++; } console.log( diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 992f663a9b0..5ded9ed606d 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -2,7 +2,7 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { getClickEvent, recordLead } from "@/lib/tinybird"; @@ -287,11 +287,11 @@ export const trackLead = async ({ link = updatedLink; // update the link variable to the latest version let result: Awaited< - ReturnType + ReturnType > | null = null; if (link.programId && link.partnerId && customer) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "lead", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 5b7a1ec95c9..689a7a2061b 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -3,7 +3,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { @@ -465,11 +465,11 @@ const _trackSale = async ({ }); let result: - | Awaited> + | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "sale", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/lib/bounty/api/approve-bounty-submission.ts b/apps/web/lib/bounty/api/approve-bounty-submission.ts index 6df7ab275c5..0d22354b3ba 100644 --- a/apps/web/lib/bounty/api/approve-bounty-submission.ts +++ b/apps/web/lib/bounty/api/approve-bounty-submission.ts @@ -3,7 +3,7 @@ import { DubApiError } from "@/lib/api/errors"; import { Session } from "@/lib/auth"; import { calculateSocialMetricsRewardAmount } from "@/lib/bounty/rewards"; import { resolveBountyDetails } from "@/lib/bounty/utils"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { approveBountySubmissionBodySchema, BountySubmissionSchema, @@ -106,26 +106,16 @@ export async function approveBountySubmission({ }); } - // TODO: - // Fix this - - const { commission } = await createPartnerCommission({ + await queuePartnerCommissionCreation({ event: "custom", partnerId: submission.partnerId, programId: submission.programId, amount: finalRewardAmount, quantity: 1, - user, + userId: user.id, description: `Commission for successfully completed "${bounty.name}" bounty.`, }); - if (!commission) { - throw new DubApiError({ - code: "internal_server_error", - message: "Failed to create commission for the bounty submission.", - }); - } - const approvedSubmission = await prisma.bountySubmission.update({ where: { id: submissionId, @@ -136,7 +126,7 @@ export async function approveBountySubmission({ userId: user.id, rejectionNote: null, rejectionReason: null, - commissionId: commission.id, + // commissionId: commission.id, }, include: { partner: { @@ -193,6 +183,7 @@ export async function approveBountySubmission({ type: bounty.type, }, }), + scheduledAt: new Date(Date.now() + 1000 * 60).toISOString(), }), ]), ); diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index 54d5720f9b6..82cb8605a46 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -2,7 +2,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { recordSale } from "@/lib/tinybird"; import { LeadEventTB } from "@/lib/types"; @@ -131,11 +131,12 @@ export async function createShopifySale({ ]); // for program links - let result: Awaited> | undefined = - undefined; + let result: + | Awaited> + | undefined = undefined; if (link.programId && link.partnerId) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "sale", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/lib/integrations/shopify/process-order.ts b/apps/web/lib/integrations/shopify/process-order.ts index 33e2d24c9af..794c57a4660 100644 --- a/apps/web/lib/integrations/shopify/process-order.ts +++ b/apps/web/lib/integrations/shopify/process-order.ts @@ -4,7 +4,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; -import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getLeadEvent, recordLead } from "@/lib/tinybird"; import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; @@ -150,11 +150,11 @@ export async function attributeViaDiscountCode({ }); let result: - | Awaited> + | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { - result = await createPartnerCommission({ + result = await queuePartnerCommissionCreation({ event: "lead", programId: link.programId, partnerId: link.partnerId, diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index acd140891a4..34c3f2e5f3f 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -1,14 +1,9 @@ -import * as z from "zod/v4"; import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; import { triggerQStashWorkflow } from "../cron/qstash-workflow"; -import { createPartnerCommissionSchema } from "../zod/schemas/commissions"; +import { CreatePartnerCommissionProps } from "../types"; import { constructWebhookPartner } from "./constuct-webhook-partner"; -export type CreatePartnerCommissionProps = z.infer< - typeof createPartnerCommissionSchema ->; - -export const createPartnerCommission = async ( +export const queuePartnerCommissionCreation = async ( params: CreatePartnerCommissionProps, ) => { const { partnerId, programId, customerId } = params; diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 21cac82baf5..0f5b07a7b18 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -96,6 +96,7 @@ import { CommissionDetailSchema, CommissionEnrichedSchema, CommissionSchema, + createPartnerCommissionSchema, } from "./zod/schemas/commissions"; import { customerActivityResponseSchema } from "./zod/schemas/customer-activity"; import { @@ -978,3 +979,7 @@ export type ApplicationAnalyticsByGroup = { }; export type CommissionProps = z.infer; + +export type CreatePartnerCommissionProps = z.infer< + typeof createPartnerCommissionSchema +>; diff --git a/apps/web/scripts/programs/backfill-reuse-commission.ts b/apps/web/scripts/programs/backfill-reuse-commission.ts index 42b90f4fd62..dfed16c2edd 100644 --- a/apps/web/scripts/programs/backfill-reuse-commission.ts +++ b/apps/web/scripts/programs/backfill-reuse-commission.ts @@ -4,10 +4,7 @@ import { updateLinkStatsForImporter } from "@/lib/api/links/update-link-stats-fo import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { qstash } from "@/lib/cron"; -import { - createPartnerCommission, - CreatePartnerCommissionProps, -} from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; import { getCustomerEventsTB } from "@/lib/tinybird/get-customer-events-tb"; import { recordClickZod, @@ -15,6 +12,7 @@ import { } from "@/lib/tinybird/record-click-zod"; import { recordLeadWithTimestamp } from "@/lib/tinybird/record-lead"; import { recordSaleWithTimestamp } from "@/lib/tinybird/record-sale"; +import { CreatePartnerCommissionProps } from "@/lib/types"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { saleEventSchemaTB } from "@/lib/zod/schemas/sales"; import { prisma } from "@dub/prisma"; @@ -315,7 +313,7 @@ async function main() { console.log("Commissions to create: ", commissionsToCreate); await Promise.allSettled( commissionsToCreate.map((commission) => - createPartnerCommission({ ...commission, skipWorkflow: true }), + queuePartnerCommissionCreation({ ...commission, skipWorkflow: true }), ), ); From 9d30c46c7210db2d0e1d1d7e06617dc721b4b83b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 18:30:58 +0530 Subject: [PATCH 06/34] Improve partner commission workflow types, flow control, and schemas --- .../create-partner-commission/route.ts | 25 ++++++++++++++++--- apps/web/lib/cron/index.ts | 2 +- apps/web/lib/cron/qstash-workflow.ts | 14 ++++++++--- .../lib/partners/create-partner-commission.ts | 4 +++ apps/web/lib/zod/schemas/commissions.ts | 2 +- apps/web/lib/zod/schemas/rewards.ts | 4 +-- 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 68d0439eb5c..e92e1143347 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -19,7 +19,15 @@ import { } from "@/lib/zod/schemas/commissions"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; -import { Commission, CommissionStatus } from "@dub/prisma/client"; +import { + Commission, + CommissionStatus, + Link, + Partner, + PartnerGroup, + ProgramEnrollment, + Reward, +} from "@dub/prisma/client"; import { currencyFormatter, log, prettyPrint, toCentsNumber } from "@dub/utils"; import { WorkflowRetryAfterError } from "@upstash/workflow"; import { serve } from "@upstash/workflow/nextjs"; @@ -29,8 +37,17 @@ import { logAndReturn } from "../../cron/utils"; type Input = z.infer; +type ProgramEnrollmentWithReward = ProgramEnrollment & { + links: Link[]; + partner: Partner; + partnerGroup: PartnerGroup | null; + clickReward?: Reward | null; + leadReward?: Reward | null; + saleReward?: Reward | null; +}; + type StepFunctionInput = Input & { - programEnrollment: Awaited>; + programEnrollment: ProgramEnrollmentWithReward; isFirstCommission?: boolean; }; @@ -65,7 +82,6 @@ export const { POST } = serve( async () => { return await stepCreateCommission({ ...input, - // @ts-ignore // Fix this programEnrollment, }); }, @@ -76,9 +92,9 @@ export const { POST } = serve( await context.run("run-side-effects", async () => { return await stepRunSideEffects({ ...input, - // @ts-ignore // Fix this programEnrollment, isFirstCommission, + commission, }); }); } @@ -388,6 +404,7 @@ async function stepRunSideEffects( }, include: { customer: true, + link: true, }, }); diff --git a/apps/web/lib/cron/index.ts b/apps/web/lib/cron/index.ts index 87184a66a76..7b9eb8fdc8a 100644 --- a/apps/web/lib/cron/index.ts +++ b/apps/web/lib/cron/index.ts @@ -1,7 +1,7 @@ import { Client } from "@upstash/qstash"; export const qstash = new Client({ - baseUrl: "https://qstash-us-east-1.upstash.io", + baseUrl: process.env.QSTASH_URL || "https://qstash-us-east-1.upstash.io", token: process.env.QSTASH_TOKEN || "", }); diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index 36fea94fea4..096a68a58f1 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -1,5 +1,6 @@ import { logger } from "@/lib/axiom/server"; -import { APP_DOMAIN } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; +import { FlowControl } from "@upstash/qstash"; import { Client } from "@upstash/workflow"; const client = new Client({ @@ -13,6 +14,7 @@ interface QStashWorkflow { workflowType: WorkflowType; workflowLabel: string; body: Record; + flowControl?: FlowControl; } // Run workflows @@ -26,17 +28,23 @@ export async function triggerQStashWorkflow( try { const response = await client.trigger( workflows.map((workflow) => ({ - url: `${APP_DOMAIN}/api/workflows/${workflow.workflowType}`, + // url: `${APP_DOMAIN}/api/workflows/${workflow.workflowType}`, + url: `${APP_DOMAIN_WITH_NGROK}/api/workflows/${workflow.workflowType}`, body: workflow.body, label: workflow.workflowLabel, retries: 5, - flowControl: { + flowControl: workflow.flowControl ?? { key: workflow.workflowType, parallelism: 15, }, })), ); + console.log( + `${response.length} QStash ${pluralize("workflow", response.length)} triggered`, + response, + ); + return response; } catch (error) { console.error("QStash workflow trigger failed", { error, workflows }); diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index 34c3f2e5f3f..8326134f1b1 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -23,6 +23,10 @@ export const queuePartnerCommissionCreation = async ( workflowType: "create-partner-commission", workflowLabel: customerId ?? partnerId, body: params, + flowControl: { + key: customerId ?? partnerId, + parallelism: 1, + }, }); return { diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index e671de83214..c0e1e6a78d8 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -439,7 +439,7 @@ export const createPartnerCommissionSchema = z.object({ linkId: z.string().optional(), customerId: z.string().optional(), eventId: z.string().optional(), - invoiceId: z.string().optional(), + invoiceId: z.string().nullish(), amount: z.number().default(0).optional(), quantity: z.number().default(1), currency: z.string().optional(), diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts index 54f4e435035..35fc88450e0 100644 --- a/apps/web/lib/zod/schemas/rewards.ts +++ b/apps/web/lib/zod/schemas/rewards.ts @@ -411,8 +411,8 @@ export const rewardContextSchema = z.object({ .object({ country: z.string().nullish(), source: z.enum(CUSTOMER_SOURCES).default("tracked").nullish(), - signupDate: z.date().nullish(), - subscriptionStartDate: z.date().nullish(), + signupDate: z.coerce.date().nullish(), + subscriptionStartDate: z.coerce.date().nullish(), subscriptionDurationMonths: z.number().nullish(), }) .optional(), From f0f08c3393da177d8c2fbb2e87b4ab6357d7972a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 20:59:11 +0530 Subject: [PATCH 07/34] Link bounty submissions to commissions in partner commission workflow --- .../create-partner-commission/route.ts | 30 +++++++++++++++++-- .../bounty/api/approve-bounty-submission.ts | 3 +- apps/web/lib/cron/qstash-workflow.ts | 9 +++--- apps/web/lib/zod/schemas/commissions.ts | 6 ++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index e92e1143347..8eb9bb10232 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -61,7 +61,7 @@ type StepCreateCommissionOutput = { export const { POST } = serve( async (context) => { const input = context.requestPayload; - const { event, partnerId, programId } = input; + const { event, partnerId, programId, bountySubmissionId } = input; const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, @@ -88,7 +88,33 @@ export const { POST } = serve( ); if (commission) { - // Step 2: Run side effects + // Step 2: Link the commission to the bounty submission + if (bountySubmissionId) { + await context.run("set-bounty-commission", async () => { + const { count } = await prisma.bountySubmission.updateMany({ + where: { + id: bountySubmissionId, + status: "approved", + commissionId: null, + }, + data: { + commissionId: commission.id, + }, + }); + + if (count) { + return logAndReturn({ + outputLog: `Linked commission ${commission.id} to bounty submission ${bountySubmissionId}`, + }); + } else { + return logAndReturn({ + outputLog: `Bounty submission ${bountySubmissionId} not found or already linked to a commission, skipping...`, + }); + } + }); + } + + // Step 3: Run side effects await context.run("run-side-effects", async () => { return await stepRunSideEffects({ ...input, diff --git a/apps/web/lib/bounty/api/approve-bounty-submission.ts b/apps/web/lib/bounty/api/approve-bounty-submission.ts index 0d22354b3ba..fd288f70d3b 100644 --- a/apps/web/lib/bounty/api/approve-bounty-submission.ts +++ b/apps/web/lib/bounty/api/approve-bounty-submission.ts @@ -114,6 +114,7 @@ export async function approveBountySubmission({ quantity: 1, userId: user.id, description: `Commission for successfully completed "${bounty.name}" bounty.`, + bountySubmissionId: submissionId, }); const approvedSubmission = await prisma.bountySubmission.update({ @@ -126,7 +127,6 @@ export async function approveBountySubmission({ userId: user.id, rejectionNote: null, rejectionReason: null, - // commissionId: commission.id, }, include: { partner: { @@ -183,7 +183,6 @@ export async function approveBountySubmission({ type: bounty.type, }, }), - scheduledAt: new Date(Date.now() + 1000 * 60).toISOString(), }), ]), ); diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index 096a68a58f1..dbcd80b783f 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -93,13 +93,12 @@ export function getWorkflowConfig({ }; case "create-partner-commission": { - const saleEvent = body.saleEvent as Record; - return { correlation: { - linkId: saleEvent.link_id, - eventId: saleEvent.event_id, - customerId: saleEvent.customer_id, + programId: body.programId, + partnerId: body.partnerId, + customerId: body.customerId, + bountySubmissionId: body.bountySubmissionId, }, }; } diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index c0e1e6a78d8..8122f83ea1c 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -448,4 +448,10 @@ export const createPartnerCommissionSchema = z.object({ userId: z.string().optional(), context: rewardContextSchema.optional(), skipWorkflow: z.boolean().default(false).optional(), + bountySubmissionId: z + .string() + .optional() + .describe( + "The ID of the bounty submission that the commission should be created for.", + ), }); From 4baa3575cab2377d2c212ba61b3b3d3ea25fd9f0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 21:05:18 +0530 Subject: [PATCH 08/34] Update route.ts --- .../(ee)/api/workflows/create-partner-commission/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 8eb9bb10232..ea468d62e6d 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -381,11 +381,14 @@ async function stepCreateCommission( console.log(prettyPrint(commission)); + const isFirstCommission = + event !== "custom" ? firstCommission === null : undefined; + return logAndReturn({ commission: { id: commission.id, }, - isFirstCommission: firstCommission === null, + isFirstCommission, outputLog: `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`, }); } catch (error) { From 8b5576efe38d3c1ab4468d8840a3b049aa4c1810 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 21:38:02 +0530 Subject: [PATCH 09/34] Move fraud detection to partner commission workflow side effects --- .../webhook/checkout-session-completed.ts | 18 ++------- .../integration/webhook/invoice-paid.ts | 18 ++------- .../create-partner-commission/route.ts | 39 ++++++++++++------- apps/web/lib/api/conversions/track-lead.ts | 17 ++------ apps/web/lib/api/conversions/track-sale.ts | 19 ++------- .../lib/integrations/shopify/create-sale.ts | 18 ++------- .../lib/partners/create-partner-commission.ts | 4 +- apps/web/lib/zod/schemas/commissions.ts | 6 +++ 8 files changed, 53 insertions(+), 86 deletions(-) diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 09064a2e35f..7e03115338b 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -508,6 +508,10 @@ export async function checkoutSessionCompleted( amount: saleData.amount, }, }, + clickEvent: { + url: saleData.url, + referer: saleData.referer, + }, }); waitUntil( @@ -533,20 +537,6 @@ export async function checkoutSessionCompleted( programId: link.programId, eventType: "sale", }), - - // webhookPartner && - // detectAndRecordFraudEvent({ - // program: { id: link.programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: { - // ...pick(customer, ["id", "email", "name"]), - // isFirstConversion: firstConversionFlag, - // }, - // link: pick(link, ["id"]), - // click: pick(saleData, ["url", "referer"]), - // event: { id: saleData.event_id }, - // }), ]), ); } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 3b0fe5e74e7..fede6294a33 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -264,6 +264,10 @@ export async function invoicePaid( amount: saleData.amount, }, }, + clickEvent: { + url: saleData.url, + referer: saleData.referer, + }, }); waitUntil( @@ -289,20 +293,6 @@ export async function invoicePaid( programId: link.programId, eventType: "sale", }), - - // webhookPartner && - // detectAndRecordFraudEvent({ - // program: { id: link.programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: { - // ...pick(customer, ["id", "email", "name"]), - // isFirstConversion: firstConversionFlag, - // }, - // link: pick(link, ["id"]), - // click: pick(saleData, ["url", "referer"]), - // event: { id: saleData.event_id }, - // }), ]), ); } diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index ea468d62e6d..b9fa26874da 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -1,5 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; +import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commission"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; @@ -28,7 +29,13 @@ import { ProgramEnrollment, Reward, } from "@dub/prisma/client"; -import { currencyFormatter, log, prettyPrint, toCentsNumber } from "@dub/utils"; +import { + currencyFormatter, + log, + pick, + prettyPrint, + toCentsNumber, +} from "@dub/utils"; import { WorkflowRetryAfterError } from "@upstash/workflow"; import { serve } from "@upstash/workflow/nextjs"; import { differenceInMonths } from "date-fns"; @@ -424,7 +431,9 @@ async function stepRunSideEffects( partnerId, userId, linkId, + eventId, skipWorkflow, + clickEvent, } = input; const commission = await prisma.commission.findUnique({ @@ -483,8 +492,10 @@ async function stepRunSideEffects( const { workspace } = program; const { customer } = commission; + const isClawback = commission.earnings < 0; const shouldTriggerWorkflow = !isClawback && !skipWorkflow; + const shouldRunFraudDetection = customer && eventId && clickEvent; await Promise.allSettled([ sendWorkspaceWebhook({ @@ -566,18 +577,18 @@ async function stepRunSideEffects( }), // Only run this for non-manual commissions - // customer && - // detectAndRecordFraudEvent({ - // program: { id: programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: { - // ...pick(customer, ["id", "email", "name"]), - // isFirstConversion: firstConversionFlag, - // }, - // link: { id: linkId }, - // click: pick(saleData, ["url", "referer"]), - // event: { id: saleData.event_id }, - // }), + shouldRunFraudDetection && + detectAndRecordFraudEvent({ + program: { id: programId }, + partner: pick(webhookPartner, ["id", "email", "name"]), + programEnrollment: pick(programEnrollment, ["status"]), + customer: { + ...pick(customer, ["id", "email", "name"]), + isFirstConversion: true, // fix it + }, + link: { id: linkId }, + click: pick(clickEvent, ["url", "referer"]), + event: { id: eventId }, + }), ]); } diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 5ded9ed606d..8365f2d96c0 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -305,6 +305,10 @@ export const trackLead = async ({ source, }, }, + clickEvent: { + url: clickData.url, + referer: clickData.referer, + }, }); await Promise.allSettled([ @@ -328,19 +332,6 @@ export const trackLead = async ({ programId: link.programId, eventType: "lead", }), - - // only run fraud checks if the commission was created - // commission && - // webhookPartner && - // detectAndRecordFraudEvent({ - // program: { id: link.programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: pick(customer, ["id", "email", "name"]), - // link: pick(link, ["id"]), - // click: pick(clickData, ["url", "referer"]), - // event: { id: leadEventId }, - // }), ]); } diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 689a7a2061b..a5db90e5ea1 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -491,6 +491,10 @@ const _trackSale = async ({ amount: saleData.amount, }, }, + clickEvent: { + url: saleData.url, + referer: saleData.referer, + }, }); await Promise.allSettled([ @@ -515,21 +519,6 @@ const _trackSale = async ({ programId: link.programId, eventType: "sale", }), - - // TODO: move to workflows/create-partner-commission - // webhookPartner && - // detectAndRecordFraudEvent({ - // program: { id: link.programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: { - // ...pick(customer, ["id", "email", "name"]), - // isFirstConversion: firstConversionFlag, - // }, - // link: pick(link, ["id"]), - // click: pick(saleData, ["url", "referer"]), - // event: { id: saleData.event_id }, - // }), ]); } diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index 82cb8605a46..0866ba7d792 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -156,6 +156,10 @@ export async function createShopifySale({ amount: saleData.amount, }, }, + clickEvent: { + url: saleData.url, + referer: saleData.referer, + }, }); waitUntil( @@ -181,20 +185,6 @@ export async function createShopifySale({ programId: link.programId, eventType: "sale", }), - - // webhookPartner && - // detectAndRecordFraudEvent({ - // program: { id: link.programId }, - // partner: pick(webhookPartner, ["id", "email", "name"]), - // programEnrollment: pick(programEnrollment, ["status"]), - // customer: { - // ...pick(customer, ["id", "email", "name"]), - // isFirstConversion: firstConversionFlag, - // }, - // link: pick(link, ["id"]), - // click: pick(saleData, ["url", "referer"]), - // event: { id: saleData.event_id }, - // }), ]), ); } diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index 8326134f1b1..ac2f9ad117a 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -6,7 +6,7 @@ import { constructWebhookPartner } from "./constuct-webhook-partner"; export const queuePartnerCommissionCreation = async ( params: CreatePartnerCommissionProps, ) => { - const { partnerId, programId, customerId } = params; + const { partnerId, programId, customerId, bountySubmissionId } = params; const result = await getProgramEnrollmentOrThrow({ partnerId, @@ -21,7 +21,7 @@ export const queuePartnerCommissionCreation = async ( await triggerQStashWorkflow({ workflowType: "create-partner-commission", - workflowLabel: customerId ?? partnerId, + workflowLabel: bountySubmissionId ?? customerId ?? partnerId, body: params, flowControl: { key: customerId ?? partnerId, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 8122f83ea1c..62b396c3664 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -454,4 +454,10 @@ export const createPartnerCommissionSchema = z.object({ .describe( "The ID of the bounty submission that the commission should be created for.", ), + clickEvent: z + .object({ + url: z.string().nullable(), + referer: z.string().nullable(), + }) + .optional(), }); From d7b7f1dd01a484483c859f78729e82ec6f130c74 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 21:45:30 +0530 Subject: [PATCH 10/34] Pass isFirstConversion through partner commission workflow --- .../stripe/integration/webhook/checkout-session-completed.ts | 1 + .../app/(ee)/api/stripe/integration/webhook/invoice-paid.ts | 1 + .../app/(ee)/api/workflows/create-partner-commission/route.ts | 3 ++- apps/web/lib/api/conversions/track-sale.ts | 1 + apps/web/lib/integrations/shopify/create-sale.ts | 1 + apps/web/lib/zod/schemas/commissions.ts | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 7e03115338b..05b2c30cef5 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -512,6 +512,7 @@ export async function checkoutSessionCompleted( url: saleData.url, referer: saleData.referer, }, + isFirstConversion: firstConversionFlag, }); waitUntil( diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index fede6294a33..3061743f40c 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -268,6 +268,7 @@ export async function invoicePaid( url: saleData.url, referer: saleData.referer, }, + isFirstConversion: firstConversionFlag, }); waitUntil( diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index b9fa26874da..080d04f09d5 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -434,6 +434,7 @@ async function stepRunSideEffects( eventId, skipWorkflow, clickEvent, + isFirstConversion, } = input; const commission = await prisma.commission.findUnique({ @@ -584,7 +585,7 @@ async function stepRunSideEffects( programEnrollment: pick(programEnrollment, ["status"]), customer: { ...pick(customer, ["id", "email", "name"]), - isFirstConversion: true, // fix it + isFirstConversion, }, link: { id: linkId }, click: pick(clickEvent, ["url", "referer"]), diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index a5db90e5ea1..545f17584ed 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -495,6 +495,7 @@ const _trackSale = async ({ url: saleData.url, referer: saleData.referer, }, + isFirstConversion: firstConversionFlag, }); await Promise.allSettled([ diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index 0866ba7d792..ed79cfd2bd6 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -160,6 +160,7 @@ export async function createShopifySale({ url: saleData.url, referer: saleData.referer, }, + isFirstConversion: firstConversionFlag, }); waitUntil( diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 62b396c3664..6f9f10c286e 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -448,6 +448,7 @@ export const createPartnerCommissionSchema = z.object({ userId: z.string().optional(), context: rewardContextSchema.optional(), skipWorkflow: z.boolean().default(false).optional(), + isFirstConversion: z.boolean().optional(), bountySubmissionId: z .string() .optional() From 7974bfd581af02b880b8c252db8113cd2f5769bb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 21:59:41 +0530 Subject: [PATCH 11/34] Update qstash-workflow.ts --- apps/web/lib/cron/qstash-workflow.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index dbcd80b783f..b0b99a36e54 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -1,5 +1,5 @@ import { logger } from "@/lib/axiom/server"; -import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; +import { APP_DOMAIN, pluralize } from "@dub/utils"; import { FlowControl } from "@upstash/qstash"; import { Client } from "@upstash/workflow"; @@ -28,8 +28,7 @@ export async function triggerQStashWorkflow( try { const response = await client.trigger( workflows.map((workflow) => ({ - // url: `${APP_DOMAIN}/api/workflows/${workflow.workflowType}`, - url: `${APP_DOMAIN_WITH_NGROK}/api/workflows/${workflow.workflowType}`, + url: `${APP_DOMAIN}/api/workflows/${workflow.workflowType}`, body: workflow.body, label: workflow.workflowLabel, retries: 5, From e49d7177522d7aa28c144dd17093777a3d89c270 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 22:29:24 +0530 Subject: [PATCH 12/34] Run fraud detection before commission side effects and queue bounty commission after approval --- .../create-partner-commission/route.ts | 114 ++++++++++-------- .../bounty/api/approve-bounty-submission.ts | 22 ++-- 2 files changed, 77 insertions(+), 59 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 080d04f09d5..6939367d0d4 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -23,6 +23,7 @@ import { prisma } from "@dub/prisma"; import { Commission, CommissionStatus, + Customer, Link, Partner, PartnerGroup, @@ -59,7 +60,8 @@ type StepFunctionInput = Input & { }; type StepCreateCommissionOutput = { - commission: Pick | null; + commission?: Pick | null; + customer?: Pick | null; outputLog: string; isFirstCommission?: boolean; }; @@ -84,7 +86,7 @@ export const { POST } = serve( }); // Step 1: Create commission - const { commission, isFirstCommission } = await context.run( + const { commission, customer, isFirstCommission } = await context.run( "create-commission", async () => { return await stepCreateCommission({ @@ -120,17 +122,18 @@ export const { POST } = serve( } }); } + } - // Step 3: Run side effects - await context.run("run-side-effects", async () => { - return await stepRunSideEffects({ - ...input, - programEnrollment, - isFirstCommission, - commission, - }); + // Step 3: Run side effects + await context.run("run-side-effects", async () => { + return await stepRunSideEffects({ + ...input, + programEnrollment, + commission, + customer, + isFirstCommission, }); - } + }); }, { initialPayloadParser: (requestPayload) => { @@ -384,6 +387,18 @@ async function stepCreateCommission( description, ...(createdAt && { createdAt }), // TODO: Check this }, + select: { + id: true, + earnings: true, + currency: true, + customer: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, }); console.log(prettyPrint(commission)); @@ -392,9 +407,7 @@ async function stepCreateCommission( event !== "custom" ? firstCommission === null : undefined; return logAndReturn({ - commission: { - id: commission.id, - }, + commission, isFirstCommission, outputLog: `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`, }); @@ -421,7 +434,10 @@ async function stepCreateCommission( } async function stepRunSideEffects( - input: StepFunctionInput & { commission: Pick }, + input: StepFunctionInput & { + commission?: Pick | null; + customer?: Pick | null; + }, ) { const { commission: _commission, @@ -435,30 +451,39 @@ async function stepRunSideEffects( skipWorkflow, clickEvent, isFirstConversion, + customer, } = input; - const commission = await prisma.commission.findUnique({ + if (customer && eventId && clickEvent) { + await detectAndRecordFraudEvent({ + program: { id: programId }, + partner: pick(programEnrollment.partner, ["id", "email", "name"]), + programEnrollment: pick(programEnrollment, ["status"]), + customer: { + ...pick(customer, ["id", "email", "name"]), + isFirstConversion, + }, + link: { id: linkId }, + click: pick(clickEvent, ["url", "referer"]), + event: { id: eventId }, + }); + } + + if (!_commission) { + return logAndReturn({ + outputLog: "Commission was not created. Skipping side effects...", + }); + } + + const commission = await prisma.commission.findUniqueOrThrow({ where: { id: _commission.id, }, include: { - customer: true, link: true, }, }); - if (!commission) { - return logAndReturn({ - commission: null, - outputLog: `Commission ${_commission.id} not found, skipping side effects...`, - }); - } - - const webhookPartner = constructWebhookPartner(programEnrollment, { - totalCommissions: - toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, - }); - const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, @@ -492,11 +517,13 @@ async function stepRunSideEffects( }); const { workspace } = program; - const { customer } = commission; - const isClawback = commission.earnings < 0; const shouldTriggerWorkflow = !isClawback && !skipWorkflow; - const shouldRunFraudDetection = customer && eventId && clickEvent; + + const webhookPartner = constructWebhookPartner(programEnrollment, { + totalCommissions: + toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, + }); await Promise.allSettled([ sendWorkspaceWebhook({ @@ -513,7 +540,7 @@ async function stepRunSideEffects( event: "commission.created", data: { ...commission, - customer: commission.customer, + customer, }, }), @@ -538,6 +565,11 @@ async function stepRunSideEffects( where: { id: userId, }, + select: { + id: true, + email: true, + name: true, + }, }) : null; @@ -561,6 +593,7 @@ async function stepRunSideEffects( ], }), + // Execute Dub workflows shouldTriggerWorkflow && executeWorkflows({ trigger: "partnerMetricsUpdated", @@ -576,20 +609,5 @@ async function stepRunSideEffects( }, }, }), - - // Only run this for non-manual commissions - shouldRunFraudDetection && - detectAndRecordFraudEvent({ - program: { id: programId }, - partner: pick(webhookPartner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion, - }, - link: { id: linkId }, - click: pick(clickEvent, ["url", "referer"]), - event: { id: eventId }, - }), ]); } diff --git a/apps/web/lib/bounty/api/approve-bounty-submission.ts b/apps/web/lib/bounty/api/approve-bounty-submission.ts index fd288f70d3b..a5a2996317c 100644 --- a/apps/web/lib/bounty/api/approve-bounty-submission.ts +++ b/apps/web/lib/bounty/api/approve-bounty-submission.ts @@ -106,17 +106,6 @@ export async function approveBountySubmission({ }); } - await queuePartnerCommissionCreation({ - event: "custom", - partnerId: submission.partnerId, - programId: submission.programId, - amount: finalRewardAmount, - quantity: 1, - userId: user.id, - description: `Commission for successfully completed "${bounty.name}" bounty.`, - bountySubmissionId: submissionId, - }); - const approvedSubmission = await prisma.bountySubmission.update({ where: { id: submissionId, @@ -147,6 +136,17 @@ export async function approveBountySubmission({ }, }); + await queuePartnerCommissionCreation({ + event: "custom", + partnerId: submission.partnerId, + programId: submission.programId, + amount: finalRewardAmount, + quantity: 1, + userId: user.id, + description: `Commission for successfully completed "${bounty.name}" bounty.`, + bountySubmissionId: submissionId, + }); + const { program, partner } = approvedSubmission; waitUntil( From 4f33974865a91ffafa18d8fc61e6a0eead6902f6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 May 2026 22:38:52 +0530 Subject: [PATCH 13/34] Update route.ts --- .../(ee)/api/workflows/create-partner-commission/route.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 6939367d0d4..1bb94cfe00e 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -443,6 +443,7 @@ async function stepRunSideEffects( commission: _commission, programEnrollment, isFirstCommission, + event, programId, partnerId, userId, @@ -454,7 +455,12 @@ async function stepRunSideEffects( customer, } = input; - if (customer && eventId && clickEvent) { + // - sale: always evaluate + // - lead: only when a commission was created + const shouldRunFraudDetection = + event === "sale" || (_commission && event === "lead"); + + if (shouldRunFraudDetection && customer && eventId && clickEvent) { await detectAndRecordFraudEvent({ program: { id: programId }, partner: pick(programEnrollment.partner, ["id", "email", "name"]), From 90e7cedd175d54ddcdbbd164e2715538862ee27d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 29 May 2026 10:50:01 -0700 Subject: [PATCH 14/34] rename queuePartnerCommissionCreation, fix tests --- .../webhook/checkout-session-completed.ts | 2 +- .../integration/webhook/invoice-paid.ts | 2 +- .../create-partner-commission/route.ts | 129 +++--- .../lib/actions/partners/create-clawback.ts | 2 +- .../partners/create-manual-commission.ts | 7 +- apps/web/lib/api/conversions/track-lead.ts | 2 +- apps/web/lib/api/conversions/track-sale.ts | 2 +- .../bounty/api/approve-bounty-submission.ts | 2 +- .../lib/integrations/shopify/create-sale.ts | 2 +- .../lib/integrations/shopify/process-order.ts | 2 +- .../lib/partners/create-partner-commission.ts | 390 ------------------ .../queue-partner-commission-creation.ts | 38 ++ .../programs/backfill-reuse-commission.ts | 2 +- apps/web/tests/fraud/index.test.ts | 93 +---- apps/web/tests/utils/verify-fraud-event.ts | 82 ++++ 15 files changed, 185 insertions(+), 572 deletions(-) delete mode 100644 apps/web/lib/partners/create-partner-commission.ts create mode 100644 apps/web/lib/partners/queue-partner-commission-creation.ts create mode 100644 apps/web/tests/utils/verify-fraud-event.ts diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 05b2c30cef5..9b46ff42d70 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -5,7 +5,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getClickEvent, diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 3061743f40c..af3e3ff9df5 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -3,7 +3,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getLeadEvent, recordSale } from "@/lib/tinybird"; import { StripeMode } from "@/lib/types"; diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 1bb94cfe00e..7f50ac83523 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -1,4 +1,3 @@ -import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commission"; @@ -96,32 +95,30 @@ export const { POST } = serve( }, ); - if (commission) { - // Step 2: Link the commission to the bounty submission - if (bountySubmissionId) { - await context.run("set-bounty-commission", async () => { - const { count } = await prisma.bountySubmission.updateMany({ - where: { - id: bountySubmissionId, - status: "approved", - commissionId: null, - }, - data: { - commissionId: commission.id, - }, - }); - - if (count) { - return logAndReturn({ - outputLog: `Linked commission ${commission.id} to bounty submission ${bountySubmissionId}`, - }); - } else { - return logAndReturn({ - outputLog: `Bounty submission ${bountySubmissionId} not found or already linked to a commission, skipping...`, - }); - } + // Step 2 (optional): Link the commission to the bounty submission + if (commission && bountySubmissionId) { + await context.run("set-bounty-commission", async () => { + const { count } = await prisma.bountySubmission.updateMany({ + where: { + id: bountySubmissionId, + status: "approved", + commissionId: null, + }, + data: { + commissionId: commission.id, + }, }); - } + + if (count) { + return logAndReturn({ + outputLog: `Linked commission ${commission.id} to bounty submission ${bountySubmissionId}`, + }); + } else { + return logAndReturn({ + outputLog: `Bounty submission ${bountySubmissionId} not found or already linked to a commission, skipping...`, + }); + } + }); } // Step 3: Run side effects @@ -455,26 +452,6 @@ async function stepRunSideEffects( customer, } = input; - // - sale: always evaluate - // - lead: only when a commission was created - const shouldRunFraudDetection = - event === "sale" || (_commission && event === "lead"); - - if (shouldRunFraudDetection && customer && eventId && clickEvent) { - await detectAndRecordFraudEvent({ - program: { id: programId }, - partner: pick(programEnrollment.partner, ["id", "email", "name"]), - programEnrollment: pick(programEnrollment, ["status"]), - customer: { - ...pick(customer, ["id", "email", "name"]), - isFirstConversion, - }, - link: { id: linkId }, - click: pick(clickEvent, ["url", "referer"]), - event: { id: eventId }, - }); - } - if (!_commission) { return logAndReturn({ outputLog: "Commission was not created. Skipping side effects...", @@ -531,7 +508,13 @@ async function stepRunSideEffects( toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, }); - await Promise.allSettled([ + // Fraud detection should be run for: + // - sale events: always evaluate + // - lead events: only when a commission was created + const shouldRunFraudDetection = + event === "sale" || (_commission && event === "lead"); + + return await Promise.allSettled([ sendWorkspaceWebhook({ workspace, trigger: "commission.created", @@ -564,40 +547,6 @@ async function stepRunSideEffects( commission, isFirstCommission, }), - ]); - - const user = userId - ? await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, - }) - : null; - - await Promise.allSettled([ - // We only capture audit logs for manual commissions - user && - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: isClawback ? "clawback.created" : "commission.created", - description: isClawback - ? `Clawback created for ${partnerId}` - : `Commission created for ${partnerId}`, - actor: user, - targets: [ - { - type: isClawback ? "clawback" : "commission", - id: commission.id, - metadata: commission, - }, - ], - }), // Execute Dub workflows shouldTriggerWorkflow && @@ -615,5 +564,23 @@ async function stepRunSideEffects( }, }, }), + + // Run fraud detection + shouldRunFraudDetection && + customer && + eventId && + clickEvent && + detectAndRecordFraudEvent({ + program: { id: programId }, + partner: pick(programEnrollment.partner, ["id", "email", "name"]), + programEnrollment: pick(programEnrollment, ["status"]), + customer: { + ...pick(customer, ["id", "email", "name"]), + isFirstConversion, + }, + link: { id: linkId }, + click: pick(clickEvent, ["url", "referer"]), + event: { id: eventId }, + }), ]); } diff --git a/apps/web/lib/actions/partners/create-clawback.ts b/apps/web/lib/actions/partners/create-clawback.ts index ec23f6352a9..5372d8d10f1 100644 --- a/apps/web/lib/actions/partners/create-clawback.ts +++ b/apps/web/lib/actions/partners/create-clawback.ts @@ -2,7 +2,7 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { createClawbackSchema } from "@/lib/zod/schemas/commissions"; import { authActionClient } from "../safe-action"; import { throwIfNoPermission } from "../throw-if-no-permission"; diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index 3f3bc57b64e..a2ae60f68d4 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -7,7 +7,7 @@ import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-sta import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { recordClickZod, recordClickZodSchema, @@ -190,9 +190,9 @@ export const createManualCommissionAction = authActionClient ); } - if (stripeCustomerInvoices.length > 12) { + if (stripeCustomerInvoices.length > 36) { throw new Error( - `Too many Stripe invoices found for customer ${customer.email} (${stripeCustomerInvoices.length}). Please import the invoices manually.`, + `Too many Stripe invoices found for customer ${customer.email} (${stripeCustomerInvoices.length}). Please contact support.`, ); } @@ -380,7 +380,6 @@ export const createManualCommissionAction = authActionClient let queuedCommissions = 0; // create partner commissions (use a for loop to make sure the commissions are created in the correct order) - // TODO: migrate to use workflow to support bulk creation for (const c of commissionsToCreate) { await queuePartnerCommissionCreation(c); queuedCommissions++; diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 8365f2d96c0..9766018b56e 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -2,7 +2,7 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { getClickEvent, recordLead } from "@/lib/tinybird"; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 545f17584ed..f8661c4f804 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -3,7 +3,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { diff --git a/apps/web/lib/bounty/api/approve-bounty-submission.ts b/apps/web/lib/bounty/api/approve-bounty-submission.ts index a5a2996317c..c7bddecea10 100644 --- a/apps/web/lib/bounty/api/approve-bounty-submission.ts +++ b/apps/web/lib/bounty/api/approve-bounty-submission.ts @@ -3,7 +3,7 @@ import { DubApiError } from "@/lib/api/errors"; import { Session } from "@/lib/auth"; import { calculateSocialMetricsRewardAmount } from "@/lib/bounty/rewards"; import { resolveBountyDetails } from "@/lib/bounty/utils"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { approveBountySubmissionBodySchema, BountySubmissionSchema, diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index ed79cfd2bd6..3b89c4e8ca0 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -2,7 +2,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { recordSale } from "@/lib/tinybird"; import { LeadEventTB } from "@/lib/types"; diff --git a/apps/web/lib/integrations/shopify/process-order.ts b/apps/web/lib/integrations/shopify/process-order.ts index 794c57a4660..cd9cfcf9929 100644 --- a/apps/web/lib/integrations/shopify/process-order.ts +++ b/apps/web/lib/integrations/shopify/process-order.ts @@ -4,7 +4,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getLeadEvent, recordLead } from "@/lib/tinybird"; import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts deleted file mode 100644 index ac2f9ad117a..00000000000 --- a/apps/web/lib/partners/create-partner-commission.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; -import { triggerQStashWorkflow } from "../cron/qstash-workflow"; -import { CreatePartnerCommissionProps } from "../types"; -import { constructWebhookPartner } from "./constuct-webhook-partner"; - -export const queuePartnerCommissionCreation = async ( - params: CreatePartnerCommissionProps, -) => { - const { partnerId, programId, customerId, bountySubmissionId } = params; - - const result = await getProgramEnrollmentOrThrow({ - partnerId, - programId, - include: { - links: true, - partner: true, - }, - }); - - const { partner, links, ...programEnrollment } = result; - - await triggerQStashWorkflow({ - workflowType: "create-partner-commission", - workflowLabel: bountySubmissionId ?? customerId ?? partnerId, - body: params, - flowControl: { - key: customerId ?? partnerId, - parallelism: 1, - }, - }); - - return { - partner, - links, - programEnrollment, - webhookPartner: constructWebhookPartner(result), - }; - - // let earnings = 0; - // let reward: RewardProps | null = null; - // let status: CommissionStatus = "pending"; - - // let firstCommission: Pick< - // Commission, - // "rewardId" | "status" | "createdAt" - // > | null = null; - // if (event === "custom") { - // earnings = amount; - // amount = 0; - // } else { - // if (["lead", "sale"].includes(event) && customerId) { - // firstCommission = await prisma.commission.findFirst({ - // where: { - // partnerId, - // customerId, - // type: event, - // }, - // orderBy: { - // createdAt: "asc", - // }, - // select: { - // rewardId: true, - // status: true, - // createdAt: true, - // }, - // }); - // const subscriptionStartDate = - // event === "sale" ? firstCommission?.createdAt ?? new Date() : undefined; - // const subscriptionDurationMonths = subscriptionStartDate - // ? differenceInMonths( - // createdAt ?? new Date(), // account for custom commission creation date - // subscriptionStartDate, - // ) - // : 0; - // context = { - // ...context, - // customer: { - // ...context?.customer, - // subscriptionStartDate, - // subscriptionDurationMonths, - // }, - // ...(event === "sale" && { - // sale: { - // ...context?.sale, - // type: firstCommission ? "recurring" : "new", - // }, - // }), - // }; - // } - // reward = determinePartnerReward({ - // event, - // programEnrollment, - // context, - // }); - // // if there is no reward, skip commission creation - // if (!reward) { - // const outputLog = `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // // for click events, it's super simple – just multiply the reward amount by the quantity - // if (event === "click") { - // earnings = getRewardAmount(reward) * quantity; - // // for lead and sale events, we need to check if this partner-customer combination was recorded already (for deduplication) - // // for sale rewards specifically, we also need to check: - // // 1. if the partner has reached the max duration for the reward (if applicable) - // // 2. if the previous commission were marked as fraud or canceled - // } else { - // if (firstCommission) { - // // if first commission is fraud or canceled, skip commission creation - // if (["fraud", "canceled"].includes(firstCommission.status)) { - // const outputLog = `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // // for lead events, we need to check if the partner has already been issued a lead reward for this customer - // if (event === "lead") { - // const outputLog = `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // // 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, - // }, - // }); - // if ( - // typeof originalReward?.maxDuration === "number" && - // originalReward.maxDuration === 0 - // ) { - // const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // } - // // 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) { - // const outputLog = `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // // Recurring sale reward (maxDuration > 0) - // else { - // const subscriptionDurationMonths = differenceInMonths( - // createdAt ?? new Date(), // account for custom commission creation date - // firstCommission.createdAt, - // ); - // if (subscriptionDurationMonths >= reward.maxDuration) { - // const outputLog = `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`; - // console.log(outputLog); - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // } - // } - // } - // } - // // for lead events, we just multiply the reward amount by the quantity - // if (event === "lead") { - // earnings = getRewardAmount(reward) * quantity; - // // for sale events, we need to calculate the earnings based on the sale amount - // } else { - // earnings = calculateSaleEarnings({ - // reward, - // sale: { quantity, amount }, - // }); - // } - // } - // } - // // skip commission creation if the earnings is zero - // if (earnings === 0) { - // console.log( - // `Partner ${partnerId} has zero earnings for ${event} event, skipping commission creation...`, - // ); - // return { - // commission: null, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } - // try { - // const commission = await prisma.commission.create({ - // data: { - // id: createId({ prefix: "cm_" }), - // programId, - // partnerId, - // rewardId: reward?.id, - // customerId, - // linkId, - // eventId: eventId || null, // empty string should convert to null - // invoiceId: invoiceId || null, // empty string should convert to null - // userId: user?.id, - // quantity, - // amount, - // type: event, - // currency, - // earnings, - // status, - // description, - // createdAt, - // }, - // include: { - // customer: true, - // link: { - // select: { - // id: true, - // shortLink: true, - // domain: true, - // key: true, - // }, - // }, - // }, - // }); - // const outputLog = `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}`; - // console.log(outputLog); - // console.log(prettyPrint(commission)); - // const webhookPartner = constructWebhookPartner(programEnrollment, { - // // check links metrics - // totalCommissions: - // toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, - // }); - // waitUntil( - // (async () => { - // const program = await prisma.program.findUniqueOrThrow({ - // where: { - // id: programId, - // }, - // select: { - // id: true, - // name: true, - // slug: true, - // logo: true, - // supportEmail: true, - // workspace: { - // select: { - // id: true, - // slug: true, - // name: true, - // webhookEnabled: true, - // }, - // }, - // // if no partner group is found, need to fetch default group to fallback to - // ...(!programEnrollment.partnerGroup && { - // groups: { - // select: { - // holdingPeriodDays: true, - // }, - // where: { - // slug: DEFAULT_PARTNER_GROUP.slug, - // }, - // }, - // }), - // }, - // }); - // const { workspace } = program; - // const isClawback = commission.earnings < 0; - // const shouldTriggerWorkflow = !isClawback && !skipWorkflow; - // await Promise.allSettled([ - // sendWorkspaceWebhook({ - // workspace, - // trigger: "commission.created", - // data: CommissionWebhookSchema.parse({ - // ...commission, - // partner: webhookPartner, - // }), - // }), - // sendPartnerPostback({ - // partnerId, - // event: "commission.created", - // data: { - // ...commission, - // customer: commission.customer, - // }, - // }), - // syncTotalCommissions({ - // partnerId, - // programId, - // }), - // !isClawback && - // notifyPartnerCommission({ - // program, - // // fallback to default group if no partner group is found - // group: programEnrollment.partnerGroup ?? program.groups[0], - // workspace, - // commission, - // isFirstCommission: firstCommission === null, - // }), - // // We only capture audit logs for manual commissions - // user && - // recordAuditLog({ - // workspaceId: workspace.id, - // programId, - // action: isClawback ? "clawback.created" : "commission.created", - // description: isClawback - // ? `Clawback created for ${partnerId}` - // : `Commission created for ${partnerId}`, - // actor: user, - // targets: [ - // { - // type: isClawback ? "clawback" : "commission", - // id: commission.id, - // metadata: commission, - // }, - // ], - // }), - // shouldTriggerWorkflow && - // executeWorkflows({ - // trigger: "partnerMetricsUpdated", - // reason: "commission", - // identity: { - // workspaceId: workspace.id, - // programId, - // partnerId, - // }, - // metrics: { - // current: { - // commissions: commission.earnings, - // }, - // }, - // }), - // ]); - // })(), - // ); - // return { - // commission, - // outputLog, - // programEnrollment, - // webhookPartner, - // }; - // } catch (error) { - // const outputLog = `Error creating commission - ${error.message}`; - // console.error(outputLog); - // // only log to Slack if the error is not a unique constraint violation - // if (error.code !== "P2002") { - // await log({ - // message: outputLog, - // type: "errors", - // mention: true, - // }); - // } - // return { - // commission: null, - // outputLog, - // programEnrollment, - // webhookPartner: constructWebhookPartner(programEnrollment), - // }; - // } -}; diff --git a/apps/web/lib/partners/queue-partner-commission-creation.ts b/apps/web/lib/partners/queue-partner-commission-creation.ts new file mode 100644 index 00000000000..b504c46e34d --- /dev/null +++ b/apps/web/lib/partners/queue-partner-commission-creation.ts @@ -0,0 +1,38 @@ +import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; +import { triggerQStashWorkflow } from "../cron/qstash-workflow"; +import { CreatePartnerCommissionProps } from "../types"; +import { constructWebhookPartner } from "./constuct-webhook-partner"; + +export const queuePartnerCommissionCreation = async ( + params: CreatePartnerCommissionProps, +) => { + const { partnerId, programId, customerId, bountySubmissionId } = params; + + const result = await getProgramEnrollmentOrThrow({ + partnerId, + programId, + include: { + links: true, + partner: true, + }, + }); + + const { partner, links, ...programEnrollment } = result; + + await triggerQStashWorkflow({ + workflowType: "create-partner-commission", + workflowLabel: bountySubmissionId ?? customerId ?? partnerId, + body: params, + flowControl: { + key: customerId ?? partnerId, + parallelism: 1, + }, + }); + + return { + partner, + links, + programEnrollment, + webhookPartner: constructWebhookPartner(result), + }; +}; diff --git a/apps/web/scripts/programs/backfill-reuse-commission.ts b/apps/web/scripts/programs/backfill-reuse-commission.ts index dfed16c2edd..07a18cd7d88 100644 --- a/apps/web/scripts/programs/backfill-reuse-commission.ts +++ b/apps/web/scripts/programs/backfill-reuse-commission.ts @@ -4,7 +4,7 @@ import { updateLinkStatsForImporter } from "@/lib/api/links/update-link-stats-fo import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { qstash } from "@/lib/cron"; -import { queuePartnerCommissionCreation } from "@/lib/partners/create-partner-commission"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; import { getCustomerEventsTB } from "@/lib/tinybird/get-customer-events-tb"; import { recordClickZod, diff --git a/apps/web/tests/fraud/index.test.ts b/apps/web/tests/fraud/index.test.ts index 21bf74b52ae..dd9b5b6e87a 100644 --- a/apps/web/tests/fraud/index.test.ts +++ b/apps/web/tests/fraud/index.test.ts @@ -1,19 +1,14 @@ import { extractEmailDomain } from "@/lib/email/extract-email-domain"; -import { Customer, TrackLeadResponse } from "@/lib/types"; -import { - CustomerEmailMatchType, - fraudEventSchemas, -} from "@/lib/zod/schemas/fraud"; -import { FraudRuleType, Partner } from "@dub/prisma/client"; -import { randomCustomer, retry } from "tests/utils/helpers"; -import { HttpClient } from "tests/utils/http"; +import { TrackLeadResponse } from "@/lib/types"; +import { CustomerEmailMatchType } from "@/lib/zod/schemas/fraud"; +import { randomCustomer } from "tests/utils/helpers"; import { E2E_FRAUD_PARTNER, E2E_FRAUD_REFERRAL_SOURCE_BANNED_DOMAIN, E2E_TRACK_CLICK_HEADERS, } from "tests/utils/resource"; -import { describe, expect, test } from "vitest"; -import * as z from "zod/v4"; +import { verifyFraudEvent } from "tests/utils/verify-fraud-event"; +import { describe, test } from "vitest"; import { IntegrationHarness } from "../utils/integration"; describe.concurrent("/fraud/**", async () => { @@ -269,81 +264,3 @@ describe.concurrent("/fraud/**", async () => { }); }); }); - -const verifyFraudEvent = async ({ - http, - partner, - customer, - ruleType, - metadata, -}: { - http: HttpClient; - partner: Pick; - customer: Pick; - ruleType: FraudRuleType; - metadata?: Record; -}) => { - // Resolve customerId from customerExternalID - const { data: customers } = await http.get({ - path: "/customers", - query: { externalId: customer.externalId }, - }); - - expect(customers.length).toBeGreaterThan(0); - - // Wait until fraud event is available - const fraudEvent = await waitForFraudEvent({ - http, - customerId: customers[0].id, - ruleType, - }); - - // Assert fraud event shape - expect(fraudEvent).toStrictEqual({ - createdAt: expect.any(String), - partner: expect.objectContaining({ - id: partner.id, - name: partner.name, - email: partner.email, - image: partner.image, - }), - ...(metadata && { metadata }), - customer: expect.objectContaining({ - id: customers[0].id, - name: customers[0].name, - email: customers[0].email, - avatar: customers[0].avatar, - }), - }); -}; - -async function waitForFraudEvent({ - http, - customerId, - ruleType, -}: { - http: HttpClient; - customerId: string; - ruleType: FraudRuleType; -}) { - return await retry( - async () => { - const { data } = await http.get< - z.infer<(typeof fraudEventSchemas)[keyof typeof fraudEventSchemas]>[] - >({ - path: "/fraud/events", - query: { - customerId, - type: ruleType, - }, - }); - - if (!data.length) { - throw new Error("Fraud event not ready."); - } - - return data[0]; - }, - { retries: 10, interval: 600 }, - ); -} diff --git a/apps/web/tests/utils/verify-fraud-event.ts b/apps/web/tests/utils/verify-fraud-event.ts new file mode 100644 index 00000000000..aa8190bbb58 --- /dev/null +++ b/apps/web/tests/utils/verify-fraud-event.ts @@ -0,0 +1,82 @@ +import { Customer } from "@/lib/types"; +import { fraudEventSchemas } from "@/lib/zod/schemas/fraud"; +import { FraudRuleType, Partner } from "@dub/prisma/client"; +import { HttpClient } from "tests/utils/http"; +import { expect } from "vitest"; +import * as z from "zod/v4"; + +const POLL_INTERVAL_MS = 5000; // 5 seconds +const TIMEOUT_MS = 45000; // 45 seconds + +export const verifyFraudEvent = async ({ + http, + partner, + customer, + ruleType, + metadata, +}: { + http: HttpClient; + partner: Pick; + customer: Pick; + ruleType: FraudRuleType; + metadata?: Record; +}) => { + // Resolve customerId from customerExternalID + const { data: customers } = await http.get({ + path: "/customers", + query: { externalId: customer.externalId }, + }); + + expect(customers.length).toBeGreaterThan(0); + + // Poll for fraud event every 5 seconds, timeout after 45 seconds + const startTime = Date.now(); + let fraudEvent: + | z.infer<(typeof fraudEventSchemas)[keyof typeof fraudEventSchemas]> + | undefined; + + while (Date.now() - startTime < TIMEOUT_MS) { + const { status, data } = await http.get< + z.infer<(typeof fraudEventSchemas)[keyof typeof fraudEventSchemas]>[] + >({ + path: "/fraud/events", + query: { + customerId: customers[0].id, + type: ruleType, + }, + }); + + if (status === 200 && data.length > 0) { + fraudEvent = data[0]; + break; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + if (!fraudEvent) { + throw new Error( + `Fraud event not found within ${TIMEOUT_MS / 1000} seconds. ` + + `Query: ${JSON.stringify({ customerId: customers[0].id, type: ruleType })}`, + ); + } + + // Assert fraud event shape + expect(fraudEvent).toStrictEqual({ + createdAt: expect.any(String), + partner: expect.objectContaining({ + id: partner.id, + name: partner.name, + email: partner.email, + image: partner.image, + }), + ...(metadata && { metadata }), + customer: expect.objectContaining({ + id: customers[0].id, + name: customers[0].name, + email: customers[0].email, + avatar: customers[0].avatar, + }), + }); +}; From 5f844badac548e77708a8a784d5a3ce12b07b4b4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 30 May 2026 21:41:19 -0700 Subject: [PATCH 15/34] increase timeout --- apps/web/tests/utils/verify-commission.ts | 2 +- apps/web/tests/utils/verify-fraud-event.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/utils/verify-commission.ts b/apps/web/tests/utils/verify-commission.ts index 13cee18ab9e..6c72b93e689 100644 --- a/apps/web/tests/utils/verify-commission.ts +++ b/apps/web/tests/utils/verify-commission.ts @@ -11,7 +11,7 @@ interface VerifyCommissionProps { } const POLL_INTERVAL_MS = 5000; // 5 seconds -const TIMEOUT_MS = 45000; // 45 seconds +const TIMEOUT_MS = 60000; // 60 seconds export const verifyCommission = async ({ http, diff --git a/apps/web/tests/utils/verify-fraud-event.ts b/apps/web/tests/utils/verify-fraud-event.ts index aa8190bbb58..69ab7de229a 100644 --- a/apps/web/tests/utils/verify-fraud-event.ts +++ b/apps/web/tests/utils/verify-fraud-event.ts @@ -6,7 +6,7 @@ import { expect } from "vitest"; import * as z from "zod/v4"; const POLL_INTERVAL_MS = 5000; // 5 seconds -const TIMEOUT_MS = 45000; // 45 seconds +const TIMEOUT_MS = 60000; // 60 seconds export const verifyFraudEvent = async ({ http, From 56c582ce0bdbb43bfd4a2024176b8c88df3702a5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 31 May 2026 15:34:07 +0530 Subject: [PATCH 16/34] Update verify-fraud-event.ts --- apps/web/tests/utils/verify-fraud-event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/utils/verify-fraud-event.ts b/apps/web/tests/utils/verify-fraud-event.ts index 69ab7de229a..3ee21dba4f3 100644 --- a/apps/web/tests/utils/verify-fraud-event.ts +++ b/apps/web/tests/utils/verify-fraud-event.ts @@ -6,7 +6,7 @@ import { expect } from "vitest"; import * as z from "zod/v4"; const POLL_INTERVAL_MS = 5000; // 5 seconds -const TIMEOUT_MS = 60000; // 60 seconds +const TIMEOUT_MS = 120000; // 120 seconds export const verifyFraudEvent = async ({ http, From 0f6236febd7bb6c01185793d69d8c262458edacb Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 31 May 2026 12:50:39 -0700 Subject: [PATCH 17/34] finalize workflow --- .../create-partner-commission/route.ts | 81 ++++++++----------- apps/web/tests/utils/verify-fraud-event.ts | 2 +- 2 files changed, 33 insertions(+), 50 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 7f50ac83523..314ee1f4e55 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -4,7 +4,7 @@ import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commi import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; -import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { executeWorkflows as executeDubWorkflows } from "@/lib/api/workflows/execute-workflows"; import { logger } from "@/lib/axiom/server"; import { getWorkflowConfig } from "@/lib/cron/qstash-workflow"; import { constructWebhookPartner } from "@/lib/partners/constuct-webhook-partner"; @@ -22,20 +22,13 @@ import { prisma } from "@dub/prisma"; import { Commission, CommissionStatus, - Customer, Link, Partner, PartnerGroup, ProgramEnrollment, Reward, } from "@dub/prisma/client"; -import { - currencyFormatter, - log, - pick, - prettyPrint, - toCentsNumber, -} from "@dub/utils"; +import { currencyFormatter, log, pick, toCentsNumber } from "@dub/utils"; import { WorkflowRetryAfterError } from "@upstash/workflow"; import { serve } from "@upstash/workflow/nextjs"; import { differenceInMonths } from "date-fns"; @@ -59,8 +52,7 @@ type StepFunctionInput = Input & { }; type StepCreateCommissionOutput = { - commission?: Pick | null; - customer?: Pick | null; + commission: Pick | null; outputLog: string; isFirstCommission?: boolean; }; @@ -85,7 +77,7 @@ export const { POST } = serve( }); // Step 1: Create commission - const { commission, customer, isFirstCommission } = await context.run( + const { commission, isFirstCommission } = await context.run( "create-commission", async () => { return await stepCreateCommission({ @@ -127,7 +119,6 @@ export const { POST } = serve( ...input, programEnrollment, commission, - customer, isFirstCommission, }); }); @@ -382,24 +373,10 @@ async function stepCreateCommission( earnings, status, description, - ...(createdAt && { createdAt }), // TODO: Check this - }, - select: { - id: true, - earnings: true, - currency: true, - customer: { - select: { - id: true, - email: true, - name: true, - }, - }, + createdAt, }, }); - console.log(prettyPrint(commission)); - const isFirstCommission = event !== "custom" ? firstCommission === null : undefined; @@ -432,24 +409,20 @@ async function stepCreateCommission( async function stepRunSideEffects( input: StepFunctionInput & { - commission?: Pick | null; - customer?: Pick | null; + commission: Pick | null; }, ) { const { commission: _commission, programEnrollment, isFirstCommission, - event, programId, partnerId, - userId, linkId, eventId, skipWorkflow, clickEvent, isFirstConversion, - customer, } = input; if (!_commission) { @@ -463,7 +436,15 @@ async function stepRunSideEffects( id: _commission.id, }, include: { - link: true, + customer: true, + link: { + select: { + id: true, + shortLink: true, + domain: true, + key: true, + }, + }, }, }); @@ -508,13 +489,7 @@ async function stepRunSideEffects( toCentsNumber(programEnrollment.totalCommissions) + commission.earnings, }); - // Fraud detection should be run for: - // - sale events: always evaluate - // - lead events: only when a commission was created - const shouldRunFraudDetection = - event === "sale" || (_commission && event === "lead"); - - return await Promise.allSettled([ + const results = await Promise.allSettled([ sendWorkspaceWebhook({ workspace, trigger: "commission.created", @@ -527,10 +502,7 @@ async function stepRunSideEffects( sendPartnerPostback({ partnerId, event: "commission.created", - data: { - ...commission, - customer, - }, + data: commission, }), syncTotalCommissions({ @@ -550,7 +522,7 @@ async function stepRunSideEffects( // Execute Dub workflows shouldTriggerWorkflow && - executeWorkflows({ + executeDubWorkflows({ trigger: "partnerMetricsUpdated", reason: "commission", identity: { @@ -566,8 +538,7 @@ async function stepRunSideEffects( }), // Run fraud detection - shouldRunFraudDetection && - customer && + commission.customer && eventId && clickEvent && detectAndRecordFraudEvent({ @@ -575,7 +546,7 @@ async function stepRunSideEffects( partner: pick(programEnrollment.partner, ["id", "email", "name"]), programEnrollment: pick(programEnrollment, ["status"]), customer: { - ...pick(customer, ["id", "email", "name"]), + ...pick(commission.customer, ["id", "email", "name"]), isFirstConversion, }, link: { id: linkId }, @@ -583,4 +554,16 @@ async function stepRunSideEffects( event: { id: eventId }, }), ]); + + return [ + "sendWorkspaceWebhook", + "sendPartnerPostback", + "syncTotalCommissions", + "notifyPartnerCommission", + "executeWorkflows", + "detectAndRecordFraudEvent", + ].map((step, index) => ({ + step, + result: results[index], + })); } diff --git a/apps/web/tests/utils/verify-fraud-event.ts b/apps/web/tests/utils/verify-fraud-event.ts index 3ee21dba4f3..69ab7de229a 100644 --- a/apps/web/tests/utils/verify-fraud-event.ts +++ b/apps/web/tests/utils/verify-fraud-event.ts @@ -6,7 +6,7 @@ import { expect } from "vitest"; import * as z from "zod/v4"; const POLL_INTERVAL_MS = 5000; // 5 seconds -const TIMEOUT_MS = 120000; // 120 seconds +const TIMEOUT_MS = 60000; // 60 seconds export const verifyFraudEvent = async ({ http, From ad443a18cbc3fdbc46bd970929bd812c5598bd0d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 31 May 2026 13:03:58 -0700 Subject: [PATCH 18/34] fix isFirstConversion flag --- .../app/(ee)/api/workflows/create-partner-commission/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 314ee1f4e55..21046eaa717 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -547,7 +547,8 @@ async function stepRunSideEffects( programEnrollment: pick(programEnrollment, ["status"]), customer: { ...pick(commission.customer, ["id", "email", "name"]), - isFirstConversion, + // only pass along isFirstConversion if it's a boolean + ...(typeof isFirstConversion === "boolean" && { isFirstConversion }), }, link: { id: linkId }, click: pick(clickEvent, ["url", "referer"]), From ceca508e6af4de35aa87e53f6ea3a5bb48da2ff0 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sun, 31 May 2026 13:21:33 -0700 Subject: [PATCH 19/34] address coderabbit feedback --- .../create-partner-commission/route.ts | 29 +++++++++---------- .../partners/create-manual-commission.ts | 4 +-- apps/web/lib/zod/schemas/commissions.ts | 1 + apps/web/tests/utils/verify-commission.ts | 2 +- apps/web/tests/utils/verify-fraud-event.ts | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 21046eaa717..a1deaae1d45 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -4,7 +4,7 @@ import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commi import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; -import { executeWorkflows as executeDubWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { logger } from "@/lib/axiom/server"; import { getWorkflowConfig } from "@/lib/cron/qstash-workflow"; import { constructWebhookPartner } from "@/lib/partners/constuct-webhook-partner"; @@ -21,7 +21,6 @@ import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { Commission, - CommissionStatus, Link, Partner, PartnerGroup, @@ -87,7 +86,17 @@ export const { POST } = serve( }, ); - // Step 2 (optional): Link the commission to the bounty submission + // Step 2: Run side effects + await context.run("run-side-effects", async () => { + return await stepRunSideEffects({ + ...input, + programEnrollment, + commission, + isFirstCommission, + }); + }); + + // Step 3 (optional): Link the created commission to the bounty submission if (commission && bountySubmissionId) { await context.run("set-bounty-commission", async () => { const { count } = await prisma.bountySubmission.updateMany({ @@ -112,16 +121,6 @@ export const { POST } = serve( } }); } - - // Step 3: Run side effects - await context.run("run-side-effects", async () => { - return await stepRunSideEffects({ - ...input, - programEnrollment, - commission, - isFirstCommission, - }); - }); }, { initialPayloadParser: (requestPayload) => { @@ -170,6 +169,7 @@ async function stepCreateCommission( currency, description, createdAt, + status, userId, context, programEnrollment, @@ -181,7 +181,6 @@ async function stepCreateCommission( let earnings = 0; let reward: RewardProps | null = null; - let status: CommissionStatus = "pending"; let firstCommission: Pick< Commission, "rewardId" | "status" | "createdAt" @@ -522,7 +521,7 @@ async function stepRunSideEffects( // Execute Dub workflows shouldTriggerWorkflow && - executeDubWorkflows({ + executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "commission", identity: { diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index a2ae60f68d4..74e5383b258 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -264,9 +264,9 @@ export const createManualCommissionAction = authActionClient ...(stripeCustomerInvoices.find( (invoice) => invoice.id === saleEvent.invoice_id, )?.refunded && { - status: "refunded", + status: "refunded" as const, }), - user, + userId: user.id, context: { customer: { country: customer.country }, }, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 6f9f10c286e..68e94142748 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -445,6 +445,7 @@ export const createPartnerCommissionSchema = z.object({ currency: z.string().optional(), description: z.string().nullish(), createdAt: z.coerce.date().optional(), + status: commissionPatchStatusSchema.optional(), // used for create-manual-commission (import commission as refunded) userId: z.string().optional(), context: rewardContextSchema.optional(), skipWorkflow: z.boolean().default(false).optional(), diff --git a/apps/web/tests/utils/verify-commission.ts b/apps/web/tests/utils/verify-commission.ts index 6c72b93e689..ed5f10c0452 100644 --- a/apps/web/tests/utils/verify-commission.ts +++ b/apps/web/tests/utils/verify-commission.ts @@ -43,7 +43,7 @@ export const verifyCommission = async ({ query.customerId = customerId; } - // Poll for commission every 5 seconds, timeout after 45 seconds + // Poll for commission every 5 seconds, timeout after 60 seconds const startTime = Date.now(); while (Date.now() - startTime < TIMEOUT_MS) { diff --git a/apps/web/tests/utils/verify-fraud-event.ts b/apps/web/tests/utils/verify-fraud-event.ts index 69ab7de229a..0661a607b42 100644 --- a/apps/web/tests/utils/verify-fraud-event.ts +++ b/apps/web/tests/utils/verify-fraud-event.ts @@ -29,7 +29,7 @@ export const verifyFraudEvent = async ({ expect(customers.length).toBeGreaterThan(0); - // Poll for fraud event every 5 seconds, timeout after 45 seconds + // Poll for fraud event every 5 seconds, timeout after 60 seconds const startTime = Date.now(); let fraudEvent: | z.infer<(typeof fraudEventSchemas)[keyof typeof fraudEventSchemas]> From 3254564d07b40abf00aac77ad92b7c37d6d5aa6e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 1 Jun 2026 17:40:35 +0530 Subject: [PATCH 20/34] Create referral commissions in workflows instead of payout cron jobs --- .../commissions/referrals/create/route.ts | 26 --- .../cron/commissions/referrals/queue/route.ts | 161 ------------------ .../charge-succeeded/send-paypal-payouts.ts | 13 +- .../create-partner-commission/route.ts | 11 ++ .../api/workflows/partner-approved/route.ts | 1 + .../create-referral-commission.ts | 29 ++-- .../lib/partners/create-stablecoin-payout.ts | 29 +--- .../lib/partners/create-stripe-transfer.ts | 29 +--- 8 files changed, 43 insertions(+), 256 deletions(-) delete mode 100644 apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts delete mode 100644 apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts deleted file mode 100644 index 1f24ae87244..00000000000 --- a/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { withCron } from "@/lib/cron/with-cron"; -import { createReferralCommission } from "@/lib/partner-referrals/create-referral-commission"; -import * as z from "zod/v4"; -import { logAndRespond } from "../../../utils"; - -export const dynamic = "force-dynamic"; - -const inputSchema = z.union([ - z.object({ sourceCommissionId: z.string() }), - z.object({ programId: z.string(), partnerId: z.string() }), -]); - -// POST /api/cron/commissions/referrals/create -export const POST = withCron(async ({ rawBody }) => { - const inputParsed = inputSchema.parse(JSON.parse(rawBody)); - - const referralCommission = await createReferralCommission(inputParsed); - - if (referralCommission === null) { - return logAndRespond("Referral commission creation skipped."); - } - - return logAndRespond( - `Referral commission ${referralCommission.id} created successfully.`, - ); -}); diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts deleted file mode 100644 index fa104f1d9d5..00000000000 --- a/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; -import { withCron } from "@/lib/cron/with-cron"; -import { createNetworkReferralCommission } from "@/lib/partner-referrals/create-network-referral-commission"; -import { referralRewardConfigSchema } from "@/lib/zod/schemas/rewards"; -import { prisma } from "@dub/prisma"; -import { CommissionType } from "@dub/prisma/client"; -import { APP_DOMAIN_WITH_NGROK, NETWORK_PROGRAM_ID } from "@dub/utils"; -import * as z from "zod/v4"; -import { logAndRespond } from "../../../utils"; - -export const dynamic = "force-dynamic"; - -const inputSchema = z.object({ - payoutId: z.string(), -}); - -// POST /api/cron/commissions/referrals/queue -export const POST = withCron(async ({ rawBody }) => { - const { payoutId } = inputSchema.parse(JSON.parse(rawBody)); - - const payout = await prisma.payout.findUnique({ - where: { - id: payoutId, - }, - select: { - id: true, - status: true, - programId: true, - amount: true, - partner: { - select: { - id: true, - referredByPartnerId: true, - }, - }, - programEnrollment: { - select: { - applicationEvent: { - select: { - referredByPartnerId: true, - }, - }, - }, - }, - commissions: { - where: { - type: CommissionType.sale, - }, - select: { - id: true, - }, - }, - invoice: { - select: { - amount: true, - fee: true, - }, - }, - }, - }); - - if (!payout) { - return logAndRespond(`Payout ${payoutId} not found.`); - } - - if (payout.programId === NETWORK_PROGRAM_ID) { - return logAndRespond( - `Payout ${payoutId} is from Network program. Skipping...`, - ); - } - - if (!["sent", "completed"].includes(payout.status)) { - return logAndRespond( - `Payout ${payoutId} is not in a valid status to create referrals.`, - ); - } - - const { programId, partner, programEnrollment, commissions } = payout; - - // Check the program level referral reward - const referredByPartnerId = - programEnrollment?.applicationEvent?.referredByPartnerId; - - if (referredByPartnerId) { - const referrerEnrollment = await prisma.programEnrollment.findUnique({ - where: { - partnerId_programId: { - programId, - partnerId: referredByPartnerId, - }, - }, - select: { - partnerId: true, - referralReward: true, - }, - }); - - if (!referrerEnrollment) { - return logAndRespond( - `Referrer partner ${referredByPartnerId} is not enrolled in program ${programId}.`, - ); - } - - const { referralReward } = referrerEnrollment; - - if (referralReward) { - const { trigger } = referralRewardConfigSchema.parse( - referralReward.config, - ); - - if (trigger === "commissionThreshold") { - await enqueueBatchJobs([ - { - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/create`, - deduplicationId: `create-referral-commissions-${payout.id}`, - body: { - programId, - partnerId: partner.id, - }, - }, - ]); - - return logAndRespond(`Enqueued referral-eligible payout ${payout.id}.`); - } - - await enqueueBatchJobs( - commissions.map((commission) => ({ - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/create`, - deduplicationId: `create-referral-commissions-${commission.id}`, - body: { - sourceCommissionId: commission.id, - }, - })), - ); - - return logAndRespond( - `Enqueued ${commissions.length} referral-eligible commissions for payout ${payout.id}.`, - ); - } - } - - // Fallback to network level bonus - if (partner.referredByPartnerId) { - const commission = await createNetworkReferralCommission({ - partner, - payout, - }); - - if (commission) { - return logAndRespond( - `Created network referral commission for payout ${payout.id}.`, - ); - } - } - - return logAndRespond( - `No referral commission created for payout ${payout.id}.`, - ); -}); diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index 041749c5cab..f388b0bc1d5 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -1,10 +1,9 @@ -import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import { createPayPalBatchPayout } from "@/lib/paypal/create-batch-payout"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; -import { APP_DOMAIN_WITH_NGROK, currencyFormatter } from "@dub/utils"; +import { currencyFormatter } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; export async function sendPaypalPayouts(invoice: Pick) { @@ -79,16 +78,6 @@ export async function sendPaypalPayouts(invoice: Pick) { }, })), ), - - enqueueBatchJobs( - payouts.map((payout) => ({ - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, - body: { - payoutId: payout.id, - }, - })), - ), ]), ); } diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index a1deaae1d45..46fc6b77a5d 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -7,6 +7,7 @@ import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { logger } from "@/lib/axiom/server"; import { getWorkflowConfig } from "@/lib/cron/qstash-workflow"; +import { createReferralCommission } from "@/lib/partner-referrals/create-referral-commission"; import { constructWebhookPartner } from "@/lib/partners/constuct-webhook-partner"; import { determinePartnerReward } from "@/lib/partners/determine-partner-reward"; import { getRewardAmount } from "@/lib/partners/get-reward-amount"; @@ -121,6 +122,16 @@ export const { POST } = serve( } }); } + + // Step 4: Create referral commission + if (commission) { + await context.run("create-referral-commission", async () => { + return await createReferralCommission({ + source: "commission", + sourceCommissionId: commission.id, + }); + }); + } }, { initialPayloadParser: (requestPayload) => { diff --git a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts index 136c388d087..ec98c935dc5 100644 --- a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts +++ b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts @@ -320,6 +320,7 @@ export const { POST } = serve( // Step 7: Create referral commission if enabled await context.run("create-referral-commission", async () => { await createReferralCommission({ + source: "partner", partnerId, programId, }); diff --git a/apps/web/lib/partner-referrals/create-referral-commission.ts b/apps/web/lib/partner-referrals/create-referral-commission.ts index 2d4d28b498d..07a6f19cc1b 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -11,14 +11,21 @@ import { sendPartnerPostback } from "../postback/send-partner-postback"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; -type CreateReferralCommissionProps = - | { sourceCommissionId: string; partnerId?: never; programId?: never } // Based on a source commission - | { sourceCommissionId?: never; partnerId: string; programId: string }; // Based on partner approval and commission threshold +type CreateReferralCommissionArgs = + | { + source: "commission"; // based on a source commission + sourceCommissionId: string; + } + | { + source: "partner"; // based on partner approval and commission threshold + partnerId: string; + programId: string; + }; export const createReferralCommission = async ( - props: CreateReferralCommissionProps, + args: CreateReferralCommissionArgs, ) => { - const context = await resolveReferralContext(props); + const context = await resolveReferralContext(args); if (!context) { return null; @@ -27,7 +34,7 @@ export const createReferralCommission = async ( const { sourceCommission, programId, partnerId, referredByPartnerId } = context; - if (partnerId && referredByPartnerId && partnerId === referredByPartnerId) { + if (partnerId === referredByPartnerId) { console.log( `Skipping referral commission creation for self-referral (partner ${partnerId}).`, ); @@ -350,9 +357,9 @@ function generateCommissionDescription({ return null; } -async function resolveReferralContext(props: CreateReferralCommissionProps) { - if (props.sourceCommissionId) { - const { sourceCommissionId } = props; +async function resolveReferralContext(args: CreateReferralCommissionArgs) { + if (args.source === "commission") { + const { sourceCommissionId } = args; const sourceCommission = await prisma.commission.findUnique({ where: { @@ -411,8 +418,8 @@ async function resolveReferralContext(props: CreateReferralCommissionProps) { }; } - if (props.partnerId && props.programId) { - const { partnerId, programId } = props; + if (args.source === "partner") { + const { partnerId, programId } = args; const programEnrollment = await prisma.programEnrollment.findUnique({ where: { diff --git a/apps/web/lib/partners/create-stablecoin-payout.ts b/apps/web/lib/partners/create-stablecoin-payout.ts index c8d1c2bcd7f..96e575d91ba 100644 --- a/apps/web/lib/partners/create-stablecoin-payout.ts +++ b/apps/web/lib/partners/create-stablecoin-payout.ts @@ -4,11 +4,7 @@ import PartnerPayoutForceWithdrawal from "@dub/email/templates/partner-payout-fo import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { PartnerPayoutMethod, Prisma } from "@dub/prisma/client"; -import { - APP_DOMAIN_WITH_NGROK, - currencyFormatter, - prettyPrint, -} from "@dub/utils"; +import { currencyFormatter, prettyPrint } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { BELOW_MIN_WITHDRAWAL_FEE_CENTS, @@ -16,7 +12,6 @@ import { MIN_WITHDRAWAL_AMOUNT_CENTS, STABLECOIN_PAYOUT_FEE_RATE, } from "../constants/payouts"; -import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; import { createStripeOutboundPayment } from "../stripe/create-stripe-outbound-payment"; @@ -281,23 +276,11 @@ export const createStablecoinPayout = async ({ ]); waitUntil( - Promise.allSettled([ - trackCommissionStatusUpdatesByProgram({ - commissions, - payouts: allPayouts, - newStatus: "paid", - }), - - enqueueBatchJobs( - payoutIds.map((payoutId) => ({ - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, - body: { - payoutId, - }, - })), - ), - ]), + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), ); if (!partner.email) { diff --git a/apps/web/lib/partners/create-stripe-transfer.ts b/apps/web/lib/partners/create-stripe-transfer.ts index 7b94f621dbe..52e9452b249 100644 --- a/apps/web/lib/partners/create-stripe-transfer.ts +++ b/apps/web/lib/partners/create-stripe-transfer.ts @@ -10,13 +10,8 @@ import PartnerPayoutForceWithdrawal from "@dub/email/templates/partner-payout-fo import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; -import { - APP_DOMAIN_WITH_NGROK, - currencyFormatter, - pluralize, -} from "@dub/utils"; +import { currencyFormatter, pluralize } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; -import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; @@ -248,23 +243,11 @@ export const createStripeTransfer = async ({ ]); waitUntil( - Promise.allSettled([ - trackCommissionStatusUpdatesByProgram({ - commissions, - payouts: allPayouts, - newStatus: "paid", - }), - - enqueueBatchJobs( - payoutIds.map((payoutId) => ({ - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, - body: { - payoutId, - }, - })), - ), - ]), + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), ); if (partner.email) { From 216d7c33e65039a27c2f6119e8dacf6a21fc267a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 1 Jun 2026 18:13:29 +0530 Subject: [PATCH 21/34] Return skip reasons from createReferralCommission --- .../create-referral-commission.ts | 100 ++++++++++-------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/apps/web/lib/partner-referrals/create-referral-commission.ts b/apps/web/lib/partner-referrals/create-referral-commission.ts index 07a6f19cc1b..866fb19137b 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -22,30 +22,38 @@ type CreateReferralCommissionArgs = programId: string; }; +type CreateReferralCommissionResult = { + commission: Commission | null; + reason: string | null; +}; + export const createReferralCommission = async ( args: CreateReferralCommissionArgs, -) => { +): Promise => { const context = await resolveReferralContext(args); if (!context) { - return null; + return { + commission: null, + reason: "No context found", + }; } const { sourceCommission, programId, partnerId, referredByPartnerId } = context; if (partnerId === referredByPartnerId) { - console.log( - `Skipping referral commission creation for self-referral (partner ${partnerId}).`, - ); - return null; + return { + commission: null, + reason: `Skipping referral commission creation for self-referral (partner ${partnerId}).`, + }; } if (programId === NETWORK_PROGRAM_ID) { - console.log( - `Skipping referral commission creation for network program ${programId}...`, - ); - return null; + return { + commission: null, + reason: `Skipping referral commission creation for network program ${programId}...`, + }; } const referrerProgramEnrollment = await prisma.programEnrollment.findUnique({ @@ -64,19 +72,19 @@ export const createReferralCommission = async ( }); if (!referrerProgramEnrollment) { - console.log( - `Referrer partner ${referredByPartnerId} is not enrolled in the program ${programId}.`, - ); - return null; + return { + commission: null, + reason: `Referrer partner ${referredByPartnerId} is not enrolled in the program ${programId}.`, + }; } const { referralReward } = referrerProgramEnrollment; if (!referralReward) { - console.log( - `Referrer partner ${referredByPartnerId} has no referral reward for the group in program ${programId}.`, - ); - return null; + return { + commission: null, + reason: `Referrer partner ${referredByPartnerId} has no referral reward for the group in program ${programId}.`, + }; } const rewardConfig = referralRewardConfigSchema.safeParse( @@ -84,8 +92,10 @@ export const createReferralCommission = async ( ); if (!rewardConfig.success) { - console.log(`Referral reward ${referralReward.id} has an invalid config.`); - return null; + return { + commission: null, + reason: `Referral reward ${referralReward.id} has an invalid config.`, + }; } let commissionData: Prisma.CommissionUncheckedCreateInput = { @@ -131,10 +141,10 @@ export const createReferralCommission = async ( // Recurring if (monthsSinceFirstCommission >= referralReward.maxDuration) { - console.log( - `Referrer ${referredByPartnerId} reached max duration (${referralReward.maxDuration} months) for referred partner ${partnerId} for the customer ${sourceCommission.customerId}.`, - ); - return null; + return { + commission: null, + reason: `Referrer ${referredByPartnerId} reached max duration (${referralReward.maxDuration} months) for referred partner ${partnerId} for the customer ${sourceCommission.customerId}.`, + }; } } } @@ -183,10 +193,10 @@ export const createReferralCommission = async ( }); if ((totalCommissionsEarned ?? 0) < (commissionsThresholdInCents ?? 0)) { - console.log( - `Referrer ${referredByPartnerId} has not reached the commission threshold for referred partner ${partnerId}.`, - ); - return null; + return { + commission: null, + reason: `Referrer ${referredByPartnerId} has not reached the commission threshold for referred partner ${partnerId}.`, + }; } commissionData = { @@ -198,14 +208,17 @@ export const createReferralCommission = async ( // When reward is based on unknown trigger else { - console.log( - `Invalid trigger ${trigger} for referral reward ${referralReward.id}.`, - ); - return null; + return { + commission: null, + reason: `Invalid trigger ${trigger} for referral reward ${referralReward.id}.`, + }; } if (commissionData.earnings === 0) { - return null; + return { + commission: null, + reason: "Skipping referral commission creation for zero earnings...", + }; } // Check if the commission already exists using the invoiceId @@ -223,10 +236,10 @@ export const createReferralCommission = async ( }); if (existingCommission) { - console.log( - `Referral commission ${existingCommission.id} already exists for the invoiceId ${commissionData.invoiceId}.`, - ); - return null; + return { + commission: null, + reason: `Referral commission ${existingCommission.id} already exists for the invoiceId ${commissionData.invoiceId}.`, + }; } } @@ -246,10 +259,10 @@ export const createReferralCommission = async ( // 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; + return { + commission: null, + reason: `Referral commission already exists for invoiceId ${commissionData.invoiceId}, skipping creation.`, + }; } console.error("Error creating referral commission", error, commissionData); @@ -315,7 +328,10 @@ export const createReferralCommission = async ( }), ]); - return commission; + return { + commission, + reason: null, + }; }; function generateCommissionDescription({ From ef08b517a606b967ecfdcb3fde4926796057c9c3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 1 Jun 2026 18:22:42 +0530 Subject: [PATCH 22/34] Restore referral commission cron route for backfill jobs --- .../commissions/referrals/create/route.ts | 44 +++++++++++++++++++ .../create-referral-commission.ts | 2 +- .../web/scripts/dev/test-partner-referrals.ts | 25 ----------- 3 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts delete mode 100644 apps/web/scripts/dev/test-partner-referrals.ts diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts new file mode 100644 index 00000000000..b5d277f208d --- /dev/null +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts @@ -0,0 +1,44 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { + createReferralCommission, + CreateReferralCommissionArgs, +} from "@/lib/partner-referrals/create-referral-commission"; +import * as z from "zod/v4"; +import { logAndRespond } from "../../../utils"; + +export const dynamic = "force-dynamic"; + +const inputSchema = z.union([ + z.object({ sourceCommissionId: z.string() }), + z.object({ programId: z.string(), partnerId: z.string() }), +]); + +// POST /api/cron/commissions/referrals/create +export const POST = withCron(async ({ rawBody }) => { + const inputParsed = inputSchema.parse(JSON.parse(rawBody)); + + let args: CreateReferralCommissionArgs; + + if ("sourceCommissionId" in inputParsed) { + args = { + source: "commission", + sourceCommissionId: inputParsed.sourceCommissionId, + }; + } else { + args = { + source: "partner", + programId: inputParsed.programId, + partnerId: inputParsed.partnerId, + }; + } + + const { commission, reason } = await createReferralCommission(args); + + if (commission) { + return logAndRespond( + `Referral commission ${commission.id} created successfully.`, + ); + } + + return logAndRespond(reason ?? "Referral commission creation skipped."); +}); diff --git a/apps/web/lib/partner-referrals/create-referral-commission.ts b/apps/web/lib/partner-referrals/create-referral-commission.ts index 866fb19137b..0d2f450665f 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -11,7 +11,7 @@ import { sendPartnerPostback } from "../postback/send-partner-postback"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; -type CreateReferralCommissionArgs = +export type CreateReferralCommissionArgs = | { source: "commission"; // based on a source commission sourceCommissionId: string; diff --git a/apps/web/scripts/dev/test-partner-referrals.ts b/apps/web/scripts/dev/test-partner-referrals.ts deleted file mode 100644 index 6a1b3f638d5..00000000000 --- a/apps/web/scripts/dev/test-partner-referrals.ts +++ /dev/null @@ -1,25 +0,0 @@ -import "dotenv-flow/config"; - -import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; - -async function main() { - const payouts = await prisma.payout.findMany({ - where: { - programId: "prog_1K2J9DRWPPJ2F1RX53N92TSGA", - }, - }); - - await enqueueBatchJobs( - payouts.map(({ id }) => ({ - queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, - body: { - payoutId: id, - }, - })), - ); -} - -main(); From 9ea82423204e80dcf78bad9ec296f6b69c88e948 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 1 Jun 2026 19:18:42 +0530 Subject: [PATCH 23/34] Queue network referral commissions on payout completion --- .../cron/commissions/referrals/queue/route.ts | 89 +++++++++++++++++++ .../charge-succeeded/send-paypal-payouts.ts | 13 ++- .../lib/partners/create-stablecoin-payout.ts | 29 ++++-- .../lib/partners/create-stripe-transfer.ts | 29 ++++-- 4 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts new file mode 100644 index 00000000000..31829f8df78 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts @@ -0,0 +1,89 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { createNetworkReferralCommission } from "@/lib/partner-referrals/create-network-referral-commission"; +import { prisma } from "@dub/prisma"; +import { CommissionType } from "@dub/prisma/client"; +import { NETWORK_PROGRAM_ID } from "@dub/utils"; +import * as z from "zod/v4"; +import { logAndRespond } from "../../../utils"; + +export const dynamic = "force-dynamic"; + +const inputSchema = z.object({ + payoutId: z.string(), +}); + +// Queue a payout to create Network referral commissions +// POST /api/cron/commissions/referrals/queue +export const POST = withCron(async ({ rawBody }) => { + const { payoutId } = inputSchema.parse(JSON.parse(rawBody)); + + const payout = await prisma.payout.findUnique({ + where: { + id: payoutId, + }, + select: { + id: true, + status: true, + programId: true, + amount: true, + partner: { + select: { + id: true, + referredByPartnerId: true, + }, + }, + programEnrollment: { + select: { + applicationEvent: { + select: { + referredByPartnerId: true, + }, + }, + }, + }, + commissions: { + where: { + type: CommissionType.sale, + }, + select: { + id: true, + }, + }, + }, + }); + + if (!payout) { + return logAndRespond(`Payout ${payoutId} not found.`); + } + + if (payout.programId === NETWORK_PROGRAM_ID) { + return logAndRespond( + `Payout ${payoutId} is from Network program. Skipping...`, + ); + } + + if (!["sent", "completed"].includes(payout.status)) { + return logAndRespond( + `Payout ${payoutId} is not in a valid status to create referrals.`, + ); + } + + const { partner } = payout; + + if (partner.referredByPartnerId) { + const commission = await createNetworkReferralCommission({ + partner, + payout, + }); + + if (commission) { + return logAndRespond( + `Created network referral commission for payout ${payout.id}.`, + ); + } + } + + return logAndRespond( + `No referral commission created for payout ${payout.id}.`, + ); +}); diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index f388b0bc1d5..041749c5cab 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -1,9 +1,10 @@ +import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import { createPayPalBatchPayout } from "@/lib/paypal/create-batch-payout"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; -import { currencyFormatter } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, currencyFormatter } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; export async function sendPaypalPayouts(invoice: Pick) { @@ -78,6 +79,16 @@ export async function sendPaypalPayouts(invoice: Pick) { }, })), ), + + enqueueBatchJobs( + payouts.map((payout) => ({ + queueName: "create-referral-commissions", + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + body: { + payoutId: payout.id, + }, + })), + ), ]), ); } diff --git a/apps/web/lib/partners/create-stablecoin-payout.ts b/apps/web/lib/partners/create-stablecoin-payout.ts index 96e575d91ba..c8d1c2bcd7f 100644 --- a/apps/web/lib/partners/create-stablecoin-payout.ts +++ b/apps/web/lib/partners/create-stablecoin-payout.ts @@ -4,7 +4,11 @@ import PartnerPayoutForceWithdrawal from "@dub/email/templates/partner-payout-fo import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { PartnerPayoutMethod, Prisma } from "@dub/prisma/client"; -import { currencyFormatter, prettyPrint } from "@dub/utils"; +import { + APP_DOMAIN_WITH_NGROK, + currencyFormatter, + prettyPrint, +} from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { BELOW_MIN_WITHDRAWAL_FEE_CENTS, @@ -12,6 +16,7 @@ import { MIN_WITHDRAWAL_AMOUNT_CENTS, STABLECOIN_PAYOUT_FEE_RATE, } from "../constants/payouts"; +import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; import { createStripeOutboundPayment } from "../stripe/create-stripe-outbound-payment"; @@ -276,11 +281,23 @@ export const createStablecoinPayout = async ({ ]); waitUntil( - trackCommissionStatusUpdatesByProgram({ - commissions, - payouts: allPayouts, - newStatus: "paid", - }), + Promise.allSettled([ + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), + + enqueueBatchJobs( + payoutIds.map((payoutId) => ({ + queueName: "create-referral-commissions", + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + body: { + payoutId, + }, + })), + ), + ]), ); if (!partner.email) { diff --git a/apps/web/lib/partners/create-stripe-transfer.ts b/apps/web/lib/partners/create-stripe-transfer.ts index 52e9452b249..7b94f621dbe 100644 --- a/apps/web/lib/partners/create-stripe-transfer.ts +++ b/apps/web/lib/partners/create-stripe-transfer.ts @@ -10,8 +10,13 @@ import PartnerPayoutForceWithdrawal from "@dub/email/templates/partner-payout-fo import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; -import { currencyFormatter, pluralize } from "@dub/utils"; +import { + APP_DOMAIN_WITH_NGROK, + currencyFormatter, + pluralize, +} from "@dub/utils"; import { waitUntil } from "@vercel/functions"; +import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; @@ -243,11 +248,23 @@ export const createStripeTransfer = async ({ ]); waitUntil( - trackCommissionStatusUpdatesByProgram({ - commissions, - payouts: allPayouts, - newStatus: "paid", - }), + Promise.allSettled([ + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), + + enqueueBatchJobs( + payoutIds.map((payoutId) => ({ + queueName: "create-referral-commissions", + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + body: { + payoutId, + }, + })), + ), + ]), ); if (partner.email) { From 4ec7c3613fb121f36f4047a961adbce720663f14 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 15:39:17 +0530 Subject: [PATCH 24/34] Void referral commissions when source commission is refunded --- .../integration/webhook/charge-refunded.ts | 9 ++ .../commissions/void-referral-commissions.ts | 103 ++++++++++++++++++ packages/prisma/schema/commission.prisma | 1 + 3 files changed, 113 insertions(+) create mode 100644 apps/web/lib/api/commissions/void-referral-commissions.ts diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts index c1719e1710d..beacd09fe38 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts @@ -1,8 +1,10 @@ import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; +import { voidReferralCommissions } from "@/lib/api/commissions/void-referral-commissions"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; import { prisma } from "@dub/prisma"; +import { CommissionStatus } from "@dub/prisma/client"; import type Stripe from "stripe"; // Handle event "charge.refunded" @@ -140,6 +142,13 @@ export async function chargeRefunded( newStatus: "refunded", }); + await voidReferralCommissions({ + workspaceId: workspace.id, + programId: commission.programId, + sourceCommissionIds: [commission.id], + sourceCommissionStatus: CommissionStatus.refunded, + }); + return { response: `Commission ${commission.id} updated to status "refunded"`, workspaceId, diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts new file mode 100644 index 00000000000..3661dc6960f --- /dev/null +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -0,0 +1,103 @@ +import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; +import { prisma } from "@dub/prisma"; +import { CommissionStatus } from "@dub/prisma/client"; +import { syncTotalCommissions } from "../partners/sync-total-commissions"; +import { trackCommissionStatusUpdate } from "./track-commission-update-activity-log"; + +const VOID_STATUSES: CommissionStatus[] = [ + "refunded", + "duplicate", + "fraud", + "canceled", +]; + +type VoidReferralCommissionsArgs = { + workspaceId: string; + programId: string; + userId?: string; + sourceCommissionIds: string[]; + sourceCommissionStatus: (typeof VOID_STATUSES)[number]; +}; + +export async function voidReferralCommissions({ + workspaceId, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, +}: VoidReferralCommissionsArgs) { + if (sourceCommissionIds.length === 0) { + console.log("No source commission IDs provided."); + return; + } + + const referralCommissions = await prisma.commission.findMany({ + where: { + sourceCommissionId: { + in: sourceCommissionIds, + }, + OR: [ + { + status: "pending", + }, + { + status: "processed", + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }, + select: { + id: true, + partnerId: true, + amount: true, + earnings: true, + status: true, + }, + }); + + if (referralCommissions.length === 0) { + console.log("No referral commissions found."); + return; + } + + const { count } = await prisma.commission.updateMany({ + where: { + id: { + in: referralCommissions.map((c) => c.id), + }, + }, + data: { + status: sourceCommissionStatus, + payoutId: null, + }, + }); + + // Find unique partner Ids + const partnerIds = [...new Set(referralCommissions.map((c) => c.partnerId))]; + + await Promise.allSettled([ + ...partnerIds.map((partnerId) => + syncTotalCommissions({ + partnerId, + programId, + }), + ), + + trackCommissionStatusUpdate({ + workspaceId, + programId, + userId, + commissions: referralCommissions, + newStatus: sourceCommissionStatus, + }), + ]); + + // TODO: + // If payout is created, we need to reduce the payout total + + console.log(`Voided ${count} referral commissions.`); +} diff --git a/packages/prisma/schema/commission.prisma b/packages/prisma/schema/commission.prisma index 64e5d321add..c3fda67013a 100644 --- a/packages/prisma/schema/commission.prisma +++ b/packages/prisma/schema/commission.prisma @@ -61,4 +61,5 @@ model Commission { @@index(status) @@index(rewardId) @@index(userId) + @@index(sourceCommissionId) } From 2fa0ad9dc84564ad440d6e20ab1c5f8d1fa28996 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 15:43:12 +0530 Subject: [PATCH 25/34] Void referral commissions on manual commission status updates --- .../commissions/bulk-update-partner-commissions.ts | 8 ++++++++ .../lib/api/commissions/update-partner-commission.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts index e855ac49c6b..ed14622a541 100644 --- a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts +++ b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts @@ -7,6 +7,7 @@ import { DubApiError } from "../errors"; import { syncTotalCommissions } from "../partners/sync-total-commissions"; import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; import { trackCommissionActivityLog } from "./track-commission-update-activity-log"; +import { voidReferralCommissions } from "./void-referral-commissions"; type BulkUpdatePartnerCommissionsProps = z.infer< typeof bulkUpdateCommissionsSchema @@ -164,6 +165,13 @@ export async function bulkUpdatePartnerCommissions({ old: commissions, new: updatedCommissions, }), + + voidReferralCommissions({ + workspaceId, + programId, + sourceCommissionIds: commissionIds, + sourceCommissionStatus: status, + }), ]), ); diff --git a/apps/web/lib/api/commissions/update-partner-commission.ts b/apps/web/lib/api/commissions/update-partner-commission.ts index 6217ba0662e..30fa035defb 100644 --- a/apps/web/lib/api/commissions/update-partner-commission.ts +++ b/apps/web/lib/api/commissions/update-partner-commission.ts @@ -15,6 +15,7 @@ import { trackCommissionActivityLog, trackCommissionStatusUpdate, } from "./track-commission-update-activity-log"; +import { voidReferralCommissions } from "./void-referral-commissions"; type UpdatePartnerCommissionProps = z.infer< typeof updateCommissionSchemaExtended @@ -315,6 +316,16 @@ export async function updatePartnerCommission({ newStatus: finalStatus!, }) : Promise.resolve(), + + voidReferralCommissions({ + workspaceId, + programId, + sourceCommissionIds: [ + commission.id, + ...relatedCommissions.map(({ id }) => id), + ], + sourceCommissionStatus: finalStatus!, + }), ]), ); From 3ef13799ce7c304967739908fb33b90fd4497fd6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 15:49:22 +0530 Subject: [PATCH 26/34] Update void-referral-commissions.ts --- .../commissions/void-referral-commissions.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts index 3661dc6960f..4c47824f7c0 100644 --- a/apps/web/lib/api/commissions/void-referral-commissions.ts +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -2,6 +2,7 @@ import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; import { prisma } from "@dub/prisma"; import { CommissionStatus } from "@dub/prisma/client"; import { syncTotalCommissions } from "../partners/sync-total-commissions"; +import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; import { trackCommissionStatusUpdate } from "./track-commission-update-activity-log"; const VOID_STATUSES: CommissionStatus[] = [ @@ -56,6 +57,7 @@ export async function voidReferralCommissions({ amount: true, earnings: true, status: true, + payoutId: true, }, }); @@ -79,7 +81,21 @@ export async function voidReferralCommissions({ // Find unique partner Ids const partnerIds = [...new Set(referralCommissions.map((c) => c.partnerId))]; + // Reconcile payout amounts for all affected payouts + const affectedPayoutIds = [ + ...referralCommissions + .filter( + (commission) => + commission.status === "processed" && commission.payoutId, + ) + .map((commission) => commission.payoutId!), + ]; + await Promise.allSettled([ + affectedPayoutIds.length > 0 + ? reconcilePayoutAmounts(affectedPayoutIds) + : Promise.resolve(), + ...partnerIds.map((partnerId) => syncTotalCommissions({ partnerId, @@ -96,8 +112,5 @@ export async function voidReferralCommissions({ }), ]); - // TODO: - // If payout is created, we need to reduce the payout total - console.log(`Voided ${count} referral commissions.`); } From b3de67fc63b64aeab404c18aa90ae9f4a4468ae5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 15:53:21 +0530 Subject: [PATCH 27/34] Update bulk-update-partner-commissions.ts --- .../bulk-update-partner-commissions.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts index ed14622a541..964f446fe7f 100644 --- a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts +++ b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts @@ -166,12 +166,16 @@ export async function bulkUpdatePartnerCommissions({ new: updatedCommissions, }), - voidReferralCommissions({ - workspaceId, - programId, - sourceCommissionIds: commissionIds, - sourceCommissionStatus: status, - }), + ...(status !== "pending" + ? [ + voidReferralCommissions({ + workspaceId, + programId, + sourceCommissionIds: commissionIds, + sourceCommissionStatus: status, + }), + ] + : []), ]), ); From e93b1d5e7e4cd023f5b84f7d40795548b3876956 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 17:11:03 +0530 Subject: [PATCH 28/34] Address CR feedback --- .../api/workflows/partner-approved/route.ts | 16 ++- .../commissions/update-partner-commission.ts | 20 +-- .../commissions/void-referral-commissions.ts | 133 ++++++++++++------ 3 files changed, 108 insertions(+), 61 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts index ec98c935dc5..03fe22e15f6 100644 --- a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts +++ b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts @@ -61,6 +61,7 @@ export const { POST } = serve( program, partner, links: existingPartnerLinks, + applicationEvent, ...programEnrollment } = await getProgramEnrollmentOrThrow({ programId, @@ -69,6 +70,7 @@ export const { POST } = serve( program: true, partner: true, links: true, + applicationEvent: true, }, }); @@ -318,13 +320,15 @@ export const { POST } = serve( }); // Step 7: Create referral commission if enabled - await context.run("create-referral-commission", async () => { - await createReferralCommission({ - source: "partner", - partnerId, - programId, + if (applicationEvent?.referredByPartnerId) { + await context.run("create-referral-commission", async () => { + await createReferralCommission({ + source: "partner", + partnerId, + programId, + }); }); - }); + } }, { initialPayloadParser: (requestPayload) => { diff --git a/apps/web/lib/api/commissions/update-partner-commission.ts b/apps/web/lib/api/commissions/update-partner-commission.ts index 30fa035defb..02b2d8d31cb 100644 --- a/apps/web/lib/api/commissions/update-partner-commission.ts +++ b/apps/web/lib/api/commissions/update-partner-commission.ts @@ -317,15 +317,17 @@ export async function updatePartnerCommission({ }) : Promise.resolve(), - voidReferralCommissions({ - workspaceId, - programId, - sourceCommissionIds: [ - commission.id, - ...relatedCommissions.map(({ id }) => id), - ], - sourceCommissionStatus: finalStatus!, - }), + finalStatus && + finalStatus !== "pending" && + voidReferralCommissions({ + workspaceId, + programId, + sourceCommissionIds: [ + commission.id, + ...relatedCommissions.map(({ id }) => id), + ], + sourceCommissionStatus: finalStatus!, + }), ]), ); diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts index 4c47824f7c0..d06fcbe889b 100644 --- a/apps/web/lib/api/commissions/void-referral-commissions.ts +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -1,6 +1,6 @@ import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; import { prisma } from "@dub/prisma"; -import { CommissionStatus } from "@dub/prisma/client"; +import { CommissionStatus, Prisma } from "@dub/prisma/client"; import { syncTotalCommissions } from "../partners/sync-total-commissions"; import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; import { trackCommissionStatusUpdate } from "./track-commission-update-activity-log"; @@ -32,63 +32,104 @@ export async function voidReferralCommissions({ return; } - const referralCommissions = await prisma.commission.findMany({ - where: { - sourceCommissionId: { - in: sourceCommissionIds, + const whereInput: Prisma.CommissionWhereInput = { + sourceCommissionId: { + in: sourceCommissionIds, + }, + OR: [ + { + status: "pending" as const, }, - OR: [ - { - status: "pending", + { + status: "processed" as const, + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }; + + const { originalCommissions, voidedCommissions } = await prisma.$transaction( + async (tx) => { + const commissions = await tx.commission.findMany({ + where: whereInput, + select: { + id: true, + partnerId: true, + amount: true, + earnings: true, + status: true, + payoutId: true, }, - { - status: "processed", - payout: { - status: { - in: MUTABLE_PAYOUT_STATUSES, - }, + }); + + if (commissions.length === 0) { + return { + originalCommissions: commissions, + voidedCommissions: [], + }; + } + + const { count } = await tx.commission.updateMany({ + where: whereInput, + data: { + status: sourceCommissionStatus, + payoutId: null, + }, + }); + + // No commissions were updated + if (count === 0) { + return { + originalCommissions: commissions, + voidedCommissions: [], + }; + } + + const voidedCommissions = await tx.commission.findMany({ + where: { + sourceCommissionId: { + in: sourceCommissionIds, }, + status: sourceCommissionStatus, }, - ], - }, - select: { - id: true, - partnerId: true, - amount: true, - earnings: true, - status: true, - payoutId: true, + select: { + id: true, + partnerId: true, + amount: true, + earnings: true, + status: true, + payoutId: true, + }, + }); + + return { + originalCommissions: commissions, + voidedCommissions, + }; }, - }); + ); - if (referralCommissions.length === 0) { + if (voidedCommissions.length === 0) { console.log("No referral commissions found."); return; } - const { count } = await prisma.commission.updateMany({ - where: { - id: { - in: referralCommissions.map((c) => c.id), - }, - }, - data: { - status: sourceCommissionStatus, - payoutId: null, - }, - }); - // Find unique partner Ids - const partnerIds = [...new Set(referralCommissions.map((c) => c.partnerId))]; + const partnerIds = [...new Set(voidedCommissions.map((c) => c.partnerId))]; // Reconcile payout amounts for all affected payouts const affectedPayoutIds = [ - ...referralCommissions - .filter( - (commission) => - commission.status === "processed" && commission.payoutId, - ) - .map((commission) => commission.payoutId!), + ...new Set( + originalCommissions + .filter( + (commission) => + commission.status === "processed" && commission.payoutId, + ) + .map((commission) => commission.payoutId!), + ), ]; await Promise.allSettled([ @@ -107,10 +148,10 @@ export async function voidReferralCommissions({ workspaceId, programId, userId, - commissions: referralCommissions, + commissions: originalCommissions, newStatus: sourceCommissionStatus, }), ]); - console.log(`Voided ${count} referral commissions.`); + console.log(`Voided ${originalCommissions.length} referral commissions.`); } From 6c1b0d0f46406ef12c1916689d1d4c61f085413d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 17:38:50 +0530 Subject: [PATCH 29/34] Only create referral commissions for sale commissions Gate referral creation on sale commission type, return createReferralCommission from partner-approved workflow, and count paid sales toward referral earnings limits. --- .../(ee)/api/workflows/create-partner-commission/route.ts | 5 +++-- apps/web/app/(ee)/api/workflows/partner-approved/route.ts | 2 +- apps/web/lib/partner-referrals/create-referral-commission.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index 46fc6b77a5d..18c51397a46 100644 --- a/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts +++ b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts @@ -22,6 +22,7 @@ import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { Commission, + CommissionType, Link, Partner, PartnerGroup, @@ -52,7 +53,7 @@ type StepFunctionInput = Input & { }; type StepCreateCommissionOutput = { - commission: Pick | null; + commission: Pick | null; outputLog: string; isFirstCommission?: boolean; }; @@ -124,7 +125,7 @@ export const { POST } = serve( } // Step 4: Create referral commission - if (commission) { + if (commission && commission.type === CommissionType.sale) { await context.run("create-referral-commission", async () => { return await createReferralCommission({ source: "commission", diff --git a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts index 03fe22e15f6..ee225c31a02 100644 --- a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts +++ b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts @@ -322,7 +322,7 @@ export const { POST } = serve( // Step 7: Create referral commission if enabled if (applicationEvent?.referredByPartnerId) { await context.run("create-referral-commission", async () => { - await createReferralCommission({ + return await createReferralCommission({ source: "partner", partnerId, programId, diff --git a/apps/web/lib/partner-referrals/create-referral-commission.ts b/apps/web/lib/partner-referrals/create-referral-commission.ts index 0d2f450665f..626b9f80696 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -186,6 +186,7 @@ export const createReferralCommission = async ( partnerId, programId, type: "sale", + status: "paid", }, _sum: { earnings: true, From fc6a69cde48643dae23f511f5944279bc9909ffd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 18:08:39 +0530 Subject: [PATCH 30/34] Rename network referral cron from referrals/queue to referrals/network --- .../referrals/{queue => network}/route.ts | 57 ++++--------------- .../charge-succeeded/send-paypal-payouts.ts | 2 +- .../lib/partners/create-stablecoin-payout.ts | 2 +- .../lib/partners/create-stripe-transfer.ts | 2 +- 4 files changed, 15 insertions(+), 48 deletions(-) rename apps/web/app/(ee)/api/cron/commissions/referrals/{queue => network}/route.ts (50%) diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/network/route.ts similarity index 50% rename from apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts rename to apps/web/app/(ee)/api/cron/commissions/referrals/network/route.ts index 31829f8df78..cdab414a95a 100644 --- a/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/network/route.ts @@ -1,8 +1,6 @@ import { withCron } from "@/lib/cron/with-cron"; import { createNetworkReferralCommission } from "@/lib/partner-referrals/create-network-referral-commission"; import { prisma } from "@dub/prisma"; -import { CommissionType } from "@dub/prisma/client"; -import { NETWORK_PROGRAM_ID } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; @@ -12,8 +10,8 @@ const inputSchema = z.object({ payoutId: z.string(), }); -// Queue a payout to create Network referral commissions -// POST /api/cron/commissions/referrals/queue +// POST /api/cron/commissions/referrals/network +// Creates a network referral commission for the referrer when a referred partner's payout is sent or completed. export const POST = withCron(async ({ rawBody }) => { const { payoutId } = inputSchema.parse(JSON.parse(rawBody)); @@ -21,34 +19,13 @@ export const POST = withCron(async ({ rawBody }) => { where: { id: payoutId, }, - select: { - id: true, - status: true, - programId: true, - amount: true, + include: { partner: { select: { id: true, referredByPartnerId: true, }, }, - programEnrollment: { - select: { - applicationEvent: { - select: { - referredByPartnerId: true, - }, - }, - }, - }, - commissions: { - where: { - type: CommissionType.sale, - }, - select: { - id: true, - }, - }, }, }); @@ -56,31 +33,21 @@ export const POST = withCron(async ({ rawBody }) => { return logAndRespond(`Payout ${payoutId} not found.`); } - if (payout.programId === NETWORK_PROGRAM_ID) { - return logAndRespond( - `Payout ${payoutId} is from Network program. Skipping...`, - ); - } - if (!["sent", "completed"].includes(payout.status)) { return logAndRespond( - `Payout ${payoutId} is not in a valid status to create referrals.`, + `Payout ${payoutId} is not in a valid status to create referral commissions.`, ); } - const { partner } = payout; - - if (partner.referredByPartnerId) { - const commission = await createNetworkReferralCommission({ - partner, - payout, - }); + const commission = await createNetworkReferralCommission({ + partner: payout.partner, + payout, + }); - if (commission) { - return logAndRespond( - `Created network referral commission for payout ${payout.id}.`, - ); - } + if (commission) { + return logAndRespond( + `Created network referral commission for payout ${payout.id}.`, + ); } return logAndRespond( diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index 041749c5cab..2c9adb34e9b 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -83,7 +83,7 @@ export async function sendPaypalPayouts(invoice: Pick) { enqueueBatchJobs( payouts.map((payout) => ({ queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/network`, body: { payoutId: payout.id, }, diff --git a/apps/web/lib/partners/create-stablecoin-payout.ts b/apps/web/lib/partners/create-stablecoin-payout.ts index c8d1c2bcd7f..303e6491d03 100644 --- a/apps/web/lib/partners/create-stablecoin-payout.ts +++ b/apps/web/lib/partners/create-stablecoin-payout.ts @@ -291,7 +291,7 @@ export const createStablecoinPayout = async ({ enqueueBatchJobs( payoutIds.map((payoutId) => ({ queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/network`, body: { payoutId, }, diff --git a/apps/web/lib/partners/create-stripe-transfer.ts b/apps/web/lib/partners/create-stripe-transfer.ts index 7b94f621dbe..6df1da053fa 100644 --- a/apps/web/lib/partners/create-stripe-transfer.ts +++ b/apps/web/lib/partners/create-stripe-transfer.ts @@ -258,7 +258,7 @@ export const createStripeTransfer = async ({ enqueueBatchJobs( payoutIds.map((payoutId) => ({ queueName: "create-referral-commissions", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/queue`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/network`, body: { payoutId, }, From f68af1b7d13feed986186a90662af2a3c91bf71c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 18:10:42 +0530 Subject: [PATCH 31/34] Format --- .../app/(ee)/api/admin/payouts/get-payouts-timeseries.ts | 2 +- .../(ee)/settings/webhooks/[webhookId]/page-client.tsx | 7 ++++--- apps/web/app/providers.tsx | 6 +++++- apps/web/playwright/workspaces/billing-trial.spec.ts | 8 ++++---- apps/web/ui/partners/partner-link-selector.tsx | 3 ++- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(ee)/api/admin/payouts/get-payouts-timeseries.ts b/apps/web/app/(ee)/api/admin/payouts/get-payouts-timeseries.ts index 5256f4931e9..fa559dc71d2 100644 --- a/apps/web/app/(ee)/api/admin/payouts/get-payouts-timeseries.ts +++ b/apps/web/app/(ee)/api/admin/payouts/get-payouts-timeseries.ts @@ -1,5 +1,5 @@ -import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { conn } from "@/lib/planetscale/connection"; +import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { InvoiceStatus } from "@dub/prisma/client"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { format } from "date-fns"; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page-client.tsx index fe7a9f3c65c..e7bb7c51cf8 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page-client.tsx @@ -39,8 +39,7 @@ export default function WebhookLogsPageClient({ ); const [detailsSheetState, setDetailsSheetState] = useState< - | { open: false; eventId: string | null } - | { open: true; eventId: string } + { open: false; eventId: string | null } | { open: true; eventId: string } >({ open: false, eventId: null }); const currentEvent = useMemo( @@ -61,7 +60,9 @@ export default function WebhookLogsPageClient({ return [ currentIndex > 0 ? events[currentIndex - 1].event_id : null, - currentIndex < events.length - 1 ? events[currentIndex + 1].event_id : null, + currentIndex < events.length - 1 + ? events[currentIndex + 1].event_id + : null, ]; }, [events, detailsSheetState.eventId]); diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 6f084b8ca46..fc4781a794b 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -10,7 +10,11 @@ export default function RootProviders({ children }: { children: ReactNode }) { - + {children} diff --git a/apps/web/playwright/workspaces/billing-trial.spec.ts b/apps/web/playwright/workspaces/billing-trial.spec.ts index d676405ac0a..cdb689e0fe2 100644 --- a/apps/web/playwright/workspaces/billing-trial.spec.ts +++ b/apps/web/playwright/workspaces/billing-trial.spec.ts @@ -122,7 +122,9 @@ test.describe("Free trial user navigation", () => { } }); - test("billing settings page shows trial banner and CTAs", async ({ page }) => { + test("billing settings page shows trial banner and CTAs", async ({ + page, + }) => { await page.goto(`/${slug}/settings/billing`); await expect(page.getByText(/Trial ends on/)).toBeVisible({ @@ -184,9 +186,7 @@ test.describe("Free trial user navigation", () => { "You'll be charged today and your trial will end.", ), ).toBeVisible(); - await confirmModal - .getByRole("button", { name: "Start paid plan" }) - .click(); + await confirmModal.getByRole("button", { name: "Start paid plan" }).click(); await page.waitForURL((u) => { const url = new URL(u); diff --git a/apps/web/ui/partners/partner-link-selector.tsx b/apps/web/ui/partners/partner-link-selector.tsx index 63cca71a6d2..58a776a55bd 100644 --- a/apps/web/ui/partners/partner-link-selector.tsx +++ b/apps/web/ui/partners/partner-link-selector.tsx @@ -86,7 +86,8 @@ export function PartnerLinkSelector({ }, [selectedLink]); const showLoadingPlaceholder = - (selectedLinkId && !selectedLink) || (!selectedLinkId && isValidating && !links); + (selectedLinkId && !selectedLink) || + (!selectedLinkId && isValidating && !links); return ( <> From 089f34d7c66cb2158a2e50385c965184b9696f16 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Jun 2026 21:48:35 +0530 Subject: [PATCH 32/34] address CR feedback --- .../lib/api/commissions/bulk-update-partner-commissions.ts | 1 + apps/web/lib/api/commissions/update-partner-commission.ts | 1 + apps/web/lib/api/commissions/void-referral-commissions.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts index 964f446fe7f..6d051aa2598 100644 --- a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts +++ b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts @@ -171,6 +171,7 @@ export async function bulkUpdatePartnerCommissions({ voidReferralCommissions({ workspaceId, programId, + userId, sourceCommissionIds: commissionIds, sourceCommissionStatus: status, }), diff --git a/apps/web/lib/api/commissions/update-partner-commission.ts b/apps/web/lib/api/commissions/update-partner-commission.ts index 02b2d8d31cb..9b4fffb96fc 100644 --- a/apps/web/lib/api/commissions/update-partner-commission.ts +++ b/apps/web/lib/api/commissions/update-partner-commission.ts @@ -322,6 +322,7 @@ export async function updatePartnerCommission({ voidReferralCommissions({ workspaceId, programId, + userId, sourceCommissionIds: [ commission.id, ...relatedCommissions.map(({ id }) => id), diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts index d06fcbe889b..b7a5d83e12e 100644 --- a/apps/web/lib/api/commissions/void-referral-commissions.ts +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -90,8 +90,8 @@ export async function voidReferralCommissions({ const voidedCommissions = await tx.commission.findMany({ where: { - sourceCommissionId: { - in: sourceCommissionIds, + id: { + in: commissions.map(({ id }) => id), }, status: sourceCommissionStatus, }, From 9da49ed2c77177d5e55d782484515934b8a46ee0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 3 Jun 2026 21:36:08 +0530 Subject: [PATCH 33/34] Queue voided referral commissions and cancel below-threshold rewards --- .../cron/commissions/referrals/void/route.ts | 19 ++ .../integration/webhook/charge-refunded.ts | 4 +- .../bulk-update-partner-commissions.ts | 4 +- .../commissions/update-partner-commission.ts | 4 +- .../commissions/void-referral-commissions.ts | 211 +++++++++++++++++- .../create-referral-commission.ts | 4 +- 6 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts new file mode 100644 index 00000000000..9a9ef916a73 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts @@ -0,0 +1,19 @@ +import { + voidReferralCommissions, + voidReferralCommissionsSchema, +} from "@/lib/api/commissions/void-referral-commissions"; +import { withCron } from "@/lib/cron/with-cron"; +import { logAndRespond } from "../../../utils"; + +export const dynamic = "force-dynamic"; + +// POST /api/cron/commissions/referrals/void +export const POST = withCron(async ({ rawBody }) => { + const input = voidReferralCommissionsSchema.parse(JSON.parse(rawBody)); + + await voidReferralCommissions(input); + + return logAndRespond( + `Voided referral commissions for ${input.sourceCommissionIds.length} source commission(s).`, + ); +}); diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts index beacd09fe38..516b5e3e2b3 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts @@ -1,5 +1,5 @@ import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; -import { voidReferralCommissions } from "@/lib/api/commissions/void-referral-commissions"; +import { queueVoidReferralCommissions } from "@/lib/api/commissions/void-referral-commissions"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; @@ -142,7 +142,7 @@ export async function chargeRefunded( newStatus: "refunded", }); - await voidReferralCommissions({ + await queueVoidReferralCommissions({ workspaceId: workspace.id, programId: commission.programId, sourceCommissionIds: [commission.id], diff --git a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts index 6d051aa2598..f114e07409c 100644 --- a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts +++ b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts @@ -7,7 +7,7 @@ import { DubApiError } from "../errors"; import { syncTotalCommissions } from "../partners/sync-total-commissions"; import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; import { trackCommissionActivityLog } from "./track-commission-update-activity-log"; -import { voidReferralCommissions } from "./void-referral-commissions"; +import { queueVoidReferralCommissions } from "./void-referral-commissions"; type BulkUpdatePartnerCommissionsProps = z.infer< typeof bulkUpdateCommissionsSchema @@ -168,7 +168,7 @@ export async function bulkUpdatePartnerCommissions({ ...(status !== "pending" ? [ - voidReferralCommissions({ + queueVoidReferralCommissions({ workspaceId, programId, userId, diff --git a/apps/web/lib/api/commissions/update-partner-commission.ts b/apps/web/lib/api/commissions/update-partner-commission.ts index 9b4fffb96fc..ba39b2b5d2b 100644 --- a/apps/web/lib/api/commissions/update-partner-commission.ts +++ b/apps/web/lib/api/commissions/update-partner-commission.ts @@ -15,7 +15,7 @@ import { trackCommissionActivityLog, trackCommissionStatusUpdate, } from "./track-commission-update-activity-log"; -import { voidReferralCommissions } from "./void-referral-commissions"; +import { queueVoidReferralCommissions } from "./void-referral-commissions"; type UpdatePartnerCommissionProps = z.infer< typeof updateCommissionSchemaExtended @@ -319,7 +319,7 @@ export async function updatePartnerCommission({ finalStatus && finalStatus !== "pending" && - voidReferralCommissions({ + queueVoidReferralCommissions({ workspaceId, programId, userId, diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts index b7a5d83e12e..e4fb567105d 100644 --- a/apps/web/lib/api/commissions/void-referral-commissions.ts +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -1,6 +1,10 @@ import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; +import { qstash } from "@/lib/cron"; +import { referralRewardConfigSchema } from "@/lib/zod/schemas/rewards"; import { prisma } from "@dub/prisma"; import { CommissionStatus, Prisma } from "@dub/prisma/client"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import * as z from "zod/v4"; import { syncTotalCommissions } from "../partners/sync-total-commissions"; import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; import { trackCommissionStatusUpdate } from "./track-commission-update-activity-log"; @@ -12,13 +16,17 @@ const VOID_STATUSES: CommissionStatus[] = [ "canceled", ]; -type VoidReferralCommissionsArgs = { - workspaceId: string; - programId: string; - userId?: string; - sourceCommissionIds: string[]; - sourceCommissionStatus: (typeof VOID_STATUSES)[number]; -}; +export const voidReferralCommissionsSchema = z.object({ + workspaceId: z.string(), + programId: z.string(), + userId: z.string().optional(), + sourceCommissionIds: z.array(z.string()).min(1), + sourceCommissionStatus: z.enum(VOID_STATUSES), +}); + +type VoidReferralCommissionsArgs = z.infer< + typeof voidReferralCommissionsSchema +>; export async function voidReferralCommissions({ workspaceId, @@ -38,10 +46,10 @@ export async function voidReferralCommissions({ }, OR: [ { - status: "pending" as const, + status: "pending", }, { - status: "processed" as const, + status: "processed", payout: { status: { in: MUTABLE_PAYOUT_STATUSES, @@ -132,7 +140,7 @@ export async function voidReferralCommissions({ ), ]; - await Promise.allSettled([ + await Promise.all([ affectedPayoutIds.length > 0 ? reconcilePayoutAmounts(affectedPayoutIds) : Promise.resolve(), @@ -153,5 +161,188 @@ export async function voidReferralCommissions({ }), ]); + // For referral commissions created by the "commissionThreshold" trigger, + // recalculate totalCommissionsEarned and void the referral commission if the + // partner no longer meets the threshold. + await cancelReferralCommissionsBelowThreshold({ + partnerIds, + programId, + }); + console.log(`Voided ${originalCommissions.length} referral commissions.`); } + +async function cancelReferralCommissionsBelowThreshold({ + partnerIds, + programId, +}: { + partnerIds: string[]; + programId: string; +}) { + if (partnerIds.length === 0) { + return; + } + + const [totalCommissionsByPartner, programEnrollments] = await Promise.all([ + prisma.commission.groupBy({ + by: ["partnerId"], + where: { + partnerId: { + in: partnerIds, + }, + programId, + type: "sale", + status: { + in: ["pending", "processed", "paid"], + }, + }, + _sum: { + earnings: true, + }, + }), + + prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: partnerIds, + }, + programId, + }, + select: { + partnerId: true, + applicationEvent: { + select: { + referredByPartnerId: true, + }, + }, + }, + }), + ]); + + // Map of partnerId to total earnings + const totalEarningsByPartnerId = new Map( + totalCommissionsByPartner.map(({ partnerId, _sum }) => [ + partnerId, + _sum.earnings ?? 0, + ]), + ); + + // Map of partnerId to referredByPartnerId + const referredByPartnerIdMap = new Map( + programEnrollments + .filter(({ applicationEvent }) => applicationEvent?.referredByPartnerId) + .map(({ partnerId, applicationEvent }) => [ + partnerId, + applicationEvent!.referredByPartnerId!, + ]), + ); + + const referredByPartnerIds = [...new Set(referredByPartnerIdMap.values())]; + + if (referredByPartnerIds.length === 0) { + return; + } + + const referrerEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: referredByPartnerIds, + }, + programId, + }, + select: { + partnerId: true, + referralReward: true, + }, + }); + + // Map of partnerId to referral reward + const referralRewardByReferrerId = new Map( + referrerEnrollments + .filter(({ referralReward }) => referralReward) + .map(({ partnerId, referralReward }) => [partnerId, referralReward!]), + ); + + const invoiceIdsToCancel: string[] = []; + + for (const partnerId of partnerIds) { + const referredByPartnerId = referredByPartnerIdMap.get(partnerId); + + if (!referredByPartnerId) { + continue; + } + + const referralReward = referralRewardByReferrerId.get(referredByPartnerId); + + if (!referralReward) { + continue; + } + + const rewardConfig = referralRewardConfigSchema.safeParse( + referralReward.config, + ); + + if (!rewardConfig.success) { + continue; + } + + const { trigger, commissionsThresholdInCents } = rewardConfig.data; + + if (trigger !== "commissionThreshold") { + continue; + } + + const totalCommissionsEarned = totalEarningsByPartnerId.get(partnerId) ?? 0; + + if (totalCommissionsEarned < (commissionsThresholdInCents ?? 0)) { + invoiceIdsToCancel.push(`referral:${trigger}:${partnerId}`); + } + } + + if (invoiceIdsToCancel.length === 0) { + return; + } + + console.log( + `Canceling ${invoiceIdsToCancel.length} commissions with invoice IDs.`, + invoiceIdsToCancel, + ); + + await prisma.commission.updateMany({ + where: { + invoiceId: { + in: invoiceIdsToCancel, + }, + programId, + OR: [ + { + status: "pending", + }, + { + status: "processed", + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }, + data: { + status: "canceled", + }, + }); +} + +export async function queueVoidReferralCommissions( + args: VoidReferralCommissionsArgs, +) { + if (args.sourceCommissionIds.length === 0) { + return; + } + + return qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/referrals/void`, + body: args, + }); +} diff --git a/apps/web/lib/partner-referrals/create-referral-commission.ts b/apps/web/lib/partner-referrals/create-referral-commission.ts index 626b9f80696..b05c714abe5 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -186,7 +186,9 @@ export const createReferralCommission = async ( partnerId, programId, type: "sale", - status: "paid", + status: { + in: ["pending", "processed", "paid"], + }, }, _sum: { earnings: true, From a536251a53e89613508d127904767424520e0b65 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 3 Jun 2026 22:42:14 +0530 Subject: [PATCH 34/34] Reconcile payouts and sync totals when canceling threshold referral commissions --- .../cron/commissions/referrals/void/route.ts | 26 ++- .../commissions/void-referral-commissions.ts | 214 ++++++++++++------ 2 files changed, 165 insertions(+), 75 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts index 9a9ef916a73..177090207fe 100644 --- a/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts @@ -1,4 +1,5 @@ import { + cancelReferralCommissionsBelowThreshold, voidReferralCommissions, voidReferralCommissionsSchema, } from "@/lib/api/commissions/void-referral-commissions"; @@ -9,11 +10,30 @@ export const dynamic = "force-dynamic"; // POST /api/cron/commissions/referrals/void export const POST = withCron(async ({ rawBody }) => { - const input = voidReferralCommissionsSchema.parse(JSON.parse(rawBody)); + const { + workspaceId, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, + } = voidReferralCommissionsSchema.parse(JSON.parse(rawBody)); - await voidReferralCommissions(input); + await voidReferralCommissions({ + workspaceId, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, + }); + + await cancelReferralCommissionsBelowThreshold({ + workspaceId, + userId, + sourceCommissionIds, + programId, + }); return logAndRespond( - `Voided referral commissions for ${input.sourceCommissionIds.length} source commission(s).`, + `Voided referral commissions for ${sourceCommissionIds.length} source commission(s).`, ); }); diff --git a/apps/web/lib/api/commissions/void-referral-commissions.ts b/apps/web/lib/api/commissions/void-referral-commissions.ts index e4fb567105d..653909ba853 100644 --- a/apps/web/lib/api/commissions/void-referral-commissions.ts +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -59,9 +59,9 @@ export async function voidReferralCommissions({ ], }; - const { originalCommissions, voidedCommissions } = await prisma.$transaction( + const { voidedReferralCommissions, count } = await prisma.$transaction( async (tx) => { - const commissions = await tx.commission.findMany({ + const referralCommissions = await tx.commission.findMany({ where: whereInput, select: { id: true, @@ -73,65 +73,42 @@ export async function voidReferralCommissions({ }, }); - if (commissions.length === 0) { + if (referralCommissions.length === 0) { return { - originalCommissions: commissions, - voidedCommissions: [], + voidedReferralCommissions: [], + count: 0, }; } const { count } = await tx.commission.updateMany({ where: whereInput, data: { - status: sourceCommissionStatus, + status: "canceled", payoutId: null, }, }); - // No commissions were updated - if (count === 0) { - return { - originalCommissions: commissions, - voidedCommissions: [], - }; - } - - const voidedCommissions = await tx.commission.findMany({ - where: { - id: { - in: commissions.map(({ id }) => id), - }, - status: sourceCommissionStatus, - }, - select: { - id: true, - partnerId: true, - amount: true, - earnings: true, - status: true, - payoutId: true, - }, - }); - return { - originalCommissions: commissions, - voidedCommissions, + voidedReferralCommissions: referralCommissions, + count, }; }, ); - if (voidedCommissions.length === 0) { + if (count === 0) { console.log("No referral commissions found."); return; } // Find unique partner Ids - const partnerIds = [...new Set(voidedCommissions.map((c) => c.partnerId))]; + const partnerIds = [ + ...new Set(voidedReferralCommissions.map((c) => c.partnerId)), + ]; // Reconcile payout amounts for all affected payouts const affectedPayoutIds = [ ...new Set( - originalCommissions + voidedReferralCommissions .filter( (commission) => commission.status === "processed" && commission.payoutId, @@ -140,7 +117,7 @@ export async function voidReferralCommissions({ ), ]; - await Promise.all([ + await Promise.allSettled([ affectedPayoutIds.length > 0 ? reconcilePayoutAmounts(affectedPayoutIds) : Promise.resolve(), @@ -156,33 +133,49 @@ export async function voidReferralCommissions({ workspaceId, programId, userId, - commissions: originalCommissions, - newStatus: sourceCommissionStatus, + commissions: voidedReferralCommissions, + newStatus: "canceled", }), ]); - // For referral commissions created by the "commissionThreshold" trigger, - // recalculate totalCommissionsEarned and void the referral commission if the - // partner no longer meets the threshold. - await cancelReferralCommissionsBelowThreshold({ - partnerIds, - programId, - }); - - console.log(`Voided ${originalCommissions.length} referral commissions.`); + console.log(`Voided ${count} referral commissions.`); } -async function cancelReferralCommissionsBelowThreshold({ - partnerIds, +// For referral commissions created by the "commissionThreshold" trigger, +// recalculate totalCommissionsEarned and void the referral commission if the +// partner no longer meets the threshold. +export async function cancelReferralCommissionsBelowThreshold({ + workspaceId, programId, + userId, + sourceCommissionIds, }: { - partnerIds: string[]; + workspaceId: string; programId: string; + userId?: string; + sourceCommissionIds: string[]; }) { - if (partnerIds.length === 0) { + if (sourceCommissionIds.length === 0) { + return; + } + + const commissions = await prisma.commission.findMany({ + where: { + id: { + in: sourceCommissionIds, + }, + }, + select: { + partnerId: true, + }, + }); + + if (commissions.length === 0) { return; } + const partnerIds = [...new Set(commissions.map((c) => c.partnerId))]; + const [totalCommissionsByPartner, programEnrollments] = await Promise.all([ prisma.commission.groupBy({ by: ["partnerId"], @@ -308,30 +301,107 @@ async function cancelReferralCommissionsBelowThreshold({ invoiceIdsToCancel, ); - await prisma.commission.updateMany({ - where: { - invoiceId: { - in: invoiceIdsToCancel, + const cancelWhereInput: Prisma.CommissionWhereInput = { + invoiceId: { + in: invoiceIdsToCancel, + }, + programId, + OR: [ + { + status: "pending", }, - programId, - OR: [ - { - status: "pending", - }, - { - status: "processed", - payout: { - status: { - in: MUTABLE_PAYOUT_STATUSES, - }, + { + status: "processed", + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, }, }, - ], - }, - data: { - status: "canceled", + }, + ], + }; + + const { canceledCommissions, count } = await prisma.$transaction( + async (tx) => { + const referralCommissions = await tx.commission.findMany({ + where: cancelWhereInput, + select: { + id: true, + partnerId: true, + amount: true, + earnings: true, + status: true, + payoutId: true, + }, + }); + + if (referralCommissions.length === 0) { + return { + canceledCommissions: [], + count: 0, + }; + } + + const { count } = await tx.commission.updateMany({ + where: cancelWhereInput, + data: { + status: "canceled", + invoiceId: null, + payoutId: null, + }, + }); + + return { + canceledCommissions: referralCommissions, + count, + }; }, - }); + ); + + if (count === 0) { + console.log("No threshold referral commissions found to cancel."); + return; + } + + // Find unique partner Ids + const canceledPartnerIds = [ + ...new Set(canceledCommissions.map((c) => c.partnerId)), + ]; + + // Reconcile payout amounts for all affected payouts + const affectedPayoutIds = [ + ...new Set( + canceledCommissions + .filter( + (commission) => + commission.status === "processed" && commission.payoutId, + ) + .map((commission) => commission.payoutId!), + ), + ]; + + await Promise.all([ + affectedPayoutIds.length > 0 + ? reconcilePayoutAmounts(affectedPayoutIds) + : Promise.resolve(), + + ...canceledPartnerIds.map((partnerId) => + syncTotalCommissions({ + partnerId, + programId, + }), + ), + + trackCommissionStatusUpdate({ + workspaceId, + programId, + userId, + commissions: canceledCommissions, + newStatus: "canceled", + }), + ]); + + console.log(`Canceled ${count} threshold referral commissions.`); } export async function queueVoidReferralCommissions(