diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml index 1fa1ec24bf..d04fb108c0 100644 --- a/.github/workflows/prettier.yaml +++ b/.github/workflows/prettier.yaml @@ -24,5 +24,8 @@ jobs: - name: Install dependencies run: pnpm install + - name: Fix prettier issues + run: pnpm run format + - name: Check prettier format run: pnpm run prettier-check diff --git a/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts index a6d5f00a37..2c57e6dc0a 100644 --- a/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/api/stripe/integration/webhook/checkout-session-completed.ts @@ -1,9 +1,21 @@ import { notifyPartnerSale } from "@/lib/api/partners/notify-partner-sale"; import { createSaleData } from "@/lib/api/sales/create-sale-data"; -import { getLeadEvent, recordSale } from "@/lib/tinybird"; +import { createId } from "@/lib/api/utils"; +import { + getClickEvent, + getLeadEvent, + recordLead, + recordSale, +} from "@/lib/tinybird"; 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 z from "@/lib/zod"; +import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; +import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; @@ -14,62 +26,168 @@ import type Stripe from "stripe"; export async function checkoutSessionCompleted(event: Stripe.Event) { const charge = event.data.object as Stripe.Checkout.Session; const dubCustomerId = charge.metadata?.dubCustomerId; + const clientReferenceId = charge.client_reference_id; const stripeAccountId = event.account as string; const stripeCustomerId = charge.customer as string; + const stripeCustomerName = charge.customer_details?.name; + const stripeCustomerEmail = charge.customer_details?.email; const invoiceId = charge.invoice as string; - if (!dubCustomerId) { - return "Customer ID not found in Stripe checkout session metadata, skipping..."; - } - let customer: Customer; - try { - // Update customer with stripe customerId if exists - customer = await prisma.customer.update({ - where: { - projectConnectId_externalId: { - projectConnectId: stripeAccountId, - externalId: dubCustomerId, + let existingCustomer: Customer | null = null; + let clickEvent: z.infer | null = null; + let leadEvent: z.infer; + let linkId: string; + + /* + for regular stripe checkout setup: + - if dubCustomerId is found, we update the customer with the stripe customerId + - we then find the lead event using the customer's unique ID on Dub + - the lead event will then be passed to the remaining logic to record a sale + */ + if (dubCustomerId) { + try { + // Update customer with stripe customerId if exists + customer = await prisma.customer.update({ + where: { + projectConnectId_externalId: { + projectConnectId: stripeAccountId, + externalId: dubCustomerId, + }, + }, + data: { + stripeCustomerId, }, + }); + } catch (error) { + // Skip if customer not found + console.log(error); + return `Customer with dubCustomerId ${dubCustomerId} not found, skipping...`; + } + + 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, + }); + + if (!ok) { + console.info( + "[Stripe Webhook] Skipping already processed invoice.", + invoiceId, + ); + return `Invoice with ID ${invoiceId} already processed, skipping...`; + } + } + + // Find lead + leadEvent = await getLeadEvent({ customerId: customer.id }).then( + (res) => res.data[0], + ); + + linkId = leadEvent.link_id; + + /* + for stripe checkout links: + - if client_reference_id is a dub_id, we find the click event + - the click event will be used to create a lead event + customer + - the lead event will then be passed to the remaining logic to record a sale + */ + } else if (clientReferenceId?.startsWith("dub_id_")) { + const dubClickId = clientReferenceId.split("dub_id_")[1]; + + clickEvent = await getClickEvent({ clickId: dubClickId }).then( + (res) => res.data[0], + ); + + if (!clickEvent) { + return `Click event with dub_id ${dubClickId} not found, skipping...`; + } + + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, }, - data: { - stripeCustomerId, + select: { + id: true, }, }); - } catch (error) { - // Skip if customer not found - console.log(error); - return `Customer with dubCustomerId ${dubCustomerId} not found, skipping...`; - } - 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, + if (!workspace) { + return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; + } + + existingCustomer = await prisma.customer.findFirst({ + where: { + projectId: workspace.id, + // check for existing customer with the same externalId (via clickId or email) + // TODO: should we support checks for email and stripeCustomerId too? + OR: [ + { + externalId: clickEvent.click_id, + }, + { + externalId: stripeCustomerEmail, + }, + ], + }, }); - if (!ok) { - console.info( - "[Stripe Webhook] Skipping already processed invoice.", - invoiceId, - ); - return `Invoice with ID ${invoiceId} already processed, skipping...`; + const payload = { + name: stripeCustomerName, + email: stripeCustomerEmail, + externalId: stripeCustomerEmail, // using Stripe customer email as externalId + projectId: workspace.id, + projectConnectId: stripeAccountId, + stripeCustomerId, + clickId: clickEvent.click_id, + linkId: clickEvent.link_id, + country: clickEvent.country, + clickedAt: new Date(clickEvent.timestamp + "Z"), + }; + + if (existingCustomer) { + customer = await prisma.customer.update({ + where: { + id: existingCustomer.id, + }, + data: payload, + }); + } else { + customer = await prisma.customer.create({ + data: { + id: createId({ prefix: "cus_" }), + ...payload, + }, + }); + } + + leadEvent = { + ...clickEvent, + event_id: nanoid(16), + event_name: "Checkout session completed", + customer_id: customer.id, + metadata: "", + }; + + if (!existingCustomer) { + await recordLead(leadEvent); } - } + linkId = clickEvent.link_id; - if (charge.amount_total === 0) { - return `Checkout session completed for customer with external ID ${dubCustomerId} and invoice ID ${invoiceId} but amount is 0, skipping...`; + // if it's not either a regular stripe checkout setup or a stripe checkout link, + // we skip the event + } else { + return `Customer ID not found in Stripe checkout session metadata and client_reference_id is not a dub_id, skipping...`; } - // Find lead - const leadEvent = await getLeadEvent({ customerId: customer.id }); - if (!leadEvent || leadEvent.data.length === 0) { - return `Lead event with customer ID ${customer.id} not found, skipping...`; + if (charge.amount_total === 0) { + return `Checkout session completed for Stripe customer ${stripeCustomerId} with invoice ID ${invoiceId} but amount is 0, skipping...`; } const saleData = { - ...leadEvent.data[0], + ...leadEvent, event_id: nanoid(16), event_name: "Subscription creation", payment_processor: "stripe", @@ -81,35 +199,36 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { }), }; - // Find link - const linkId = leadEvent.data[0].link_id; const link = await prisma.link.findUnique({ where: { id: linkId, }, }); - if (!link) { - return `Link with ID ${linkId} not found, skipping...`; - } - const [_sale, _link, workspace] = await Promise.all([ recordSale(saleData), // update link sales count - prisma.link.update({ - where: { - id: linkId, - }, - data: { - sales: { - increment: 1, + link && + prisma.link.update({ + where: { + id: link.id, }, - saleAmount: { - increment: charge.amount_total!, + data: { + // if the clickEvent variable exists, it means that a new lead was created + ...(clickEvent && { + leads: { + increment: 1, + }, + }), + sales: { + increment: 1, + }, + saleAmount: { + increment: charge.amount_total!, + }, }, - }, - }), + }), // update workspace sales usage prisma.project.update({ @@ -118,7 +237,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { }, data: { usage: { - increment: 1, + increment: clickEvent ? 2 : 1, }, salesUsage: { increment: charge.amount_total!, @@ -128,7 +247,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ]); // for program links - if (link.programId) { + if (link?.programId) { const { program, partnerId, commissionAmount } = await prisma.programEnrollment.findUniqueOrThrow({ where: { @@ -160,7 +279,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { paymentProcessor: saleData.payment_processor, }, metadata: { - ...leadEvent.data[0], + ...leadEvent, stripeMetadata: charge, }, }); @@ -184,22 +303,44 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ); } - // send workspace webhook waitUntil( - sendWorkspaceWebhook({ - trigger: "sale.created", - workspace, - data: transformSaleEventData({ - ...saleData, - link, - customerId: customer.id, - customerExternalId: customer.externalId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, - customerCreatedAt: customer.createdAt, - }), - }), + (async () => { + // if the clickEvent variable exists and there was no existing customer before, + // we send a lead.created webhook + if (clickEvent && !existingCustomer) { + await sendWorkspaceWebhook({ + trigger: "lead.created", + workspace, + data: transformLeadEventData({ + ...clickEvent, + link, + eventName: "Checkout session completed", + customerId: customer.id, + customerExternalId: customer.externalId, + customerName: customer.name, + customerEmail: customer.email, + customerAvatar: customer.avatar, + customerCreatedAt: customer.createdAt, + }), + }); + } + + // send workspace webhook + await sendWorkspaceWebhook({ + trigger: "sale.created", + workspace, + data: transformSaleEventData({ + ...saleData, + link, + customerId: customer.id, + customerExternalId: customer.externalId, + customerName: customer.name, + customerEmail: customer.email, + customerAvatar: customer.avatar, + customerCreatedAt: customer.createdAt, + }), + }); + })(), ); return `Checkout session completed for customer with external ID ${dubCustomerId} and invoice ID ${invoiceId}`; diff --git a/apps/web/lib/actions/send-otp.ts b/apps/web/lib/actions/send-otp.ts index e171bd3f3b..8e0f1ac9cd 100644 --- a/apps/web/lib/actions/send-otp.ts +++ b/apps/web/lib/actions/send-otp.ts @@ -43,29 +43,31 @@ export const sendOtpAction = actionClient const domain = email.split("@")[1]; - const [isDisposable, emailDomainTerms] = await Promise.all([ - redis.sismember("disposableEmailDomains", domain), - get("emailDomainTerms"), - ]); - - if (isDisposable) { - throw new Error( - "Invalid email address – please use your work email instead. If you think this is a mistake, please contact us at support@dub.co", - ); - } + if (process.env.NEXT_PUBLIC_IS_DUB) { + const [isDisposable, emailDomainTerms] = await Promise.all([ + redis.sismember("disposableEmailDomains", domain), + process.env.EDGE_CONFIG ? get("emailDomainTerms") : [], + ]); - if (emailDomainTerms && Array.isArray(emailDomainTerms)) { - const blacklistedEmailDomainTermsRegex = new RegExp( - emailDomainTerms - .map((term: string) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) // replace special characters with escape sequences - .join("|"), - ); - - if (blacklistedEmailDomainTermsRegex.test(domain)) { + if (isDisposable) { throw new Error( "Invalid email address – please use your work email instead. If you think this is a mistake, please contact us at support@dub.co", ); } + + if (emailDomainTerms && Array.isArray(emailDomainTerms)) { + const blacklistedEmailDomainTermsRegex = new RegExp( + emailDomainTerms + .map((term: string) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) // replace special characters with escape sequences + .join("|"), + ); + + if (blacklistedEmailDomainTermsRegex.test(domain)) { + throw new Error( + "Invalid email address – please use your work email instead. If you think this is a mistake, please contact us at support@dub.co", + ); + } + } } const code = generateOTP();