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/(ee)/api/cron/commissions/referrals/create/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts index 1f24ae87244..b5d277f208d 100644 --- a/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts @@ -1,5 +1,8 @@ import { withCron } from "@/lib/cron/with-cron"; -import { createReferralCommission } from "@/lib/partner-referrals/create-referral-commission"; +import { + createReferralCommission, + CreateReferralCommissionArgs, +} from "@/lib/partner-referrals/create-referral-commission"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; @@ -14,13 +17,28 @@ const inputSchema = z.union([ export const POST = withCron(async ({ rawBody }) => { const inputParsed = inputSchema.parse(JSON.parse(rawBody)); - const referralCommission = await createReferralCommission(inputParsed); + let args: CreateReferralCommissionArgs; - if (referralCommission === null) { - return logAndRespond("Referral commission creation skipped."); + if ("sourceCommissionId" in inputParsed) { + args = { + source: "commission", + sourceCommissionId: inputParsed.sourceCommissionId, + }; + } else { + args = { + source: "partner", + programId: inputParsed.programId, + partnerId: inputParsed.partnerId, + }; } - return logAndRespond( - `Referral commission ${referralCommission.id} created successfully.`, - ); + 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/app/(ee)/api/cron/commissions/referrals/network/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/network/route.ts new file mode 100644 index 00000000000..cdab414a95a --- /dev/null +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/network/route.ts @@ -0,0 +1,56 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { createNetworkReferralCommission } from "@/lib/partner-referrals/create-network-referral-commission"; +import { prisma } from "@dub/prisma"; +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/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)); + + const payout = await prisma.payout.findUnique({ + where: { + id: payoutId, + }, + include: { + partner: { + select: { + id: true, + referredByPartnerId: true, + }, + }, + }, + }); + + if (!payout) { + return logAndRespond(`Payout ${payoutId} not found.`); + } + + if (!["sent", "completed"].includes(payout.status)) { + return logAndRespond( + `Payout ${payoutId} is not in a valid status to create referral commissions.`, + ); + } + + const commission = await createNetworkReferralCommission({ + partner: payout.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/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/commissions/referrals/void/route.ts b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts new file mode 100644 index 00000000000..177090207fe --- /dev/null +++ b/apps/web/app/(ee)/api/cron/commissions/referrals/void/route.ts @@ -0,0 +1,39 @@ +import { + cancelReferralCommissionsBelowThreshold, + 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 { + workspaceId, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, + } = voidReferralCommissionsSchema.parse(JSON.parse(rawBody)); + + await voidReferralCommissions({ + workspaceId, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, + }); + + await cancelReferralCommissionsBelowThreshold({ + workspaceId, + userId, + sourceCommissionIds, + programId, + }); + + return logAndRespond( + `Voided referral commissions for ${sourceCommissionIds.length} source commission(s).`, + ); +}); 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/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts index c1719e1710d..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,8 +1,10 @@ import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; +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"; 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 queueVoidReferralCommissions({ + 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/app/(ee)/api/workflows/create-partner-commission/route.ts b/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts index a1deaae1d45..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 @@ -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"; @@ -21,6 +22,7 @@ import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { Commission, + CommissionType, Link, Partner, PartnerGroup, @@ -51,7 +53,7 @@ type StepFunctionInput = Input & { }; type StepCreateCommissionOutput = { - commission: Pick | null; + commission: Pick | null; outputLog: string; isFirstCommission?: boolean; }; @@ -121,6 +123,16 @@ export const { POST } = serve( } }); } + + // Step 4: Create referral commission + if (commission && commission.type === CommissionType.sale) { + 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..ee225c31a02 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,12 +320,15 @@ export const { POST } = serve( }); // Step 7: Create referral commission if enabled - await context.run("create-referral-commission", async () => { - await createReferralCommission({ - partnerId, - programId, + if (applicationEvent?.referredByPartnerId) { + await context.run("create-referral-commission", async () => { + return await createReferralCommission({ + source: "partner", + partnerId, + programId, + }); }); - }); + } }, { initialPayloadParser: (requestPayload) => { 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/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts index e855ac49c6b..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,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 { queueVoidReferralCommissions } from "./void-referral-commissions"; type BulkUpdatePartnerCommissionsProps = z.infer< typeof bulkUpdateCommissionsSchema @@ -164,6 +165,18 @@ export async function bulkUpdatePartnerCommissions({ old: commissions, new: updatedCommissions, }), + + ...(status !== "pending" + ? [ + queueVoidReferralCommissions({ + 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 6217ba0662e..ba39b2b5d2b 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 { queueVoidReferralCommissions } from "./void-referral-commissions"; type UpdatePartnerCommissionProps = z.infer< typeof updateCommissionSchemaExtended @@ -315,6 +316,19 @@ export async function updatePartnerCommission({ newStatus: finalStatus!, }) : Promise.resolve(), + + finalStatus && + finalStatus !== "pending" && + queueVoidReferralCommissions({ + workspaceId, + programId, + userId, + 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 new file mode 100644 index 00000000000..653909ba853 --- /dev/null +++ b/apps/web/lib/api/commissions/void-referral-commissions.ts @@ -0,0 +1,418 @@ +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"; + +const VOID_STATUSES: CommissionStatus[] = [ + "refunded", + "duplicate", + "fraud", + "canceled", +]; + +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, + programId, + userId, + sourceCommissionIds, + sourceCommissionStatus, +}: VoidReferralCommissionsArgs) { + if (sourceCommissionIds.length === 0) { + console.log("No source commission IDs provided."); + return; + } + + const whereInput: Prisma.CommissionWhereInput = { + sourceCommissionId: { + in: sourceCommissionIds, + }, + OR: [ + { + status: "pending", + }, + { + status: "processed", + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }; + + const { voidedReferralCommissions, count } = await prisma.$transaction( + async (tx) => { + const referralCommissions = await tx.commission.findMany({ + where: whereInput, + select: { + id: true, + partnerId: true, + amount: true, + earnings: true, + status: true, + payoutId: true, + }, + }); + + if (referralCommissions.length === 0) { + return { + voidedReferralCommissions: [], + count: 0, + }; + } + + const { count } = await tx.commission.updateMany({ + where: whereInput, + data: { + status: "canceled", + payoutId: null, + }, + }); + + return { + voidedReferralCommissions: referralCommissions, + count, + }; + }, + ); + + if (count === 0) { + console.log("No referral commissions found."); + return; + } + + // Find unique partner Ids + const partnerIds = [ + ...new Set(voidedReferralCommissions.map((c) => c.partnerId)), + ]; + + // Reconcile payout amounts for all affected payouts + const affectedPayoutIds = [ + ...new Set( + voidedReferralCommissions + .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, + programId, + }), + ), + + trackCommissionStatusUpdate({ + workspaceId, + programId, + userId, + commissions: voidedReferralCommissions, + newStatus: "canceled", + }), + ]); + + console.log(`Voided ${count} referral commissions.`); +} + +// 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, +}: { + workspaceId: string; + programId: string; + userId?: string; + sourceCommissionIds: string[]; +}) { + 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"], + 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, + ); + + const cancelWhereInput: Prisma.CommissionWhereInput = { + invoiceId: { + in: invoiceIdsToCancel, + }, + programId, + OR: [ + { + status: "pending", + }, + { + status: "processed", + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }; + + 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( + 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 2d4d28b498d..b05c714abe5 100644 --- a/apps/web/lib/partner-referrals/create-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-referral-commission.ts @@ -11,34 +11,49 @@ 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 +export type CreateReferralCommissionArgs = + | { + source: "commission"; // based on a source commission + sourceCommissionId: string; + } + | { + source: "partner"; // based on partner approval and commission threshold + partnerId: string; + programId: string; + }; + +type CreateReferralCommissionResult = { + commission: Commission | null; + reason: string | null; +}; export const createReferralCommission = async ( - props: CreateReferralCommissionProps, -) => { - const context = await resolveReferralContext(props); + 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 && partnerId === referredByPartnerId) { - console.log( - `Skipping referral commission creation for self-referral (partner ${partnerId}).`, - ); - return null; + if (partnerId === referredByPartnerId) { + 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({ @@ -57,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( @@ -77,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 = { @@ -124,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}.`, + }; } } } @@ -169,6 +186,9 @@ export const createReferralCommission = async ( partnerId, programId, type: "sale", + status: { + in: ["pending", "processed", "paid"], + }, }, _sum: { earnings: true, @@ -176,10 +196,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 = { @@ -191,14 +211,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 @@ -216,10 +239,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}.`, + }; } } @@ -239,10 +262,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); @@ -308,7 +331,10 @@ export const createReferralCommission = async ( }), ]); - return commission; + return { + commission, + reason: null, + }; }; function generateCommissionDescription({ @@ -350,9 +376,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 +437,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..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, }, 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/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(); 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 ( <> 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) }