From aeec6f77b4482ea214f4e3ede68c2dd7c4a6e332 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Fri, 24 Jul 2026 14:13:46 -0300 Subject: [PATCH 1/9] initial lemon squeezy importer structure --- .../api/cron/import/lemonsqueezy/route.ts | 26 + .../partners/partners-menu-popover.tsx | 3 + .../partners/set-lemonsqueezy-token.ts | 50 ++ .../partners/start-lemonsqueezy-import.ts | 62 ++ apps/web/lib/constants/program.ts | 6 + apps/web/lib/lemonsqueezy/api.ts | 249 +++++++ .../lib/lemonsqueezy/import-commissions.ts | 610 ++++++++++++++++++ apps/web/lib/lemonsqueezy/import-customers.ts | 296 +++++++++ apps/web/lib/lemonsqueezy/import-partners.ts | 250 +++++++ apps/web/lib/lemonsqueezy/importer.ts | 50 ++ apps/web/lib/lemonsqueezy/schemas.ts | 158 +++++ apps/web/lib/lemonsqueezy/types.ts | 29 + apps/web/lib/zod/schemas/import-error-log.ts | 1 + .../ui/modals/import-lemonsqueezy-modal.tsx | 328 ++++++++++ apps/web/ui/modals/modal-provider.tsx | 7 + .../email/src/templates/program-imported.tsx | 3 +- 16 files changed, 2127 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts create mode 100644 apps/web/lib/actions/partners/set-lemonsqueezy-token.ts create mode 100644 apps/web/lib/actions/partners/start-lemonsqueezy-import.ts create mode 100644 apps/web/lib/lemonsqueezy/api.ts create mode 100644 apps/web/lib/lemonsqueezy/import-commissions.ts create mode 100644 apps/web/lib/lemonsqueezy/import-customers.ts create mode 100644 apps/web/lib/lemonsqueezy/import-partners.ts create mode 100644 apps/web/lib/lemonsqueezy/importer.ts create mode 100644 apps/web/lib/lemonsqueezy/schemas.ts create mode 100644 apps/web/lib/lemonsqueezy/types.ts create mode 100644 apps/web/ui/modals/import-lemonsqueezy-modal.tsx diff --git a/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts b/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts new file mode 100644 index 00000000000..e392cc124db --- /dev/null +++ b/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts @@ -0,0 +1,26 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { importCommissions } from "@/lib/lemonsqueezy/import-commissions"; +import { importCustomers } from "@/lib/lemonsqueezy/import-customers"; +import { importPartners } from "@/lib/lemonsqueezy/import-partners"; +import { lemonSqueezyImportPayloadSchema } from "@/lib/lemonsqueezy/schemas"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +export const POST = withCron(async ({ rawBody }) => { + const payload = lemonSqueezyImportPayloadSchema.parse(JSON.parse(rawBody)); + + switch (payload.action) { + case "import-partners": + await importPartners(payload); + break; + case "import-customers": + await importCustomers(payload); + break; + case "import-commissions": + await importCommissions(payload); + break; + } + + return logAndRespond("OK"); +}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx index ca526a1b76c..3d124ffc53b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx @@ -4,6 +4,7 @@ import { PROGRAM_IMPORT_SOURCES } from "@/lib/constants/program"; import useWorkspace from "@/lib/swr/use-workspace"; import { useExportPartnersModal } from "@/ui/modals/export-partners-modal"; import { useImportFirstPromoterModal } from "@/ui/modals/import-firstpromoter-modal"; +import { useImportLemonSqueezyModal } from "@/ui/modals/import-lemonsqueezy-modal"; import { useImportPartnerStackModal } from "@/ui/modals/import-partnerstack-modal"; import { useImportRewardfulModal } from "@/ui/modals/import-rewardful-modal"; import { useImportTapfiliateModal } from "@/ui/modals/import-tapfiliate-modal"; @@ -23,6 +24,7 @@ export function PartnersMenuPopover() { const { ImportPartnerStackModal } = useImportPartnerStackModal(); const { ImportFirstPromoterModal } = useImportFirstPromoterModal(); const { ImportTapfiliateModal } = useImportTapfiliateModal(); + const { ImportLemonSqueezyModal } = useImportLemonSqueezyModal(); const { ExportPartnersModal, setShowExportPartnersModal } = useExportPartnersModal(); @@ -34,6 +36,7 @@ export function PartnersMenuPopover() { + { + const { workspace } = ctx; + const { apiKey } = parsedInput; + + throwIfNoPermission({ + role: workspace.role, + requiredRoles: ["owner", "member"], + }); + + const lemonSqueezyApi = new LemonSqueezyApi({ + apiKey, + }); + + let stores: LemonSqueezyStore[]; + + try { + stores = await lemonSqueezyApi.listStores(); + } catch (error) { + console.error(error); + throw new Error("Invalid Lemon Squeezy API key."); + } + + if (stores.length === 0) { + throw new Error("No stores found in your Lemon Squeezy account."); + } + + await lemonSqueezyImporter.setCredentials(workspace.id, { + apiKey, + }); + + return { + stores, + }; + }); diff --git a/apps/web/lib/actions/partners/start-lemonsqueezy-import.ts b/apps/web/lib/actions/partners/start-lemonsqueezy-import.ts new file mode 100644 index 00000000000..6c727303a2c --- /dev/null +++ b/apps/web/lib/actions/partners/start-lemonsqueezy-import.ts @@ -0,0 +1,62 @@ +"use server"; + +import { createId } from "@/lib/api/create-id"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { LemonSqueezyApi } from "@/lib/lemonsqueezy/api"; +import { lemonSqueezyImporter } from "@/lib/lemonsqueezy/importer"; +import * as z from "zod/v4"; +import { getProgramOrThrow } from "../../api/programs/get-program-or-throw"; +import { authActionClient } from "../safe-action"; +import { throwIfNoPermission } from "../throw-if-no-permission"; + +const schema = z.object({ + workspaceId: z.string(), + storeId: z.string().trim().min(1), +}); + +export const startLemonSqueezyImportAction = authActionClient + .inputSchema(schema) + .action(async ({ ctx, parsedInput }) => { + const { workspace, user } = ctx; + const { storeId } = parsedInput; + + throwIfNoPermission({ + role: workspace.role, + requiredRoles: ["owner", "member"], + }); + + const programId = getDefaultProgramIdOrThrow(workspace); + + const program = await getProgramOrThrow({ + workspaceId: workspace.id, + programId, + }); + + if (!program.domain) { + throw new Error("Program domain is not set."); + } + + if (!program.url) { + throw new Error("Program URL is not set."); + } + + const credentials = await lemonSqueezyImporter.getCredentials(workspace.id); + + const lemonSqueezyApi = new LemonSqueezyApi({ + apiKey: credentials.apiKey, + }); + + const stores = await lemonSqueezyApi.listStores(); + + if (!stores.some((store) => store.id === storeId)) { + throw new Error("Invalid Lemon Squeezy store ID."); + } + + await lemonSqueezyImporter.queue({ + importId: createId({ prefix: "import_" }), + userId: user.id, + programId: program.id, + storeId, + action: "import-partners", + }); + }); diff --git a/apps/web/lib/constants/program.ts b/apps/web/lib/constants/program.ts index c3cb7e2132a..ee463f5dbc1 100644 --- a/apps/web/lib/constants/program.ts +++ b/apps/web/lib/constants/program.ts @@ -37,6 +37,12 @@ export const PROGRAM_IMPORT_SOURCES = [ image: "https://assets.dub.co/misc/icons/tapfiliate.svg", helpUrl: "https://dub.co/help/article/migrating-from-tapfiliate", }, + { + id: "lemonsqueezy", + value: "Lemon Squeezy", + image: "https://assets.dub.co/misc/icons/lemonsqueezy.svg", + helpUrl: "https://dub.co/help/article/migrating-from-lemonsqueezy", + }, ] as const; export const PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB = 5; diff --git a/apps/web/lib/lemonsqueezy/api.ts b/apps/web/lib/lemonsqueezy/api.ts new file mode 100644 index 00000000000..7cc6d20cb84 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/api.ts @@ -0,0 +1,249 @@ +import * as z from "zod/v4"; +import { + lemonSqueezyAffiliateSchema, + lemonSqueezyCustomerSchema, + lemonSqueezyJsonApiListSchema, + lemonSqueezyOrderSchema, + lemonSqueezyStoreSchema, + lemonSqueezySubscriptionInvoiceSchema, +} from "./schemas"; +import { + LemonSqueezyAffiliate, + LemonSqueezyCustomer, + LemonSqueezyOrder, + LemonSqueezyStore, + LemonSqueezySubscriptionInvoice, +} from "./types"; + +const LEMONSQUEEZY_PAGE_SIZE = 100; + +type JsonApiResource = { + type: string; + id: string; + attributes: Record; + relationships?: Record; +}; + +function flattenResource( + resource: JsonApiResource, + schema: T, + extra?: Record, +): z.infer { + return schema.parse({ + id: resource.id, + ...resource.attributes, + ...extra, + }); +} + +function getRelationshipIds( + resource: JsonApiResource, + relationshipName: string, +): string[] { + const relationship = resource.relationships?.[relationshipName] as + | { + data?: + | { type: string; id: string } + | Array<{ type: string; id: string }> + | null; + } + | undefined; + + if (!relationship?.data) { + return []; + } + + if (Array.isArray(relationship.data)) { + return relationship.data.map((item) => item.id); + } + + return [relationship.data.id]; +} + +export class LemonSqueezyApi { + private readonly baseUrl = "https://api.lemonsqueezy.com/v1"; + private readonly apiKey: string; + + constructor({ apiKey }: { apiKey: string }) { + this.apiKey = apiKey; + } + + private async fetch( + path: string, + searchParams?: URLSearchParams, + ): Promise { + const url = new URL(`${this.baseUrl}${path}`); + if (searchParams) { + searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + let response: Response; + + try { + response = await fetch(url.toString(), { + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${this.apiKey}`, + }, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const error = await response.text(); + console.error("Lemon Squeezy API Error:", error); + + const retryAfter = response.headers.get("Retry-After"); + const rateLimitMessage = + response.status === 429 + ? ` Rate limited.${retryAfter ? ` Retry after ${retryAfter}s.` : ""}` + : ""; + + throw new Error( + `[Lemon Squeezy API] ${ + error || + `Request to ${path} failed with status ${response.status}.${rateLimitMessage}` + }`, + ); + } + + return (await response.json()) as T; + } + + private async listResources({ + path, + storeId, + page = 1, + include, + }: { + path: string; + storeId?: string; + page?: number; + include?: string; + }) { + const searchParams = new URLSearchParams({ + "page[number]": page.toString(), + "page[size]": LEMONSQUEEZY_PAGE_SIZE.toString(), + }); + + if (storeId) { + searchParams.set("filter[store_id]", storeId); + } + + if (include) { + searchParams.set("include", include); + } + + const payload = await this.fetch(path, searchParams); + return lemonSqueezyJsonApiListSchema.parse(payload); + } + + async listStores(): Promise { + const { data } = await this.listResources({ path: "/stores" }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyStoreSchema), + ); + } + + async listAffiliates({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/affiliates", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyAffiliateSchema), + ); + } + + async listCustomers({ + storeId, + page = 1, + include, + }: { + storeId: string; + page?: number; + include?: string; + }): Promise { + const { data } = await this.listResources({ + path: "/customers", + storeId, + page, + include, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyCustomerSchema, { + // When `include=affiliates` (or relationship data is sideloaded), + // JSON:API puts affiliate refs on relationships.affiliates.data + affiliate_ids: getRelationshipIds(resource, "affiliates"), + }), + ); + } + + async listCustomerAffiliates({ + customerId, + }: { + customerId: string; + }): Promise { + const { data } = await this.listResources({ + path: `/customers/${customerId}/affiliates`, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyAffiliateSchema), + ); + } + + async listOrders({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/orders", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyOrderSchema), + ); + } + + async listSubscriptionInvoices({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/subscription-invoices", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezySubscriptionInvoiceSchema), + ); + } +} diff --git a/apps/web/lib/lemonsqueezy/import-commissions.ts b/apps/web/lib/lemonsqueezy/import-commissions.ts new file mode 100644 index 00000000000..e37affaac8c --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-commissions.ts @@ -0,0 +1,610 @@ +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@dub/email"; +import ProgramImported from "@dub/email/templates/program-imported"; +import { chunk, nanoid } from "@dub/utils"; +import { + CommissionStatus, + Customer, + Link, + Program, + Reward, +} from "@prisma/client"; +import { convertCurrencyWithFxRates } from "../analytics/convert-currency"; +import { isFirstConversion } from "../analytics/is-first-conversion"; +import { createId } from "../api/create-id"; +import { updateLinkStatsForImporter } from "../api/links/update-link-stats-for-importer"; +import { syncPartnerLinksStats } from "../api/partners/sync-partner-links-stats"; +import { syncTotalCommissions } from "../api/partners/sync-total-commissions"; +import { calculateSaleEarnings } from "../api/sales/calculate-sale-earnings"; +import { getLeadEvents } from "../tinybird/get-lead-events"; +import { logImportError } from "../tinybird/log-import-error"; +import { recordSaleWithTimestamp } from "../tinybird/record-sale"; +import { LeadEventTB } from "../types"; +import { redis } from "../upstash"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { LemonSqueezyApi } from "./api"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { + LemonSqueezyImportPayload, + LemonSqueezyOrder, + LemonSqueezySubscriptionInvoice, +} from "./types"; + +type SaleEvent = { + invoiceId: string; + affiliateId: string; + customerExternalId: string; + amount: number; + currency: string; + amountUsd: number | null | undefined; + status: string; + createdAt: string; + metadata: Record; +}; + +const toDubStatus = (status: string): CommissionStatus | null => { + switch (status) { + case "paid": + return "paid"; + case "pending": + return "pending"; + case "refunded": + case "partial_refund": + return "canceled"; + case "void": + case "failed": + case "fraudulent": + return null; // skip + default: + return "pending"; + } +}; + +export async function importCommissions(payload: LemonSqueezyImportPayload) { + const { + importId, + programId, + storeId, + userId, + page = 1, + resource = "orders", + } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain) { + console.error("Program domain not found", program.id); + return; + } + + const { apiKey } = await lemonSqueezyImporter.getCredentials( + program.workspaceId, + ); + const lemonSqueezyApi = new LemonSqueezyApi({ apiKey }); + + const fxRates = await redis.hgetall>("fxRates:usd"); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const { saleEvents, pageEmpty } = + resource === "orders" + ? await listOrderSaleEvents({ + lemonSqueezyApi, + storeId, + page: currentPage, + }) + : await listInvoiceSaleEvents({ + lemonSqueezyApi, + storeId, + page: currentPage, + }); + + if (pageEmpty) { + hasMore = false; + break; + } + + if (saleEvents.length > 0) { + await processSaleEvents({ + program, + domain: program.domain, + saleEvents, + fxRates, + importId, + }); + } + + currentPage++; + processedBatches++; + } + + if (hasMore) { + await lemonSqueezyImporter.queue({ + ...payload, + action: "import-commissions", + resource, + page: currentPage, + }); + return; + } + + // Finished orders → continue with subscription invoices (skip initial to avoid double-count) + if (resource === "orders") { + await lemonSqueezyImporter.queue({ + ...payload, + action: "import-commissions", + resource: "subscription-invoices", + page: 1, + }); + return; + } + + // Imports finished + await lemonSqueezyImporter.deleteCredentials(program.workspaceId); + + const workspaceUser = await prisma.projectUsers.findUnique({ + where: { + userId_projectId: { + userId, + projectId: program.workspaceId, + }, + }, + include: { + project: true, + user: true, + }, + }); + + if (workspaceUser?.user.email) { + await sendEmail({ + to: workspaceUser.user.email, + subject: "Lemon Squeezy program imported", + react: ProgramImported({ + email: workspaceUser.user.email, + workspace: workspaceUser.project, + program, + provider: "Lemon Squeezy", + importId, + }), + }); + } +} + +async function listOrderSaleEvents({ + lemonSqueezyApi, + storeId, + page, +}: { + lemonSqueezyApi: LemonSqueezyApi; + storeId: string; + page: number; +}): Promise<{ saleEvents: SaleEvent[]; pageEmpty: boolean }> { + const orders = await lemonSqueezyApi.listOrders({ storeId, page }); + + if (orders.length === 0) { + return { saleEvents: [], pageEmpty: true }; + } + + const saleEvents = orders + .filter((order): order is LemonSqueezyOrder & { affiliate_id: number } => + Boolean(order.affiliate_id), + ) + .map((order) => ({ + invoiceId: `ls_order_${order.id}`, + affiliateId: String(order.affiliate_id), + customerExternalId: String(order.customer_id), + amount: order.subtotal, + currency: order.currency, + amountUsd: order.subtotal_usd, + status: order.status, + createdAt: order.created_at || new Date().toISOString(), + metadata: order as unknown as Record, + })); + + return { saleEvents, pageEmpty: false }; +} + +async function listInvoiceSaleEvents({ + lemonSqueezyApi, + storeId, + page, +}: { + lemonSqueezyApi: LemonSqueezyApi; + storeId: string; + page: number; +}): Promise<{ saleEvents: SaleEvent[]; pageEmpty: boolean }> { + const invoices = await lemonSqueezyApi.listSubscriptionInvoices({ + storeId, + page, + }); + + if (invoices.length === 0) { + return { saleEvents: [], pageEmpty: true }; + } + + const saleEvents = invoices + .filter( + ( + invoice, + ): invoice is LemonSqueezySubscriptionInvoice & { + affiliate_id: number; + } => + Boolean(invoice.affiliate_id) && + // Initial invoices are covered by the Order import + invoice.billing_reason !== "initial", + ) + .map((invoice) => ({ + invoiceId: `ls_invoice_${invoice.id}`, + affiliateId: String(invoice.affiliate_id), + customerExternalId: String(invoice.customer_id), + amount: invoice.subtotal, + currency: invoice.currency, + amountUsd: invoice.subtotal_usd, + status: invoice.status, + createdAt: invoice.created_at || new Date().toISOString(), + metadata: invoice as unknown as Record, + })); + + return { saleEvents, pageEmpty: false }; +} + +async function processSaleEvents({ + program, + domain, + saleEvents, + fxRates, + importId, +}: { + program: Pick; + domain: string; + saleEvents: SaleEvent[]; + fxRates: Record | null; + importId: string; +}) { + const affiliateIds = [ + ...new Set(saleEvents.map((event) => event.affiliateId)), + ]; + const customerExternalIds = [ + ...new Set(saleEvents.map((event) => event.customerExternalId)), + ]; + + const [links, customersData] = await Promise.all([ + prisma.link.findMany({ + where: { + domain, + key: { + in: affiliateIds, + }, + }, + }), + prisma.customer.findMany({ + where: { + projectId: program.workspaceId, + externalId: { + in: customerExternalIds, + }, + }, + include: { + link: true, + }, + orderBy: { + createdAt: "asc", + }, + }), + ]); + + const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); + + const partnerIds = [ + ...new Set( + links + .map((link) => link.partnerId) + .filter((id): id is string => Boolean(id)), + ), + ]; + + const enrollments = await prisma.programEnrollment.findMany({ + where: { + programId: program.id, + partnerId: { + in: partnerIds, + }, + }, + include: { + saleReward: true, + }, + }); + + const partnerIdToSaleReward = new Map( + enrollments.map((enrollment) => [ + enrollment.partnerId, + enrollment.saleReward, + ]), + ); + + const customerLeadEvents = await getLeadEvents({ + customerIds: customersData.map((customer) => customer.id), + }).then((res) => res.data); + + const saleChunks = chunk(saleEvents, 10); + + for (const saleChunk of saleChunks) { + await Promise.all( + saleChunk.map((saleEvent) => + createCommission({ + program, + saleEvent, + partnerLink: affiliateIdToLink.get(saleEvent.affiliateId), + saleReward: (() => { + const partnerId = affiliateIdToLink.get( + saleEvent.affiliateId, + )?.partnerId; + return partnerId + ? partnerIdToSaleReward.get(partnerId) ?? null + : null; + })(), + fxRates, + importId, + customersData, + customerLeadEvents, + }), + ), + ); + } +} + +async function createCommission({ + program, + saleEvent, + partnerLink, + saleReward, + fxRates, + importId, + customersData, + customerLeadEvents, +}: { + program: Pick; + saleEvent: SaleEvent; + partnerLink?: Link; + saleReward: Reward | null; + fxRates: Record | null; + importId: string; + customersData: (Customer & { link: Link | null })[]; + customerLeadEvents: LeadEventTB[]; +}) { + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "commission" as const, + entity_id: saleEvent.invoiceId, + }; + + const status = toDubStatus(saleEvent.status); + if (!status) { + return; + } + + const existingCommission = await prisma.commission.findUnique({ + where: { + invoiceId_programId: { + invoiceId: saleEvent.invoiceId, + programId: program.id, + }, + }, + select: { + id: true, + }, + }); + + if (existingCommission) { + console.log( + `Commission ${saleEvent.invoiceId} already exists, skipping...`, + ); + return; + } + + if (!partnerLink?.partnerId) { + await logImportError({ + ...commonImportLogInputs, + code: "PARTNER_NOT_FOUND", + message: `No imported partner found for affiliate ${saleEvent.affiliateId} (commission ${saleEvent.invoiceId}).`, + }); + return; + } + + const existingCustomer = customersData.find( + ({ externalId }) => externalId === saleEvent.customerExternalId, + ); + + if (!existingCustomer) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_NOT_FOUND", + message: `No customer ${saleEvent.customerExternalId} found for commission ${saleEvent.invoiceId}.`, + }); + return; + } + + if (!existingCustomer.clickId) { + await logImportError({ + ...commonImportLogInputs, + code: "CLICK_NOT_FOUND", + message: `No click found for customer ${existingCustomer.id}.`, + }); + return; + } + + const leadEvent = customerLeadEvents.find( + (event) => event.customer_id === existingCustomer.id, + ); + + if (!leadEvent) { + await logImportError({ + ...commonImportLogInputs, + code: "LEAD_NOT_FOUND", + message: `No lead event found for customer ${existingCustomer.id}.`, + }); + return; + } + + // Prefer LS-provided USD amounts; otherwise convert + let saleAmount = saleEvent.amountUsd ?? saleEvent.amount; + if ( + saleEvent.amountUsd == null && + saleEvent.currency.toUpperCase() !== "USD" && + fxRates + ) { + const { amount: convertedAmount } = convertCurrencyWithFxRates({ + currency: saleEvent.currency, + amount: saleAmount, + fxRates, + }); + saleAmount = convertedAmount; + } + + const createdAt = new Date(saleEvent.createdAt); + const trackedCommission = await prisma.commission.findFirst({ + where: { + customerId: existingCustomer.id, + programId: program.id, + createdAt: { + gte: new Date(createdAt.getTime() - 60 * 60 * 1000), + lte: new Date(createdAt.getTime() + 60 * 60 * 1000), + }, + type: "sale", + amount: saleAmount, + }, + }); + + if (trackedCommission) { + console.log( + `Commission ${saleEvent.invoiceId} with sale amount ${saleAmount} was already recorded on Dub. Skipping...`, + ); + return; + } + + // LS does not expose per-order commission amounts; derive from Dub sale reward + const earnings = saleReward + ? calculateSaleEarnings({ + reward: { + type: saleReward.type, + amountInCents: saleReward.amountInCents, + amountInPercentage: saleReward.amountInPercentage + ? Number(saleReward.amountInPercentage) + : null, + }, + sale: { + amount: saleAmount, + quantity: 1, + }, + }) + : 0; + + const clickData = clickEventSchemaTB + .omit({ timestamp: true }) + .parse(leadEvent); + + const eventId = nanoid(16); + + await Promise.all([ + prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + eventId, + type: "sale", + programId: program.id, + partnerId: partnerLink.partnerId, + linkId: partnerLink.id, + customerId: existingCustomer.id, + amount: saleAmount, + earnings, + currency: "usd", + quantity: 1, + status, + invoiceId: saleEvent.invoiceId, + createdAt, + }, + }), + + saleAmount > 0 && + recordSaleWithTimestamp({ + ...clickData, + event_id: eventId, + event_name: "Invoice paid", + amount: saleAmount, + customer_id: existingCustomer.id, + payment_processor: "lemonsqueezy", + currency: "usd", + metadata: JSON.stringify(saleEvent.metadata), + timestamp: createdAt.toISOString(), + }), + + prisma.link.update({ + where: { + id: partnerLink.id, + }, + data: { + ...(isFirstConversion({ + customer: existingCustomer, + linkId: partnerLink.id, + }) && { + conversions: { + increment: 1, + }, + lastConversionAt: updateLinkStatsForImporter({ + currentTimestamp: partnerLink.lastConversionAt, + newTimestamp: createdAt, + }), + }), + ...(saleAmount > 0 && { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + }), + }, + }), + + syncPartnerLinksStats({ + partnerId: partnerLink.partnerId, + programId: program.id, + eventType: "sale", + }), + + saleAmount > 0 && + prisma.customer.update({ + where: { + id: existingCustomer.id, + }, + data: { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + firstSaleAt: existingCustomer.firstSaleAt ? undefined : createdAt, + }, + }), + ]); + + await syncTotalCommissions({ + partnerId: partnerLink.partnerId, + programId: program.id, + }); +} diff --git a/apps/web/lib/lemonsqueezy/import-customers.ts b/apps/web/lib/lemonsqueezy/import-customers.ts new file mode 100644 index 00000000000..7196480f5ad --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-customers.ts @@ -0,0 +1,296 @@ +import { prisma } from "@/lib/prisma"; +import { chunk, nanoid } from "@dub/utils"; +import { Customer, Link, Project } from "@prisma/client"; +import { createId } from "../api/create-id"; +import { updateLinkStatsForImporter } from "../api/links/update-link-stats-for-importer"; +import { syncPartnerLinksStats } from "../api/partners/sync-partner-links-stats"; +import { recordClick, recordLeadWithTimestamp } from "../tinybird"; +import { logImportError } from "../tinybird/log-import-error"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { LemonSqueezyApi } from "./api"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { LemonSqueezyCustomer, LemonSqueezyImportPayload } from "./types"; + +export async function importCustomers(payload: LemonSqueezyImportPayload) { + const { importId, programId, storeId, page = 1 } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + include: { + workspace: { + select: { + id: true, + plan: true, + stripeConnectId: true, + }, + }, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain || !program.url) { + console.error("Program domain or url not found", program.id); + return; + } + + const { workspace } = program; + const { apiKey } = await lemonSqueezyImporter.getCredentials(workspace.id); + const lemonSqueezyApi = new LemonSqueezyApi({ apiKey }); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const customers = await lemonSqueezyApi.listCustomers({ + storeId, + page: currentPage, + include: "affiliates", + }); + + if (customers.length === 0) { + hasMore = false; + break; + } + + // Gate: only import customers with a non-empty affiliates relationship + const referredCustomers = customers.filter( + (customer) => customer.affiliate_ids.length > 0, + ); + + if (referredCustomers.length > 0) { + const affiliateIds = [ + ...new Set( + referredCustomers.flatMap((customer) => customer.affiliate_ids), + ), + ]; + + const links = await prisma.link.findMany({ + where: { + domain: program.domain, + key: { + in: affiliateIds, + }, + }, + select: { + id: true, + key: true, + domain: true, + url: true, + partnerId: true, + programId: true, + lastLeadAt: true, + }, + }); + + const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); + + const customerExternalIds = referredCustomers.map( + (customer) => customer.id, + ); + + const existingCustomers = await prisma.customer.findMany({ + where: { + projectId: workspace.id, + externalId: { + in: customerExternalIds, + }, + }, + select: { + id: true, + externalId: true, + }, + }); + + const existingExternalIds = new Set( + existingCustomers.map((customer) => customer.externalId), + ); + + const newCustomers = referredCustomers.filter( + (customer) => !existingExternalIds.has(customer.id), + ); + + if (newCustomers.length > 0) { + const customerChunks = chunk(newCustomers, 10); + + for (const customerChunk of customerChunks) { + await Promise.all( + customerChunk.map((customer) => { + // Deterministic: first affiliate_id that has an imported partner link + const affiliateId = customer.affiliate_ids.find((id) => + affiliateIdToLink.has(id), + ); + + return createCustomer({ + workspace, + customer, + link: affiliateId + ? affiliateIdToLink.get(affiliateId) + : undefined, + importId, + }); + }), + ); + } + } + } + + currentPage++; + processedBatches++; + } + + await lemonSqueezyImporter.queue({ + ...payload, + action: hasMore ? "import-customers" : "import-commissions", + page: hasMore ? currentPage : undefined, + resource: hasMore ? undefined : "orders", + }); +} + +async function createCustomer({ + workspace, + customer, + link, + importId, +}: { + workspace: Pick; + customer: LemonSqueezyCustomer; + link?: Pick< + Link, + "id" | "key" | "domain" | "url" | "partnerId" | "programId" | "lastLeadAt" + >; + importId: string; +}) { + const externalId = customer.id; + + const commonImportLogInputs = { + workspace_id: workspace.id, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "customer" as const, + entity_id: externalId, + }; + + if (!customer.email) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_EMAIL_NOT_FOUND", + message: `Customer ${externalId} not imported because it has no email.`, + }); + + return; + } + + if (!link) { + await logImportError({ + ...commonImportLogInputs, + code: "LINK_NOT_FOUND", + message: `No imported partner link found for customer ${externalId} (affiliates: ${customer.affiliate_ids.join(", ")}).`, + }); + + return; + } + + const clickedAt = new Date(customer.created_at || Date.now()); + + const dummyRequest = new Request(link.url, { + headers: new Headers({ + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "x-forwarded-for": "127.0.0.1", + "x-vercel-ip-country": customer.country || "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + clickId: nanoid(16), + workspaceId: workspace.id, + linkId: link.id, + domain: link.domain, + key: link.key, + url: link.url, + skipRatelimit: true, + timestamp: clickedAt.toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, + }); + + let createdCustomer: Customer | null = null; + + try { + createdCustomer = await prisma.customer.create({ + data: { + id: createId({ prefix: "cus_" }), + name: customer.name || customer.email, + email: customer.email, + externalId, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickEvent.click_id, + linkId: link.id, + programId: link.programId, + partnerId: link.partnerId, + country: customer.country || clickEvent.country, + clickedAt, + createdAt: clickedAt, + }, + }); + } catch (error) { + if (error.code === "P2002") { + console.warn( + `Customer with external ID ${externalId} already exists. Skipping...`, + ); + } else { + console.error("Error creating customer", customer, error); + } + + return; + } + + await Promise.all([ + recordLeadWithTimestamp({ + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: createdCustomer.id, + timestamp: clickedAt.toISOString(), + }), + + prisma.link.update({ + where: { + id: link.id, + }, + data: { + leads: { + increment: 1, + }, + lastLeadAt: updateLinkStatsForImporter({ + currentTimestamp: link.lastLeadAt, + newTimestamp: clickedAt, + }), + }, + }), + + ...(link.partnerId && link.programId + ? [ + syncPartnerLinksStats({ + partnerId: link.partnerId, + programId: link.programId, + eventType: "lead", + }), + ] + : []), + ]); +} diff --git a/apps/web/lib/lemonsqueezy/import-partners.ts b/apps/web/lib/lemonsqueezy/import-partners.ts new file mode 100644 index 00000000000..c3eab9d3315 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-partners.ts @@ -0,0 +1,250 @@ +import { prisma } from "@/lib/prisma"; +import { PartnerGroup, Program } from "@prisma/client"; +import { createId } from "../api/create-id"; +import { createLink } from "../api/links"; +import { generatePartnerLink } from "../api/partners/generate-partner-link"; +import { logImportError } from "../tinybird/log-import-error"; +import { WorkspaceProps } from "../types"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; +import { LemonSqueezyApi } from "./api"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { LemonSqueezyAffiliate, LemonSqueezyImportPayload } from "./types"; + +export async function importPartners(payload: LemonSqueezyImportPayload) { + const { importId, programId, storeId, userId, page = 1 } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + include: { + groups: { + select: { + id: true, + slug: true, + clickRewardId: true, + leadRewardId: true, + saleRewardId: true, + referralRewardId: true, + discountId: true, + }, + }, + workspace: { + select: { + id: true, + plan: true, + }, + }, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain || !program.url) { + console.error("Program domain or url not found", program.id); + return; + } + + const defaultGroup = program.groups.find( + (group) => group.slug === DEFAULT_PARTNER_GROUP.slug, + ); + + if (!defaultGroup) { + console.error(`Default group not found for program ${programId}.`); + return; + } + + const workspace = program.workspace as WorkspaceProps; + + const { apiKey } = await lemonSqueezyImporter.getCredentials(workspace.id); + const lemonSqueezyApi = new LemonSqueezyApi({ apiKey }); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "partner" as const, + }; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const affiliates = await lemonSqueezyApi.listAffiliates({ + storeId, + page: currentPage, + }); + + if (affiliates.length === 0) { + hasMore = false; + break; + } + + const activeAffiliates: LemonSqueezyAffiliate[] = []; + const notImportedAffiliates: LemonSqueezyAffiliate[] = []; + + for (const affiliate of affiliates) { + // LS has no leads count on affiliates. Import all active partners; + // pending/disabled are skipped. Customer/commission steps only + // attach referred activity. + if (affiliate.status === "active") { + activeAffiliates.push(affiliate); + } else { + notImportedAffiliates.push(affiliate); + } + } + + if (activeAffiliates.length > 0) { + await Promise.allSettled( + activeAffiliates.map((affiliate) => + createPartnerAndLinks({ + workspace, + program, + affiliate, + group: defaultGroup, + userId, + importId, + }), + ), + ); + } + + if (notImportedAffiliates.length > 0) { + await logImportError( + notImportedAffiliates.map((affiliate) => ({ + ...commonImportLogInputs, + entity_id: affiliate.id, + code: "INACTIVE_PARTNER", + message: `Partner ${affiliate.user_email} not imported because status is "${affiliate.status}" (only active affiliates are imported).`, + })), + ); + } + + currentPage++; + processedBatches++; + } + + await lemonSqueezyImporter.queue({ + ...payload, + action: hasMore ? "import-partners" : "import-customers", + page: hasMore ? currentPage : undefined, + }); +} + +async function createPartnerAndLinks({ + workspace, + program, + affiliate, + group, + userId, + importId, +}: { + workspace: Pick; + program: Pick< + Program, + "id" | "workspaceId" | "domain" | "url" | "defaultFolderId" + >; + affiliate: LemonSqueezyAffiliate; + group: Pick< + PartnerGroup, + | "id" + | "discountId" + | "clickRewardId" + | "leadRewardId" + | "saleRewardId" + | "referralRewardId" + >; + userId: string; + importId: string; +}) { + if (!affiliate.user_email) { + await logImportError({ + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy", + entity: "partner", + entity_id: affiliate.id, + code: "PARTNER_NOT_FOUND", + message: `Affiliate ${affiliate.id} not imported because it has no email.`, + }); + + return; + } + + const partner = await prisma.partner.upsert({ + where: { + email: affiliate.user_email, + }, + create: { + id: createId({ prefix: "pn_" }), + name: affiliate.user_name || affiliate.user_email, + email: affiliate.user_email, + }, + update: {}, + }); + + const { links } = await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + create: { + id: createId({ prefix: "pge_" }), + programId: program.id, + partnerId: partner.id, + status: "approved", + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + referralRewardId: group.referralRewardId, + discountId: group.discountId, + }, + update: { + status: "approved", + }, + select: { + links: { + select: { + key: true, + }, + }, + }, + }); + + if (links.length > 0 && links.some((link) => link.key === affiliate.id)) { + console.log( + `Partner ${partner.email} already has a link with key ${affiliate.id}, skipping...`, + ); + return; + } + + try { + const partnerLink = await generatePartnerLink({ + workspace, + program, + partner: { + id: partner.id, + name: partner.name, + email: partner.email!, + }, + link: { + domain: program.domain!, + url: program.url!, + // Use affiliate id so commissions can map affiliate_id → partner link + key: affiliate.id, + }, + userId, + }); + + await createLink(partnerLink); + } catch (error) { + console.error("Error creating partner link", error, affiliate); + } +} diff --git a/apps/web/lib/lemonsqueezy/importer.ts b/apps/web/lib/lemonsqueezy/importer.ts new file mode 100644 index 00000000000..fcb395b874e --- /dev/null +++ b/apps/web/lib/lemonsqueezy/importer.ts @@ -0,0 +1,50 @@ +import { qstash } from "@/lib/cron"; +import { redis } from "@/lib/upstash"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { LemonSqueezyCredentials, LemonSqueezyImportPayload } from "./types"; + +// Lemon Squeezy rate limit is 300 requests per minute +export const LEMONSQUEEZY_MAX_BATCHES = 10; + +export const CACHE_EXPIRY = 60 * 60 * 24; +export const CACHE_KEY_PREFIX = "lemonsqueezy:import"; + +class LemonSqueezyImporter { + async setCredentials( + workspaceId: string, + credentials: LemonSqueezyCredentials, + ) { + await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, credentials, { + ex: CACHE_EXPIRY, + }); + } + + async getCredentials(workspaceId: string): Promise { + const credentials = await redis.get( + `${CACHE_KEY_PREFIX}:${workspaceId}`, + ); + + if (!credentials) { + throw new Error( + "Lemon Squeezy credentials not found. Please restart the import process.", + ); + } + + return credentials; + } + + async deleteCredentials(workspaceId: string) { + return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`); + } + + async queue(body: LemonSqueezyImportPayload, options?: { delay?: number }) { + return await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/lemonsqueezy`, + body, + contentBasedDeduplication: true, + ...(options?.delay != null && { delay: options.delay }), + }); + } +} + +export const lemonSqueezyImporter = new LemonSqueezyImporter(); diff --git a/apps/web/lib/lemonsqueezy/schemas.ts b/apps/web/lib/lemonsqueezy/schemas.ts new file mode 100644 index 00000000000..5aba0a5bd01 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/schemas.ts @@ -0,0 +1,158 @@ +import * as z from "zod/v4"; + +export const lemonSqueezyImportSteps = z.enum([ + "import-partners", + "import-customers", + "import-commissions", +]); + +export const lemonSqueezyImportPayloadSchema = z.object({ + importId: z.string(), + userId: z.string(), + programId: z.string(), + storeId: z.string(), + action: lemonSqueezyImportSteps, + page: z.number().optional(), + // Used by import-commissions to paginate orders first, then subscription invoices + resource: z.enum(["orders", "subscription-invoices"]).optional(), +}); + +const jsonApiResourceSchema = z.object({ + type: z.string(), + id: z.string(), + attributes: z.record(z.string(), z.unknown()), + relationships: z.record(z.string(), z.unknown()).optional(), +}); + +export const lemonSqueezyJsonApiListSchema = z.object({ + data: z.array(jsonApiResourceSchema), + included: z.array(jsonApiResourceSchema).optional(), + meta: z + .object({ + page: z + .object({ + currentPage: z.number(), + from: z.number().nullable().optional(), + lastPage: z.number(), + perPage: z.number(), + to: z.number().nullable().optional(), + total: z.number(), + }) + .optional(), + }) + .optional(), + links: z + .object({ + first: z.string().optional(), + last: z.string().optional(), + next: z.string().nullable().optional(), + prev: z.string().nullable().optional(), + }) + .optional(), +}); + +export const lemonSqueezyStoreSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + domain: z.string(), + url: z.string(), + currency: z.string().nullish(), + total_sales: z.number().nullish(), + total_revenue: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), +}); + +export const lemonSqueezyAffiliateSchema = z.object({ + id: z.string(), + store_id: z.number(), + user_id: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string(), + share_domain: z.string().nullish(), + status: z.string(), + products: z.unknown().nullish(), + application_note: z.string().nullish(), + total_earnings: z.number().nullish(), + unpaid_earnings: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + // Optional if Lemon Squeezy exposes the affiliate link token + token: z.string().nullish(), +}); + +export const lemonSqueezyCustomerSchema = z.object({ + id: z.string(), + store_id: z.number(), + name: z.string().nullish(), + email: z.string(), + status: z.string().nullish(), + city: z.string().nullish(), + region: z.string().nullish(), + country: z.string().nullish(), + total_revenue_currency: z.number().nullish(), + mrr: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), + affiliate_ids: z.array(z.string()).default([]), +}); + +export const lemonSqueezyOrderSchema = z.object({ + id: z.string(), + store_id: z.number(), + customer_id: z.number(), + affiliate_id: z.number().nullish(), + identifier: z.string().nullish(), + order_number: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string().nullish(), + currency: z.string(), + currency_rate: z.union([z.string(), z.number()]).nullish(), + subtotal: z.number(), + discount_total: z.number().nullish(), + tax: z.number().nullish(), + total: z.number().nullish(), + subtotal_usd: z.number().nullish(), + discount_total_usd: z.number().nullish(), + tax_usd: z.number().nullish(), + total_usd: z.number().nullish(), + refunded_amount: z.number().nullish(), + refunded_amount_usd: z.number().nullish(), + status: z.string(), + refunded: z.boolean().nullish(), + refunded_at: z.string().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), +}); + +export const lemonSqueezySubscriptionInvoiceSchema = z.object({ + id: z.string(), + store_id: z.number(), + subscription_id: z.number().nullish(), + customer_id: z.number(), + affiliate_id: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string().nullish(), + billing_reason: z.string().nullish(), + currency: z.string(), + currency_rate: z.union([z.string(), z.number()]).nullish(), + status: z.string(), + refunded: z.boolean().nullish(), + refunded_at: z.string().nullish(), + subtotal: z.number(), + discount_total: z.number().nullish(), + tax: z.number().nullish(), + total: z.number().nullish(), + refunded_amount: z.number().nullish(), + subtotal_usd: z.number().nullish(), + discount_total_usd: z.number().nullish(), + tax_usd: z.number().nullish(), + total_usd: z.number().nullish(), + refunded_amount_usd: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), +}); diff --git a/apps/web/lib/lemonsqueezy/types.ts b/apps/web/lib/lemonsqueezy/types.ts new file mode 100644 index 00000000000..3706b240f23 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/types.ts @@ -0,0 +1,29 @@ +import * as z from "zod/v4"; +import { + lemonSqueezyAffiliateSchema, + lemonSqueezyCustomerSchema, + lemonSqueezyImportPayloadSchema, + lemonSqueezyOrderSchema, + lemonSqueezyStoreSchema, + lemonSqueezySubscriptionInvoiceSchema, +} from "./schemas"; + +export interface LemonSqueezyCredentials { + apiKey: string; +} + +export type LemonSqueezyImportPayload = z.infer< + typeof lemonSqueezyImportPayloadSchema +>; + +export type LemonSqueezyStore = z.infer; + +export type LemonSqueezyAffiliate = z.infer; + +export type LemonSqueezyCustomer = z.infer; + +export type LemonSqueezyOrder = z.infer; + +export type LemonSqueezySubscriptionInvoice = z.infer< + typeof lemonSqueezySubscriptionInvoiceSchema +>; diff --git a/apps/web/lib/zod/schemas/import-error-log.ts b/apps/web/lib/zod/schemas/import-error-log.ts index bde1e972d2a..f215e4107f6 100644 --- a/apps/web/lib/zod/schemas/import-error-log.ts +++ b/apps/web/lib/zod/schemas/import-error-log.ts @@ -9,6 +9,7 @@ export const importErrorLogSchema = z.object({ "partnerstack", "firstpromoter", "tapfiliate", + "lemonsqueezy", ]), entity: z.enum(["partner", "link", "customer", "commission"]), entity_id: z.string(), diff --git a/apps/web/ui/modals/import-lemonsqueezy-modal.tsx b/apps/web/ui/modals/import-lemonsqueezy-modal.tsx new file mode 100644 index 00000000000..d0e0166a2f6 --- /dev/null +++ b/apps/web/ui/modals/import-lemonsqueezy-modal.tsx @@ -0,0 +1,328 @@ +import { setLemonSqueezyTokenAction } from "@/lib/actions/partners/set-lemonsqueezy-token"; +import { startLemonSqueezyImportAction } from "@/lib/actions/partners/start-lemonsqueezy-import"; +import { LemonSqueezyStore } from "@/lib/lemonsqueezy/types"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { + Button, + Check2, + Logo, + Modal, + ScrollContainer, + useMediaQuery, + useRouterStuff, +} from "@dub/ui"; +import { cn, nFormatter } from "@dub/utils"; +import { ArrowRight } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useAction } from "next-safe-action/hooks"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { toast } from "sonner"; +import { MarkdownDescription } from "../shared/markdown-description"; + +type Step = "set-token" | "select-store"; + +function ImportLemonSqueezyModal({ + showImportLemonSqueezyModal, + setShowImportLemonSqueezyModal, +}: { + showImportLemonSqueezyModal: boolean; + setShowImportLemonSqueezyModal: Dispatch>; +}) { + const searchParams = useSearchParams(); + const { queryParams } = useRouterStuff(); + const [step, setStep] = useState("set-token"); + const [stores, setStores] = useState([]); + + useEffect(() => { + if (searchParams?.get("import") === "lemonsqueezy") { + setShowImportLemonSqueezyModal(true); + } else { + setShowImportLemonSqueezyModal(false); + } + }, [searchParams]); + + useEffect(() => { + if (!showImportLemonSqueezyModal) { + setStep("set-token"); + setStores([]); + } + }, [showImportLemonSqueezyModal]); + + return ( + + queryParams({ + del: "import", + }) + } + > +
+
+ Lemon Squeezy logo + + +
+

+ Import your Lemon Squeezy program +

+ + [Migrate your existing Lemon Squeezy + program](https://dub.co/help/article/migrating-from-lemonsqueezy), + partners, and historical stats into Dub in just a few clicks. + +
+ +
+ + {step === "set-token" ? ( + + + + ) : ( + + { + setShowImportLemonSqueezyModal(false); + queryParams({ + del: "import", + }); + }} + /> + + )} + +
+
+ ); +} + +function TokenForm({ + setStep, + setStores, +}: { + setStep: Dispatch>; + setStores: Dispatch>; +}) { + const { isMobile } = useMediaQuery(); + const { id: workspaceId } = useWorkspace(); + + const [apiKey, setApiKey] = useState(""); + + const { executeAsync, isPending } = useAction(setLemonSqueezyTokenAction, { + onSuccess: ({ data }) => { + if (data?.stores) { + setStores(data.stores); + setStep("select-store"); + } + }, + onError: ({ error }) => { + toast.error(error.serverError); + }, + }); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!workspaceId || !apiKey) { + return; + } + + await executeAsync({ + workspaceId, + apiKey, + }); + }; + + return ( +
+
+ + setApiKey(e.target.value)} + className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + required + /> +

+ You can create an API key in your{" "} + + Lemon Squeezy settings + + . Use a live-mode key for production migrations. +

+
+ +
+ ); + })} + + + + +