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 1627cd24778..fdf4e60ab48 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 @@ -27,7 +27,7 @@ import { Customer, Project } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; -import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; +import { getCheckoutSessionProducts } from "./utils/get-checkout-session-products"; import { getConnectedCustomer } from "./utils/get-connected-customer"; import { getPromotionCode } from "./utils/get-promotion-code"; import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id"; @@ -358,9 +358,10 @@ export async function checkoutSessionCompleted( if (!ok) { console.info( - "[Stripe Webhook] Skipping already processed invoice.", + "[checkout.session.completed] Skipping already processed invoice.", invoiceId, ); + return { response: `Invoice with ID ${invoiceId} already processed, skipping...`, workspaceId: workspace.id, @@ -483,7 +484,7 @@ export async function checkoutSessionCompleted( | undefined = undefined; if (link && link.programId && link.partnerId) { - const productId = await getCheckoutSessionProductId({ + const products = await getCheckoutSessionProducts({ checkoutSessionId: charge.id, stripeAccountId, mode, @@ -506,7 +507,7 @@ export async function checkoutSessionCompleted( signupDate: customer.createdAt, }, sale: { - productId, + products, amount: saleData.amount, }, }, 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 64bf076b016..c698551acff 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 @@ -105,7 +105,7 @@ export async function invoicePaid( if (!ok) { console.info( - "[Stripe Webhook] Skipping already processed invoice.", + "[invoice.paid] Skipping already processed invoice.", invoiceId, ); return { @@ -244,6 +244,23 @@ export async function invoicePaid( | undefined = undefined; if (link.programId && link.partnerId) { + const products = invoice.lines.data + .map((line) => { + const productId = line.pricing?.price_details?.product; + + if (!productId) return null; + + return { + id: productId, + amount: line.amount, + quantity: line.quantity, + }; + }) + .filter( + (p): p is { id: string; amount: number; quantity: number } => + p !== null, + ); + createdCommission = await createPartnerCommission({ event: "sale", programId: link.programId, @@ -261,7 +278,7 @@ export async function invoicePaid( signupDate: customer.createdAt, }, sale: { - productId: invoice.lines.data[0]?.pricing?.price_details?.product, + products, amount: saleData.amount, }, }, diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-product-id.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-product-id.ts deleted file mode 100644 index 2191ecfee1e..00000000000 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-product-id.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { stripeAppClient } from "@/lib/stripe"; -import { StripeMode } from "@/lib/types"; -import type Stripe from "stripe"; - -function productIdFromLineItemPrice( - price: Stripe.Price | string | null | undefined, -): string | null { - if (!price || typeof price === "string") { - return null; - } - - if (!price.product) { - return null; - } - - const product = price.product; - - if (typeof product === "string") { - return product; - } - - if ("deleted" in product && product.deleted) { - return null; - } - - return product.id; -} - -export async function getCheckoutSessionProductId({ - checkoutSessionId, - stripeAccountId, - mode, -}: { - checkoutSessionId: string; - stripeAccountId?: string | null; - mode: StripeMode; -}): Promise { - if (!stripeAccountId) { - return null; - } - - try { - const lineItems = await stripeAppClient({ - mode, - }).checkout.sessions.listLineItems( - checkoutSessionId, - { - expand: ["data.price.product"], - limit: 10, - }, - { - stripeAccount: stripeAccountId, - }, - ); - - for (const item of lineItems.data) { - const productId = productIdFromLineItemPrice(item.price); - if (productId) { - return productId; - } - } - - return null; - } catch (error) { - console.log("Failed to get checkout session product ID:", error); - return null; - } -} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts new file mode 100644 index 00000000000..28c0c8a2f86 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-checkout-session-products.ts @@ -0,0 +1,97 @@ +import { stripeAppClient } from "@/lib/stripe"; +import { StripeMode } from "@/lib/types"; +import type Stripe from "stripe"; + +export function productIdFromLineItemPrice( + price: Stripe.Price | string | null | undefined, +): string | null { + if (!price || typeof price === "string") { + return null; + } + + if (!price.product) { + return null; + } + + const product = price.product; + + if (typeof product === "string") { + return product; + } + + if ("deleted" in product && product.deleted) { + return null; + } + + return product.id; +} + +export async function getCheckoutSessionProducts({ + checkoutSessionId, + stripeAccountId, + mode, +}: { + checkoutSessionId: string; + stripeAccountId?: string | null; + mode: StripeMode; +}) { + if (!stripeAccountId) { + return []; + } + + try { + const stripeApp = stripeAppClient({ + mode, + }); + + const lineItems = await stripeApp.checkout.sessions.listLineItems( + checkoutSessionId, + { + expand: ["data.price.product"], + limit: 10, + }, + { + stripeAccount: stripeAccountId, + }, + ); + + if (lineItems.data.length === 0) { + console.log( + `[getCheckoutSessionProducts] No line items found for checkout session ${checkoutSessionId}.`, + ); + return []; + } + + const products = lineItems.data + .map((line) => { + const productId = productIdFromLineItemPrice(line.price); + + if (!productId) return null; + + return { + id: productId, + amount: line.amount_total, + quantity: line.quantity, + }; + }) + .filter( + (p): p is { id: string; amount: number; quantity: number } => + p !== null, + ); + + if (products.length === 0) { + console.log( + `[getCheckoutSessionProducts] No valid products found for checkout session ${checkoutSessionId}.`, + ); + return []; + } + + return products; + } catch (error) { + console.log( + "[getCheckoutSessionProducts] Failed to get checkout session products:", + error, + ); + return []; + } +} diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index b3daed102d9..26abeda0fd7 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -23,6 +23,7 @@ import { RewardContext, RewardProps } from "../types"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; +import { rewardConditionsArraySchema } from "../zod/schemas/rewards"; import { aggregatePartnerLinksStats } from "./aggregate-partner-links-stats"; import { determinePartnerReward } from "./determine-partner-reward"; import { getRewardAmount } from "./get-reward-amount"; @@ -45,6 +46,11 @@ export type CreatePartnerCommissionProps = { skipWorkflow?: boolean; }; +type RewardWithProduct = { + reward: RewardProps; + sale: { amount: number; quantity: number }; +}; + const constructWebhookPartner = ( programEnrollment: ProgramEnrollment & { partner: Partner; links: Link[] }, { @@ -79,8 +85,9 @@ export const createPartnerCommission = async ({ skipWorkflow = false, }: CreatePartnerCommissionProps) => { let earnings = 0; - let reward: RewardProps | null = null; let status: CommissionStatus = "pending"; + let reward: RewardProps | null = null; + let rewards: RewardWithProduct[] = []; // TODO: Find a better name const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, @@ -147,17 +154,87 @@ export const createPartnerCommission = async ({ }; } - reward = determinePartnerReward({ - event, - programEnrollment, - context, - }); + const products = context?.sale?.products ?? []; + + const modifiers = rewardConditionsArraySchema.safeParse( + programEnrollment.saleReward?.modifiers, + ); + + const hasProductIdModifier = modifiers.success + ? modifiers.data.some((m) => + m.conditions.some( + (c) => c.entity === "sale" && c.attribute === "productId", + ), + ) + : false; + + // If there are products and a productId modifier, + // we need to calculate the reward for each product (for Stripe integration only) + if (products.length > 0 && hasProductIdModifier) { + for (const product of products) { + const reward = determinePartnerReward({ + event, + programEnrollment, + context: { + ...context, + sale: { + ...context?.sale, + productId: product.id, + amount: product.amount, + }, + }, + }); + + if (reward) { + rewards.push({ + reward, + sale: { + amount: product.amount, + quantity: product.quantity, + }, + }); + } + } + + if (rewards.length > 0) { + reward = rewards[0].reward; + } + } else { + context = { + ...context, + sale: { + ...context?.sale, + // Callers that pass it explicitly keep their value, and + // Stripe webhooks (which send `products[]`) still surface a productId via the first line item + ...(event === "sale" && { + productId: context?.sale?.productId ?? products[0]?.id, + }), + }, + }; + + reward = determinePartnerReward({ + event, + programEnrollment, + context, + }); + + if (reward) { + rewards.push({ + reward, + sale: { + amount, + quantity, + }, + }); + } + } // if there is no reward, skip commission creation if (!reward) { console.log( `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`, ); + return { commission: null, programEnrollment, @@ -273,12 +350,18 @@ export const createPartnerCommission = async ({ // for lead events, we just multiply the reward amount by the quantity if (event === "lead") { earnings = getRewardAmount(reward) * quantity; - // for sale events, we need to calculate the earnings based on the sale amount - } else { - earnings = calculateSaleEarnings({ - reward, - sale: { quantity, amount }, - }); + } + // for sale events, we need to calculate the earnings based on the sale amount + else { + earnings = rewards.reduce( + (acc, { reward, sale }) => + acc + + calculateSaleEarnings({ + reward, + sale, + }), + 0, + ); } } } diff --git a/apps/web/lib/partners/evaluate-reward-conditions.ts b/apps/web/lib/partners/evaluate-reward-conditions.ts index 6822736917a..83fea66b1cb 100644 --- a/apps/web/lib/partners/evaluate-reward-conditions.ts +++ b/apps/web/lib/partners/evaluate-reward-conditions.ts @@ -82,50 +82,75 @@ const evaluateCondition = ({ condition: RewardCondition; fieldValue: string | number | string[] | number[]; }) => { - switch (condition.operator) { - case "equals_to": - return fieldValue === condition.value; - case "not_equals": - return fieldValue !== condition.value; - case "starts_with": - if ( - typeof fieldValue === "string" && - typeof condition.value === "string" - ) { - return fieldValue.startsWith(condition.value); - } + // Equals + if (condition.operator === "equals_to") { + return fieldValue === condition.value; + } + + // Not equals + if (condition.operator === "not_equals") { + return fieldValue !== condition.value; + } + + // Starts with + if (condition.operator === "starts_with") { + if (typeof fieldValue !== "string" || typeof condition.value !== "string") { return false; - case "ends_with": - if ( - typeof fieldValue === "string" && - typeof condition.value === "string" - ) { - return fieldValue.endsWith(condition.value); - } + } + + return fieldValue.startsWith(condition.value); + } + + // Ends with + if (condition.operator === "ends_with") { + if (typeof fieldValue !== "string" || typeof condition.value !== "string") { return false; - case "in": - if (Array.isArray(condition.value)) { - return (condition.value as (string | number)[]).includes( - fieldValue as string | number, - ); - } + } + + return fieldValue.endsWith(condition.value); + } + + // In + if (condition.operator === "in") { + if (!Array.isArray(condition.value)) { return false; - case "not_in": - if (Array.isArray(condition.value)) { - return !(condition.value as (string | number)[]).includes( - fieldValue as string | number, - ); - } - return true; - case "greater_than": - return Number(fieldValue) > Number(condition.value); - case "greater_than_or_equal": - return Number(fieldValue) >= Number(condition.value); - case "less_than": - return Number(fieldValue) < Number(condition.value); - case "less_than_or_equal": - return Number(fieldValue) <= Number(condition.value); - default: + } + + return (condition.value as (string | number)[]).includes( + fieldValue as string | number, + ); + } + + // Not in + if (condition.operator === "not_in") { + if (!Array.isArray(condition.value)) { return false; + } + + return !(condition.value as (string | number)[]).includes( + fieldValue as string | number, + ); + } + + // Greater than + if (condition.operator === "greater_than") { + return Number(fieldValue) > Number(condition.value); } + + // Greater than or equal + if (condition.operator === "greater_than_or_equal") { + return Number(fieldValue) >= Number(condition.value); + } + + // Less than + if (condition.operator === "less_than") { + return Number(fieldValue) < Number(condition.value); + } + + // Less than or equal + if (condition.operator === "less_than_or_equal") { + return Number(fieldValue) <= Number(condition.value); + } + + return false; }; diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts index aff6e3c75fc..20c0735cdb6 100644 --- a/apps/web/lib/zod/schemas/rewards.ts +++ b/apps/web/lib/zod/schemas/rewards.ts @@ -395,6 +395,16 @@ export const rewardContextSchema = z.object({ productId: z.string().nullish(), amount: z.number().nullish(), type: z.enum(["new", "recurring"]).nullish(), + products: z + .array( + z.object({ + id: z.string(), + amount: z.number(), + quantity: z.number(), + }), + ) + .nullish() + .describe("Only used in Stripe integration."), }) .optional(),