From ea583c719a469694ed4fcde1005ce52446bd2416 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 9 Sep 2025 16:33:32 +0530 Subject: [PATCH 1/7] Allow automatic lead event creation in track/sale --- apps/web/lib/api/conversions/track-sale.ts | 392 +++++++++++++++++---- apps/web/lib/zod/schemas/sales.ts | 28 ++ 2 files changed, 356 insertions(+), 64 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 99c5d449b1e..91457f1366b 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -2,23 +2,34 @@ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; +import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { getLeadEvent, recordSale } from "@/lib/tinybird"; +import { isStored, storage } from "@/lib/storage"; +import { + getClickEvent, + getLeadEvent, + recordLead, + recordSale, +} from "@/lib/tinybird"; import { logConversionEvent } from "@/lib/tinybird/log-conversion-events"; -import { LeadEventTB, WorkspaceProps } from "@/lib/types"; +import { ClickEventTB, LeadEventTB, WorkspaceProps } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; -import { transformSaleEventData } from "@/lib/webhook/transform"; +import { + transformLeadEventData, + transformSaleEventData, +} from "@/lib/webhook/transform"; import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { trackSaleRequestSchema, trackSaleResponseSchema, } from "@/lib/zod/schemas/sales"; import { prisma } from "@dub/prisma"; -import { WorkflowTrigger } from "@dub/prisma/client"; -import { nanoid } from "@dub/utils"; +import { Customer, WorkflowTrigger } from "@dub/prisma/client"; +import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { z } from "zod"; +import { createId } from "../create-id"; import { executeWorkflows } from "../workflows/execute-workflows"; type TrackSaleParams = z.input & { @@ -27,7 +38,11 @@ type TrackSaleParams = z.input & { }; export const trackSale = async ({ + clickId, customerExternalId, + customerName, + customerEmail, + customerAvatar, amount, currency = "usd", eventName, @@ -39,7 +54,7 @@ export const trackSale = async ({ workspace, }: TrackSaleParams) => { if (invoiceId) { - // Skip if invoice id is already processed + // skip if invoice id is already processed const ok = await redis.set(`dub_sale_events:invoiceId:${invoiceId}`, 1, { ex: 60 * 60 * 24 * 7, nx: true, @@ -54,7 +69,7 @@ export const trackSale = async ({ } } - // Find customer + // find customer const customer = await prisma.customer.findUnique({ where: { projectId_externalId: { @@ -64,7 +79,7 @@ export const trackSale = async ({ }, }); - if (!customer) { + if (!customer && !clickId) { waitUntil( logConversionEvent({ workspace_id: workspace.id, @@ -81,49 +96,299 @@ export const trackSale = async ({ }; } - // Find lead event - const leadEvent = await getLeadEvent({ - customerId: customer.id, - eventName: leadEventName, - }); + // find click event if clickId is provided + let clickData: ClickEventTB | null = null; - let leadEventData: LeadEventTB | null = null; - - if (!leadEvent || leadEvent.data.length === 0) { - // Check cache to see if the lead event exists - // if leadEventName is provided, we only check for that specific event - // otherwise, we check for all cached lead events for that customer - - const cachedLeadEvent = await redis.get( - `leadCache:${customer.id}${leadEventName ? `:${leadEventName.toLowerCase().replaceAll(" ", "-")}` : ""}`, - ); + if (clickId) { + const clickEvent = await getClickEvent({ + clickId, + }); - if (!cachedLeadEvent) { - const errorMessage = `Lead event not found for externalId: ${customerExternalId} and leadEventName: ${leadEventName}`; + if (clickEvent && clickEvent.data && clickEvent.data.length > 0) { + clickData = clickEvent.data[0]; + } - waitUntil( - logConversionEvent({ - workspace_id: workspace.id, - path: "/track/sale", - body: JSON.stringify(rawBody), - error: errorMessage, - }), + // if there is no click data in Tinybird yet, check the clickIdCache + if (!clickData) { + const cachedClickData = await redis.get( + `clickIdCache:${clickId}`, ); + if (cachedClickData) { + clickData = { + ...cachedClickData, + timestamp: cachedClickData.timestamp + .replace("T", " ") + .replace("Z", ""), + qr: cachedClickData.qr ? 1 : 0, + bot: cachedClickData.bot ? 1 : 0, + }; + } + } + + if (!clickData) { throw new DubApiError({ code: "not_found", - message: errorMessage, + message: `Click event not found for clickId: ${clickId}`, }); } + } + + // find lead event if customer exists + if (customer) { + let leadEventData: LeadEventTB | null = null; + + const leadEvent = await getLeadEvent({ + customerId: customer.id, + eventName: leadEventName, + }); + + if (!leadEvent || leadEvent.data.length === 0) { + // Check cache to see if the lead event exists + // if leadEventName is provided, we only check for that specific event + // otherwise, we check for all cached lead events for that customer + + const cachedLeadEvent = await redis.get( + `leadCache:${customer.id}${leadEventName ? `:${leadEventName.toLowerCase().replaceAll(" ", "-")}` : ""}`, + ); + + if (!cachedLeadEvent) { + const errorMessage = `Lead event not found for externalId: ${customerExternalId} and leadEventName: ${leadEventName}`; + + waitUntil( + logConversionEvent({ + workspace_id: workspace.id, + path: "/track/sale", + body: JSON.stringify(rawBody), + error: errorMessage, + }), + ); + + throw new DubApiError({ + code: "not_found", + message: errorMessage, + }); + } + + leadEventData = cachedLeadEvent; + } else { + leadEventData = leadEvent.data[0]; + } + + clickData = clickEventSchemaTB.parse(leadEventData); + } + + await Promise.all([ + _trackLead({ + customerExternalId, + customerName, + customerEmail, + customerAvatar, + workspace, + clickData, + }), + + _trackSale({ + amount, + currency, + eventName, + paymentProcessor, + invoiceId, + metadata, + rawBody, + workspace, + clickData, + customer, + }), + ]); +}; + +const _trackLead = async ({ + customerExternalId, + customerName, + customerEmail, + customerAvatar, + workspace, + clickData, +}: Pick< + TrackSaleParams, + | "clickId" + | "customerExternalId" + | "customerName" + | "customerEmail" + | "customerAvatar" + | "workspace" +> & { + clickData: ClickEventTB | null; +}) => { + if (!clickData) { + return; + } + + // get the referral link from the from the clickData + const linkFound = await prisma.link.findUnique({ + where: { + id: clickData.link_id, + }, + select: { + id: true, + projectId: true, + }, + }); - leadEventData = cachedLeadEvent; - } else { - leadEventData = leadEvent.data[0]; + if (!linkFound) { + throw new DubApiError({ + code: "not_found", + message: `Link not found for clickId: ${clickData.click_id}`, + }); } - const clickData = clickEventSchemaTB - .omit({ timestamp: true }) - .parse(leadEventData); + if (linkFound.projectId !== workspace.id) { + throw new DubApiError({ + code: "not_found", + message: `Link for clickId ${clickData.click_id} does not belong to the workspace`, + }); + } + + // prepare the customer data + const eventQuantity = 1; + const finalCustomerId = createId({ prefix: "cus_" }); + const finalCustomerName = + customerName || customerEmail || generateRandomName(); + const finalCustomerAvatar = + customerAvatar && !isStored(customerAvatar) + ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` + : customerAvatar; + + // construct the lead event payload + const leadEventId = nanoid(16); + const leadEventName = "Sign up"; + + // create a new customer + const customer = await prisma.customer.create({ + data: { + id: finalCustomerId, + name: finalCustomerName, + email: customerEmail, + avatar: finalCustomerAvatar, + externalId: customerExternalId, + linkId: clickData.link_id, + clickId: clickData.click_id, + country: clickData.country, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickedAt: new Date(clickData.timestamp + "Z"), + }, + }); + + const [_leadEvent, link, _workspace] = await Promise.all([ + // record the lead event for the customer + recordLead({ + ...clickData, + event_id: leadEventId, + event_name: leadEventName, + customer_id: finalCustomerId, + }), + + // update link leads count + prisma.link.update({ + where: { + id: clickData.link_id, + }, + data: { + leads: { + increment: eventQuantity, + }, + }, + include: includeTags, + }), + + // update workspace events usage + prisma.project.update({ + where: { + id: workspace.id, + }, + data: { + usage: { + increment: eventQuantity, + }, + }, + }), + + // persist customer avatar to R2 + customerAvatar && + !isStored(customerAvatar) && + finalCustomerAvatar && + storage.upload( + finalCustomerAvatar.replace(`${R2_URL}/`, ""), + customerAvatar, + { + width: 128, + height: 128, + }, + ), + ]); + + // create partner commission and execute workflows + if (link.programId && link.partnerId && customer) { + await createPartnerCommission({ + event: "lead", + programId: link.programId, + partnerId: link.partnerId, + linkId: link.id, + eventId: leadEventId, + customerId: customer.id, + quantity: eventQuantity, + context: { + customer: { + country: customer.country, + }, + }, + }); + + await executeWorkflows({ + trigger: WorkflowTrigger.leadRecorded, + programId: link.programId, + partnerId: link.partnerId, + }); + } + + // send workspace webhook + const webhookPayload = transformLeadEventData({ + ...clickData, + eventName: leadEventName, + link, + customer, + }); + + await sendWorkspaceWebhook({ + trigger: "lead.created", + data: webhookPayload, + workspace, + }); +}; + +const _trackSale = async ({ + amount, + currency = "usd", + eventName, + paymentProcessor, + invoiceId, + metadata, + rawBody, + workspace, + clickData, + customer, +}: TrackSaleParams & { + clickData: ClickEventTB | null; + customer: Pick< + Customer, + "id" | "sales" | "linkId" | "country" | "clickedAt" | "createdAt" + >; +}) => { + if (!clickData) { + return; + } // if currency is not USD, convert it to USD based on the current FX rate // TODO: allow custom "defaultCurrency" on workspace table in the future @@ -138,34 +403,33 @@ export const trackSale = async ({ amount = convertedAmount; } - const eventId = nanoid(16); - - const saleData = { - ...clickData, - event_id: eventId, - event_name: eventName, - customer_id: customer.id, - payment_processor: paymentProcessor, - amount, - currency, - invoice_id: invoiceId || "", - metadata: metadata ? JSON.stringify(metadata) : "", - }; - waitUntil( (async () => { + const saleEvent = { + ...clickData, + event_id: nanoid(16), + event_name: eventName, + customer_id: customer.id, + payment_processor: paymentProcessor, + amount, + currency, + invoice_id: invoiceId || "", + metadata: metadata ? JSON.stringify(metadata) : "", + }; + const [_sale, link] = await Promise.all([ - recordSale(saleData), + // record sale event + recordSale(saleEvent), // update link conversions, sales, and saleAmount prisma.link.update({ where: { - id: clickData.link_id, + id: saleEvent.link_id, }, data: { ...(isFirstConversion({ customer, - linkId: clickData.link_id, + linkId: saleEvent.link_id, }) && { conversions: { increment: 1, @@ -210,22 +474,22 @@ export const trackSale = async ({ logConversionEvent({ workspace_id: workspace.id, - link_id: clickData.link_id, + link_id: saleEvent.link_id, path: "/track/sale", body: JSON.stringify(rawBody), }), ]); - // for program links + // create partner commission and execute workflows if (link.programId && link.partnerId) { await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, linkId: link.id, - eventId, customerId: customer.id, - amount: saleData.amount, + eventId: saleEvent.event_id, + amount: saleEvent.amount, quantity: 1, invoiceId, currency, @@ -246,9 +510,9 @@ export const trackSale = async ({ }); } - // Send workspace webhook - const sale = transformSaleEventData({ - ...saleData, + // send workspace webhook + const webhookPayload = transformSaleEventData({ + ...saleEvent, clickedAt: customer.clickedAt || customer.createdAt, link, customer, @@ -256,7 +520,7 @@ export const trackSale = async ({ await sendWorkspaceWebhook({ trigger: "sale.created", - data: sale, + data: webhookPayload, workspace, }); })(), diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index fbc1e1bc23c..313022ef5fa 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -8,10 +8,38 @@ export const trackSaleRequestSchema = z.object({ customerExternalId: z .string() .trim() + .min(1, "customerExternalId is required") .max(100) .describe( "The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.", ), + customerName: z + .string() + .max(100) + .nullish() + .default(null) + .describe( + "The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).", + ), + customerEmail: z + .string() + .email() + .max(100) + .nullish() + .default(null) + .describe("The email address of the customer."), + customerAvatar: z + .string() + .nullish() + .default(null) + .describe("The avatar URL of the customer."), + clickId: z + .string() + .trim() + .nullish() + .describe( + "The unique ID of the click that the sale conversion event is attributed to. You can read this value from `dub_id` cookie. If not provided, Dub will try to find an existing customer with the provided `customerExternalId` and use the `clickId` from the customer if found.", + ), amount: z .number({ required_error: "amount is required" }) .int() From 79b9e2d54be3199713feab281bc9d7e4b0fbcd45 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 9 Sep 2025 16:56:44 +0530 Subject: [PATCH 2/7] Update track-sale.ts --- apps/web/lib/api/conversions/track-sale.ts | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 91457f1366b..d4af8496091 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -31,6 +31,7 @@ import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { createId } from "../create-id"; import { executeWorkflows } from "../workflows/execute-workflows"; +import { trackLead } from "./track-lead"; type TrackSaleParams = z.input & { rawBody: any; @@ -179,14 +180,27 @@ export const trackSale = async ({ } await Promise.all([ - _trackLead({ - customerExternalId, - customerName, - customerEmail, - customerAvatar, - workspace, - clickData, - }), + // _trackLead({ + // customerExternalId, + // customerName, + // customerEmail, + // customerAvatar, + // workspace, + // clickData, + // }), + + // trackLead({ + // clickId, + // customerExternalId, + // customerName, + // customerEmail, + // customerAvatar, + // eventName: "Sign Up", + // eventQuantity: 1, + // metadata: null, + // rawBody, + // workspace, + // }) _trackSale({ amount, From e9364ef0c02a7031c901548587c452153bbb6c51 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 9 Sep 2025 23:26:36 +0530 Subject: [PATCH 3/7] Updated logic to create new customers and track lead events more effectively. --- apps/web/app/(ee)/api/track/sale/route.ts | 8 + apps/web/lib/api/conversions/track-sale.ts | 405 ++++++++++----------- 2 files changed, 204 insertions(+), 209 deletions(-) diff --git a/apps/web/app/(ee)/api/track/sale/route.ts b/apps/web/app/(ee)/api/track/sale/route.ts index da5d814e6fe..8fd7de7457b 100644 --- a/apps/web/app/(ee)/api/track/sale/route.ts +++ b/apps/web/app/(ee)/api/track/sale/route.ts @@ -15,6 +15,10 @@ export const POST = withWorkspace( customerExternalId: newExternalId, externalId: oldExternalId, // deprecated customerId: oldCustomerId, // deprecated + customerName, + customerEmail, + customerAvatar, + clickId, paymentProcessor, invoiceId, amount, @@ -42,6 +46,10 @@ export const POST = withWorkspace( const response = await trackSale({ customerExternalId, + customerName, + customerEmail, + customerAvatar, + clickId, amount, currency, eventName, diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index d4af8496091..5efd3e5c7ce 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -19,7 +19,6 @@ import { transformLeadEventData, transformSaleEventData, } from "@/lib/webhook/transform"; -import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { trackSaleRequestSchema, trackSaleResponseSchema, @@ -31,7 +30,6 @@ import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { createId } from "../create-id"; import { executeWorkflows } from "../workflows/execute-workflows"; -import { trackLead } from "./track-lead"; type TrackSaleParams = z.input & { rawBody: any; @@ -54,8 +52,13 @@ export const trackSale = async ({ rawBody, workspace, }: TrackSaleParams) => { + let existingCustomer: Customer | null = null; + let newCustomer: Customer | null = null; + let clickData: ClickEventTB | null = null; + let leadEventData: LeadEventTB | null = null; + + // Skip if invoice id is already processed if (invoiceId) { - // skip if invoice id is already processed const ok = await redis.set(`dub_sale_events:invoiceId:${invoiceId}`, 1, { ex: 60 * 60 * 24 * 7, nx: true, @@ -70,8 +73,8 @@ export const trackSale = async ({ } } - // find customer - const customer = await prisma.customer.findUnique({ + // Find existing customer + existingCustomer = await prisma.customer.findUnique({ where: { projectId_externalId: { projectId: workspace.id, @@ -80,27 +83,67 @@ export const trackSale = async ({ }, }); - if (!customer && !clickId) { - waitUntil( - logConversionEvent({ - workspace_id: workspace.id, - path: "/track/sale", - body: JSON.stringify(rawBody), - error: `Customer not found for externalId: ${customerExternalId}`, - }), - ); + // Existing customer is found, find the lead event + if (existingCustomer) { + const leadEvent = await getLeadEvent({ + customerId: existingCustomer.id, + eventName: leadEventName, + }); - return { - eventName, - customer: null, - sale: null, - }; + if (!leadEvent || leadEvent.data.length === 0) { + // Check cache to see if the lead event exists + // if leadEventName is provided, we only check for that specific event + // otherwise, we check for all cached lead events for that customer + + const cachedLeadEvent = await redis.get( + `leadCache:${existingCustomer.id}${leadEventName ? `:${leadEventName.toLowerCase().replaceAll(" ", "-")}` : ""}`, + ); + + if (!cachedLeadEvent) { + const errorMessage = `Lead event not found for externalId: ${customerExternalId} and leadEventName: ${leadEventName}`; + + waitUntil( + logConversionEvent({ + workspace_id: workspace.id, + path: "/track/sale", + body: JSON.stringify(rawBody), + error: errorMessage, + }), + ); + + throw new DubApiError({ + code: "not_found", + message: errorMessage, + }); + } + + leadEventData = cachedLeadEvent; + } else { + leadEventData = leadEvent.data[0]; + } } - // find click event if clickId is provided - let clickData: ClickEventTB | null = null; + // No existing customer is found, find the click event and create a new customer + else { + if (!clickId) { + waitUntil( + logConversionEvent({ + workspace_id: workspace.id, + path: "/track/sale", + body: JSON.stringify(rawBody), + error: + "The `clickId` property was not provided in the request, and no existing customer with the provided `customerExternalId` was found.", + }), + ); - if (clickId) { + return { + eventName, + customer: null, + sale: null, + }; + } + + // Find the click event for the given clickId const clickEvent = await getClickEvent({ clickId, }); @@ -109,7 +152,7 @@ export const trackSale = async ({ clickData = clickEvent.data[0]; } - // if there is no click data in Tinybird yet, check the clickIdCache + // If there is no click data in Tinybird yet, check the clickIdCache if (!clickData) { const cachedClickData = await redis.get( `clickIdCache:${clickId}`, @@ -133,74 +176,82 @@ export const trackSale = async ({ message: `Click event not found for clickId: ${clickId}`, }); } - } - // find lead event if customer exists - if (customer) { - let leadEventData: LeadEventTB | null = null; - - const leadEvent = await getLeadEvent({ - customerId: customer.id, - eventName: leadEventName, + // Create a new customer + const link = await prisma.link.findUnique({ + where: { + id: clickData.link_id, + }, + select: { + id: true, + projectId: true, + }, }); - if (!leadEvent || leadEvent.data.length === 0) { - // Check cache to see if the lead event exists - // if leadEventName is provided, we only check for that specific event - // otherwise, we check for all cached lead events for that customer + if (!link) { + throw new DubApiError({ + code: "not_found", + message: `Link not found for clickId: ${clickData.click_id}`, + }); + } - const cachedLeadEvent = await redis.get( - `leadCache:${customer.id}${leadEventName ? `:${leadEventName.toLowerCase().replaceAll(" ", "-")}` : ""}`, - ); + if (link.projectId !== workspace.id) { + throw new DubApiError({ + code: "not_found", + message: `Link for clickId ${clickData.click_id} does not belong to the workspace`, + }); + } - if (!cachedLeadEvent) { - const errorMessage = `Lead event not found for externalId: ${customerExternalId} and leadEventName: ${leadEventName}`; + const finalCustomerId = createId({ prefix: "cus_" }); + const finalCustomerName = + customerName || customerEmail || generateRandomName(); + const finalCustomerAvatar = + customerAvatar && !isStored(customerAvatar) + ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` + : customerAvatar; - waitUntil( - logConversionEvent({ - workspace_id: workspace.id, - path: "/track/sale", - body: JSON.stringify(rawBody), - error: errorMessage, - }), - ); + newCustomer = await prisma.customer.create({ + data: { + id: finalCustomerId, + name: finalCustomerName, + email: customerEmail, + avatar: finalCustomerAvatar, + externalId: customerExternalId, + linkId: clickData.link_id, + clickId: clickData.click_id, + country: clickData.country, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickedAt: new Date(clickData.timestamp + "Z"), + }, + }); - throw new DubApiError({ - code: "not_found", - message: errorMessage, - }); - } + leadEventData = { + ...clickData, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: newCustomer.id, + metadata: metadata ? JSON.stringify(metadata) : "", + }; + } - leadEventData = cachedLeadEvent; - } else { - leadEventData = leadEvent.data[0]; - } + const customer: Customer = existingCustomer ?? newCustomer!; - clickData = clickEventSchemaTB.parse(leadEventData); + // This should never happen + if (!customer) { + throw new DubApiError({ + code: "not_found", + message: "Customer not found.", + }); } - await Promise.all([ - // _trackLead({ - // customerExternalId, - // customerName, - // customerEmail, - // customerAvatar, - // workspace, - // clickData, - // }), - - // trackLead({ - // clickId, - // customerExternalId, - // customerName, - // customerEmail, - // customerAvatar, - // eventName: "Sign Up", - // eventQuantity: 1, - // metadata: null, - // rawBody, - // workspace, - // }) + const [_, trackedSale] = await Promise.all([ + newCustomer && + _trackLead({ + workspace, + leadEventData, + customer: newCustomer, + }), _trackSale({ amount, @@ -211,131 +262,65 @@ export const trackSale = async ({ metadata, rawBody, workspace, - clickData, + leadEventData, customer, }), ]); + + return trackedSale; }; +// Track the lead event const _trackLead = async ({ - customerExternalId, - customerName, - customerEmail, - customerAvatar, workspace, - clickData, -}: Pick< - TrackSaleParams, - | "clickId" - | "customerExternalId" - | "customerName" - | "customerEmail" - | "customerAvatar" - | "workspace" -> & { - clickData: ClickEventTB | null; + leadEventData, + customer, +}: Pick & { + leadEventData: LeadEventTB | null; + customer: Customer; }) => { - if (!clickData) { - return; - } - - // get the referral link from the from the clickData - const linkFound = await prisma.link.findUnique({ - where: { - id: clickData.link_id, - }, - select: { - id: true, - projectId: true, - }, - }); - - if (!linkFound) { - throw new DubApiError({ - code: "not_found", - message: `Link not found for clickId: ${clickData.click_id}`, - }); - } - - if (linkFound.projectId !== workspace.id) { + if (!leadEventData) { throw new DubApiError({ code: "not_found", - message: `Link for clickId ${clickData.click_id} does not belong to the workspace`, + message: `Lead event data not found for the customer ${customer.id}`, }); } - // prepare the customer data - const eventQuantity = 1; - const finalCustomerId = createId({ prefix: "cus_" }); - const finalCustomerName = - customerName || customerEmail || generateRandomName(); - const finalCustomerAvatar = - customerAvatar && !isStored(customerAvatar) - ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}` - : customerAvatar; - - // construct the lead event payload - const leadEventId = nanoid(16); - const leadEventName = "Sign up"; - - // create a new customer - const customer = await prisma.customer.create({ - data: { - id: finalCustomerId, - name: finalCustomerName, - email: customerEmail, - avatar: finalCustomerAvatar, - externalId: customerExternalId, - linkId: clickData.link_id, - clickId: clickData.click_id, - country: clickData.country, - projectId: workspace.id, - projectConnectId: workspace.stripeConnectId, - clickedAt: new Date(clickData.timestamp + "Z"), - }, - }); - const [_leadEvent, link, _workspace] = await Promise.all([ - // record the lead event for the customer - recordLead({ - ...clickData, - event_id: leadEventId, - event_name: leadEventName, - customer_id: finalCustomerId, - }), + // Record the lead event for the customer + recordLead(leadEventData), - // update link leads count + // Update link leads count prisma.link.update({ where: { - id: clickData.link_id, + id: leadEventData.link_id, }, data: { leads: { - increment: eventQuantity, + increment: 1, }, }, include: includeTags, }), - // update workspace events usage + // Update workspace events usage prisma.project.update({ where: { id: workspace.id, }, data: { usage: { - increment: eventQuantity, + increment: 1, }, }, }), - // persist customer avatar to R2 - customerAvatar && - !isStored(customerAvatar) && - finalCustomerAvatar && + // Persist customer avatar to R2 + customer.avatar && + !isStored(customer.avatar) && storage.upload( - finalCustomerAvatar.replace(`${R2_URL}/`, ""), - customerAvatar, + customer.avatar.replace(`${R2_URL}/`, ""), + customer.avatar, { width: 128, height: 128, @@ -343,16 +328,16 @@ const _trackLead = async ({ ), ]); - // create partner commission and execute workflows + // Create partner commission and execute workflows if (link.programId && link.partnerId && customer) { await createPartnerCommission({ event: "lead", programId: link.programId, partnerId: link.partnerId, linkId: link.id, - eventId: leadEventId, + eventId: leadEventData.event_id, customerId: customer.id, - quantity: eventQuantity, + quantity: 1, context: { customer: { country: customer.country, @@ -367,10 +352,9 @@ const _trackLead = async ({ }); } - // send workspace webhook + // Send workspace webhook const webhookPayload = transformLeadEventData({ - ...clickData, - eventName: leadEventName, + ...leadEventData, link, customer, }); @@ -382,6 +366,7 @@ const _trackLead = async ({ }); }; +// Track the sale event const _trackSale = async ({ amount, currency = "usd", @@ -391,17 +376,17 @@ const _trackSale = async ({ metadata, rawBody, workspace, - clickData, + leadEventData, customer, -}: TrackSaleParams & { - clickData: ClickEventTB | null; - customer: Pick< - Customer, - "id" | "sales" | "linkId" | "country" | "clickedAt" | "createdAt" - >; +}: Omit & { + leadEventData: LeadEventTB | null; + customer: Customer; }) => { - if (!clickData) { - return; + if (!leadEventData) { + throw new DubApiError({ + code: "not_found", + message: `Lead event data not found for the customer ${customer.id}`, + }); } // if currency is not USD, convert it to USD based on the current FX rate @@ -417,33 +402,34 @@ const _trackSale = async ({ amount = convertedAmount; } + const saleData = { + ...leadEventData, + event_id: nanoid(16), + event_name: eventName, + customer_id: customer.id, + payment_processor: paymentProcessor, + amount, + currency, + invoice_id: invoiceId || "", + metadata: metadata ? JSON.stringify(metadata) : "", + timestamp: undefined, + }; + waitUntil( (async () => { - const saleEvent = { - ...clickData, - event_id: nanoid(16), - event_name: eventName, - customer_id: customer.id, - payment_processor: paymentProcessor, - amount, - currency, - invoice_id: invoiceId || "", - metadata: metadata ? JSON.stringify(metadata) : "", - }; - const [_sale, link] = await Promise.all([ - // record sale event - recordSale(saleEvent), + // Record sale event + recordSale(saleData), - // update link conversions, sales, and saleAmount + // Update link conversions, sales, and saleAmount prisma.link.update({ where: { - id: saleEvent.link_id, + id: saleData.link_id, }, data: { ...(isFirstConversion({ customer, - linkId: saleEvent.link_id, + linkId: saleData.link_id, }) && { conversions: { increment: 1, @@ -459,7 +445,7 @@ const _trackSale = async ({ include: includeTags, }), - // update workspace events usage + // Update workspace events usage prisma.project.update({ where: { id: workspace.id, @@ -471,7 +457,7 @@ const _trackSale = async ({ }, }), - // update customer sales count + // Update customer sales count prisma.customer.update({ where: { id: customer.id, @@ -486,15 +472,16 @@ const _trackSale = async ({ }, }), + // Log conversion event logConversionEvent({ workspace_id: workspace.id, - link_id: saleEvent.link_id, + link_id: saleData.link_id, path: "/track/sale", body: JSON.stringify(rawBody), }), ]); - // create partner commission and execute workflows + // Create partner commission and execute workflows if (link.programId && link.partnerId) { await createPartnerCommission({ event: "sale", @@ -502,8 +489,8 @@ const _trackSale = async ({ partnerId: link.partnerId, linkId: link.id, customerId: customer.id, - eventId: saleEvent.event_id, - amount: saleEvent.amount, + eventId: saleData.event_id, + amount: saleData.amount, quantity: 1, invoiceId, currency, @@ -524,9 +511,9 @@ const _trackSale = async ({ }); } - // send workspace webhook + // Send workspace webhook const webhookPayload = transformSaleEventData({ - ...saleEvent, + ...saleData, clickedAt: customer.clickedAt || customer.createdAt, link, customer, From 760570f0ccdeec8a8bdc16161441991d0a1e3e78 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 9 Sep 2025 13:47:02 -0700 Subject: [PATCH 4/7] rearrange props + improve description --- apps/web/lib/zod/schemas/sales.ts | 59 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index 313022ef5fa..25cf856898f 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -13,33 +13,6 @@ export const trackSaleRequestSchema = z.object({ .describe( "The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.", ), - customerName: z - .string() - .max(100) - .nullish() - .default(null) - .describe( - "The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).", - ), - customerEmail: z - .string() - .email() - .max(100) - .nullish() - .default(null) - .describe("The email address of the customer."), - customerAvatar: z - .string() - .nullish() - .default(null) - .describe("The avatar URL of the customer."), - clickId: z - .string() - .trim() - .nullish() - .describe( - "The unique ID of the click that the sale conversion event is attributed to. You can read this value from `dub_id` cookie. If not provided, Dub will try to find an existing customer with the provided `customerExternalId` and use the `clickId` from the customer if found.", - ), amount: z .number({ required_error: "amount is required" }) .int() @@ -92,6 +65,38 @@ export const trackSaleRequestSchema = z.object({ .describe( "Additional metadata to be stored with the sale event. Max 10,000 characters when stringified.", ), + // for tracking lead + sale together + clickId: z + .string() + .trim() + .nullish() + .describe( + "[For sale tracking without a pre-existing lead event]: The unique ID of the click that the sale conversion event is attributed to. You can read this value from `dub_id` cookie.", + ), + customerName: z + .string() + .max(100) + .nullish() + .default(null) + .describe( + "[For sale tracking without a pre-existing lead event]: The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).", + ), + customerEmail: z + .string() + .email() + .max(100) + .nullish() + .default(null) + .describe( + "[For sale tracking without a pre-existing lead event]: The email address of the customer.", + ), + customerAvatar: z + .string() + .nullish() + .default(null) + .describe( + "[For sale tracking without a pre-existing lead event]: The avatar URL of the customer.", + ), }); export const trackSaleResponseSchema = z.object({ From f1c586c6c3c69261d0b3a74ba63eaf5c3c2ab0ff Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 9 Sep 2025 14:16:07 -0700 Subject: [PATCH 5/7] finalize comments, add tests --- apps/web/lib/api/conversions/track-sale.ts | 31 +++++++----- apps/web/tests/tracks/track-sale.test.ts | 56 +++++++++++++++++++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 5efd3e5c7ce..b1d5fe9ebdb 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -83,7 +83,7 @@ export const trackSale = async ({ }, }); - // Existing customer is found, find the lead event + // Existing customer is found, find the lead event to associate the sale with if (existingCustomer) { const leadEvent = await getLeadEvent({ customerId: existingCustomer.id, @@ -123,7 +123,7 @@ export const trackSale = async ({ } } - // No existing customer is found, find the click event and create a new customer + // No existing customer is found, find the click event and create a new customer (for sale tracking without a pre-existing lead event) else { if (!clickId) { waitUntil( @@ -131,8 +131,7 @@ export const trackSale = async ({ workspace_id: workspace.id, path: "/track/sale", body: JSON.stringify(rawBody), - error: - "The `clickId` property was not provided in the request, and no existing customer with the provided `customerExternalId` was found.", + error: `No existing customer with the provided customerExternalId (${customerExternalId}) was found, and there was no clickId provided for sale tracking without a pre-existing lead event.`, }), ); @@ -235,14 +234,24 @@ export const trackSale = async ({ }; } - const customer: Customer = existingCustomer ?? newCustomer!; + const customer = existingCustomer ?? newCustomer; - // This should never happen + // This should never happen, but just in case if (!customer) { - throw new DubApiError({ - code: "not_found", - message: "Customer not found.", - }); + waitUntil( + logConversionEvent({ + workspace_id: workspace.id, + path: "/track/sale", + body: JSON.stringify(rawBody), + error: `Customer not found for customerExternalId: ${customerExternalId}`, + }), + ); + + return { + eventName, + customer: null, + sale: null, + }; } const [_, trackedSale] = await Promise.all([ @@ -389,7 +398,7 @@ const _trackSale = async ({ }); } - // if currency is not USD, convert it to USD based on the current FX rate + // if currency is not USD, convert it to USD based on the current FX rate // TODO: allow custom "defaultCurrency" on workspace table in the future if (currency !== "usd") { const { currency: convertedCurrency, amount: convertedAmount } = diff --git a/apps/web/tests/tracks/track-sale.test.ts b/apps/web/tests/tracks/track-sale.test.ts index 986808076fa..db75d28d72d 100644 --- a/apps/web/tests/tracks/track-sale.test.ts +++ b/apps/web/tests/tracks/track-sale.test.ts @@ -1,10 +1,15 @@ import { TrackSaleResponse } from "@/lib/types"; -import { randomId, randomSaleAmount } from "tests/utils/helpers"; +import { + randomCustomer, + randomId, + randomSaleAmount, +} from "tests/utils/helpers"; import { E2E_CUSTOMER_EXTERNAL_ID, E2E_CUSTOMER_EXTERNAL_ID_2, E2E_CUSTOMER_ID, E2E_REWARD, + E2E_TRACK_CLICK_HEADERS, } from "tests/utils/resource"; import { describe, expect, test } from "vitest"; import { IntegrationHarness } from "../utils/integration"; @@ -221,4 +226,53 @@ describe("POST /track/sale", async () => { expect(response.data.sale?.amount).toBeGreaterThanOrEqual(900); // 900 cents expect(response.data.sale?.amount).toBeLessThanOrEqual(1100); // 1100 cents }); + + test("track a sale without a pre-existing lead event", async () => { + const clickResponse = await http.post<{ clickId: string }>({ + path: "/track/click", + headers: E2E_TRACK_CLICK_HEADERS, + body: { + domain: "getacme.link", + key: "derek", + }, + }); + expect(clickResponse.status).toEqual(200); + expect(clickResponse.data.clickId).toStrictEqual(expect.any(String)); + const trackedClickId = clickResponse.data.clickId; + const saleCustomer = randomCustomer(); + const salePayload = { + ...sale, + eventName: "Purchase (no lead event)", + amount: randomSaleAmount(), + invoiceId: `INV_${randomId()}`, + }; + + const response = await http.post({ + path: "/track/sale", + body: { + ...salePayload, + clickId: trackedClickId, + customerExternalId: saleCustomer.externalId, + customerName: saleCustomer.name, + customerEmail: saleCustomer.email, + customerAvatar: saleCustomer.avatar, + }, + }); + + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ + eventName: salePayload.eventName, + customer: { + id: expect.any(String), + ...saleCustomer, + }, + sale: { + amount: salePayload.amount, + currency: salePayload.currency, + paymentProcessor: salePayload.paymentProcessor, + invoiceId: salePayload.invoiceId, + metadata: null, + }, + }); + }); }); From dc06679ba5ef911105ef6c4f536df555aa78532a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 9 Sep 2025 14:27:35 -0700 Subject: [PATCH 6/7] put _trackLead in waitUntil, fix customerAvatar upload --- apps/web/lib/api/conversions/track-sale.ts | 150 +++++++++++---------- 1 file changed, 78 insertions(+), 72 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index b1d5fe9ebdb..29163c650b5 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -225,6 +225,20 @@ export const trackSale = async ({ }, }); + if (customerAvatar && !isStored(customerAvatar) && finalCustomerAvatar) { + // persist customer avatar to R2 if it's not already stored + waitUntil( + storage.upload( + finalCustomerAvatar.replace(`${R2_URL}/`, ""), + customerAvatar, + { + width: 128, + height: 128, + }, + ), + ); + } + leadEventData = { ...clickData, event_id: nanoid(16), @@ -295,84 +309,76 @@ const _trackLead = async ({ }); } - const [_leadEvent, link, _workspace] = await Promise.all([ - // Record the lead event for the customer - recordLead(leadEventData), - - // Update link leads count - prisma.link.update({ - where: { - id: leadEventData.link_id, - }, - data: { - leads: { - increment: 1, - }, - }, - include: includeTags, - }), + waitUntil( + (async () => { + const [_leadEvent, link, _workspace] = await Promise.all([ + // Record the lead event for the customer + recordLead(leadEventData), - // Update workspace events usage - prisma.project.update({ - where: { - id: workspace.id, - }, - data: { - usage: { - increment: 1, - }, - }, - }), + // Update link leads count + prisma.link.update({ + where: { + id: leadEventData.link_id, + }, + data: { + leads: { + increment: 1, + }, + }, + include: includeTags, + }), - // Persist customer avatar to R2 - customer.avatar && - !isStored(customer.avatar) && - storage.upload( - customer.avatar.replace(`${R2_URL}/`, ""), - customer.avatar, - { - width: 128, - height: 128, - }, - ), - ]); + // Update workspace events usage + prisma.project.update({ + where: { + id: workspace.id, + }, + data: { + usage: { + increment: 1, + }, + }, + }), + ]); - // Create partner commission and execute workflows - if (link.programId && link.partnerId && customer) { - await createPartnerCommission({ - event: "lead", - programId: link.programId, - partnerId: link.partnerId, - linkId: link.id, - eventId: leadEventData.event_id, - customerId: customer.id, - quantity: 1, - context: { - customer: { - country: customer.country, - }, - }, - }); + // Create partner commission and execute workflows + if (link.programId && link.partnerId && customer) { + await createPartnerCommission({ + event: "lead", + programId: link.programId, + partnerId: link.partnerId, + linkId: link.id, + eventId: leadEventData.event_id, + customerId: customer.id, + quantity: 1, + context: { + customer: { + country: customer.country, + }, + }, + }); - await executeWorkflows({ - trigger: WorkflowTrigger.leadRecorded, - programId: link.programId, - partnerId: link.partnerId, - }); - } + await executeWorkflows({ + trigger: WorkflowTrigger.leadRecorded, + programId: link.programId, + partnerId: link.partnerId, + }); + } - // Send workspace webhook - const webhookPayload = transformLeadEventData({ - ...leadEventData, - link, - customer, - }); + // Send workspace webhook + const webhookPayload = transformLeadEventData({ + ...leadEventData, + link, + customer, + }); - await sendWorkspaceWebhook({ - trigger: "lead.created", - data: webhookPayload, - workspace, - }); + await sendWorkspaceWebhook({ + trigger: "lead.created", + data: webhookPayload, + workspace, + }); + })(), + ); }; // Track the sale event From 1673578c5b19ee86a81b86210ce6e096c38cf988 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 9 Sep 2025 15:27:27 -0700 Subject: [PATCH 7/7] update leadEventName --- apps/web/lib/api/conversions/track-sale.ts | 3 ++- apps/web/lib/zod/schemas/sales.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 29163c650b5..702e638d9ce 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -242,7 +242,8 @@ export const trackSale = async ({ leadEventData = { ...clickData, event_id: nanoid(16), - event_name: "Sign up", + // if leadEventName is provided, use it, otherwise use "Sign up" + event_name: leadEventName ?? "Sign up", customer_id: newCustomer.id, metadata: metadata ? JSON.stringify(metadata) : "", }; diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index 25cf856898f..f6403e0fdea 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -47,14 +47,6 @@ export const trackSaleRequestSchema = z.object({ .describe( "The invoice ID of the sale. Can be used as a idempotency key – only one sale event can be recorded for a given invoice ID.", ), - leadEventName: z - .string() - .nullish() - .default(null) - .describe( - "The name of the lead event that occurred before the sale (case-sensitive). This is used to associate the sale event with a particular lead event (instead of the latest lead event for a link-customer combination, which is the default behavior).", - ) - .openapi({ example: "Cloned template 1481267" }), metadata: z .record(z.unknown()) .nullish() @@ -65,7 +57,15 @@ export const trackSaleRequestSchema = z.object({ .describe( "Additional metadata to be stored with the sale event. Max 10,000 characters when stringified.", ), - // for tracking lead + sale together + // advanced fields: leadEventName + fields for sale tracking without a pre-existing lead event + leadEventName: z + .string() + .nullish() + .default(null) + .describe( + "The name of the lead event that occurred before the sale (case-sensitive). This is used to associate the sale event with a particular lead event (instead of the latest lead event for a link-customer combination, which is the default behavior). For sale tracking without a pre-existing lead event, this field can also be used to specify the lead event name.", + ) + .openapi({ example: "Cloned template 1481267" }), clickId: z .string() .trim()