diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts index 59ec4ee631f..53e820be22f 100644 --- a/apps/web/lib/actions/partners/create-manual-commission.ts +++ b/apps/web/lib/actions/partners/create-manual-commission.ts @@ -292,8 +292,12 @@ export const createManualCommissionAction = authActionClient createdAt: new Date(saleEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC user, context: { - customer: { country: customer.country }, - sale: { productId }, + customer: { + country: customer.country, + }, + sale: { + productId, + }, }, })), ); diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index f2c4e8c8550..caa5c4bc818 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -552,10 +552,10 @@ const _trackSale = async ({ context: { customer: { country: customer.country, - source: source!, + source, }, sale: { - productId: metadata?.productId as string, + productId: metadata?.productId, amount: saleData.amount, }, }, diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index 573b0e7be86..dfd6f7d94dd 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -99,6 +99,36 @@ export const createPartnerCommission = async ({ 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 subscriptionDurationMonths = firstCommission + ? differenceInMonths(new Date(), firstCommission.createdAt) + : 0; + + context = { + ...context, + customer: { + ...context?.customer, + subscriptionDurationMonths, + }, + }; + } + reward = determinePartnerReward({ event, programEnrollment, @@ -126,22 +156,6 @@ export const createPartnerCommission = async ({ // 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 { - firstCommission = await prisma.commission.findFirst({ - where: { - partnerId, - customerId, - type: event, - }, - orderBy: { - createdAt: "asc", - }, - select: { - rewardId: true, - status: true, - createdAt: true, - }, - }); - if (firstCommission) { // if first commission is fraud or canceled, skip commission creation if (["fraud", "canceled"].includes(firstCommission.status)) { @@ -207,6 +221,7 @@ export const createPartnerCommission = async ({ console.log( `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`, ); + return { commission: null, programEnrollment, @@ -216,15 +231,16 @@ export const createPartnerCommission = async ({ // Recurring sale reward (maxDuration > 0) else { - const monthsDifference = differenceInMonths( + const subscriptionDurationMonths = differenceInMonths( new Date(), firstCommission.createdAt, ); - if (monthsDifference >= reward.maxDuration) { + if (subscriptionDurationMonths >= reward.maxDuration) { console.log( `Partner ${partnerId} has reached max duration for ${event} event, skipping commission creation...`, ); + return { commission: null, programEnrollment, diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts index 17205b9ba15..cf3ceb1e250 100644 --- a/apps/web/lib/zod/schemas/rewards.ts +++ b/apps/web/lib/zod/schemas/rewards.ts @@ -147,6 +147,11 @@ export const REWARD_CONDITIONS: Record< }, ], }, + { + id: "subscriptionDurationMonths", + label: "Subscription duration", + type: "number", + }, ], }, PARTNER_ENTITY, @@ -347,6 +352,7 @@ export const rewardContextSchema = z.object({ .object({ country: z.string().nullish(), source: z.enum(CUSTOMER_SOURCES).default("tracked").nullish(), + subscriptionDurationMonths: z.number().nullish(), }) .optional(), diff --git a/apps/web/tests/rewards/reward-conditions.test.ts b/apps/web/tests/rewards/reward-conditions.test.ts index f644dad294f..cc28f4c154f 100644 --- a/apps/web/tests/rewards/reward-conditions.test.ts +++ b/apps/web/tests/rewards/reward-conditions.test.ts @@ -1660,4 +1660,70 @@ describe("evaluateRewardConditions", () => { expect(result).toBeNull(); // Should not match when country is null }); }); + + describe("subscription duration conditions", () => { + describe("customer.subscriptionDurationMonths", () => { + test("should match when subscription duration meets greater_than_or_equal condition", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 5000, + conditions: [ + { + entity: "customer" as const, + attribute: "subscriptionDurationMonths" as const, + operator: "greater_than_or_equal" as const, + value: 12, + }, + ], + }, + ]; + + const context: RewardContext = { + customer: { + subscriptionDurationMonths: 12, + }, + }; + + const result = evaluateRewardConditions({ + conditions, + context, + }); + + expect(result).toEqual(conditions[0]); + }); + + test("should not match when subscription duration is less than condition value", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 5000, + conditions: [ + { + entity: "customer" as const, + attribute: "subscriptionDurationMonths" as const, + operator: "greater_than_or_equal" as const, + value: 12, + }, + ], + }, + ]; + + const context: RewardContext = { + customer: { + subscriptionDurationMonths: 6, + }, + }; + + const result = evaluateRewardConditions({ + conditions, + context, + }); + + expect(result).toBe(null); + }); + }); + }); }); diff --git a/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx b/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx index 0d772b4602a..10b71139f81 100644 --- a/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx +++ b/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx @@ -17,6 +17,7 @@ import { currencyFormatter, pluralize, } from "@dub/utils"; +import { formatDuration } from "date-fns"; import { useRef } from "react"; import * as z from "zod/v4"; @@ -118,6 +119,8 @@ export function ProgramRewardModifiersTooltipContent({ ); } +// TODO: +// This became a bit of a mess, let's clean it up a bit. const RewardItem = ({ reward, conditions, @@ -168,8 +171,7 @@ const RewardItem = ({ {idx === 0 ? "If" : capitalize(operator.toLowerCase())}{" "} - {condition.entity}{" "} - {(attribute?.label ?? condition.attribute)?.toLowerCase()}{" "} + {condition.entity} {attribute?.label?.toLowerCase()}{" "} {CONDITION_OPERATOR_LABELS[condition.operator]}{" "} {condition.value && (condition.attribute === "country" @@ -180,29 +182,32 @@ const RewardItem = ({ .join(", ") : COUNTRIES[condition.value?.toString()] ?? condition.value - : // Non-country value(s) - Array.isArray(condition.value) - ? // Basic array - (attribute?.options - ? (condition.value as string[] | number[]).map( - (v) => - attribute.options?.find((o) => o.id === v) - ?.label ?? v, - ) - : condition.value - ).join(", ") - : condition.attribute === "productId" && condition.label - ? // Product label - condition.label - : attribute?.type === "currency" - ? // Currency value - currencyFormatter(Number(condition.value)) - : // Everything else - attribute?.options - ? attribute.options.find( - (o) => o.id === condition.value, - )?.label ?? condition.value.toString() - : condition.value.toString())} + : condition.attribute === "subscriptionDurationMonths" + ? formatSubscriptionDuration(Number(condition.value)) + : // Non-country value(s) + Array.isArray(condition.value) + ? // Basic array + (attribute?.options + ? (condition.value as string[] | number[]).map( + (v) => + attribute.options?.find((o) => o.id === v) + ?.label ?? v, + ) + : condition.value + ).join(", ") + : condition.attribute === "productId" && + condition.label + ? // Product label + condition.label + : attribute?.type === "currency" + ? // Currency value + currencyFormatter(Number(condition.value)) + : // Everything else + attribute?.options + ? attribute.options.find( + (o) => o.id === condition.value, + )?.label ?? condition.value.toString() + : condition.value.toString())} ); @@ -212,3 +217,9 @@ const RewardItem = ({ ); }; + +function formatSubscriptionDuration(v: number): string { + return formatDuration( + v >= 12 ? { years: Math.floor(v / 12), months: v % 12 } : { months: v }, + ); +} diff --git a/apps/web/ui/partners/rewards/rewards-logic.tsx b/apps/web/ui/partners/rewards/rewards-logic.tsx index 32c3327b0ef..af7c4a46566 100644 --- a/apps/web/ui/partners/rewards/rewards-logic.tsx +++ b/apps/web/ui/partners/rewards/rewards-logic.tsx @@ -565,6 +565,10 @@ function ConditionLogic({ )} + {condition.attribute === "subscriptionDurationMonths" && ( + months + )} + {condition.attribute === "productId" && condition.value && (