Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/lib/actions/partners/create-manual-commission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
})),
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/lib/api/conversions/track-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
28 changes: 26 additions & 2 deletions apps/web/lib/partners/create-partner-commission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,12 @@ export const createPartnerCommission = async ({

// Recurring sale reward (maxDuration > 0)
else {
const monthsDifference = differenceInMonths(
const subscriptionDuration = differenceInMonths(
new Date(),
firstCommission.createdAt,
);

if (monthsDifference >= reward.maxDuration) {
if (subscriptionDuration >= reward.maxDuration) {
console.log(
`Partner ${partnerId} has reached max duration for ${event} event, skipping commission creation...`,
);
Expand All @@ -231,6 +231,30 @@ export const createPartnerCommission = async ({
webhookPartner: constructWebhookPartner(programEnrollment),
};
}

// re-run determinePartnerReward with the customer's subscription duration
reward = determinePartnerReward({
event,
programEnrollment,
context: {
...context,
customer: {
...context?.customer,
subscriptionDuration,
},
},
});

if (!reward) {
console.log(
`Partner ${partnerId} does not have a qualifying reward for subscription duration ${subscriptionDuration}, skipping commission creation...`,
);
return {
commission: null,
programEnrollment,
webhookPartner: constructWebhookPartner(programEnrollment),
};
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/zod/schemas/rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ export const REWARD_CONDITIONS: Record<
},
],
},
{
id: "subscriptionDuration",
label: "Subscription duration",
type: "number",
},
],
},
PARTNER_ENTITY,
Expand Down Expand Up @@ -347,6 +352,7 @@ export const rewardContextSchema = z.object({
.object({
country: z.string().nullish(),
source: z.enum(CUSTOMER_SOURCES).default("tracked").nullish(),
subscriptionDuration: z.number().nullish(),
})
.optional(),

Expand Down
66 changes: 66 additions & 0 deletions apps/web/tests/rewards/reward-conditions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,4 +1660,70 @@ describe("evaluateRewardConditions", () => {
expect(result).toBeNull(); // Should not match when country is null
});
});

describe("subscription duration conditions", () => {
describe("customer.subscriptionDuration", () => {
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: "subscriptionDuration" as const,
operator: "greater_than_or_equal" as const,
value: 12,
},
],
},
];

const context: RewardContext = {
customer: {
subscriptionDuration: 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: "subscriptionDuration" as const,
operator: "greater_than_or_equal" as const,
value: 12,
},
],
},
];

const context: RewardContext = {
customer: {
subscriptionDuration: 6,
},
};

const result = evaluateRewardConditions({
conditions,
context,
});

expect(result).toBe(null);
});
});
});
});
69 changes: 45 additions & 24 deletions apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -169,7 +172,12 @@ const RewardItem = ({
<span className="min-w-0">
{idx === 0 ? "If" : capitalize(operator.toLowerCase())}{" "}
{condition.entity}{" "}
{(attribute?.label ?? condition.attribute)?.toLowerCase()}{" "}
{(
attribute?.label ??
(condition.attribute === "subscriptionDuration"
? "Subscription duration"
: condition.attribute)
)?.toLowerCase()}{" "}
{CONDITION_OPERATOR_LABELS[condition.operator]}{" "}
{condition.value &&
(condition.attribute === "country"
Expand All @@ -180,29 +188,36 @@ 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 === "subscriptionDuration"
? Array.isArray(condition.value)
? (condition.value as number[])
.map(formatSubscriptionDuration)
.join(", ")
: 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())}
</span>
</li>
);
Expand All @@ -212,3 +227,9 @@ const RewardItem = ({
</div>
);
};

function formatSubscriptionDuration(v: number): string {
return formatDuration(
v >= 12 ? { years: Math.floor(v / 12), months: v % 12 } : { months: v },
);
}
4 changes: 4 additions & 0 deletions apps/web/ui/partners/rewards/rewards-logic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,10 @@ function ConditionLogic({
)}
</InlineBadgePopover>

{condition.attribute === "subscriptionDuration" && (
<span> months</span>
)}

{condition.attribute === "productId" && condition.value && (
<button
type="button"
Expand Down