Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ export async function checkoutSessionCompleted(
context: {
customer: {
country: customer.country,
firstSaleAt: customer.firstSaleAt,
},
sale: {
productId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export async function invoicePaid(event: Stripe.Event, mode: StripeMode) {
context: {
customer: {
country: customer.country,
firstSaleAt: customer.firstSaleAt,
},
sale: {
productId: invoice.lines.data[0]?.pricing?.price_details?.product,
Expand Down
9 changes: 7 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,13 @@ 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,
firstSaleAt: customer.firstSaleAt,
},
sale: {
productId,
},
},
})),
);
Expand Down
5 changes: 3 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,11 @@ const _trackSale = async ({
context: {
customer: {
country: customer.country,
source: source!,
source,
firstSaleAt: customer.firstSaleAt,
},
sale: {
productId: metadata?.productId as string,
productId: metadata?.productId,
amount: saleData.amount,
},
},
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/integrations/shopify/create-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export async function createShopifySale({
context: {
customer: {
country: customer.country,
firstSaleAt: customer.firstSaleAt,
},
sale: {
amount: saleData.amount,
Expand Down
8 changes: 8 additions & 0 deletions apps/web/lib/partners/determine-partner-reward.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventType, Link, Prisma, Reward } from "@dub/prisma/client";
import { differenceInMonths } from "date-fns";
import { serializeReward } from "../api/partners/serialize-reward";
import { RewardContext } from "../types";
import {
Expand Down Expand Up @@ -51,6 +52,13 @@ export const determinePartnerReward = ({
totalCommissions: programEnrollment.totalCommissions,
country: programEnrollment.partner?.country,
},
customer: {
...context?.customer,
subscriptionDuration: differenceInMonths(
new Date(),
context?.customer?.firstSaleAt ?? new Date(),
),
},
};

if (partnerReward.modifiers && context) {
Expand Down
7 changes: 7 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,8 @@ export const rewardContextSchema = z.object({
.object({
country: z.string().nullish(),
source: z.enum(CUSTOMER_SOURCES).default("tracked").nullish(),
firstSaleAt: z.date().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 },
);
}