diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index c274aa30748..e3fd9add567 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -36,6 +36,7 @@ jobs: E2E_TOKEN_MEMBER: ${{ secrets.E2E_TOKEN_MEMBER }} E2E_TOKEN_OLD: ${{ secrets.E2E_TOKEN_OLD }} E2E_PUBLISHABLE_KEY: ${{ secrets.E2E_PUBLISHABLE_KEY }} + QSTASH_URL: "https://qstash-us-east-1.upstash.io" QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} NEXT_PUBLIC_NGROK_URL: ${{ github.event.deployment_status.environment_url }} diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index cd0e4f23839..f81f9d93a2e 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -43,6 +43,7 @@ jobs: UPSTASH_REDIS_REST_TOKEN: "e2e_srh_token" UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io" UPSTASH_VECTOR_REST_TOKEN: "xx" + QSTASH_URL: "https://qstash-us-east-1.upstash.io" QSTASH_TOKEN: "xx" QSTASH_CURRENT_SIGNING_KEY: "xx" QSTASH_NEXT_SIGNING_KEY: "xx" diff --git a/apps/web/.env.example b/apps/web/.env.example index 033f320ab94..fe76db1ae1d 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -25,6 +25,7 @@ UPSTASH_REDIS_REST_TOKEN= # Upstash QStash – required for queues and background jobs # Get your QStash Token here: https://upstash.com/docs/qstash/overall/getstarted +QSTASH_URL="https://qstash-us-east-1.upstash.io" QSTASH_TOKEN= QSTASH_CURRENT_SIGNING_KEY= QSTASH_NEXT_SIGNING_KEY= diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 88031a15006..1eca82db2d5 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -517,6 +517,9 @@ export async function checkoutSessionCompleted( sale: { products, amount: saleData.amount, + ...(charge.metadata && Object.keys(charge.metadata).length > 0 + ? { metadata: charge.metadata } + : {}), }, }, clickEvent: { diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index f869474e2a4..2c4aa8bb932 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -321,6 +321,12 @@ export async function invoicePaid( | undefined = undefined; if (link.programId && link.partnerId) { + const saleMetadata = { + ...invoice.parent?.subscription_details?.metadata, + ...invoice.lines.data[0]?.metadata, + ...invoice.metadata, + }; + const products = invoice.lines.data .map((line) => { const productId = line.pricing?.price_details?.product; @@ -357,6 +363,9 @@ export async function invoicePaid( sale: { products, amount: saleData.amount, + ...(Object.keys(saleMetadata).length > 0 + ? { metadata: saleMetadata } + : {}), }, }, clickEvent: { diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 102a57d2af3..92c16dfd350 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -304,6 +304,9 @@ export const trackLead = async ({ country: customer.country, source, }, + lead: { + ...(metadata != null && { metadata }), + }, }, clickEvent: { url: clickData.url, diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index c92feaa1ae8..3fe25ac040a 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -568,6 +568,7 @@ const _trackSale = async ({ sale: { productId: metadata?.productId, amount: saleData.amount, + ...(metadata != null && { metadata }), }, }, clickEvent: { diff --git a/apps/web/lib/api/rewards/reward-condition-metadata.ts b/apps/web/lib/api/rewards/reward-condition-metadata.ts new file mode 100644 index 00000000000..6e6639f0dc0 --- /dev/null +++ b/apps/web/lib/api/rewards/reward-condition-metadata.ts @@ -0,0 +1,88 @@ +import { RewardCondition } from "@/lib/types"; +import { + METADATA_CONDITION_OPERATORS, + METADATA_NUMBER_CONDITION_OPERATORS, +} from "@/lib/zod/schemas/rewards"; + +function toNumber(fieldKey: unknown): number | undefined { + if (fieldKey == null) { + return undefined; + } + + if (typeof fieldKey === "number" && !Number.isNaN(fieldKey)) { + return fieldKey; + } + + if (typeof fieldKey === "boolean") { + return undefined; + } + + if (Array.isArray(fieldKey)) { + return undefined; + } + + if (typeof fieldKey === "string") { + if (fieldKey.trim() === "" || Number.isNaN(Number(fieldKey))) { + return undefined; + } + + return Number(fieldKey); + } + + const n = Number(fieldKey); + return Number.isNaN(n) ? undefined : n; +} + +function toString(fieldKey: unknown) { + return typeof fieldKey === "string" ? fieldKey : String(fieldKey); +} + +/** + * Normalizes a raw metadata value (from lead/sale JSON) into the type that + * `evaluateCondition` expects for the given operator. + * + * Metadata is stored as loosely typed JSON, so the same field may arrive as a + * string, number, or other primitive. Text operators (starts_with, contains, + * etc.) always receive a string; numeric operators receive a parsed number or + * undefined when parsing fails. For equals_to / not_equals, the return type is + * aligned with `condition.value` so strict equality checks compare like types. + */ +export function prepareMetadataFieldValue( + fieldKey: unknown, + condition: RewardCondition, +): string | number | string[] | number[] | undefined { + if (fieldKey == null) { + return undefined; + } + + const { operator, value: conditionValue } = condition; + + // Metadata conditions do not support in / not_in and other non-metadata operators. + if (!METADATA_CONDITION_OPERATORS.includes(operator)) { + return undefined; + } + + // Pattern text operators always compare against a stringified metadata value. + if ( + operator === "starts_with" || + operator === "ends_with" || + operator === "contains" || + operator === "not_contains" + ) { + return toString(fieldKey); + } + + // Numeric operators require a parsed number, or undefined when coercion fails. + if (METADATA_NUMBER_CONDITION_OPERATORS.includes(operator)) { + return toNumber(fieldKey); + } + + // For equals_to / not_equals with a numeric condition value, prefer number coercion. + if (typeof conditionValue === "number") { + const numeric = toNumber(fieldKey); + return numeric !== undefined ? numeric : toString(fieldKey); + } + + // All other operators use string comparison. + return toString(fieldKey); +} diff --git a/apps/web/lib/partners/determine-partner-reward.ts b/apps/web/lib/partners/determine-partner-reward.ts index 5feaccde8d3..1bc18e417ec 100644 --- a/apps/web/lib/partners/determine-partner-reward.ts +++ b/apps/web/lib/partners/determine-partner-reward.ts @@ -1,4 +1,4 @@ -import { toCentsNumber } from "@dub/utils"; +import { prettyPrint, toCentsNumber } from "@dub/utils"; import { EventType, Link, Prisma, Reward } from "@prisma/client"; import { serializeReward } from "../api/partners/serialize-reward"; import { RewardContext, RewardProps } from "../types"; @@ -177,7 +177,7 @@ export const determinePartnerRewards = ({ } } - console.info("Resolved rewards", rewards); + console.log("Reward context", prettyPrint(context)); return rewards; }; diff --git a/apps/web/lib/partners/evaluate-reward-conditions.ts b/apps/web/lib/partners/evaluate-reward-conditions.ts index 83fea66b1cb..6f08136c5f7 100644 --- a/apps/web/lib/partners/evaluate-reward-conditions.ts +++ b/apps/web/lib/partners/evaluate-reward-conditions.ts @@ -1,3 +1,4 @@ +import { prepareMetadataFieldValue } from "../api/rewards/reward-condition-metadata"; import { RewardCondition, RewardConditions, @@ -22,15 +23,7 @@ export const evaluateRewardConditions = ({ for (const conditionGroup of conditions) { // Evaluate each condition in the group const conditionResults = conditionGroup.conditions.map((condition) => { - let fieldValue = undefined; - - if (condition.entity === "customer") { - fieldValue = context.customer?.[condition.attribute]; - } else if (condition.entity === "sale") { - fieldValue = context.sale?.[condition.attribute]; - } else if (condition.entity === "partner") { - fieldValue = context.partner?.[condition.attribute]; - } + const fieldValue = resolveConditionFieldValue({ condition, context }); if (fieldValue === undefined) { return false; @@ -75,6 +68,43 @@ export const evaluateRewardConditions = ({ )[0]; }; +function resolveConditionFieldValue({ + condition, + context, +}: { + condition: RewardCondition; + context: RewardContext; +}): string | number | string[] | number[] | undefined { + if (condition.attribute === "metadata") { + const metaKey = condition.metadataField?.trim(); + + if (!metaKey) { + return undefined; + } + + const entityMap = { + partner: undefined, + customer: undefined, + lead: context.lead, + sale: context.sale, + } as const; + + return prepareMetadataFieldValue( + entityMap[condition.entity]?.metadata?.[metaKey], + condition, + ); + } + + const entityMap = { + partner: context.partner, + customer: context.customer, + lead: undefined, + sale: context.sale, + } as const; + + return entityMap[condition.entity]?.[condition.attribute]; +} + const evaluateCondition = ({ condition, fieldValue, @@ -94,7 +124,11 @@ const evaluateCondition = ({ // Starts with if (condition.operator === "starts_with") { - if (typeof fieldValue !== "string" || typeof condition.value !== "string") { + if ( + typeof fieldValue !== "string" || + typeof condition.value !== "string" || + condition.value === "" + ) { return false; } @@ -103,13 +137,47 @@ const evaluateCondition = ({ // Ends with if (condition.operator === "ends_with") { - if (typeof fieldValue !== "string" || typeof condition.value !== "string") { + if ( + typeof fieldValue !== "string" || + typeof condition.value !== "string" || + condition.value === "" + ) { return false; } return fieldValue.endsWith(condition.value); } + // Contains + if (condition.operator === "contains") { + if (typeof fieldValue !== "string" || typeof condition.value !== "string") { + return false; + } + + const trimmedValue = condition.value.trim(); + + if (trimmedValue === "") { + return false; + } + + return String(fieldValue).includes(trimmedValue); + } + + // Not contains + if (condition.operator === "not_contains") { + if (typeof fieldValue !== "string" || typeof condition.value !== "string") { + return false; + } + + const trimmedValue = condition.value.trim(); + + if (trimmedValue === "") { + return false; + } + + return !String(fieldValue).includes(trimmedValue); + } + // In if (condition.operator === "in") { if (!Array.isArray(condition.value)) { @@ -123,7 +191,7 @@ const evaluateCondition = ({ // Not in if (condition.operator === "not_in") { - if (!Array.isArray(condition.value)) { + if (!Array.isArray(condition.value) || condition.value.length === 0) { return false; } diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts index 8411af998a7..75207b3b9cf 100644 --- a/apps/web/lib/zod/schemas/rewards.ts +++ b/apps/web/lib/zod/schemas/rewards.ts @@ -26,19 +26,31 @@ export const COMMISSION_TYPES = [ export type RewardConditionEntityAttribute = { id: string; label: string; - type: "string" | "enum" | "number" | "currency" | "date"; + type: "string" | "enum" | "number" | "currency" | "date" | "metadata"; options?: { id: string; label: string; }[]; }; -export type RewardConditionEntity = { - id: "partner" | "customer" | "sale"; +type RewardConditionEntity = { + id: "partner" | "customer" | "sale" | "lead"; label: string; attributes: RewardConditionEntityAttribute[]; }; +const LEAD_ENTITY: RewardConditionEntity = { + id: "lead", + label: "Lead", + attributes: [ + { + id: "metadata", + label: "Metadata", + type: "metadata", + }, + ], +}; + const PARTNER_ENTITY: RewardConditionEntity = { id: "partner", label: "Partner", @@ -128,6 +140,7 @@ export const REWARD_CONDITIONS: Record< ], }, PARTNER_ENTITY, + LEAD_ENTITY, ], }, @@ -200,6 +213,11 @@ export const REWARD_CONDITIONS: Record< }, ], }, + { + id: "metadata", + label: "Metadata", + type: "metadata", + }, ], }, ], @@ -211,25 +229,29 @@ export const REWARD_CONDITIONS: Record< }, }; -export const REWARD_CONDITION_ENTITIES = [ +const REWARD_CONDITION_ENTITIES = [ ...new Set( Object.values(REWARD_CONDITIONS).flatMap(({ entities }) => entities), ), ]; -export const REWARD_CONDITION_ATTRIBUTES = [ - ...new Set( - Object.values(REWARD_CONDITIONS).flatMap(({ entities }) => - entities.flatMap(({ attributes }) => attributes), - ), - ), +export const REWARD_CONDITION_ATTRIBUTES = Object.values( + REWARD_CONDITIONS, +).flatMap(({ entities }) => entities.flatMap(({ attributes }) => attributes)); + +const REWARD_CONDITION_ATTRIBUTE_IDS = [ + ...new Set(REWARD_CONDITION_ATTRIBUTES.map(({ id }) => id)), ]; +const REWARD_METADATA_CONDITION_ENTITIES = ["lead", "sale"] as const; + export const CONDITION_OPERATORS = [ "equals_to", "not_equals", "starts_with", "ends_with", + "contains", + "not_contains", "in", "not_in", "greater_than", @@ -257,11 +279,32 @@ export const NUMBER_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] export const DATE_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] = ["greater_than", "greater_than_or_equal", "less_than", "less_than_or_equal"]; +export const METADATA_NUMBER_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] = + ["greater_than", "greater_than_or_equal", "less_than", "less_than_or_equal"]; + +export const METADATA_TEXT_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] = + [ + "equals_to", + "not_equals", + "starts_with", + "ends_with", + "contains", + "not_contains", + ]; + +export const METADATA_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] = + [ + ...METADATA_TEXT_CONDITION_OPERATORS, + ...METADATA_NUMBER_CONDITION_OPERATORS, + ]; + export const CONDITION_OPERATOR_LABELS = { equals_to: "is", not_equals: "is not", starts_with: "starts with", ends_with: "ends with", + contains: "contains", + not_contains: "does not contain", in: "is one of", not_in: "is not one of", greater_than: "is greater than", @@ -270,13 +313,9 @@ export const CONDITION_OPERATOR_LABELS = { less_than_or_equal: "is less than or equal to", } as const; -export const rewardConditionSchema = z.object({ - entity: z.enum( - REWARD_CONDITION_ENTITIES.map(({ id }) => id) as [string, ...string[]], - ), - attribute: z.enum( - REWARD_CONDITION_ATTRIBUTES.map(({ id }) => id) as [string, ...string[]], - ), +export const rewardConditionBaseSchema = z.object({ + entity: z.enum(REWARD_CONDITION_ENTITIES.map(({ id }) => id)), + attribute: z.enum(REWARD_CONDITION_ATTRIBUTE_IDS), operator: z.enum(CONDITION_OPERATORS), value: z.union([ z.string(), @@ -288,8 +327,47 @@ export const rewardConditionSchema = z.object({ .string() .nullish() .describe("Product name used for display purposes in the UI."), + metadataField: z.string().optional(), }); +export const rewardConditionSchema = rewardConditionBaseSchema.superRefine( + (data, ctx) => { + if (data.entity === "lead" && data.attribute !== "metadata") { + ctx.addIssue({ + code: "custom", + message: "Lead conditions only support the Metadata attribute.", + path: ["attribute"], + }); + return; + } + + if (data.attribute !== "metadata") { + return; + } + + const metadataEntities = + REWARD_METADATA_CONDITION_ENTITIES as readonly string[]; + if (!metadataEntities.includes(data.entity)) { + ctx.addIssue({ + code: "custom", + message: + "Metadata is only valid for lead and sale reward condition entities.", + path: ["entity"], + }); + return; + } + + const key = data.metadataField?.trim() ?? ""; + if (!key) { + ctx.addIssue({ + code: "custom", + message: "Metadata field name is required when attribute is Metadata.", + path: ["metadataField"], + }); + } + }, +); + export const PERCENTAGE_REWARD_AMOUNT_SCHEMA = z .number() .min(0, { message: "Reward percentage amount cannot be less than 0%" }) @@ -435,11 +513,18 @@ export const rewardContextSchema = z.object({ }) .optional(), + lead: z + .object({ + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + sale: z .object({ productId: z.string().nullish(), amount: z.number().nullish(), type: z.enum(["new", "recurring"]).nullish(), + metadata: z.record(z.string(), z.unknown()).optional(), products: z .array( z.object({ diff --git a/apps/web/tests/rewards/reward-conditions.test.ts b/apps/web/tests/rewards/reward-conditions.test.ts index 026ba6c3a47..610911404b3 100644 --- a/apps/web/tests/rewards/reward-conditions.test.ts +++ b/apps/web/tests/rewards/reward-conditions.test.ts @@ -1,5 +1,6 @@ import { evaluateRewardConditions } from "@/lib/partners/evaluate-reward-conditions"; import { RewardContext } from "@/lib/types"; +import { rewardConditionSchema } from "@/lib/zod/schemas/rewards"; import { describe, expect, test } from "vitest"; describe("evaluateRewardConditions", () => { @@ -597,6 +598,198 @@ describe("evaluateRewardConditions", () => { expect(result).toBe(null); }); + + test("should not match when condition value is an empty array", () => { + expect( + evaluateRewardConditions({ + conditions: [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "customer" as const, + attribute: "country" as const, + operator: "not_in" as const, + value: [], + }, + ], + }, + ], + context: { customer: { country: "FR" } }, + }), + ).toBe(null); + }); + + test("should not match when condition value is not an array", () => { + expect( + evaluateRewardConditions({ + conditions: [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "customer" as const, + attribute: "country" as const, + operator: "not_in" as const, + value: "US", + }, + ], + }, + ], + context: { customer: { country: "FR" } }, + }), + ).toBe(null); + }); + }); + + describe("contains", () => { + test("should match when field contains the substring", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "contains" as const, + value: "plan", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "premium-plan" } }, + }), + ).toEqual(conditions[0]); + }); + + test("should not match when field does not contain the substring", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "contains" as const, + value: "plan", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "basic-tier" } }, + }), + ).toBe(null); + }); + + test("should not match when needle is empty string", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "contains" as const, + value: " ", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "any-value" } }, + }), + ).toBe(null); + }); + }); + + describe("not_contains", () => { + test("should match when field does not contain the substring", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "not_contains" as const, + value: "plan", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "basic-tier" } }, + }), + ).toEqual(conditions[0]); + }); + + test("should not match when field contains the substring", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "not_contains" as const, + value: "plan", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "premium-plan" } }, + }), + ).toBe(null); + }); + + test("should not match when needle is empty string", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "not_contains" as const, + value: " ", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "any-value" } }, + }), + ).toBe(null); + }); }); describe("starts_with", () => { @@ -659,6 +852,30 @@ describe("evaluateRewardConditions", () => { expect(result).toBe(null); }); + + test("should not match when value is empty string", () => { + const conditions = [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "starts_with" as const, + value: "", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { productId: "any-value" } }, + }), + ).toBe(null); + }); }); describe("ends_with", () => { @@ -692,6 +909,28 @@ describe("evaluateRewardConditions", () => { expect(result).toEqual(conditions[0]); }); + test("should not match when value is empty string", () => { + expect( + evaluateRewardConditions({ + conditions: [ + { + operator: "AND" as const, + amountInCents: 5000, + conditions: [ + { + entity: "sale" as const, + attribute: "productId" as const, + operator: "ends_with" as const, + value: "", + }, + ], + }, + ], + context: { sale: { productId: "any-value" } }, + }), + ).toBe(null); + }); + test("should not match when string does not end with value", () => { const conditions = [ { @@ -1869,4 +2108,377 @@ describe("evaluateRewardConditions", () => { expect(result).toBeNull(); }); }); + + describe("metadata conditions (lead / sale)", () => { + test("matches lead metadata equals_to", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 100, + conditions: [ + { + entity: "lead" as const, + attribute: "metadata" as const, + metadataField: "plan", + operator: "equals_to" as const, + value: "pro", + }, + ], + }, + ]; + + const context: RewardContext = { + lead: { metadata: { plan: "pro" } }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toEqual( + conditions[0], + ); + }); + + test("matches sale metadata greater_than for string number", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 200, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "seats", + operator: "greater_than" as const, + value: 5, + }, + ], + }, + ]; + + const context: RewardContext = { + sale: { + amount: 1000, + metadata: { seats: "10" }, + }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toEqual( + conditions[0], + ); + }); + + test("matches sale metadata equals_to when metadata is number and condition value is number", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 500, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "seats", + operator: "equals_to" as const, + value: 42, + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { metadata: { seats: 42 } } }, + }), + ).toEqual(conditions[0]); + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { metadata: { seats: "42" } } }, + }), + ).toEqual(conditions[0]); + }); + + test("equals_to uses string comparison when condition.value is a string", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 200, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "tier", + operator: "equals_to" as const, + value: "gold", + }, + ], + }, + ]; + + // metadata value is the string "gold" — should match + expect( + evaluateRewardConditions({ + conditions, + context: { sale: { metadata: { tier: "gold" } } }, + }), + ).toEqual(conditions[0]); + + // metadata value is numeric "42" but condition.value is string "42" — should match + const numericStringConditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 200, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "tier", + operator: "equals_to" as const, + value: "42", + }, + ], + }, + ]; + + expect( + evaluateRewardConditions({ + conditions: numericStringConditions, + context: { sale: { metadata: { tier: "42" } } }, + }), + ).toEqual(numericStringConditions[0]); + + // metadata value is number 42 and condition.value is string "42" — SHOULD match + // because we stringify the metadata before comparing when condition.value is a string + expect( + evaluateRewardConditions({ + conditions: numericStringConditions, + context: { sale: { metadata: { tier: 42 } } }, + }), + ).toEqual(numericStringConditions[0]); + }); + + test("returns null when metadata key is missing", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 100, + conditions: [ + { + entity: "lead" as const, + attribute: "metadata" as const, + metadataField: "missing", + operator: "equals_to" as const, + value: "x", + }, + ], + }, + ]; + + const context: RewardContext = { + lead: { metadata: { plan: "pro" } }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toBeNull(); + }); + + test("returns null when metadataField is empty", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 100, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: " ", + operator: "equals_to" as const, + value: "a", + }, + ], + }, + ]; + + const context: RewardContext = { + sale: { metadata: { tier: "a" } }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toBeNull(); + }); + + test("returns null when metadata value is null (numeric operator)", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 100, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "seats", + operator: "greater_than" as const, + value: 0, + }, + ], + }, + ]; + + const context: RewardContext = { + sale: { + metadata: { seats: null }, + }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toBeNull(); + }); + + test("returns null when metadata value is empty string (numeric operator)", () => { + const conditions = [ + { + operator: "AND" as const, + type: "flat" as const, + amountInCents: 100, + conditions: [ + { + entity: "sale" as const, + attribute: "metadata" as const, + metadataField: "seats", + operator: "greater_than" as const, + value: 0, + }, + ], + }, + ]; + + const context: RewardContext = { + sale: { + metadata: { seats: "" }, + }, + }; + + expect(evaluateRewardConditions({ conditions, context })).toBeNull(); + }); + }); +}); + +describe("rewardConditionSchema", () => { + test("rejects metadata attribute when metadataField is missing or only whitespace", () => { + expect( + rewardConditionSchema.safeParse({ + entity: "lead", + attribute: "metadata", + operator: "equals_to", + value: "x", + }).success, + ).toBe(false); + + expect( + rewardConditionSchema.safeParse({ + entity: "lead", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: " ", + }).success, + ).toBe(false); + + expect( + rewardConditionSchema.safeParse({ + entity: "lead", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: "plan", + }).success, + ).toBe(true); + + expect( + rewardConditionSchema.safeParse({ + entity: "sale", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: "plan", + }).success, + ).toBe(true); + }); + + test("rejects metadata attribute for entities other than lead and sale", () => { + expect( + rewardConditionSchema.safeParse({ + entity: "customer", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: "plan", + }).success, + ).toBe(false); + + expect( + rewardConditionSchema.safeParse({ + entity: "partner", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: "plan", + }).success, + ).toBe(false); + }); + + test("allows is one of / is not one of with string arrays", () => { + expect( + rewardConditionSchema.safeParse({ + entity: "customer", + attribute: "country", + operator: "in", + value: ["US", "CA"], + }).success, + ).toBe(true); + + expect( + rewardConditionSchema.safeParse({ + entity: "customer", + attribute: "country", + operator: "not_in", + value: ["US", "CA"], + }).success, + ).toBe(true); + }); + + test("rejects non-metadata attribute for lead entity", () => { + expect( + rewardConditionSchema.safeParse({ + entity: "lead", + attribute: "country", + operator: "equals_to", + value: "US", + }).success, + ).toBe(false); + + expect( + rewardConditionSchema.safeParse({ + entity: "lead", + attribute: "metadata", + operator: "equals_to", + value: "x", + metadataField: "plan", + }).success, + ).toBe(true); + }); + + test("allows non-metadata attributes without metadataField", () => { + expect( + rewardConditionSchema.safeParse({ + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }).success, + ).toBe(true); + }); }); diff --git a/apps/web/tests/rewards/sale-reward.test.ts b/apps/web/tests/rewards/sale-reward.test.ts index 27598ba279c..fa0c166e4a2 100644 --- a/apps/web/tests/rewards/sale-reward.test.ts +++ b/apps/web/tests/rewards/sale-reward.test.ts @@ -207,5 +207,28 @@ describe("Sale rewards with conditions", async () => { expectedEarnings: E2E_SALE_REWARD.modifiers[4].amountInCents!, }); }); + + test("when {Sale} {Metadata} {Key} is {Value}", async () => { + const sale = randomSale("E2E sale metadata key-value condition"); + + const trackSaleResponse = await http.post({ + path: "/track/sale", + body: { + ...sale, + customerExternalId: newCustomer.externalId, + metadata: { + bookTitle: "THGTTG", + }, + }, + }); + + expect(trackSaleResponse.status).toEqual(200); + + await verifyCommission({ + http, + invoiceId: sale.invoiceId, + expectedEarnings: E2E_SALE_REWARD.modifiers[6].amountInCents!, + }); + }); }); }); diff --git a/apps/web/tests/utils/resource.ts b/apps/web/tests/utils/resource.ts index 364be221053..6eae6c1557f 100644 --- a/apps/web/tests/utils/resource.ts +++ b/apps/web/tests/utils/resource.ts @@ -147,6 +147,22 @@ export const E2E_SALE_REWARD = { maxDuration: null, amountInCents: 9000, }, + { + id: "939e0cbd-fe8d-4cee-8a71-f5b7a5737887", + type: "flat", + operator: "AND", + conditions: [ + { + value: "THGTTG", + entity: "sale", + operator: "equals_to", + attribute: "metadata", + metadataField: "bookTitle", + }, + ], + maxDuration: null, + amountInCents: 4200, + }, ], }; diff --git a/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx b/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx index 6464c8ea988..8d55ec69b7d 100644 --- a/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx +++ b/apps/web/ui/partners/program-reward-modifiers-tooltip.tsx @@ -171,7 +171,13 @@ const RewardItem = ({ {idx === 0 ? "If" : capitalize(operator)}{" "} - {capitalize(condition.entity)} {capitalize(attribute?.label)}{" "} + {capitalize(condition.entity)}{" "} + {(condition.entity === "lead" || + condition.entity === "sale") && + condition.attribute === "metadata" && + condition.metadataField?.trim() + ? `"${condition.metadataField.trim()}"` + : capitalize(attribute?.label)}{" "} {condition.label ? "is" // if custom label is set, use "is" instead of the actual operator to sound more natural : CONDITION_OPERATOR_LABELS[condition.operator]}{" "} diff --git a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx index 6a8f3bd639e..001fbb68a4e 100644 --- a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx +++ b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx @@ -19,8 +19,8 @@ import { REWARD_CONDITION_ATTRIBUTES, REWARD_DESCRIPTION_MAX_LENGTH, REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH, + rewardConditionBaseSchema, rewardConditionsArraySchema, - rewardConditionSchema, rewardConditionsSchema, } from "@/lib/zod/schemas/rewards"; import { DurationPopoverContent } from "@/ui/shared/duration-popover-content"; @@ -84,7 +84,7 @@ const formSchema = createOrUpdateRewardSchema.extend({ modifiers: z .array( rewardConditionsSchema.extend({ - conditions: z.array(rewardConditionSchema.partial()).min(1), + conditions: z.array(rewardConditionBaseSchema.partial()).min(1), }), ) .min(1), diff --git a/apps/web/ui/partners/rewards/rewards-logic.tsx b/apps/web/ui/partners/rewards/rewards-logic.tsx index 7c1af69b26a..9d0cb5cc16f 100644 --- a/apps/web/ui/partners/rewards/rewards-logic.tsx +++ b/apps/web/ui/partners/rewards/rewards-logic.tsx @@ -11,6 +11,9 @@ import { CONDITION_OPERATORS, DATE_CONDITION_OPERATORS, ENUM_CONDITION_OPERATORS, + METADATA_CONDITION_OPERATORS, + METADATA_NUMBER_CONDITION_OPERATORS, + METADATA_TEXT_CONDITION_OPERATORS, NUMBER_CONDITION_OPERATORS, REWARD_CONDITIONS, RewardConditionEntityAttribute, @@ -31,6 +34,7 @@ import { User, Users, } from "@dub/ui"; +import { UserPlus } from "@dub/ui/icons"; import { capitalize, cn, @@ -44,12 +48,13 @@ import { RewardStructure } from "@prisma/client"; import { Command } from "cmdk"; import { Package } from "lucide-react"; import { motion } from "motion/react"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useContext, useEffect, useState } from "react"; import { useFieldArray, useWatch } from "react-hook-form"; import { v4 as uuid } from "uuid"; import { InlineBadgePopover, InlineBadgePopoverAmountInput, + InlineBadgePopoverContext, InlineBadgePopoverInput, InlineBadgePopoverInputs, InlineBadgePopoverMenu, @@ -223,7 +228,17 @@ function ConditionalGroup({ const formatValue = ( value: string | number | string[] | number[] | undefined, attribute?: Pick, + metadataOperator?: (typeof CONDITION_OPERATORS)[number], ) => { + if ( + metadataOperator && + METADATA_NUMBER_CONDITION_OPERATORS.includes(metadataOperator) + ) { + if (value === "" || value === undefined || isNaN(Number(value))) + return "Value"; + return String(value); + } + const type = attribute?.type ?? "string"; if ( @@ -281,6 +296,60 @@ const formatValue = ( return truncate(value!.toString(), 20); }; +function MetadataConditionOperatorMenu({ + selectedValue, + onSelect, +}: { + selectedValue?: (typeof CONDITION_OPERATORS)[number]; + onSelect: (value: (typeof CONDITION_OPERATORS)[number]) => void; +}) { + const { setIsOpen } = useContext(InlineBadgePopoverContext); + + const renderItem = (op: (typeof CONDITION_OPERATORS)[number]) => ( + { + onSelect(op); + setIsOpen(false); + }} + className="flex cursor-pointer items-center justify-between rounded-md px-1.5 py-1 transition-colors duration-150 data-[selected=true]:bg-neutral-100" + > + + {CONDITION_OPERATOR_LABELS[op]} + + {selectedValue === op && ( + + )} + + ); + + return ( +
+ + +
+ Text fields +
+
+ {METADATA_TEXT_CONDITION_OPERATORS.map(renderItem)} +
+
+
+ Number fields +
+
+ {METADATA_NUMBER_CONDITION_OPERATORS.map(renderItem)} +
+ + +
+ ); +} + function ConditionLogic({ modifierIndex, conditionIndex, @@ -312,12 +381,29 @@ function ConditionLogic({ const attributeType = attribute?.type ?? "string"; + const isMetadataCondition = + (condition.entity === "lead" || condition.entity === "sale") && + condition.attribute === "metadata"; + + const isMetadataNumeric = + isMetadataCondition && + !!condition.operator && + METADATA_NUMBER_CONDITION_OPERATORS.includes(condition.operator); + const icon = entity - ? { customer: User, sale: InvoiceDollar, partner: Users }[entity.id] ?? User + ? { + customer: User, + sale: InvoiceDollar, + partner: Users, + lead: UserPlus, + }[entity.id] ?? User : ArrowTurnRight2; const isArrayValue = - condition.operator && ["in", "not_in"].includes(condition.operator); + condition.operator === "in" || condition.operator === "not_in"; + + const isContainsOperator = + condition.operator === "contains" || condition.operator === "not_contains"; const [displayProductLabel, setDisplayProductLabel] = useState(false); @@ -327,16 +413,42 @@ function ConditionLogic({ const isSaleTypeCondition = condition.entity === "sale" && condition.attribute === "type"; - const availableConditionOperators: (typeof CONDITION_OPERATORS)[number][] = [ - "number", - "currency", - ].includes(attributeType) - ? NUMBER_CONDITION_OPERATORS - : attributeType === "enum" - ? ENUM_CONDITION_OPERATORS - : attributeType === "date" - ? DATE_CONDITION_OPERATORS - : STRING_CONDITION_OPERATORS; + const availableConditionOperators: (typeof CONDITION_OPERATORS)[number][] = + attributeType === "metadata" + ? METADATA_CONDITION_OPERATORS + : ["number", "currency"].includes(attributeType) + ? NUMBER_CONDITION_OPERATORS + : attributeType === "enum" + ? ENUM_CONDITION_OPERATORS + : attributeType === "date" + ? DATE_CONDITION_OPERATORS + : STRING_CONDITION_OPERATORS; + + useEffect(() => { + if ( + isMetadataCondition && + condition.operator && + !METADATA_CONDITION_OPERATORS.includes(condition.operator) + ) { + setValue( + conditionKey, + { + ...condition, + operator: undefined, + value: undefined, + }, + { + shouldDirty: true, + }, + ); + } + }, [ + isMetadataCondition, + condition.operator, + condition, + conditionKey, + setValue, + ]); useEffect(() => { if ( @@ -385,6 +497,7 @@ function ConditionLogic({ attribute: undefined, operator: undefined, value: undefined, + metadataField: undefined, }, { shouldDirty: true, @@ -417,6 +530,9 @@ function ConditionLogic({ { entity: condition.entity, attribute: value, + ...(value !== "metadata" + ? { metadataField: undefined } + : {}), }, { shouldDirty: true, @@ -438,6 +554,23 @@ function ConditionLogic({ }))} /> {" "} + {isMetadataCondition && ( + <> + + + {" "} + + )} {isCustomerSourceCondition || isSaleTypeCondition ? ( is ) : ( @@ -449,47 +582,77 @@ function ConditionLogic({ } invalid={!condition.operator} > - - setValue( - conditionKey, - { - ...condition, - operator: - value as (typeof CONDITION_OPERATORS)[number], - // Update value to array / string / number if needed - ...(["in", "not_in"].includes(value) - ? !Array.isArray(condition.value) + {isMetadataCondition ? ( + { + const metadataNumeric = + METADATA_NUMBER_CONDITION_OPERATORS.includes(value); + setValue( + conditionKey, + { + ...condition, + operator: value, + ...(["in", "not_in"].includes(value) ? { value: [] } - : null - : ["number", "currency"].includes(attributeType) - ? typeof condition.value !== "number" + : ["contains", "not_contains"].includes(value) ? { value: "" } - : null - : attributeType === "date" + : metadataNumeric + ? typeof condition.value !== "number" + ? { value: "" } + : null + : typeof condition.value !== "string" && + !Array.isArray(condition.value) + ? { value: "" } + : null), + }, + { + shouldDirty: true, + }, + ); + }} + /> + ) : ( + + setValue( + conditionKey, + { + ...condition, + operator: + value as (typeof CONDITION_OPERATORS)[number], + // Reset value shape when operator changes + ...(["in", "not_in"].includes(value) + ? { value: [] } + : ["number", "currency"].includes(attributeType) ? typeof condition.value !== "number" - ? { value: undefined } - : null - : typeof condition.value !== "string" ? { value: "" } - : null), - }, - { - shouldDirty: true, - }, - ) - } - items={availableConditionOperators.map((operator) => ({ - text: CONDITION_OPERATOR_LABELS[operator], - value: operator, - }))} - /> + : null + : attributeType === "date" + ? typeof condition.value !== "number" + ? { value: undefined } + : null + : typeof condition.value !== "string" + ? { value: "" } + : null), + }, + { + shouldDirty: true, + }, + ) + } + items={availableConditionOperators.map((operator) => ({ + text: CONDITION_OPERATOR_LABELS[operator], + value: operator, + }))} + /> + )} )}{" "} {condition.operator && ( <> - {attributeType === "date" ? ( + {attributeType === "date" && !isMetadataCondition ? ( ) : ( - {/* Country selection */} - {condition.attribute === "country" ? ( + {/* Country selection (single value only) */} + {condition.attribute === "country" && !isArrayValue ? ( // Country selector ({ @@ -559,30 +730,16 @@ function ConditionLogic({ onSelect={(value) => { setValue(conditionKey, { ...condition, - value: isArrayValue - ? Array.isArray(condition.value) - ? (condition.value as string[]).includes( - value, - ) - ? (condition.value.filter( - (v) => v !== value, - ) as string[]) - : ([ - ...condition.value, - value, - ] as string[]) - : [value] - : value, + value, }); }} /> - ) : attribute?.options ? ( + ) : attribute?.options && !isArrayValue ? ( // Select option selector 4} selectedValue={ - (condition.value as string[] | undefined) ?? - (isArrayValue ? [] : undefined) + condition.value as string | undefined } items={attribute.options.map(({ id, label }) => ({ text: label, @@ -591,32 +748,24 @@ function ConditionLogic({ onSelect={(value) => { setValue(conditionKey, { ...condition, - value: isArrayValue - ? Array.isArray(condition.value) - ? (condition.value as string[]).includes( - value, - ) - ? (condition.value.filter( - (v) => v !== value, - ) as string[]) - : ([ - ...condition.value, - value, - ] as string[]) - : [value] - : value, + value, }); }} /> + ) : isContainsOperator ? ( + ) : isArrayValue ? ( - // String array input + // Multi-value list input for is one of / is not one of { setValue(conditionKey, { @@ -625,11 +774,16 @@ function ConditionLogic({ }); }} /> - ) : ["number", "currency"].includes(attributeType) ? ( + ) : ["number", "currency"].includes(attributeType) || + isMetadataNumeric ? ( // Number/currency input ) : ( // String input