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 && (