From ef0e24e9965158f7f0b86a8ba749ac9adc40e8c0 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Sun, 13 Jul 2025 14:07:31 +0530
Subject: [PATCH 01/31] wip
---
.../api/cron/import/partnerstack/route.ts | 53 +++++++++++++++++++
apps/web/lib/partnerstack/api.ts | 0
.../web/lib/partnerstack/import-affiliates.ts | 0
.../lib/partnerstack/import-commissions.ts | 0
apps/web/lib/partnerstack/import-referrals.ts | 0
apps/web/lib/partnerstack/importer.ts | 0
apps/web/lib/partnerstack/schemas.ts | 0
apps/web/lib/partnerstack/types.ts | 0
.../partnerstack/update-stripe-customers.ts | 0
9 files changed, 53 insertions(+)
create mode 100644 apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
create mode 100644 apps/web/lib/partnerstack/api.ts
create mode 100644 apps/web/lib/partnerstack/import-affiliates.ts
create mode 100644 apps/web/lib/partnerstack/import-commissions.ts
create mode 100644 apps/web/lib/partnerstack/import-referrals.ts
create mode 100644 apps/web/lib/partnerstack/importer.ts
create mode 100644 apps/web/lib/partnerstack/schemas.ts
create mode 100644 apps/web/lib/partnerstack/types.ts
create mode 100644 apps/web/lib/partnerstack/update-stripe-customers.ts
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
new file mode 100644
index 00000000000..73f9b5c0970
--- /dev/null
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -0,0 +1,53 @@
+import { handleAndReturnErrorResponse } from "@/lib/api/errors";
+import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
+import { importAffiliates } from "@/lib/tolt/import-affiliates";
+import { importCommissions } from "@/lib/tolt/import-commissions";
+import { importLinks } from "@/lib/tolt/import-links";
+import { importReferrals } from "@/lib/tolt/import-referrals";
+import { importSteps } from "@/lib/tolt/importer";
+import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+export const dynamic = "force-dynamic";
+
+const schema = z.object({
+ action: importSteps,
+ programId: z.string(),
+ startingAfter: z.string().optional(),
+});
+
+export async function POST(req: Request) {
+ try {
+ const rawBody = await req.text();
+
+ await verifyQstashSignature({
+ req,
+ rawBody,
+ });
+
+ const { action, ...payload } = schema.parse(JSON.parse(rawBody));
+
+ switch (action) {
+ case "import-affiliates":
+ await importAffiliates(payload);
+ break;
+ case "import-links":
+ await importLinks(payload);
+ break;
+ case "import-referrals":
+ await importReferrals(payload);
+ break;
+ case "import-commissions":
+ await importCommissions(payload);
+ break;
+ case "update-stripe-customers":
+ await updateStripeCustomers(payload);
+ break;
+ }
+
+ return NextResponse.json("OK");
+ } catch (error) {
+ return handleAndReturnErrorResponse(error);
+ }
+}
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/import-affiliates.ts b/apps/web/lib/partnerstack/import-affiliates.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/import-referrals.ts b/apps/web/lib/partnerstack/import-referrals.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apps/web/lib/partnerstack/update-stripe-customers.ts b/apps/web/lib/partnerstack/update-stripe-customers.ts
new file mode 100644
index 00000000000..e69de29bb2d
From 7bc5cd7d3e2742a6fb513e7e4739a7483f4dbc8b Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Sun, 13 Jul 2025 14:33:51 +0530
Subject: [PATCH 02/31] add modal
---
.../partners/set-partnerstack-token.ts | 47 ++++
.../partners/start-partnerstack-import.ts | 45 ++++
apps/web/lib/partners/constants.ts | 6 +
apps/web/lib/partnerstack/api.ts | 39 +++
apps/web/lib/partnerstack/importer.ts | 90 +++++++
apps/web/lib/partnerstack/types.ts | 53 ++++
.../ui/modals/import-partnerstack-modal.tsx | 231 ++++++++++++++++++
apps/web/ui/modals/modal-provider.tsx | 7 +
8 files changed, 518 insertions(+)
create mode 100644 apps/web/lib/actions/partners/set-partnerstack-token.ts
create mode 100644 apps/web/lib/actions/partners/start-partnerstack-import.ts
create mode 100644 apps/web/ui/modals/import-partnerstack-modal.tsx
diff --git a/apps/web/lib/actions/partners/set-partnerstack-token.ts b/apps/web/lib/actions/partners/set-partnerstack-token.ts
new file mode 100644
index 00000000000..0eb395c7af9
--- /dev/null
+++ b/apps/web/lib/actions/partners/set-partnerstack-token.ts
@@ -0,0 +1,47 @@
+"use server";
+
+import { PartnerStackApi } from "@/lib/partnerstack/api";
+import { partnerstackImporter } from "@/lib/partnerstack/importer";
+import { z } from "zod";
+import { authActionClient } from "../safe-action";
+
+const schema = z.object({
+ workspaceId: z.string(),
+ partnerstackProgramId: z.string().describe("PartnerStack program ID to import."),
+ token: z.string(),
+});
+
+export const setPartnerStackTokenAction = authActionClient
+ .schema(schema)
+ .action(async ({ parsedInput, ctx }) => {
+ const { workspace, user } = ctx;
+ const { token, partnerstackProgramId } = parsedInput;
+
+ if (!workspace.partnersEnabled) {
+ throw new Error("You are not allowed to perform this action.");
+ }
+
+ const partnerstackApi = new PartnerStackApi({ token });
+
+ try {
+ // Test the API connection by attempting to fetch program info
+ // Note: PartnerStack doesn't return detailed program info, so we'll just validate the token
+ await partnerstackApi.testConnection();
+ } catch (error) {
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : "Invalid PartnerStack token or program ID.",
+ );
+ }
+
+ await partnerstackImporter.setCredentials(workspace.id, {
+ userId: user.id,
+ token,
+ partnerstackProgramId,
+ });
+
+ return {
+ success: true,
+ };
+ });
\ No newline at end of file
diff --git a/apps/web/lib/actions/partners/start-partnerstack-import.ts b/apps/web/lib/actions/partners/start-partnerstack-import.ts
new file mode 100644
index 00000000000..e7c82e333d3
--- /dev/null
+++ b/apps/web/lib/actions/partners/start-partnerstack-import.ts
@@ -0,0 +1,45 @@
+"use server";
+
+import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
+import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
+import { partnerstackImporter } from "@/lib/partnerstack/importer";
+import { authActionClient } from "../safe-action";
+import { z } from "zod";
+
+const schema = z.object({
+ workspaceId: z.string(),
+});
+
+export const startPartnerStackImportAction = authActionClient
+ .schema(schema)
+ .action(async ({ ctx }) => {
+ const { workspace } = ctx;
+
+ 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 partnerstackImporter.getCredentials(workspace.id);
+
+ if (!credentials) {
+ throw new Error(
+ "PartnerStack credentials not found. Please restart the import process.",
+ );
+ }
+
+ await partnerstackImporter.queue({
+ action: "import-affiliates",
+ programId,
+ });
+ });
\ No newline at end of file
diff --git a/apps/web/lib/partners/constants.ts b/apps/web/lib/partners/constants.ts
index f7c1c880475..26286e40cca 100644
--- a/apps/web/lib/partners/constants.ts
+++ b/apps/web/lib/partners/constants.ts
@@ -78,4 +78,10 @@ export const PROGRAM_IMPORT_SOURCES = [
image: "https://assets.dub.co/misc/icons/tolt.svg",
helpUrl: "https://dub.co/help/article/migrating-from-tolt",
},
+ {
+ id: "partnerstack",
+ value: "PartnerStack",
+ image: "https://assets.dub.co/misc/icons/partnerstack.svg",
+ helpUrl: "https://dub.co/help/article/migrating-from-partnerstack",
+ },
] as const;
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index e69de29bb2d..0c609d41286 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -0,0 +1,39 @@
+const PAGE_LIMIT = 100;
+
+export class PartnerStackApi {
+ private readonly baseUrl = "https://api.partnerstack.com/api/v2";
+ private readonly token: string;
+
+ constructor({ token }: { token: string }) {
+ this.token = token;
+ }
+
+ private async fetch(url: string): Promise {
+ const response = await fetch(url, {
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+
+ console.error("PartnerStack API Error:", error);
+
+ throw new Error(error.message || "Unknown error from PartnerStack API.");
+ }
+
+ return await response.json();
+ }
+
+ async testConnection(): Promise {
+ try {
+ // Test the API connection by making a simple request
+ // We'll use a basic endpoint to validate the token
+ await this.fetch(`${this.baseUrl}/test`);
+ return true;
+ } catch (error) {
+ throw new Error("Invalid PartnerStack API token.");
+ }
+ }
+}
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index e69de29bb2d..833c1ea0e21 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -0,0 +1,90 @@
+import { qstash } from "@/lib/cron";
+import { redis } from "@/lib/upstash";
+import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
+import { z } from "zod";
+import { PartnerStackConfig } from "./types";
+
+export const MAX_BATCHES = 5;
+export const CACHE_EXPIRY = 60 * 60 * 24;
+export const CACHE_KEY_PREFIX = "partnerstack:import";
+export const PARTNER_IDS_KEY_PREFIX = "partnerstack:import:partnerIds";
+
+export const importSteps = z.enum([
+ "import-affiliates",
+ "import-links",
+ "import-referrals",
+ "import-commissions",
+ "update-stripe-customers", // update the customers with their stripe customer ID
+ "cleanup-partners", // remove partners with 0 leads
+]);
+
+class PartnerStackImporter {
+ async setCredentials(workspaceId: string, payload: PartnerStackConfig) {
+ await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, {
+ ex: CACHE_EXPIRY,
+ });
+ }
+
+ async getCredentials(workspaceId: string) {
+ const config = await redis.get(
+ `${CACHE_KEY_PREFIX}:${workspaceId}`,
+ );
+
+ if (!config) {
+ throw new Error("PartnerStack configuration not found.");
+ }
+
+ return config;
+ }
+
+ async deleteCredentials(workspaceId: string) {
+ return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);
+ }
+
+ async queue(body: {
+ action: z.infer;
+ programId: string;
+ startingAfter?: string;
+ }) {
+ return await qstash.publishJSON({
+ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/partnerstack`,
+ body,
+ });
+ }
+
+ async addPartners({
+ programId,
+ partnerIds,
+ }: {
+ programId: string;
+ partnerIds: string[];
+ }) {
+ if (!partnerIds || partnerIds.length === 0) {
+ return;
+ }
+
+ await redis.lpush(`${PARTNER_IDS_KEY_PREFIX}:${programId}`, ...partnerIds);
+ }
+
+ async scanPartnerIds({
+ programId,
+ start,
+ end,
+ }: {
+ programId: string;
+ start: number;
+ end: number;
+ }) {
+ return await redis.lrange(
+ `${PARTNER_IDS_KEY_PREFIX}:${programId}`,
+ start,
+ end,
+ );
+ }
+
+ async deletePartnerIds(programId: string) {
+ return await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`);
+ }
+}
+
+export const partnerstackImporter = new PartnerStackImporter();
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index e69de29bb2d..83bc015500b 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+
+export interface PartnerStackConfig {
+ token: string;
+ userId: string;
+ partnerstackProgramId: string;
+}
+
+export interface PartnerStackListResponse {
+ success: true;
+ total_count: number;
+ data: T[];
+}
+
+// Basic types - these will be expanded as we implement the API
+export interface PartnerStackAffiliate {
+ id: string;
+ email: string;
+ first_name: string;
+ last_name: string;
+ company_name?: string;
+ country_code?: string;
+ profile_type?: string;
+ created_at: string;
+}
+
+export interface PartnerStackLink {
+ id: string;
+ tracking_url: string;
+ token: string;
+ partner_id: string;
+ created_at: string;
+}
+
+export interface PartnerStackReferral {
+ id: string;
+ email: string;
+ first_name: string;
+ last_name: string;
+ company_name?: string;
+ partner_id: string;
+ created_at: string;
+}
+
+export interface PartnerStackCommission {
+ id: string;
+ amount: number;
+ commission_amount: number;
+ status: string;
+ partner_id: string;
+ customer_id: string;
+ created_at: string;
+}
diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx
new file mode 100644
index 00000000000..5a045475a4d
--- /dev/null
+++ b/apps/web/ui/modals/import-partnerstack-modal.tsx
@@ -0,0 +1,231 @@
+import { setPartnerStackTokenAction } from "@/lib/actions/partners/set-partnerstack-token";
+import { startPartnerStackImportAction } from "@/lib/actions/partners/start-partnerstack-import";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui";
+import { ArrowRight } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ Dispatch,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useState,
+} from "react";
+import { toast } from "sonner";
+
+function ImportPartnerStackModal({
+ showImportPartnerStackModal,
+ setShowImportPartnerStackModal,
+}: {
+ showImportPartnerStackModal: boolean;
+ setShowImportPartnerStackModal: Dispatch>;
+}) {
+ const searchParams = useSearchParams();
+ const { queryParams } = useRouterStuff();
+
+ useEffect(() => {
+ if (searchParams?.get("import") === "partnerstack") {
+ setShowImportPartnerStackModal(true);
+ } else {
+ setShowImportPartnerStackModal(false);
+ }
+ }, [searchParams]);
+
+ return (
+
+ queryParams({
+ del: "import",
+ })
+ }
+ >
+
+
+

+
+
+
+
Import Your PartnerStack Program
+
+ Import your existing PartnerStack program into{" "}
+ {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks.
+
+
+
+
+ {
+ setShowImportPartnerStackModal(false);
+ queryParams({
+ del: "import",
+ });
+ }}
+ />
+
+
+ );
+}
+
+function TokenForm({
+ onClose,
+}: {
+ onClose: () => void;
+}) {
+ const { isMobile } = useMediaQuery();
+ const router = useRouter();
+ const { id: workspaceId, slug } = useWorkspace();
+
+ const [token, setToken] = useState("");
+ const [programId, setProgramId] = useState("");
+
+ const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction(
+ setPartnerStackTokenAction,
+ {
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ },
+ );
+
+ const { executeAsync: startImportAsync, isPending: isStartingImport } =
+ useAction(startPartnerStackImportAction, {
+ onSuccess: () => {
+ onClose();
+ toast.success(
+ "Successfully added program to import queue! We will send you an email when your program has been fully imported.",
+ );
+ router.push(`/${slug}/program/partners`);
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!workspaceId || !token || !programId) {
+ return;
+ }
+
+ try {
+ // First set the token
+ await setTokenAsync({
+ workspaceId,
+ partnerstackProgramId: programId,
+ token,
+ });
+
+ // Then start the import
+ await startImportAsync({
+ workspaceId,
+ });
+ } catch (error) {
+ // Error handling is done in the action callbacks
+ console.error("Import error:", error);
+ }
+ };
+
+ const isLoading = isSettingToken || isStartingImport;
+
+ return (
+
+ );
+}
+
+export function useImportPartnerStackModal() {
+ const [showImportPartnerStackModal, setShowImportPartnerStackModal] =
+ useState(false);
+
+ const ImportPartnerStackModalCallback = useCallback(
+ () => (
+
+ ),
+ [showImportPartnerStackModal],
+ );
+
+ return {
+ showImportPartnerStackModal,
+ setShowImportPartnerStackModal,
+ ImportPartnerStackModal: ImportPartnerStackModalCallback,
+ };
+}
\ No newline at end of file
diff --git a/apps/web/ui/modals/modal-provider.tsx b/apps/web/ui/modals/modal-provider.tsx
index 3558d0e7b23..9af6053697e 100644
--- a/apps/web/ui/modals/modal-provider.tsx
+++ b/apps/web/ui/modals/modal-provider.tsx
@@ -26,6 +26,7 @@ import {
import { toast } from "sonner";
import { useAddEditTagModal } from "./add-edit-tag-modal";
import { useImportRebrandlyModal } from "./import-rebrandly-modal";
+import { useImportPartnerStackModal } from "./import-partnerstack-modal";
import { useImportRewardfulModal } from "./import-rewardful-modal";
import { useImportToltModal } from "./import-tolt-modal";
import { useLinkBuilder } from "./link-builder";
@@ -42,6 +43,7 @@ export const ModalContext = createContext<{
setShowImportShortModal: Dispatch>;
setShowImportRebrandlyModal: Dispatch>;
setShowImportCsvModal: Dispatch>;
+ setShowImportPartnerStackModal: Dispatch>;
setShowImportRewardfulModal: Dispatch>;
setShowImportToltModal: Dispatch>;
}>({
@@ -53,6 +55,7 @@ export const ModalContext = createContext<{
setShowImportShortModal: () => {},
setShowImportRebrandlyModal: () => {},
setShowImportCsvModal: () => {},
+ setShowImportPartnerStackModal: () => {},
setShowImportRewardfulModal: () => {},
setShowImportToltModal: () => {},
});
@@ -106,6 +109,8 @@ function ModalProviderClient({ children }: { children: ReactNode }) {
const { setShowUpgradedModal, UpgradedModal } = useUpgradedModal();
const { setShowProgramWelcomeModal, ProgramWelcomeModal } =
useProgramWelcomeModal();
+ const { setShowImportPartnerStackModal, ImportPartnerStackModal } =
+ useImportPartnerStackModal();
const { setShowImportRewardfulModal, ImportRewardfulModal } =
useImportRewardfulModal();
const { setShowImportToltModal, ImportToltModal } = useImportToltModal();
@@ -199,6 +204,7 @@ function ModalProviderClient({ children }: { children: ReactNode }) {
setShowImportShortModal,
setShowImportRebrandlyModal,
setShowImportCsvModal,
+ setShowImportPartnerStackModal,
setShowImportRewardfulModal,
setShowImportToltModal,
}}
@@ -212,6 +218,7 @@ function ModalProviderClient({ children }: { children: ReactNode }) {
+
From 3bc2ab78b3690291050de411ef868a58718d72f0 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 08:40:31 +0530
Subject: [PATCH 03/31] wip
---
.../partners/set-partnerstack-token.ts | 4 +--
apps/web/lib/partnerstack/types.ts | 2 +-
.../ui/modals/import-partnerstack-modal.tsx | 34 ++-----------------
3 files changed, 5 insertions(+), 35 deletions(-)
diff --git a/apps/web/lib/actions/partners/set-partnerstack-token.ts b/apps/web/lib/actions/partners/set-partnerstack-token.ts
index 0eb395c7af9..3945400e996 100644
--- a/apps/web/lib/actions/partners/set-partnerstack-token.ts
+++ b/apps/web/lib/actions/partners/set-partnerstack-token.ts
@@ -7,7 +7,6 @@ import { authActionClient } from "../safe-action";
const schema = z.object({
workspaceId: z.string(),
- partnerstackProgramId: z.string().describe("PartnerStack program ID to import."),
token: z.string(),
});
@@ -15,7 +14,7 @@ export const setPartnerStackTokenAction = authActionClient
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace, user } = ctx;
- const { token, partnerstackProgramId } = parsedInput;
+ const { token } = parsedInput;
if (!workspace.partnersEnabled) {
throw new Error("You are not allowed to perform this action.");
@@ -38,7 +37,6 @@ export const setPartnerStackTokenAction = authActionClient
await partnerstackImporter.setCredentials(workspace.id, {
userId: user.id,
token,
- partnerstackProgramId,
});
return {
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index 83bc015500b..aca341366de 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
export interface PartnerStackConfig {
token: string;
userId: string;
- partnerstackProgramId: string;
+ partnerstackProgramId?: string;
}
export interface PartnerStackListResponse {
diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx
index 5a045475a4d..7487be79a2b 100644
--- a/apps/web/ui/modals/import-partnerstack-modal.tsx
+++ b/apps/web/ui/modals/import-partnerstack-modal.tsx
@@ -83,7 +83,6 @@ function TokenForm({
const { id: workspaceId, slug } = useWorkspace();
const [token, setToken] = useState("");
- const [programId, setProgramId] = useState("");
const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction(
setPartnerStackTokenAction,
@@ -111,7 +110,7 @@ function TokenForm({
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!workspaceId || !token || !programId) {
+ if (!workspaceId || !token) {
return;
}
@@ -119,7 +118,6 @@ function TokenForm({
// First set the token
await setTokenAsync({
workspaceId,
- partnerstackProgramId: programId,
token,
});
@@ -166,33 +164,7 @@ function TokenForm({
-
-
-
setProgramId(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 find your program ID in your{" "}
-
- Programs page
-
-
-
+
);
From 62d08666dffb38632fb077b82ecb263cf550679b Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Sun, 13 Jul 2025 14:07:31 +0530
Subject: [PATCH 04/31] wip
From 083daeb819cc807dcec135ae4edf5da78a38c232 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Sun, 13 Jul 2025 14:33:51 +0530
Subject: [PATCH 05/31] add modal
From 59f4efc524c8d0713913cd9366572a022f343c72 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 10:17:32 +0530
Subject: [PATCH 06/31] Implement PartnerStack import functionality and update
UI components.
---
.../partners/import-export-buttons.tsx | 5 +-
.../partners/set-partnerstack-token.ts | 45 ----------
.../partners/start-partnerstack-import.ts | 28 +++---
apps/web/lib/partnerstack/api.ts | 10 +--
apps/web/lib/partnerstack/importer.ts | 53 +-----------
apps/web/lib/partnerstack/schemas.ts | 15 ++++
apps/web/lib/partnerstack/types.ts | 4 -
.../ui/modals/import-partnerstack-modal.tsx | 85 ++++++-------------
8 files changed, 66 insertions(+), 179 deletions(-)
delete mode 100644 apps/web/lib/actions/partners/set-partnerstack-token.ts
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx
index f7f3d4f4eb5..d43065552bd 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx
@@ -3,6 +3,7 @@
import { PROGRAM_IMPORT_SOURCES } from "@/lib/partners/constants";
import useWorkspace from "@/lib/swr/use-workspace";
import { useExportPartnersModal } from "@/ui/modals/export-partners-modal";
+import { useImportPartnerStackModal } from "@/ui/modals/import-partnerstack-modal";
import { useImportRewardfulModal } from "@/ui/modals/import-rewardful-modal";
import { useImportToltModal } from "@/ui/modals/import-tolt-modal";
import { Download, ThreeDots } from "@/ui/shared/icons";
@@ -17,6 +18,7 @@ export function ImportExportButtons() {
const { ImportToltModal } = useImportToltModal();
const { ImportRewardfulModal } = useImportRewardfulModal();
+ const { ImportPartnerStackModal } = useImportPartnerStackModal();
const { ExportPartnersModal, setShowExportPartnersModal } =
useExportPartnersModal();
@@ -25,10 +27,11 @@ export function ImportExportButtons() {
<>
+
+
Import Partners
diff --git a/apps/web/lib/actions/partners/set-partnerstack-token.ts b/apps/web/lib/actions/partners/set-partnerstack-token.ts
deleted file mode 100644
index 3945400e996..00000000000
--- a/apps/web/lib/actions/partners/set-partnerstack-token.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-"use server";
-
-import { PartnerStackApi } from "@/lib/partnerstack/api";
-import { partnerstackImporter } from "@/lib/partnerstack/importer";
-import { z } from "zod";
-import { authActionClient } from "../safe-action";
-
-const schema = z.object({
- workspaceId: z.string(),
- token: z.string(),
-});
-
-export const setPartnerStackTokenAction = authActionClient
- .schema(schema)
- .action(async ({ parsedInput, ctx }) => {
- const { workspace, user } = ctx;
- const { token } = parsedInput;
-
- if (!workspace.partnersEnabled) {
- throw new Error("You are not allowed to perform this action.");
- }
-
- const partnerstackApi = new PartnerStackApi({ token });
-
- try {
- // Test the API connection by attempting to fetch program info
- // Note: PartnerStack doesn't return detailed program info, so we'll just validate the token
- await partnerstackApi.testConnection();
- } catch (error) {
- throw new Error(
- error instanceof Error
- ? error.message
- : "Invalid PartnerStack token or program ID.",
- );
- }
-
- await partnerstackImporter.setCredentials(workspace.id, {
- userId: user.id,
- token,
- });
-
- return {
- success: true,
- };
- });
\ No newline at end of file
diff --git a/apps/web/lib/actions/partners/start-partnerstack-import.ts b/apps/web/lib/actions/partners/start-partnerstack-import.ts
index e7c82e333d3..e3e07760eae 100644
--- a/apps/web/lib/actions/partners/start-partnerstack-import.ts
+++ b/apps/web/lib/actions/partners/start-partnerstack-import.ts
@@ -2,18 +2,21 @@
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
-import { partnerstackImporter } from "@/lib/partnerstack/importer";
-import { authActionClient } from "../safe-action";
+import { PartnerStackApi } from "@/lib/partnerstack/api";
+import { partnerStackImporter } from "@/lib/partnerstack/importer";
import { z } from "zod";
+import { authActionClient } from "../safe-action";
const schema = z.object({
workspaceId: z.string(),
+ token: z.string().min(1),
});
export const startPartnerStackImportAction = authActionClient
.schema(schema)
- .action(async ({ ctx }) => {
- const { workspace } = ctx;
+ .action(async ({ ctx, parsedInput }) => {
+ const { workspace, user } = ctx;
+ const { token } = parsedInput;
const programId = getDefaultProgramIdOrThrow(workspace);
@@ -30,16 +33,15 @@ export const startPartnerStackImportAction = authActionClient
throw new Error("Program URL is not set.");
}
- const credentials = await partnerstackImporter.getCredentials(workspace.id);
+ const partnerStackApi = new PartnerStackApi({
+ token,
+ });
- if (!credentials) {
- throw new Error(
- "PartnerStack credentials not found. Please restart the import process.",
- );
- }
+ await partnerStackApi.testConnection();
- await partnerstackImporter.queue({
- action: "import-affiliates",
+ await partnerStackImporter.queue({
programId,
+ userId: user.id,
+ action: "import-affiliates",
});
- });
\ No newline at end of file
+ });
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 0c609d41286..7937e36a39e 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -8,8 +8,8 @@ export class PartnerStackApi {
this.token = token;
}
- private async fetch(url: string): Promise {
- const response = await fetch(url, {
+ private async fetch(path: string): Promise {
+ const response = await fetch(`${this.baseUrl}${path}`, {
headers: {
Authorization: `Bearer ${this.token}`,
},
@@ -26,11 +26,9 @@ export class PartnerStackApi {
return await response.json();
}
- async testConnection(): Promise {
+ async testConnection() {
try {
- // Test the API connection by making a simple request
- // We'll use a basic endpoint to validate the token
- await this.fetch(`${this.baseUrl}/test`);
+ await this.fetch("/customers?limit=1");
return true;
} catch (error) {
throw new Error("Invalid PartnerStack API token.");
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index 833c1ea0e21..bcaf34880fe 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -2,21 +2,12 @@ import { qstash } from "@/lib/cron";
import { redis } from "@/lib/upstash";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { z } from "zod";
+import { partnerStackImportPayload } from "./schemas";
import { PartnerStackConfig } from "./types";
export const MAX_BATCHES = 5;
export const CACHE_EXPIRY = 60 * 60 * 24;
export const CACHE_KEY_PREFIX = "partnerstack:import";
-export const PARTNER_IDS_KEY_PREFIX = "partnerstack:import:partnerIds";
-
-export const importSteps = z.enum([
- "import-affiliates",
- "import-links",
- "import-referrals",
- "import-commissions",
- "update-stripe-customers", // update the customers with their stripe customer ID
- "cleanup-partners", // remove partners with 0 leads
-]);
class PartnerStackImporter {
async setCredentials(workspaceId: string, payload: PartnerStackConfig) {
@@ -41,50 +32,12 @@ class PartnerStackImporter {
return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);
}
- async queue(body: {
- action: z.infer;
- programId: string;
- startingAfter?: string;
- }) {
+ async queue(body: z.infer) {
return await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/partnerstack`,
body,
});
}
-
- async addPartners({
- programId,
- partnerIds,
- }: {
- programId: string;
- partnerIds: string[];
- }) {
- if (!partnerIds || partnerIds.length === 0) {
- return;
- }
-
- await redis.lpush(`${PARTNER_IDS_KEY_PREFIX}:${programId}`, ...partnerIds);
- }
-
- async scanPartnerIds({
- programId,
- start,
- end,
- }: {
- programId: string;
- start: number;
- end: number;
- }) {
- return await redis.lrange(
- `${PARTNER_IDS_KEY_PREFIX}:${programId}`,
- start,
- end,
- );
- }
-
- async deletePartnerIds(programId: string) {
- return await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`);
- }
}
-export const partnerstackImporter = new PartnerStackImporter();
+export const partnerStackImporter = new PartnerStackImporter();
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index e69de29bb2d..ec48438d5d7 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+export const partnerStackImportSteps = z.enum([
+ "import-affiliates",
+ "import-links",
+ "import-referrals",
+ "import-commissions",
+ "update-stripe-customers",
+]);
+
+export const partnerStackImportPayload = z.object({
+ userId: z.string(),
+ programId: z.string(),
+ action: partnerStackImportSteps,
+});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index aca341366de..23b4536bc3c 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,9 +1,5 @@
-import { z } from "zod";
-
export interface PartnerStackConfig {
token: string;
- userId: string;
- partnerstackProgramId?: string;
}
export interface PartnerStackListResponse {
diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx
index 7487be79a2b..46df2c121a9 100644
--- a/apps/web/ui/modals/import-partnerstack-modal.tsx
+++ b/apps/web/ui/modals/import-partnerstack-modal.tsx
@@ -1,4 +1,3 @@
-import { setPartnerStackTokenAction } from "@/lib/actions/partners/set-partnerstack-token";
import { startPartnerStackImportAction } from "@/lib/actions/partners/start-partnerstack-import";
import useWorkspace from "@/lib/swr/use-workspace";
import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui";
@@ -52,7 +51,9 @@ function ImportPartnerStackModal({
-
Import Your PartnerStack Program
+
+ Import Your PartnerStack Program
+
Import your existing PartnerStack program into{" "}
{process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks.
@@ -73,39 +74,24 @@ function ImportPartnerStackModal({
);
}
-function TokenForm({
- onClose,
-}: {
- onClose: () => void;
-}) {
- const { isMobile } = useMediaQuery();
+function TokenForm({ onClose }: { onClose: () => void }) {
const router = useRouter();
- const { id: workspaceId, slug } = useWorkspace();
-
+ const { isMobile } = useMediaQuery();
const [token, setToken] = useState("");
+ const { id: workspaceId, slug } = useWorkspace();
- const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction(
- setPartnerStackTokenAction,
- {
- onError: ({ error }) => {
- toast.error(error.serverError);
- },
+ const { executeAsync, isPending } = useAction(startPartnerStackImportAction, {
+ onSuccess: () => {
+ onClose();
+ toast.success(
+ "Successfully added program to import queue! We will send you an email when your program has been fully imported.",
+ );
+ router.push(`/${slug}/program/partners`);
},
- );
-
- const { executeAsync: startImportAsync, isPending: isStartingImport } =
- useAction(startPartnerStackImportAction, {
- onSuccess: () => {
- onClose();
- toast.success(
- "Successfully added program to import queue! We will send you an email when your program has been fully imported.",
- );
- router.push(`/${slug}/program/partners`);
- },
- onError: ({ error }) => {
- toast.error(error.serverError);
- },
- });
+ onError: ({ error }) => {
+ toast.error(error.serverError);
+ },
+ });
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -114,25 +100,12 @@ function TokenForm({
return;
}
- try {
- // First set the token
- await setTokenAsync({
- workspaceId,
- token,
- });
-
- // Then start the import
- await startImportAsync({
- workspaceId,
- });
- } catch (error) {
- // Error handling is done in the action callbacks
- console.error("Import error:", error);
- }
+ await executeAsync({
+ workspaceId,
+ token,
+ });
};
- const isLoading = isSettingToken || isStartingImport;
-
return (
);
@@ -200,4 +165,4 @@ export function useImportPartnerStackModal() {
setShowImportPartnerStackModal,
ImportPartnerStackModal: ImportPartnerStackModalCallback,
};
-}
\ No newline at end of file
+}
From d86dff33cf5f2bc49d4144bca8fd4c7bd1e49ab3 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 11:03:07 +0530
Subject: [PATCH 07/31] Refactor PartnerStack import logic to use new schemas
and API methods
---
.../api/cron/import/partnerstack/route.ts | 15 +--
apps/web/lib/partnerstack/api.ts | 21 +++
.../web/lib/partnerstack/import-affiliates.ts | 125 ++++++++++++++++++
apps/web/lib/partnerstack/importer.ts | 4 +-
apps/web/lib/partnerstack/schemas.ts | 13 +-
apps/web/lib/partnerstack/types.ts | 54 ++------
6 files changed, 177 insertions(+), 55 deletions(-)
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index 73f9b5c0970..bddb0d0d23a 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,22 +1,15 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
-import { importAffiliates } from "@/lib/tolt/import-affiliates";
+import { importAffiliates } from "@/lib/partnerstack/import-affiliates";
+import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
import { importCommissions } from "@/lib/tolt/import-commissions";
import { importLinks } from "@/lib/tolt/import-links";
import { importReferrals } from "@/lib/tolt/import-referrals";
-import { importSteps } from "@/lib/tolt/importer";
import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers";
import { NextResponse } from "next/server";
-import { z } from "zod";
export const dynamic = "force-dynamic";
-const schema = z.object({
- action: importSteps,
- programId: z.string(),
- startingAfter: z.string().optional(),
-});
-
export async function POST(req: Request) {
try {
const rawBody = await req.text();
@@ -26,9 +19,9 @@ export async function POST(req: Request) {
rawBody,
});
- const { action, ...payload } = schema.parse(JSON.parse(rawBody));
+ const payload = partnerStackImportPayloadSchema.parse(JSON.parse(rawBody));
- switch (action) {
+ switch (payload.action) {
case "import-affiliates":
await importAffiliates(payload);
break;
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 7937e36a39e..7deae6115fb 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,3 +1,6 @@
+import { partnerStackAffiliate } from "./schemas";
+import { PartnerStackAffiliate, PartnerStackListResponse } from "./types";
+
const PAGE_LIMIT = 100;
export class PartnerStackApi {
@@ -34,4 +37,22 @@ export class PartnerStackApi {
throw new Error("Invalid PartnerStack API token.");
}
}
+
+ async listAffiliates({ startingAfter }: { startingAfter?: string }) {
+ const searchParams = new URLSearchParams();
+ searchParams.append("approved_status", "approved");
+ searchParams.append("limit", PAGE_LIMIT.toString());
+
+ if (startingAfter) {
+ searchParams.append("starting_after", startingAfter);
+ }
+
+ const {
+ data: { items },
+ } = await this.fetch>(
+ `/partnerships?${searchParams.toString()}`,
+ );
+
+ return partnerStackAffiliate.array().parse(items);
+ }
}
diff --git a/apps/web/lib/partnerstack/import-affiliates.ts b/apps/web/lib/partnerstack/import-affiliates.ts
index e69de29bb2d..ed8a841630f 100644
--- a/apps/web/lib/partnerstack/import-affiliates.ts
+++ b/apps/web/lib/partnerstack/import-affiliates.ts
@@ -0,0 +1,125 @@
+import { prisma } from "@dub/prisma";
+import { Program, Reward } from "@dub/prisma/client";
+import { COUNTRY_CODES } from "@dub/utils";
+import { createId } from "../api/create-id";
+import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards";
+import { PartnerStackApi } from "./api";
+import { MAX_BATCHES, partnerStackImporter } from "./importer";
+import { PartnerStackAffiliate, PartnerStackImportPayload } from "./types";
+
+export async function importAffiliates(payload: PartnerStackImportPayload) {
+ const { programId, startingAfter } = payload;
+
+ const program = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ include: {
+ rewards: {
+ where: {
+ default: true,
+ },
+ },
+ },
+ });
+
+ const { token } = await partnerStackImporter.getCredentials(
+ program.workspaceId,
+ );
+
+ const partnerStackApi = new PartnerStackApi({
+ token,
+ });
+
+ const saleReward = program.rewards.find((r) => r.event === "sale");
+ const leadReward = program.rewards.find((r) => r.event === "lead");
+ const clickReward = program.rewards.find((r) => r.event === "click");
+ const reward = saleReward || leadReward || clickReward;
+
+ let hasMore = true;
+ let processedBatches = 0;
+ let currentStartingAfter = startingAfter;
+
+ while (hasMore && processedBatches < MAX_BATCHES) {
+ const affiliates = await partnerStackApi.listAffiliates({
+ startingAfter: currentStartingAfter,
+ });
+
+ if (affiliates.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ if (affiliates.length > 0) {
+ await Promise.allSettled(
+ affiliates.map((affiliate) =>
+ createPartner({
+ program,
+ affiliate,
+ reward,
+ }),
+ ),
+ );
+
+ // TODO:
+ // Remove the partners with 0 leads
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ processedBatches++;
+ currentStartingAfter = affiliates[affiliates.length - 1].key;
+ }
+
+ await partnerStackImporter.queue({
+ ...payload,
+ ...(hasMore && { startingAfter: currentStartingAfter }),
+ action: hasMore ? "import-affiliates" : "import-links",
+ });
+}
+
+// Create partner
+async function createPartner({
+ program,
+ affiliate,
+ reward,
+}: {
+ program: Program;
+ affiliate: PartnerStackAffiliate;
+ reward?: Pick;
+}) {
+ const partner = await prisma.partner.upsert({
+ where: {
+ email: affiliate.email,
+ },
+ create: {
+ id: createId({ prefix: "pn_" }),
+ name: `${affiliate.first_name} ${affiliate.last_name}`,
+ email: affiliate.email,
+ country: COUNTRY_CODES[affiliate.address.country],
+ },
+ update: {
+ // do nothing
+ },
+ });
+
+ await prisma.programEnrollment.upsert({
+ where: {
+ partnerId_programId: {
+ partnerId: partner.id,
+ programId: program.id,
+ },
+ },
+ create: {
+ programId: program.id,
+ partnerId: partner.id,
+ status: "approved",
+ ...(reward && { [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id }),
+ },
+ update: {
+ status: "approved",
+ },
+ });
+
+ return partner;
+}
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index bcaf34880fe..a8cf4759e65 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -2,7 +2,7 @@ import { qstash } from "@/lib/cron";
import { redis } from "@/lib/upstash";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { z } from "zod";
-import { partnerStackImportPayload } from "./schemas";
+import { partnerStackImportPayloadSchema } from "./schemas";
import { PartnerStackConfig } from "./types";
export const MAX_BATCHES = 5;
@@ -32,7 +32,7 @@ class PartnerStackImporter {
return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);
}
- async queue(body: z.infer) {
+ async queue(body: z.infer) {
return await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/partnerstack`,
body,
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index ec48438d5d7..d6e87f35fe4 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -8,8 +8,19 @@ export const partnerStackImportSteps = z.enum([
"update-stripe-customers",
]);
-export const partnerStackImportPayload = z.object({
+export const partnerStackImportPayloadSchema = z.object({
userId: z.string(),
programId: z.string(),
action: partnerStackImportSteps,
+ startingAfter: z.string().optional(),
+});
+
+export const partnerStackAffiliate = z.object({
+ key: z.string(),
+ email: z.string(),
+ first_name: z.string(),
+ last_name: z.string(),
+ address: z.object({
+ country: z.string(),
+ }),
});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index 23b4536bc3c..cc7cc434350 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,49 +1,21 @@
+import { z } from "zod";
+import {
+ partnerStackAffiliate,
+ partnerStackImportPayloadSchema,
+} from "./schemas";
+
export interface PartnerStackConfig {
token: string;
}
export interface PartnerStackListResponse {
- success: true;
- total_count: number;
- data: T[];
-}
-
-// Basic types - these will be expanded as we implement the API
-export interface PartnerStackAffiliate {
- id: string;
- email: string;
- first_name: string;
- last_name: string;
- company_name?: string;
- country_code?: string;
- profile_type?: string;
- created_at: string;
-}
-
-export interface PartnerStackLink {
- id: string;
- tracking_url: string;
- token: string;
- partner_id: string;
- created_at: string;
+ data: {
+ items: T[];
+ };
}
-export interface PartnerStackReferral {
- id: string;
- email: string;
- first_name: string;
- last_name: string;
- company_name?: string;
- partner_id: string;
- created_at: string;
-}
+export type PartnerStackAffiliate = z.infer;
-export interface PartnerStackCommission {
- id: string;
- amount: number;
- commission_amount: number;
- status: string;
- partner_id: string;
- customer_id: string;
- created_at: string;
-}
+export type PartnerStackImportPayload = z.infer<
+ typeof partnerStackImportPayloadSchema
+>;
From cdca0314f54c3ab8515b151e32c2a4337ba112db Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 11:04:47 +0530
Subject: [PATCH 08/31] Update types.ts
---
apps/web/lib/partnerstack/types.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index cc7cc434350..8d908a136d7 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -14,8 +14,8 @@ export interface PartnerStackListResponse {
};
}
-export type PartnerStackAffiliate = z.infer;
-
export type PartnerStackImportPayload = z.infer<
typeof partnerStackImportPayloadSchema
>;
+
+export type PartnerStackAffiliate = z.infer;
From 9eecfcafa1b49144573578885ae3b8b049083076 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 12:02:07 +0530
Subject: [PATCH 09/31] import links
---
.../api/cron/import/partnerstack/route.ts | 2 +-
apps/web/lib/partnerstack/api.ts | 18 ++-
apps/web/lib/partnerstack/import-links.ts | 151 ++++++++++++++++++
apps/web/lib/partnerstack/schemas.ts | 6 +
apps/web/lib/partnerstack/types.ts | 3 +
apps/web/lib/tolt/import-links.ts | 8 +-
6 files changed, 182 insertions(+), 6 deletions(-)
create mode 100644 apps/web/lib/partnerstack/import-links.ts
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index bddb0d0d23a..63730e65bf8 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,9 +1,9 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { importAffiliates } from "@/lib/partnerstack/import-affiliates";
+import { importLinks } from "@/lib/partnerstack/import-links";
import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
import { importCommissions } from "@/lib/tolt/import-commissions";
-import { importLinks } from "@/lib/tolt/import-links";
import { importReferrals } from "@/lib/tolt/import-referrals";
import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers";
import { NextResponse } from "next/server";
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 7deae6115fb..a0b9f2822f5 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,5 +1,9 @@
-import { partnerStackAffiliate } from "./schemas";
-import { PartnerStackAffiliate, PartnerStackListResponse } from "./types";
+import { partnerStackAffiliate, partnerStackLink } from "./schemas";
+import {
+ PartnerStackAffiliate,
+ PartnerStackLink,
+ PartnerStackListResponse,
+} from "./types";
const PAGE_LIMIT = 100;
@@ -55,4 +59,14 @@ export class PartnerStackApi {
return partnerStackAffiliate.array().parse(items);
}
+
+ async listLinks({ identifier }: { identifier: string }) {
+ const {
+ data: { items },
+ } = await this.fetch>(
+ `/links/partnership/${identifier}`,
+ );
+
+ return partnerStackLink.array().parse(items);
+ }
}
diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts
new file mode 100644
index 00000000000..973a82ae955
--- /dev/null
+++ b/apps/web/lib/partnerstack/import-links.ts
@@ -0,0 +1,151 @@
+import { prisma } from "@dub/prisma";
+import { createLink } from "../api/links";
+import { generatePartnerLink } from "../api/partners/create-partner-link";
+import { PartnerProps, ProgramProps, WorkspaceProps } from "../types";
+import { PartnerStackApi } from "./api";
+import { partnerStackImporter } from "./importer";
+import { PartnerStackImportPayload, PartnerStackLink } from "./types";
+
+export async function importLinks(payload: PartnerStackImportPayload) {
+ const { programId, userId, startingAfter } = payload;
+
+ const program = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ include: {
+ workspace: true,
+ },
+ });
+
+ const { token } = await partnerStackImporter.getCredentials(
+ program.workspaceId,
+ );
+
+ const partnerStackApi = new PartnerStackApi({
+ token,
+ });
+
+ let hasMore = true;
+ let currentStartingAfter = startingAfter;
+
+ const enrollments = await prisma.programEnrollment.findMany({
+ where: {
+ programId,
+ },
+ select: {
+ id: true,
+ partner: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ },
+ },
+ skip: currentStartingAfter ? 1 : 0,
+ ...(currentStartingAfter && {
+ cursor: {
+ id: currentStartingAfter,
+ },
+ }),
+ });
+
+ if (enrollments.length === 0) {
+ hasMore = false;
+ console.log("No enrollments found.");
+ }
+
+ for (const { partner } of enrollments) {
+ if (!partner.email) {
+ console.log("Partner has no email. Skipping the link import.");
+ continue;
+ }
+
+ const links = await partnerStackApi.listLinks({
+ identifier: partner.email!,
+ });
+
+ if (links.length === 0) {
+ console.log(`No links found for partner ${partner.email}`);
+ continue;
+ }
+
+ await Promise.all(
+ links.map(async (link) =>
+ createPartnerLink({
+ workspace: program.workspace as WorkspaceProps,
+ program,
+ partner,
+ link,
+ userId,
+ }),
+ ),
+ );
+
+ currentStartingAfter = enrollments[enrollments.length - 1].id;
+ }
+
+ await partnerStackImporter.queue({
+ ...payload,
+ ...(hasMore && { startingAfter: currentStartingAfter }),
+ action: hasMore ? "import-links" : "import-referrals",
+ });
+}
+
+async function createPartnerLink({
+ workspace,
+ program,
+ partner,
+ link,
+ userId,
+}: {
+ workspace: WorkspaceProps;
+ program: ProgramProps;
+ partner: Pick;
+ link: PartnerStackLink;
+ userId: string;
+}) {
+ const key = link.url.split("/").pop();
+
+ if (!key) {
+ console.error("No key found in the link", link);
+ return null;
+ }
+
+ const linkFound = await prisma.link.findUnique({
+ where: {
+ domain_key: {
+ domain: program.domain!,
+ key,
+ },
+ },
+ select: {
+ partnerId: true,
+ },
+ });
+
+ if (linkFound?.partnerId === partner.id) {
+ console.log(`Partner ${partner.id} already has a link with key ${key}`);
+ return null;
+ }
+
+ try {
+ const partnerLink = await generatePartnerLink({
+ workspace,
+ program,
+ partner: {
+ name: partner.name,
+ email: partner.email!,
+ },
+ key,
+ partnerId: partner.id,
+ userId,
+ });
+
+ return createLink(partnerLink);
+ } catch (error) {
+ console.error("Error creating partner link", error, link);
+ return null;
+ }
+}
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index d6e87f35fe4..501e6ff047f 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -24,3 +24,9 @@ export const partnerStackAffiliate = z.object({
country: z.string(),
}),
});
+
+export const partnerStackLink = z.object({
+ key: z.string(),
+ dest: z.string(),
+ url: z.string(),
+});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index 8d908a136d7..67a8e8f56f1 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import {
partnerStackAffiliate,
partnerStackImportPayloadSchema,
+ partnerStackLink,
} from "./schemas";
export interface PartnerStackConfig {
@@ -19,3 +20,5 @@ export type PartnerStackImportPayload = z.infer<
>;
export type PartnerStackAffiliate = z.infer;
+
+export type PartnerStackLink = z.infer;
diff --git a/apps/web/lib/tolt/import-links.ts b/apps/web/lib/tolt/import-links.ts
index 2c00df5600a..5ef653dc944 100644
--- a/apps/web/lib/tolt/import-links.ts
+++ b/apps/web/lib/tolt/import-links.ts
@@ -107,10 +107,12 @@ async function createPartnerLink({
partnerId: string;
userId: string;
}) {
- const linkFound = await prisma.link.findFirst({
+ const linkFound = await prisma.link.findUnique({
where: {
- domain: program.domain!,
- key: link.value,
+ domain_key: {
+ domain: program.domain!,
+ key: link.value,
+ },
},
select: {
partnerId: true,
From 7113d82cbebd381b39f9b219064d5d2061a24a70 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 13:09:22 +0530
Subject: [PATCH 10/31] Implement customer import functionality in PartnerStack
API and update related schemas
---
.../api/cron/import/partnerstack/route.ts | 6 +-
apps/web/lib/partnerstack/api.ts | 24 +-
apps/web/lib/partnerstack/import-customers.ts | 233 ++++++++++++++++++
apps/web/lib/partnerstack/import-referrals.ts | 0
apps/web/lib/partnerstack/schemas.ts | 15 +-
apps/web/lib/partnerstack/types.ts | 3 +
6 files changed, 276 insertions(+), 5 deletions(-)
create mode 100644 apps/web/lib/partnerstack/import-customers.ts
delete mode 100644 apps/web/lib/partnerstack/import-referrals.ts
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index 63730e65bf8..2c2b084cafc 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,10 +1,10 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { importAffiliates } from "@/lib/partnerstack/import-affiliates";
+import { importCustomers } from "@/lib/partnerstack/import-customers";
import { importLinks } from "@/lib/partnerstack/import-links";
import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
import { importCommissions } from "@/lib/tolt/import-commissions";
-import { importReferrals } from "@/lib/tolt/import-referrals";
import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers";
import { NextResponse } from "next/server";
@@ -28,8 +28,8 @@ export async function POST(req: Request) {
case "import-links":
await importLinks(payload);
break;
- case "import-referrals":
- await importReferrals(payload);
+ case "import-customers":
+ await importCustomers(payload);
break;
case "import-commissions":
await importCommissions(payload);
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index a0b9f2822f5..91e4c29b98f 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,6 +1,11 @@
-import { partnerStackAffiliate, partnerStackLink } from "./schemas";
+import {
+ partnerStackAffiliate,
+ partnerStackCustomer,
+ partnerStackLink,
+} from "./schemas";
import {
PartnerStackAffiliate,
+ PartnerStackCustomer,
PartnerStackLink,
PartnerStackListResponse,
} from "./types";
@@ -69,4 +74,21 @@ export class PartnerStackApi {
return partnerStackLink.array().parse(items);
}
+
+ async listCustomers({ startingAfter }: { startingAfter?: string }) {
+ const searchParams = new URLSearchParams();
+ searchParams.append("limit", PAGE_LIMIT.toString());
+
+ if (startingAfter) {
+ searchParams.append("starting_after", startingAfter);
+ }
+
+ const {
+ data: { items },
+ } = await this.fetch>(
+ `/customers?${searchParams.toString()}`,
+ );
+
+ return partnerStackCustomer.array().parse(items);
+ }
}
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
new file mode 100644
index 00000000000..b496530235d
--- /dev/null
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -0,0 +1,233 @@
+import { prisma } from "@dub/prisma";
+import { nanoid } from "@dub/utils";
+import { Link, Project } from "@prisma/client";
+import { createId } from "../api/create-id";
+import { recordClick, recordLeadWithTimestamp } from "../tinybird";
+import { clickEventSchemaTB } from "../zod/schemas/clicks";
+import { PartnerStackApi } from "./api";
+import { MAX_BATCHES, partnerStackImporter } from "./importer";
+import {
+ PartnerStackAffiliate,
+ PartnerStackCustomer,
+ PartnerStackImportPayload,
+} from "./types";
+
+export async function importCustomers(payload: PartnerStackImportPayload) {
+ const { programId, startingAfter } = payload;
+
+ const program = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ select: {
+ workspaceId: true,
+ },
+ });
+
+ const { token } = await partnerStackImporter.getCredentials(
+ program.workspaceId,
+ );
+
+ const partnerStackApi = new PartnerStackApi({
+ token,
+ });
+
+ let hasMore = true;
+ let processedBatches = 0;
+ let currentStartingAfter = startingAfter;
+
+ while (hasMore && processedBatches < MAX_BATCHES) {
+ const customers = await partnerStackApi.listCustomers({
+ startingAfter,
+ });
+
+ if (customers.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ const partners = await prisma.partner.findMany({
+ where: {
+ email: {
+ in: customers.map(({ partner }) => partner.email),
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ const programEnrollments = await prisma.programEnrollment.findMany({
+ where: {
+ partnerId: {
+ in: partners.map((partner) => partner.id),
+ },
+ programId,
+ },
+ select: {
+ partner: {
+ select: {
+ id: true,
+ email: true,
+ },
+ },
+ links: {
+ select: {
+ id: true,
+ key: true,
+ domain: true,
+ url: true,
+ },
+ },
+ },
+ });
+
+ const partnerEmailToLinks = new Map<
+ string,
+ (typeof programEnrollments)[0]["links"]
+ >();
+
+ for (const { partner, links } of programEnrollments) {
+ if (!partner.email) {
+ continue;
+ }
+
+ partnerEmailToLinks.set(partner.email, links);
+ }
+
+ await Promise.allSettled(
+ customers.map(({ partner, ...customer }) =>
+ createCustomer({
+ workspace,
+ customer,
+ partner,
+ links: partnerEmailToLinks.get(partner.email) ?? [],
+ }),
+ ),
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ processedBatches++;
+ currentStartingAfter = customers[customers.length - 1].key;
+ }
+
+ await partnerStackImporter.queue({
+ ...payload,
+ ...(hasMore && { startingAfter: currentStartingAfter }),
+ action: hasMore ? "import-customers" : "import-commissions",
+ });
+}
+
+async function createCustomer({
+ customer,
+ workspace,
+ links,
+ partner,
+}: {
+ customer: PartnerStackCustomer;
+ partner: PartnerStackAffiliate;
+ workspace: Pick;
+ links: Pick[];
+}) {
+ if (links.length === 0) {
+ console.log("Link not found for referral, skipping...", {
+ customerEmail: customer.email,
+ partnerEmail: partner.email,
+ });
+ return;
+ }
+
+ const customerFound = await prisma.customer.findUnique({
+ where: {
+ projectId_externalId: {
+ projectId: workspace.id,
+ externalId: customer.customer_id,
+ },
+ },
+ });
+
+ if (customerFound) {
+ console.log(
+ `A customer already exists with customer_id ${customer.customer_id}`,
+ );
+ return;
+ }
+
+ const link = links[0];
+
+ 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": "US",
+ "x-vercel-ip-country-region": "CA",
+ "x-vercel-ip-continent": "NA",
+ }),
+ });
+
+ const clickData = await recordClick({
+ req: dummyRequest,
+ linkId: link.id,
+ clickId: nanoid(16),
+ url: link.url,
+ domain: link.domain,
+ key: link.key,
+ workspaceId: workspace.id,
+ skipRatelimit: true,
+ timestamp: new Date(customer.created_at).toISOString(),
+ });
+
+ const clickEvent = clickEventSchemaTB.parse({
+ ...clickData,
+ bot: 0,
+ qr: 0,
+ });
+
+ const customerId = createId({ prefix: "cus_" });
+
+ try {
+ await prisma.customer.create({
+ data: {
+ id: customerId,
+ name:
+ // if name is null/undefined or starts with cus_, use email as name
+ !customer.name || customer.name.startsWith("cus_")
+ ? customer.email
+ : customer.name,
+ email: customer.email,
+ projectId: workspace.id,
+ projectConnectId: workspace.stripeConnectId,
+ clickId: clickEvent.click_id,
+ linkId: link.id,
+ country: clickEvent.country,
+ clickedAt: new Date(customer.created_at),
+ createdAt: new Date(customer.created_at),
+ externalId: customer.customer_id,
+ },
+ });
+
+ await Promise.all([
+ recordLeadWithTimestamp({
+ ...clickEvent,
+ event_id: nanoid(16),
+ event_name: "Sign up",
+ customer_id: customerId,
+ timestamp: new Date(customer.created_at).toISOString(),
+ }),
+
+ prisma.link.update({
+ where: {
+ id: link.id,
+ },
+ data: {
+ leads: {
+ increment: 1,
+ },
+ },
+ }),
+ ]);
+ } catch (error) {
+ console.error("Error creating customer", customer, error);
+ }
+}
diff --git a/apps/web/lib/partnerstack/import-referrals.ts b/apps/web/lib/partnerstack/import-referrals.ts
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index 501e6ff047f..d7069ee02df 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
export const partnerStackImportSteps = z.enum([
"import-affiliates",
"import-links",
- "import-referrals",
+ "import-customers",
"import-commissions",
"update-stripe-customers",
]);
@@ -30,3 +30,16 @@ export const partnerStackLink = z.object({
dest: z.string(),
url: z.string(),
});
+
+export const partnerStackCustomer = z.object({
+ key: z.string(),
+ customer_key: z.string().describe("External ID."),
+ name: z.string(),
+ email: z.string(),
+ provider_key: z
+ .string()
+ .describe("A unique identifier given by a payment provider."),
+ test: z.boolean().describe("True if created by a test partner."),
+ created_at: z.number(),
+ updated_at: z.number(),
+});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index 67a8e8f56f1..edcbbafe8af 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import {
partnerStackAffiliate,
+ partnerStackCustomer,
partnerStackImportPayloadSchema,
partnerStackLink,
} from "./schemas";
@@ -22,3 +23,5 @@ export type PartnerStackImportPayload = z.infer<
export type PartnerStackAffiliate = z.infer;
export type PartnerStackLink = z.infer;
+
+export type PartnerStackCustomer = z.infer;
From c62430104f9841902920e41fea7f780933d6b7de Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 13:24:11 +0530
Subject: [PATCH 11/31] Remove update-stripe-customers schema, add
partnerStackCommission schema, and update types to include new commission
type.
---
apps/web/lib/partnerstack/schemas.ts | 21 +++++++++++++++++--
apps/web/lib/partnerstack/types.ts | 3 +++
.../partnerstack/update-stripe-customers.ts | 0
3 files changed, 22 insertions(+), 2 deletions(-)
delete mode 100644 apps/web/lib/partnerstack/update-stripe-customers.ts
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index d7069ee02df..b3355b39458 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -5,7 +5,6 @@ export const partnerStackImportSteps = z.enum([
"import-links",
"import-customers",
"import-commissions",
- "update-stripe-customers",
]);
export const partnerStackImportPayloadSchema = z.object({
@@ -39,7 +38,25 @@ export const partnerStackCustomer = z.object({
provider_key: z
.string()
.describe("A unique identifier given by a payment provider."),
- test: z.boolean().describe("True if created by a test partner."),
created_at: z.number(),
updated_at: z.number(),
+ test: z.boolean().describe("True if created by a test."),
+});
+
+export const partnerStackCommission = z.object({
+ key: z.string(),
+ amount: z.number().describe("The amount of the reward in cents (USD)."),
+ currency: z.string(),
+ customer: z.object({
+ email: z.string(),
+ external_key: z.string(),
+ }),
+ invoice: z.object({
+ key: z.string(),
+ }),
+ transaction: z.object({
+ amount: z.number().describe("The amount of the transaction."),
+ }),
+ reward_status: z.enum(["hold", "pending", "approved", "declined", "paid"]),
+ test: z.boolean().describe("True if created by a test."),
});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index edcbbafe8af..d9d09f12450 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import {
partnerStackAffiliate,
+ partnerStackCommission,
partnerStackCustomer,
partnerStackImportPayloadSchema,
partnerStackLink,
@@ -25,3 +26,5 @@ export type PartnerStackAffiliate = z.infer;
export type PartnerStackLink = z.infer;
export type PartnerStackCustomer = z.infer;
+
+export type PartnerStackCommission = z.infer;
diff --git a/apps/web/lib/partnerstack/update-stripe-customers.ts b/apps/web/lib/partnerstack/update-stripe-customers.ts
deleted file mode 100644
index e69de29bb2d..00000000000
From 0f3443965c68abcd102e978856b701b7de8cd1b9 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 13:27:50 +0530
Subject: [PATCH 12/31] Implement importCommissions functionality in
PartnerStack API, update route to handle commission imports, and remove
unused update-stripe-customers case.
---
.../api/cron/import/partnerstack/route.ts | 6 +-
apps/web/lib/partnerstack/api.ts | 19 +++
.../lib/partnerstack/import-commissions.ts | 111 ++++++++++++++++++
3 files changed, 131 insertions(+), 5 deletions(-)
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index 2c2b084cafc..204a0265af7 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,11 +1,10 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { importAffiliates } from "@/lib/partnerstack/import-affiliates";
+import { importCommissions } from "@/lib/partnerstack/import-commissions";
import { importCustomers } from "@/lib/partnerstack/import-customers";
import { importLinks } from "@/lib/partnerstack/import-links";
import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
-import { importCommissions } from "@/lib/tolt/import-commissions";
-import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
@@ -34,9 +33,6 @@ export async function POST(req: Request) {
case "import-commissions":
await importCommissions(payload);
break;
- case "update-stripe-customers":
- await updateStripeCustomers(payload);
- break;
}
return NextResponse.json("OK");
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 91e4c29b98f..ea08951aea0 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,10 +1,12 @@
import {
partnerStackAffiliate,
+ partnerStackCommission,
partnerStackCustomer,
partnerStackLink,
} from "./schemas";
import {
PartnerStackAffiliate,
+ PartnerStackCommission,
PartnerStackCustomer,
PartnerStackLink,
PartnerStackListResponse,
@@ -91,4 +93,21 @@ export class PartnerStackApi {
return partnerStackCustomer.array().parse(items);
}
+
+ async listCommissions({ startingAfter }: { startingAfter?: string }) {
+ const searchParams = new URLSearchParams();
+ searchParams.append("limit", PAGE_LIMIT.toString());
+
+ if (startingAfter) {
+ searchParams.append("starting_after", startingAfter);
+ }
+
+ const {
+ data: { items },
+ } = await this.fetch>(
+ `/rewards?${searchParams.toString()}`,
+ );
+
+ return partnerStackCommission.array().parse(items);
+ }
}
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index e69de29bb2d..666ae400c79 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -0,0 +1,111 @@
+import { prisma } from "@dub/prisma";
+import { PartnerStackApi } from "./api";
+import { MAX_BATCHES, partnerStackImporter } from "./importer";
+import { PartnerStackImportPayload } from "./types";
+
+export async function importCommissions(payload: PartnerStackImportPayload) {
+ const { programId, startingAfter } = payload;
+
+ const program = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ select: {
+ workspaceId: true,
+ },
+ });
+
+ const { token } = await partnerStackImporter.getCredentials(
+ program.workspaceId,
+ );
+
+ const partnerStackApi = new PartnerStackApi({
+ token,
+ });
+
+ let hasMore = true;
+ let processedBatches = 0;
+ let currentStartingAfter = startingAfter;
+
+ while (hasMore && processedBatches < MAX_BATCHES) {
+ const commissions = await partnerStackApi.listCommissions({
+ startingAfter,
+ });
+
+ if (commissions.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ const partners = await prisma.partner.findMany({
+ where: {
+ email: {
+ in: commissions.map(({ partner }) => partner.email),
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ const programEnrollments = await prisma.programEnrollment.findMany({
+ where: {
+ partnerId: {
+ in: partners.map((partner) => partner.id),
+ },
+ programId,
+ },
+ select: {
+ partner: {
+ select: {
+ id: true,
+ email: true,
+ },
+ },
+ links: {
+ select: {
+ id: true,
+ key: true,
+ domain: true,
+ url: true,
+ },
+ },
+ },
+ });
+
+ const partnerEmailToLinks = new Map<
+ string,
+ (typeof programEnrollments)[0]["links"]
+ >();
+
+ for (const { partner, links } of programEnrollments) {
+ if (!partner.email) {
+ continue;
+ }
+
+ partnerEmailToLinks.set(partner.email, links);
+ }
+
+ await Promise.allSettled(
+ commissions.map(({ partner, ...customer }) =>
+ createCustomer({
+ workspace,
+ customer,
+ partner,
+ links: partnerEmailToLinks.get(partner.email) ?? [],
+ }),
+ ),
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ processedBatches++;
+ currentStartingAfter = commissions[commissions.length - 1].key;
+ }
+
+ await partnerStackImporter.queue({
+ ...payload,
+ ...(hasMore && { startingAfter: currentStartingAfter }),
+ action: hasMore ? "import-customers" : "import-commissions",
+ });
+}
From 57f246e28bb5824a638668b1662f6c356197aec7 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 21:37:26 +0530
Subject: [PATCH 13/31] Refactor PartnerStack import to use public and secret
keys
---
.../partners/start-partnerstack-import.ts | 15 +++++--
apps/web/lib/partnerstack/api.ts | 24 ++++++++---
.../lib/partnerstack/import-commissions.ts | 5 ++-
apps/web/lib/partnerstack/import-customers.ts | 5 ++-
apps/web/lib/partnerstack/import-links.ts | 7 ++--
...mport-affiliates.ts => import-partners.ts} | 33 ++++++++-------
apps/web/lib/partnerstack/importer.ts | 2 +-
apps/web/lib/partnerstack/types.ts | 3 +-
.../ui/modals/import-partnerstack-modal.tsx | 42 ++++++++++++++-----
9 files changed, 92 insertions(+), 44 deletions(-)
rename apps/web/lib/partnerstack/{import-affiliates.ts => import-partners.ts} (79%)
diff --git a/apps/web/lib/actions/partners/start-partnerstack-import.ts b/apps/web/lib/actions/partners/start-partnerstack-import.ts
index e3e07760eae..f07c004b055 100644
--- a/apps/web/lib/actions/partners/start-partnerstack-import.ts
+++ b/apps/web/lib/actions/partners/start-partnerstack-import.ts
@@ -9,14 +9,15 @@ import { authActionClient } from "../safe-action";
const schema = z.object({
workspaceId: z.string(),
- token: z.string().min(1),
+ publicKey: z.string().min(1),
+ secretKey: z.string().min(1),
});
export const startPartnerStackImportAction = authActionClient
.schema(schema)
.action(async ({ ctx, parsedInput }) => {
const { workspace, user } = ctx;
- const { token } = parsedInput;
+ const { publicKey, secretKey } = parsedInput;
const programId = getDefaultProgramIdOrThrow(workspace);
@@ -34,14 +35,20 @@ export const startPartnerStackImportAction = authActionClient
}
const partnerStackApi = new PartnerStackApi({
- token,
+ publicKey,
+ secretKey,
});
await partnerStackApi.testConnection();
+ await partnerStackImporter.setCredentials(workspace.id, {
+ publicKey,
+ secretKey,
+ });
+
await partnerStackImporter.queue({
programId,
userId: user.id,
- action: "import-affiliates",
+ action: "import-partners",
});
});
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index ea08951aea0..7147be77dad 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -12,20 +12,32 @@ import {
PartnerStackListResponse,
} from "./types";
-const PAGE_LIMIT = 100;
+const PAGE_LIMIT = 50;
export class PartnerStackApi {
private readonly baseUrl = "https://api.partnerstack.com/api/v2";
- private readonly token: string;
-
- constructor({ token }: { token: string }) {
- this.token = token;
+ private readonly publicKey: string;
+ private readonly secretKey: string;
+
+ constructor({
+ publicKey,
+ secretKey,
+ }: {
+ publicKey: string;
+ secretKey: string;
+ }) {
+ this.publicKey = publicKey;
+ this.secretKey = secretKey;
}
private async fetch(path: string): Promise {
+ const token = Buffer.from(`${this.publicKey}:${this.secretKey}`).toString(
+ "base64",
+ );
+
const response = await fetch(`${this.baseUrl}${path}`, {
headers: {
- Authorization: `Bearer ${this.token}`,
+ Authorization: `Basic ${token}`,
},
});
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index 666ae400c79..e06f753cae4 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -15,12 +15,13 @@ export async function importCommissions(payload: PartnerStackImportPayload) {
},
});
- const { token } = await partnerStackImporter.getCredentials(
+ const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
program.workspaceId,
);
const partnerStackApi = new PartnerStackApi({
- token,
+ publicKey,
+ secretKey,
});
let hasMore = true;
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index b496530235d..f58bf5e8abd 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -24,12 +24,13 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
},
});
- const { token } = await partnerStackImporter.getCredentials(
+ const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
program.workspaceId,
);
const partnerStackApi = new PartnerStackApi({
- token,
+ publicKey,
+ secretKey,
});
let hasMore = true;
diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts
index 973a82ae955..a4373701b55 100644
--- a/apps/web/lib/partnerstack/import-links.ts
+++ b/apps/web/lib/partnerstack/import-links.ts
@@ -18,12 +18,13 @@ export async function importLinks(payload: PartnerStackImportPayload) {
},
});
- const { token } = await partnerStackImporter.getCredentials(
+ const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
program.workspaceId,
);
const partnerStackApi = new PartnerStackApi({
- token,
+ publicKey,
+ secretKey,
});
let hasMore = true;
@@ -89,7 +90,7 @@ export async function importLinks(payload: PartnerStackImportPayload) {
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
- action: hasMore ? "import-links" : "import-referrals",
+ action: hasMore ? "import-links" : "import-customers",
});
}
diff --git a/apps/web/lib/partnerstack/import-affiliates.ts b/apps/web/lib/partnerstack/import-partners.ts
similarity index 79%
rename from apps/web/lib/partnerstack/import-affiliates.ts
rename to apps/web/lib/partnerstack/import-partners.ts
index ed8a841630f..306d82c220a 100644
--- a/apps/web/lib/partnerstack/import-affiliates.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -1,13 +1,13 @@
import { prisma } from "@dub/prisma";
import { Program, Reward } from "@dub/prisma/client";
-import { COUNTRY_CODES } from "@dub/utils";
+import { COUNTRIES } from "@dub/utils";
import { createId } from "../api/create-id";
import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards";
import { PartnerStackApi } from "./api";
import { MAX_BATCHES, partnerStackImporter } from "./importer";
import { PartnerStackAffiliate, PartnerStackImportPayload } from "./types";
-export async function importAffiliates(payload: PartnerStackImportPayload) {
+export async function importPartners(payload: PartnerStackImportPayload) {
const { programId, startingAfter } = payload;
const program = await prisma.program.findUniqueOrThrow({
@@ -23,12 +23,13 @@ export async function importAffiliates(payload: PartnerStackImportPayload) {
},
});
- const { token } = await partnerStackImporter.getCredentials(
+ const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
program.workspaceId,
);
const partnerStackApi = new PartnerStackApi({
- token,
+ publicKey,
+ secretKey,
});
const saleReward = program.rewards.find((r) => r.event === "sale");
@@ -50,9 +51,13 @@ export async function importAffiliates(payload: PartnerStackImportPayload) {
break;
}
- if (affiliates.length > 0) {
+ const activeAffiliates = affiliates.filter(
+ (affiliate) => affiliate.stats.CUSTOMER_COUNT > 0,
+ );
+
+ if (activeAffiliates.length > 0) {
await Promise.allSettled(
- affiliates.map((affiliate) =>
+ activeAffiliates.map((affiliate) =>
createPartner({
program,
affiliate,
@@ -60,9 +65,6 @@ export async function importAffiliates(payload: PartnerStackImportPayload) {
}),
),
);
-
- // TODO:
- // Remove the partners with 0 leads
}
await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -74,11 +76,10 @@ export async function importAffiliates(payload: PartnerStackImportPayload) {
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
- action: hasMore ? "import-affiliates" : "import-links",
+ action: hasMore ? "import-partners" : "import-links",
});
}
-// Create partner
async function createPartner({
program,
affiliate,
@@ -88,6 +89,12 @@ async function createPartner({
affiliate: PartnerStackAffiliate;
reward?: Pick;
}) {
+ const countryCode = affiliate.address?.country
+ ? Object.keys(COUNTRIES).find(
+ (key) => COUNTRIES[key] === affiliate.address?.country,
+ )
+ : null;
+
const partner = await prisma.partner.upsert({
where: {
email: affiliate.email,
@@ -96,7 +103,7 @@ async function createPartner({
id: createId({ prefix: "pn_" }),
name: `${affiliate.first_name} ${affiliate.last_name}`,
email: affiliate.email,
- country: COUNTRY_CODES[affiliate.address.country],
+ country: countryCode,
},
update: {
// do nothing
@@ -120,6 +127,4 @@ async function createPartner({
status: "approved",
},
});
-
- return partner;
}
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index a8cf4759e65..e95d5ca5b09 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -5,7 +5,7 @@ import { z } from "zod";
import { partnerStackImportPayloadSchema } from "./schemas";
import { PartnerStackConfig } from "./types";
-export const MAX_BATCHES = 5;
+export const MAX_BATCHES = 1;
export const CACHE_EXPIRY = 60 * 60 * 24;
export const CACHE_KEY_PREFIX = "partnerstack:import";
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index d9d09f12450..5954ebd443b 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -8,7 +8,8 @@ import {
} from "./schemas";
export interface PartnerStackConfig {
- token: string;
+ publicKey: string;
+ secretKey: string;
}
export interface PartnerStackListResponse {
diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx
index 46df2c121a9..8d8c836d2c3 100644
--- a/apps/web/ui/modals/import-partnerstack-modal.tsx
+++ b/apps/web/ui/modals/import-partnerstack-modal.tsx
@@ -77,7 +77,8 @@ function ImportPartnerStackModal({
function TokenForm({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { isMobile } = useMediaQuery();
- const [token, setToken] = useState("");
+ const [publicKey, setPublicKey] = useState("");
+ const [secretKey, setSecretKey] = useState("");
const { id: workspaceId, slug } = useWorkspace();
const { executeAsync, isPending } = useAction(startPartnerStackImportAction, {
@@ -96,13 +97,14 @@ function TokenForm({ onClose }: { onClose: () => void }) {
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!workspaceId || !token) {
+ if (!workspaceId || !publicKey || !secretKey) {
return;
}
await executeAsync({
workspaceId,
- token,
+ publicKey,
+ secretKey,
});
};
@@ -110,37 +112,55 @@ function TokenForm({ onClose }: { onClose: () => void }) {
);
From 3d347ba8823fa013b874388bcea07c6c775deb62 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Mon, 14 Jul 2025 22:12:32 +0530
Subject: [PATCH 14/31] fix import-links
---
.../api/cron/import/partnerstack/route.ts | 13 +++----
apps/web/lib/partnerstack/api.ts | 10 ++---
apps/web/lib/partnerstack/import-customers.ts | 4 +-
apps/web/lib/partnerstack/import-links.ts | 10 +++--
apps/web/lib/partnerstack/import-partners.ts | 38 +++++++++----------
apps/web/lib/partnerstack/schemas.ts | 15 ++++++--
apps/web/lib/partnerstack/types.ts | 4 +-
.../lib/planetscale/get-partner-discount.ts | 4 --
8 files changed, 51 insertions(+), 47 deletions(-)
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index 204a0265af7..42612d1153e 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,9 +1,8 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
-import { importAffiliates } from "@/lib/partnerstack/import-affiliates";
-import { importCommissions } from "@/lib/partnerstack/import-commissions";
import { importCustomers } from "@/lib/partnerstack/import-customers";
import { importLinks } from "@/lib/partnerstack/import-links";
+import { importPartners } from "@/lib/partnerstack/import-partners";
import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
import { NextResponse } from "next/server";
@@ -21,8 +20,8 @@ export async function POST(req: Request) {
const payload = partnerStackImportPayloadSchema.parse(JSON.parse(rawBody));
switch (payload.action) {
- case "import-affiliates":
- await importAffiliates(payload);
+ case "import-partners":
+ await importPartners(payload);
break;
case "import-links":
await importLinks(payload);
@@ -30,9 +29,9 @@ export async function POST(req: Request) {
case "import-customers":
await importCustomers(payload);
break;
- case "import-commissions":
- await importCommissions(payload);
- break;
+ // case "import-commissions":
+ // await importCommissions(payload);
+ // break;
}
return NextResponse.json("OK");
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 7147be77dad..d1fbb7df75e 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,11 +1,11 @@
import {
- partnerStackAffiliate,
+ partnerStackPartner,
partnerStackCommission,
partnerStackCustomer,
partnerStackLink,
} from "./schemas";
import {
- PartnerStackAffiliate,
+ PartnerStackPartner,
PartnerStackCommission,
PartnerStackCustomer,
PartnerStackLink,
@@ -61,7 +61,7 @@ export class PartnerStackApi {
}
}
- async listAffiliates({ startingAfter }: { startingAfter?: string }) {
+ async listPartners({ startingAfter }: { startingAfter?: string }) {
const searchParams = new URLSearchParams();
searchParams.append("approved_status", "approved");
searchParams.append("limit", PAGE_LIMIT.toString());
@@ -72,11 +72,11 @@ export class PartnerStackApi {
const {
data: { items },
- } = await this.fetch>(
+ } = await this.fetch>(
`/partnerships?${searchParams.toString()}`,
);
- return partnerStackAffiliate.array().parse(items);
+ return partnerStackPartner.array().parse(items);
}
async listLinks({ identifier }: { identifier: string }) {
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index f58bf5e8abd..05a8b253c70 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -7,7 +7,7 @@ import { clickEventSchemaTB } from "../zod/schemas/clicks";
import { PartnerStackApi } from "./api";
import { MAX_BATCHES, partnerStackImporter } from "./importer";
import {
- PartnerStackAffiliate,
+ PartnerStackPartner,
PartnerStackCustomer,
PartnerStackImportPayload,
} from "./types";
@@ -127,7 +127,7 @@ async function createCustomer({
partner,
}: {
customer: PartnerStackCustomer;
- partner: PartnerStackAffiliate;
+ partner: PartnerStackPartner;
workspace: Pick;
links: Pick[];
}) {
diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts
index a4373701b55..84322e2512b 100644
--- a/apps/web/lib/partnerstack/import-links.ts
+++ b/apps/web/lib/partnerstack/import-links.ts
@@ -44,6 +44,7 @@ export async function importLinks(payload: PartnerStackImportPayload) {
},
},
},
+ take: 50,
skip: currentStartingAfter ? 1 : 0,
...(currentStartingAfter && {
cursor: {
@@ -54,7 +55,8 @@ export async function importLinks(payload: PartnerStackImportPayload) {
if (enrollments.length === 0) {
hasMore = false;
- console.log("No enrollments found.");
+ } else {
+ currentStartingAfter = enrollments[enrollments.length - 1].id;
}
for (const { partner } of enrollments) {
@@ -64,7 +66,7 @@ export async function importLinks(payload: PartnerStackImportPayload) {
}
const links = await partnerStackApi.listLinks({
- identifier: partner.email!,
+ identifier: partner.email,
});
if (links.length === 0) {
@@ -84,7 +86,7 @@ export async function importLinks(payload: PartnerStackImportPayload) {
),
);
- currentStartingAfter = enrollments[enrollments.length - 1].id;
+ await new Promise((resolve) => setTimeout(resolve, 100));
}
await partnerStackImporter.queue({
@@ -110,7 +112,7 @@ async function createPartnerLink({
const key = link.url.split("/").pop();
if (!key) {
- console.error("No key found in the link", link);
+ console.error(`No key found in the link ${link.url}`);
return null;
}
diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts
index 306d82c220a..6992a73fb3c 100644
--- a/apps/web/lib/partnerstack/import-partners.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -5,7 +5,7 @@ import { createId } from "../api/create-id";
import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards";
import { PartnerStackApi } from "./api";
import { MAX_BATCHES, partnerStackImporter } from "./importer";
-import { PartnerStackAffiliate, PartnerStackImportPayload } from "./types";
+import { PartnerStackImportPayload, PartnerStackPartner } from "./types";
export async function importPartners(payload: PartnerStackImportPayload) {
const { programId, startingAfter } = payload;
@@ -42,25 +42,25 @@ export async function importPartners(payload: PartnerStackImportPayload) {
let currentStartingAfter = startingAfter;
while (hasMore && processedBatches < MAX_BATCHES) {
- const affiliates = await partnerStackApi.listAffiliates({
+ const partners = await partnerStackApi.listPartners({
startingAfter: currentStartingAfter,
});
- if (affiliates.length === 0) {
+ if (partners.length === 0) {
hasMore = false;
break;
}
- const activeAffiliates = affiliates.filter(
- (affiliate) => affiliate.stats.CUSTOMER_COUNT > 0,
+ const activePartners = partners.filter(
+ ({ stats }) => stats.CUSTOMER_COUNT > 0,
);
- if (activeAffiliates.length > 0) {
+ if (activePartners.length > 0) {
await Promise.allSettled(
- activeAffiliates.map((affiliate) =>
+ activePartners.map((partner) =>
createPartner({
program,
- affiliate,
+ partner,
reward,
}),
),
@@ -70,7 +70,7 @@ export async function importPartners(payload: PartnerStackImportPayload) {
await new Promise((resolve) => setTimeout(resolve, 2000));
processedBatches++;
- currentStartingAfter = affiliates[affiliates.length - 1].key;
+ currentStartingAfter = partners[partners.length - 1].key;
}
await partnerStackImporter.queue({
@@ -82,27 +82,27 @@ export async function importPartners(payload: PartnerStackImportPayload) {
async function createPartner({
program,
- affiliate,
+ partner,
reward,
}: {
program: Program;
- affiliate: PartnerStackAffiliate;
+ partner: PartnerStackPartner;
reward?: Pick;
}) {
- const countryCode = affiliate.address?.country
+ const countryCode = partner.address?.country
? Object.keys(COUNTRIES).find(
- (key) => COUNTRIES[key] === affiliate.address?.country,
+ (key) => COUNTRIES[key] === partner.address?.country,
)
: null;
- const partner = await prisma.partner.upsert({
+ const { id: partnerId } = await prisma.partner.upsert({
where: {
- email: affiliate.email,
+ email: partner.email,
},
create: {
id: createId({ prefix: "pn_" }),
- name: `${affiliate.first_name} ${affiliate.last_name}`,
- email: affiliate.email,
+ name: `${partner.first_name} ${partner.last_name}`,
+ email: partner.email,
country: countryCode,
},
update: {
@@ -113,13 +113,13 @@ async function createPartner({
await prisma.programEnrollment.upsert({
where: {
partnerId_programId: {
- partnerId: partner.id,
+ partnerId,
programId: program.id,
},
},
create: {
programId: program.id,
- partnerId: partner.id,
+ partnerId,
status: "approved",
...(reward && { [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id }),
},
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index b3355b39458..a116e541c5d 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
export const partnerStackImportSteps = z.enum([
- "import-affiliates",
+ "import-partners",
"import-links",
"import-customers",
"import-commissions",
@@ -14,13 +14,20 @@ export const partnerStackImportPayloadSchema = z.object({
startingAfter: z.string().optional(),
});
-export const partnerStackAffiliate = z.object({
+export const partnerStackPartner = z.object({
key: z.string(),
email: z.string(),
first_name: z.string(),
last_name: z.string(),
- address: z.object({
- country: z.string(),
+ address: z
+ .object({
+ country: z.string().nullable(),
+ })
+ .nullable(),
+ stats: z.object({
+ CUSTOMER_COUNT: z
+ .number()
+ .describe("Only import if CUSTOMER_COUNT is greater than 0."),
}),
});
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index 5954ebd443b..ce46dd0af25 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
import {
- partnerStackAffiliate,
+ partnerStackPartner,
partnerStackCommission,
partnerStackCustomer,
partnerStackImportPayloadSchema,
@@ -22,7 +22,7 @@ export type PartnerStackImportPayload = z.infer<
typeof partnerStackImportPayloadSchema
>;
-export type PartnerStackAffiliate = z.infer;
+export type PartnerStackPartner = z.infer;
export type PartnerStackLink = z.infer;
diff --git a/apps/web/lib/planetscale/get-partner-discount.ts b/apps/web/lib/planetscale/get-partner-discount.ts
index b541a91c3c0..abf3ec0d8a6 100644
--- a/apps/web/lib/planetscale/get-partner-discount.ts
+++ b/apps/web/lib/planetscale/get-partner-discount.ts
@@ -27,8 +27,6 @@ export const getPartnerAndDiscount = async ({
};
}
- console.time("getPartnerAndDiscount");
-
const { rows } = await conn.execute(
`SELECT
Partner.id,
@@ -47,8 +45,6 @@ export const getPartnerAndDiscount = async ({
[partnerId, programId],
);
- console.timeEnd("getPartnerAndDiscount");
-
const result =
rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
From 3f0bf93190de23c94315558263a71a024a7b8c5f Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 01:04:51 +0530
Subject: [PATCH 15/31] fix import-customers
---
apps/web/lib/partnerstack/import-customers.ts | 159 +++++++++---------
apps/web/lib/partnerstack/import-links.ts | 2 +
apps/web/lib/partnerstack/import-partners.ts | 16 +-
apps/web/lib/partnerstack/importer.ts | 5 +-
apps/web/lib/partnerstack/schemas.ts | 9 +-
5 files changed, 111 insertions(+), 80 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index 05a8b253c70..b3da61723a6 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -3,14 +3,15 @@ import { nanoid } from "@dub/utils";
import { Link, Project } from "@prisma/client";
import { createId } from "../api/create-id";
import { recordClick, recordLeadWithTimestamp } from "../tinybird";
+import { redis } from "../upstash";
import { clickEventSchemaTB } from "../zod/schemas/clicks";
import { PartnerStackApi } from "./api";
-import { MAX_BATCHES, partnerStackImporter } from "./importer";
import {
- PartnerStackPartner,
- PartnerStackCustomer,
- PartnerStackImportPayload,
-} from "./types";
+ MAX_BATCHES,
+ PARTNER_IDS_KEY_PREFIX,
+ partnerStackImporter,
+} from "./importer";
+import { PartnerStackCustomer, PartnerStackImportPayload } from "./types";
export async function importCustomers(payload: PartnerStackImportPayload) {
const { programId, startingAfter } = payload;
@@ -20,12 +21,17 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
id: programId,
},
select: {
- workspaceId: true,
+ workspace: {
+ select: {
+ id: true,
+ stripeConnectId: true,
+ },
+ },
},
});
const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
- program.workspaceId,
+ program.workspace.id,
);
const partnerStackApi = new PartnerStackApi({
@@ -38,81 +44,88 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
let currentStartingAfter = startingAfter;
while (hasMore && processedBatches < MAX_BATCHES) {
- const customers = await partnerStackApi.listCustomers({
+ let customers = await partnerStackApi.listCustomers({
startingAfter,
});
+ customers = customers.filter(({ test }) => !test);
+
if (customers.length === 0) {
hasMore = false;
break;
}
- const partners = await prisma.partner.findMany({
- where: {
- email: {
- in: customers.map(({ partner }) => partner.email),
- },
- },
- select: {
- id: true,
- },
- });
+ const partnerKeys = [
+ ...new Set(customers.map(({ partnership_key }) => partnership_key)),
+ ];
- const programEnrollments = await prisma.programEnrollment.findMany({
- where: {
- partnerId: {
- in: partners.map((partner) => partner.id),
- },
- programId,
- },
- select: {
- partner: {
- select: {
- id: true,
- email: true,
+ let partnerKeysToId =
+ (await redis.hmget>(
+ `${PARTNER_IDS_KEY_PREFIX}:${programId}`,
+ ...partnerKeys,
+ )) || {};
+
+ partnerKeysToId = Object.fromEntries(
+ Object.entries(partnerKeysToId).filter(([_, id]) => id !== null),
+ );
+
+ const partnerIds = Object.values(partnerKeysToId).filter(
+ (id): id is string => id !== null,
+ );
+
+ if (partnerIds.length > 0) {
+ const programEnrollments = await prisma.programEnrollment.findMany({
+ where: {
+ partnerId: {
+ in: partnerIds,
},
+ programId,
},
- links: {
- select: {
- id: true,
- key: true,
- domain: true,
- url: true,
+ select: {
+ partnerId: true,
+ links: {
+ select: {
+ id: true,
+ key: true,
+ domain: true,
+ url: true,
+ },
},
},
- },
- });
+ });
- const partnerEmailToLinks = new Map<
- string,
- (typeof programEnrollments)[0]["links"]
- >();
+ const partnerIdToLinks = new Map<
+ string,
+ (typeof programEnrollments)[number]["links"]
+ >();
- for (const { partner, links } of programEnrollments) {
- if (!partner.email) {
- continue;
+ for (const { partnerId, links } of programEnrollments) {
+ const existing = partnerIdToLinks.get(partnerId) ?? [];
+ partnerIdToLinks.set(partnerId, [...existing, ...links]);
}
- partnerEmailToLinks.set(partner.email, links);
- }
+ await Promise.allSettled(
+ customers.map((customer) => {
+ const partnerId = partnerKeysToId[customer.partnership_key];
+ const links = partnerId ? partnerIdToLinks.get(partnerId) ?? [] : [];
- await Promise.allSettled(
- customers.map(({ partner, ...customer }) =>
- createCustomer({
- workspace,
- customer,
- partner,
- links: partnerEmailToLinks.get(partner.email) ?? [],
+ return createCustomer({
+ workspace: program.workspace,
+ links,
+ customer,
+ });
}),
- ),
- );
-
- await new Promise((resolve) => setTimeout(resolve, 2000));
+ );
+ }
processedBatches++;
currentStartingAfter = customers[customers.length - 1].key;
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
}
+ delete payload?.startingAfter;
+
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
@@ -121,37 +134,33 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
}
async function createCustomer({
- customer,
workspace,
links,
- partner,
+ customer,
}: {
- customer: PartnerStackCustomer;
- partner: PartnerStackPartner;
workspace: Pick;
links: Pick[];
+ customer: PartnerStackCustomer;
}) {
if (links.length === 0) {
- console.log("Link not found for referral, skipping...", {
- customerEmail: customer.email,
- partnerEmail: partner.email,
- });
+ console.log("Link not found for customer, skipping...");
return;
}
- const customerFound = await prisma.customer.findUnique({
+ if (!customer.email) {
+ console.log("Customer email not found, skipping...");
+ return;
+ }
+
+ const customerFound = await prisma.customer.findFirst({
where: {
- projectId_externalId: {
- projectId: workspace.id,
- externalId: customer.customer_id,
- },
+ projectId: workspace.id,
+ email: customer.email,
},
});
if (customerFound) {
- console.log(
- `A customer already exists with customer_id ${customer.customer_id}`,
- );
+ console.log(`A customer already exists with email ${customer.email}`);
return;
}
@@ -204,7 +213,7 @@ async function createCustomer({
country: clickEvent.country,
clickedAt: new Date(customer.created_at),
createdAt: new Date(customer.created_at),
- externalId: customer.customer_id,
+ externalId: customer.customer_key,
},
});
diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts
index 84322e2512b..e7ca4050f4c 100644
--- a/apps/web/lib/partnerstack/import-links.ts
+++ b/apps/web/lib/partnerstack/import-links.ts
@@ -89,6 +89,8 @@ export async function importLinks(payload: PartnerStackImportPayload) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
+ delete payload?.startingAfter;
+
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts
index 6992a73fb3c..3a0017425f0 100644
--- a/apps/web/lib/partnerstack/import-partners.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -2,9 +2,14 @@ import { prisma } from "@dub/prisma";
import { Program, Reward } from "@dub/prisma/client";
import { COUNTRIES } from "@dub/utils";
import { createId } from "../api/create-id";
+import { redis } from "../upstash";
import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards";
import { PartnerStackApi } from "./api";
-import { MAX_BATCHES, partnerStackImporter } from "./importer";
+import {
+ MAX_BATCHES,
+ PARTNER_IDS_KEY_PREFIX,
+ partnerStackImporter,
+} from "./importer";
import { PartnerStackImportPayload, PartnerStackPartner } from "./types";
export async function importPartners(payload: PartnerStackImportPayload) {
@@ -73,6 +78,8 @@ export async function importPartners(payload: PartnerStackImportPayload) {
currentStartingAfter = partners[partners.length - 1].key;
}
+ delete payload?.startingAfter;
+
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
@@ -127,4 +134,11 @@ async function createPartner({
status: "approved",
},
});
+
+ // PS doesn't return the partner email address in the customers response
+ // so we need to update keep a map of partner_key (PS) -> partner_id (Dub)
+ // and use it to identify the partner in the customers response
+ await redis.hset(`${PARTNER_IDS_KEY_PREFIX}:${program.id}`, {
+ [partner.key]: partnerId,
+ });
}
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index e95d5ca5b09..4afc18b087d 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -5,9 +5,10 @@ import { z } from "zod";
import { partnerStackImportPayloadSchema } from "./schemas";
import { PartnerStackConfig } from "./types";
-export const MAX_BATCHES = 1;
+export const MAX_BATCHES = 5;
export const CACHE_EXPIRY = 60 * 60 * 24;
-export const CACHE_KEY_PREFIX = "partnerstack:import";
+export const CACHE_KEY_PREFIX = "partnerstack:import"; // Fix this
+export const PARTNER_IDS_KEY_PREFIX = "partnerstack:import:partner_ids"; // Fix this
class PartnerStackImporter {
async setCredentials(workspaceId: string, payload: PartnerStackConfig) {
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index a116e541c5d..476b6496183 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -39,15 +39,20 @@ export const partnerStackLink = z.object({
export const partnerStackCustomer = z.object({
key: z.string(),
- customer_key: z.string().describe("External ID."),
name: z.string(),
email: z.string(),
provider_key: z
.string()
+ .nullable()
.describe("A unique identifier given by a payment provider."),
+ customer_key: z
+ .string()
+ .nullable()
+ .describe("External customer key that can be configured on creation."),
+ test: z.boolean().describe("True if created by a test."),
+ partnership_key: z.string(),
created_at: z.number(),
updated_at: z.number(),
- test: z.boolean().describe("True if created by a test."),
});
export const partnerStackCommission = z.object({
From d56aa35612887667fb2425254909b1f72334cade Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 01:22:53 +0530
Subject: [PATCH 16/31] Update api.ts
---
apps/web/lib/partnerstack/api.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index d1fbb7df75e..9926a6960aa 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -12,7 +12,7 @@ import {
PartnerStackListResponse,
} from "./types";
-const PAGE_LIMIT = 50;
+const PAGE_LIMIT = 100;
export class PartnerStackApi {
private readonly baseUrl = "https://api.partnerstack.com/api/v2";
From 04372492f3954bf1dd2a636a79846a1807bf9006 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 01:23:36 +0530
Subject: [PATCH 17/31] Update import-customers.ts
---
apps/web/lib/partnerstack/import-customers.ts | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index b3da61723a6..98f10844d35 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -45,7 +45,7 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
while (hasMore && processedBatches < MAX_BATCHES) {
let customers = await partnerStackApi.listCustomers({
- startingAfter,
+ startingAfter: currentStartingAfter,
});
customers = customers.filter(({ test }) => !test);
@@ -118,10 +118,10 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
);
}
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
processedBatches++;
currentStartingAfter = customers[customers.length - 1].key;
-
- await new Promise((resolve) => setTimeout(resolve, 2000));
}
delete payload?.startingAfter;
@@ -143,7 +143,9 @@ async function createCustomer({
customer: PartnerStackCustomer;
}) {
if (links.length === 0) {
- console.log("Link not found for customer, skipping...");
+ console.log(
+ `Link not found for customer. See the details at https://app.partnerstack.com/partners/${customer.partnership_key}/details`,
+ );
return;
}
From 68d5694fb125c9d4bddaebdfb0a6bc3d5f4bfb71 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 09:29:15 +0530
Subject: [PATCH 18/31] wip import commissions
---
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts | 7 ++++---
apps/web/lib/partnerstack/import-commissions.ts | 2 +-
apps/web/lib/partnerstack/import-customers.ts | 4 ++++
apps/web/lib/partnerstack/schemas.ts | 4 +++-
4 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index 42612d1153e..ab0dab99c52 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -1,5 +1,6 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
+import { importCommissions } from "@/lib/partnerstack/import-commissions";
import { importCustomers } from "@/lib/partnerstack/import-customers";
import { importLinks } from "@/lib/partnerstack/import-links";
import { importPartners } from "@/lib/partnerstack/import-partners";
@@ -29,9 +30,9 @@ export async function POST(req: Request) {
case "import-customers":
await importCustomers(payload);
break;
- // case "import-commissions":
- // await importCommissions(payload);
- // break;
+ case "import-commissions":
+ await importCommissions(payload);
+ break;
}
return NextResponse.json("OK");
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index e06f753cae4..0db4e3c87f5 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -30,7 +30,7 @@ export async function importCommissions(payload: PartnerStackImportPayload) {
while (hasMore && processedBatches < MAX_BATCHES) {
const commissions = await partnerStackApi.listCommissions({
- startingAfter,
+ startingAfter: currentStartingAfter,
});
if (commissions.length === 0) {
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index 98f10844d35..7c49c8dc682 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -131,6 +131,10 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
...(hasMore && { startingAfter: currentStartingAfter }),
action: hasMore ? "import-customers" : "import-commissions",
});
+
+ if (!hasMore) {
+ await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`);
+ }
}
async function createCustomer({
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index 476b6496183..6b16f38168e 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -57,7 +57,9 @@ export const partnerStackCustomer = z.object({
export const partnerStackCommission = z.object({
key: z.string(),
- amount: z.number().describe("The amount of the reward in cents (USD)."),
+ amount_usd: z.number().describe("The amount of the reward in cents (USD)."),
+ approved: z.boolean(),
+ created_at: z.string(),
currency: z.string(),
customer: z.object({
email: z.string(),
From d60c05992bcf13164410f6e37a8c15e4cd489553 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 12:17:40 +0530
Subject: [PATCH 19/31] Add updateStripeCustomers functionality to PartnerStack
import process
---
.../api/cron/import/partnerstack/route.ts | 4 +
apps/web/lib/partnerstack/api.ts | 4 +-
.../lib/partnerstack/import-commissions.ts | 277 ++++++++++++++----
apps/web/lib/partnerstack/schemas.ts | 30 +-
.../partnerstack/update-stripe-customers.ts | 176 +++++++++++
.../email/src/templates/campaign-imported.tsx | 2 +-
6 files changed, 412 insertions(+), 81 deletions(-)
create mode 100644 apps/web/lib/partnerstack/update-stripe-customers.ts
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index ab0dab99c52..ca23b0aa9d5 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -5,6 +5,7 @@ import { importCustomers } from "@/lib/partnerstack/import-customers";
import { importLinks } from "@/lib/partnerstack/import-links";
import { importPartners } from "@/lib/partnerstack/import-partners";
import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas";
+import { updateStripeCustomers } from "@/lib/partnerstack/update-stripe-customers";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
@@ -33,6 +34,9 @@ export async function POST(req: Request) {
case "import-commissions":
await importCommissions(payload);
break;
+ case "update-stripe-customers":
+ await updateStripeCustomers(payload);
+ break;
}
return NextResponse.json("OK");
diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts
index 9926a6960aa..780bb3d7807 100644
--- a/apps/web/lib/partnerstack/api.ts
+++ b/apps/web/lib/partnerstack/api.ts
@@ -1,15 +1,15 @@
import {
- partnerStackPartner,
partnerStackCommission,
partnerStackCustomer,
partnerStackLink,
+ partnerStackPartner,
} from "./schemas";
import {
- PartnerStackPartner,
PartnerStackCommission,
PartnerStackCustomer,
PartnerStackLink,
PartnerStackListResponse,
+ PartnerStackPartner,
} from "./types";
const PAGE_LIMIT = 100;
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index 0db4e3c87f5..0af5d0d0475 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -1,12 +1,29 @@
import { prisma } from "@dub/prisma";
+import { nanoid } from "@dub/utils";
+import { CommissionStatus } from "@prisma/client";
+import { createId } from "../api/create-id";
+import { syncTotalCommissions } from "../api/partners/sync-total-commissions";
+import { getLeadEvent, recordSaleWithTimestamp } from "../tinybird";
+import { clickEventSchemaTB } from "../zod/schemas/clicks";
import { PartnerStackApi } from "./api";
import { MAX_BATCHES, partnerStackImporter } from "./importer";
-import { PartnerStackImportPayload } from "./types";
+import { PartnerStackCommission, PartnerStackImportPayload } from "./types";
+
+const toDubStatus: Record<
+ PartnerStackCommission["reward_status"],
+ CommissionStatus
+> = {
+ hold: "pending",
+ pending: "pending",
+ approved: "processed",
+ declined: "canceled",
+ paid: "paid",
+};
export async function importCommissions(payload: PartnerStackImportPayload) {
const { programId, startingAfter } = payload;
- const program = await prisma.program.findUniqueOrThrow({
+ const { workspaceId } = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
},
@@ -15,9 +32,8 @@ export async function importCommissions(payload: PartnerStackImportPayload) {
},
});
- const { publicKey, secretKey } = await partnerStackImporter.getCredentials(
- program.workspaceId,
- );
+ const { publicKey, secretKey } =
+ await partnerStackImporter.getCredentials(workspaceId);
const partnerStackApi = new PartnerStackApi({
publicKey,
@@ -38,75 +54,210 @@ export async function importCommissions(payload: PartnerStackImportPayload) {
break;
}
- const partners = await prisma.partner.findMany({
- where: {
- email: {
- in: commissions.map(({ partner }) => partner.email),
- },
- },
- select: {
- id: true,
- },
- });
-
- const programEnrollments = await prisma.programEnrollment.findMany({
- where: {
- partnerId: {
- in: partners.map((partner) => partner.id),
- },
- programId,
- },
- select: {
- partner: {
- select: {
- id: true,
- email: true,
- },
- },
- links: {
- select: {
- id: true,
- key: true,
- domain: true,
- url: true,
- },
- },
- },
- });
-
- const partnerEmailToLinks = new Map<
- string,
- (typeof programEnrollments)[0]["links"]
- >();
-
- for (const { partner, links } of programEnrollments) {
- if (!partner.email) {
- continue;
- }
-
- partnerEmailToLinks.set(partner.email, links);
- }
-
await Promise.allSettled(
- commissions.map(({ partner, ...customer }) =>
- createCustomer({
- workspace,
- customer,
- partner,
- links: partnerEmailToLinks.get(partner.email) ?? [],
+ commissions.map((commission) =>
+ createCommission({
+ workspaceId,
+ programId,
+ commission,
}),
),
);
- await new Promise((resolve) => setTimeout(resolve, 2000));
+ await new Promise((resolve) => setTimeout(resolve, 1000));
- processedBatches++;
currentStartingAfter = commissions[commissions.length - 1].key;
+ processedBatches++;
+ }
+
+ delete payload?.startingAfter;
+
+ if (!hasMore) {
+ await partnerStackImporter.deleteCredentials(workspaceId);
}
await partnerStackImporter.queue({
...payload,
...(hasMore && { startingAfter: currentStartingAfter }),
- action: hasMore ? "import-customers" : "import-commissions",
+ action: hasMore ? "import-commissions" : "update-stripe-customers",
+ });
+}
+
+async function createCommission({
+ workspaceId,
+ programId,
+ commission,
+}: {
+ workspaceId: string;
+ programId: string;
+ commission: PartnerStackCommission;
+}) {
+ if (!commission.transaction) {
+ console.log(`Commission ${commission.key} has no transaction, skipping...`);
+ return;
+ }
+
+ if (!commission.customer) {
+ console.log(`Commission ${commission.key} has no customer, skipping...`);
+ return;
+ }
+
+ const commissionFound = await prisma.commission.findUnique({
+ where: {
+ invoiceId_programId: {
+ invoiceId: commission.key, // This is not the actual invoice ID, but we use this to deduplicate the commissions
+ programId,
+ },
+ },
+ });
+
+ if (commissionFound) {
+ console.log(`Commission ${commission.key} already exists, skipping...`);
+ return;
+ }
+
+ const customer = await prisma.customer.findFirst({
+ where: {
+ projectId: workspaceId,
+ email: commission.customer.email,
+ },
+ include: {
+ link: true,
+ },
+ orderBy: {
+ id: "asc",
+ },
+ });
+
+ if (!customer) {
+ console.log(
+ `No customer found for customer email ${commission.customer.email}, skipping...`,
+ );
+ return;
+ }
+
+ // here, we also check for commissions that have already been recorded on Dub
+ // e.g. during the transition period
+ // since we don't have the Stripe invoiceId from PartnerStack, we use the referral's customer ID
+ // and check for commissions that were created with the same amount and within a +-1 hour window
+ const chargedAt = new Date(commission.created_at);
+ const trackedCommission = await prisma.commission.findFirst({
+ where: {
+ programId,
+ type: "sale",
+ customer: {
+ id: customer.id,
+ },
+ amount: commission.transaction.amount,
+ createdAt: {
+ gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before
+ lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after
+ },
+ },
+ });
+
+ if (trackedCommission) {
+ console.log(
+ `Commission ${trackedCommission.id} was already recorded on Dub, skipping...`,
+ );
+ return;
+ }
+
+ if (!customer.linkId) {
+ console.log(`No link found for customer ${customer.id}, skipping...`);
+ return;
+ }
+
+ if (!customer.clickId) {
+ console.log(`No click ID found for customer ${customer.id}, skipping...`);
+ return;
+ }
+
+ if (!customer.link?.partnerId) {
+ console.log(`No partner ID found for customer ${customer.id}, skipping...`);
+ return;
+ }
+
+ const leadEvent = await getLeadEvent({
+ customerId: customer.id,
+ });
+
+ if (!leadEvent || leadEvent.data.length === 0) {
+ console.log(`No lead event found for customer ${customer.id}, skipping...`);
+ return;
+ }
+
+ const clickData = clickEventSchemaTB
+ .omit({ timestamp: true })
+ .parse(leadEvent.data[0]);
+
+ const eventId = nanoid(16);
+
+ await Promise.all([
+ prisma.commission.create({
+ data: {
+ id: createId({ prefix: "cm_" }),
+ eventId,
+ type: "sale",
+ programId,
+ partnerId: customer.link.partnerId,
+ linkId: customer.linkId,
+ customerId: customer.id,
+ amount: commission.transaction.amount,
+ earnings: commission.amount,
+ currency: commission.transaction.currency,
+ quantity: 1,
+ status: toDubStatus[commission.reward_status],
+ invoiceId: commission.key, // this is not the actual invoice ID, but we use this to deduplicate the sales
+ createdAt: new Date(commission.created_at),
+ },
+ }),
+
+ recordSaleWithTimestamp({
+ ...clickData,
+ event_id: eventId,
+ event_name: "Invoice paid",
+ amount: commission.transaction.amount,
+ customer_id: customer.id,
+ payment_processor: "stripe",
+ currency: commission.transaction.currency,
+ metadata: JSON.stringify(commission),
+ timestamp: new Date(commission.created_at).toISOString(),
+ }),
+
+ // update link stats
+ prisma.link.update({
+ where: {
+ id: customer.linkId,
+ },
+ data: {
+ sales: {
+ increment: 1,
+ },
+ saleAmount: {
+ increment: commission.transaction.amount,
+ },
+ },
+ }),
+
+ // update customer stats
+ prisma.customer.update({
+ where: {
+ id: customer.id,
+ },
+ data: {
+ sales: {
+ increment: 1,
+ },
+ saleAmount: {
+ increment: commission.transaction.amount,
+ },
+ },
+ }),
+ ]);
+
+ await syncTotalCommissions({
+ partnerId: customer.link.partnerId,
+ programId,
});
}
diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts
index 6b16f38168e..ee1af4fae0a 100644
--- a/apps/web/lib/partnerstack/schemas.ts
+++ b/apps/web/lib/partnerstack/schemas.ts
@@ -5,6 +5,7 @@ export const partnerStackImportSteps = z.enum([
"import-links",
"import-customers",
"import-commissions",
+ "update-stripe-customers",
]);
export const partnerStackImportPayloadSchema = z.object({
@@ -57,20 +58,19 @@ export const partnerStackCustomer = z.object({
export const partnerStackCommission = z.object({
key: z.string(),
- amount_usd: z.number().describe("The amount of the reward in cents (USD)."),
- approved: z.boolean(),
- created_at: z.string(),
- currency: z.string(),
- customer: z.object({
- email: z.string(),
- external_key: z.string(),
- }),
- invoice: z.object({
- key: z.string(),
- }),
- transaction: z.object({
- amount: z.number().describe("The amount of the transaction."),
- }),
+ amount: z.number().describe("The amount of the reward in cents (USD)."),
+ created_at: z.number(),
+ customer: z
+ .object({
+ email: z.string(),
+ external_key: z.string().nullable(),
+ })
+ .nullable(),
+ transaction: z
+ .object({
+ amount: z.number().describe("The amount of the transaction."),
+ currency: z.string(),
+ })
+ .nullable(),
reward_status: z.enum(["hold", "pending", "approved", "declined", "paid"]),
- test: z.boolean().describe("True if created by a test."),
});
diff --git a/apps/web/lib/partnerstack/update-stripe-customers.ts b/apps/web/lib/partnerstack/update-stripe-customers.ts
new file mode 100644
index 00000000000..2eb575ff341
--- /dev/null
+++ b/apps/web/lib/partnerstack/update-stripe-customers.ts
@@ -0,0 +1,176 @@
+import { sendEmail } from "@dub/email";
+import CampaignImported from "@dub/email/templates/campaign-imported";
+import { prisma } from "@dub/prisma";
+import { Customer, Project } from "@dub/prisma/client";
+import { log } from "@dub/utils";
+import { stripeAppClient } from "../stripe";
+import { MAX_BATCHES, partnerStackImporter } from "./importer";
+import { PartnerStackImportPayload } from "./types";
+
+const CUSTOMERS_PER_BATCH = 20;
+
+const stripe = stripeAppClient({
+ ...(process.env.VERCEL_ENV && { livemode: true }),
+});
+
+// PartnerStack API doesn't return the Stripe customer ID,
+// so we'll search for Stripe customers by email and update the customer record with the Stripe customer ID, if found.
+export async function updateStripeCustomers(
+ payload: PartnerStackImportPayload,
+) {
+ const { programId, userId, startingAfter } = payload;
+
+ const { workspace, ...program } = await prisma.program.findUniqueOrThrow({
+ where: {
+ id: programId,
+ },
+ select: {
+ name: true,
+ workspace: {
+ select: {
+ id: true,
+ slug: true,
+ stripeConnectId: true,
+ },
+ },
+ },
+ });
+
+ if (!workspace.stripeConnectId) {
+ console.error(
+ `Workspace ${workspace.id} has no stripeConnectId. Skipping...`,
+ );
+ return;
+ }
+
+ let hasMore = true;
+ let processedBatches = 0;
+ let currentStartingAfter = startingAfter;
+
+ while (hasMore && processedBatches < MAX_BATCHES) {
+ const customers = await prisma.customer.findMany({
+ where: {
+ projectId: workspace.id,
+ stripeCustomerId: null,
+ },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ orderBy: {
+ createdAt: "asc",
+ },
+ take: CUSTOMERS_PER_BATCH,
+ skip: currentStartingAfter ? 1 : 0,
+ ...(currentStartingAfter && {
+ cursor: {
+ id: currentStartingAfter,
+ },
+ }),
+ });
+
+ if (customers.length === 0) {
+ hasMore = false;
+ break;
+ }
+
+ await Promise.all(
+ customers.map((customer) =>
+ searchStripeAndUpdateCustomer({
+ workspace,
+ customer,
+ }),
+ ),
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ processedBatches++;
+ currentStartingAfter = customers[customers.length - 1].id;
+ }
+
+ if (hasMore) {
+ await partnerStackImporter.queue({
+ ...payload,
+ startingAfter: currentStartingAfter,
+ action: "update-stripe-customers",
+ });
+ return;
+ }
+
+ const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({
+ where: {
+ userId_projectId: {
+ userId,
+ projectId: workspace.id,
+ },
+ },
+ select: {
+ user: {
+ select: {
+ email: true,
+ },
+ },
+ },
+ });
+
+ if (workspaceUser && workspaceUser.user.email) {
+ await sendEmail({
+ email: workspaceUser.user.email,
+ subject: "PartnerStack program imported",
+ react: CampaignImported({
+ email: workspaceUser.user.email,
+ workspace,
+ program,
+ provider: "PartnerStack",
+ }),
+ });
+ }
+}
+
+async function searchStripeAndUpdateCustomer({
+ workspace,
+ customer,
+}: {
+ workspace: Pick;
+ customer: Pick;
+}) {
+ const stripeCustomers = await stripe.customers.search(
+ {
+ query: `email:'${customer.email}'`,
+ },
+ {
+ stripeAccount: workspace.stripeConnectId!,
+ },
+ );
+
+ if (stripeCustomers.data.length === 0) {
+ console.error(`Stripe search returned no customer for ${customer.email}`);
+ return null;
+ }
+
+ if (stripeCustomers.data.length > 1) {
+ await log({
+ message: `Stripe search returned multiple customers for ${customer.email} for workspace ${workspace.slug}`,
+ type: "errors",
+ });
+
+ console.error(
+ `Stripe search returned multiple customers for ${customer.email}`,
+ );
+
+ return null;
+ }
+
+ const stripeCustomer = stripeCustomers.data[0];
+
+ await prisma.customer.update({
+ where: {
+ id: customer.id,
+ },
+ data: {
+ stripeCustomerId: stripeCustomer.id,
+ },
+ });
+}
diff --git a/packages/email/src/templates/campaign-imported.tsx b/packages/email/src/templates/campaign-imported.tsx
index 2d08b759744..fc2496c3fc1 100644
--- a/packages/email/src/templates/campaign-imported.tsx
+++ b/packages/email/src/templates/campaign-imported.tsx
@@ -25,7 +25,7 @@ export default function CampaignImported({
},
}: {
email: string;
- provider: "Rewardful" | "Tolt";
+ provider: "Rewardful" | "Tolt" | "PartnerStack";
workspace: {
slug: string;
};
From 7187aa4a787f99d5bd47624f06307b56f8461856 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 12:18:31 +0530
Subject: [PATCH 20/31] format
---
apps/web/lib/partnerstack/types.ts | 2 +-
apps/web/ui/modals/modal-provider.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts
index ce46dd0af25..e2db94c8b33 100644
--- a/apps/web/lib/partnerstack/types.ts
+++ b/apps/web/lib/partnerstack/types.ts
@@ -1,10 +1,10 @@
import { z } from "zod";
import {
- partnerStackPartner,
partnerStackCommission,
partnerStackCustomer,
partnerStackImportPayloadSchema,
partnerStackLink,
+ partnerStackPartner,
} from "./schemas";
export interface PartnerStackConfig {
diff --git a/apps/web/ui/modals/modal-provider.tsx b/apps/web/ui/modals/modal-provider.tsx
index 9af6053697e..804f56772b3 100644
--- a/apps/web/ui/modals/modal-provider.tsx
+++ b/apps/web/ui/modals/modal-provider.tsx
@@ -25,8 +25,8 @@ import {
} from "react";
import { toast } from "sonner";
import { useAddEditTagModal } from "./add-edit-tag-modal";
-import { useImportRebrandlyModal } from "./import-rebrandly-modal";
import { useImportPartnerStackModal } from "./import-partnerstack-modal";
+import { useImportRebrandlyModal } from "./import-rebrandly-modal";
import { useImportRewardfulModal } from "./import-rewardful-modal";
import { useImportToltModal } from "./import-tolt-modal";
import { useLinkBuilder } from "./link-builder";
From b4f83a063cccb21d1eec4f77424fc4465425bca6 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 12:21:20 +0530
Subject: [PATCH 21/31] Update importer.ts
---
apps/web/lib/partnerstack/importer.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts
index 4afc18b087d..807220a27c0 100644
--- a/apps/web/lib/partnerstack/importer.ts
+++ b/apps/web/lib/partnerstack/importer.ts
@@ -7,8 +7,8 @@ import { PartnerStackConfig } from "./types";
export const MAX_BATCHES = 5;
export const CACHE_EXPIRY = 60 * 60 * 24;
-export const CACHE_KEY_PREFIX = "partnerstack:import"; // Fix this
-export const PARTNER_IDS_KEY_PREFIX = "partnerstack:import:partner_ids"; // Fix this
+export const CACHE_KEY_PREFIX = "partnerStack:import";
+export const PARTNER_IDS_KEY_PREFIX = "partnerStack:import:partnerIds";
class PartnerStackImporter {
async setCredentials(workspaceId: string, payload: PartnerStackConfig) {
From 1aedb3e48a080fcca4509d34f09dbd9373b1eb8a Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 13:41:04 +0530
Subject: [PATCH 22/31] Update route.ts
---
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
index ca23b0aa9d5..e1cc6e84fa1 100644
--- a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
+++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
@@ -37,6 +37,8 @@ export async function POST(req: Request) {
case "update-stripe-customers":
await updateStripeCustomers(payload);
break;
+ default:
+ throw new Error(`Unknown action: ${payload.action}`);
}
return NextResponse.json("OK");
From fb1edc412bbd1c2cb97b352f532abe7a6df31beb Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 13:41:07 +0530
Subject: [PATCH 23/31] Update import-links.ts
---
apps/web/lib/partnerstack/import-links.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts
index e7ca4050f4c..c22953fe80e 100644
--- a/apps/web/lib/partnerstack/import-links.ts
+++ b/apps/web/lib/partnerstack/import-links.ts
@@ -74,7 +74,7 @@ export async function importLinks(payload: PartnerStackImportPayload) {
continue;
}
- await Promise.all(
+ await Promise.allSettled(
links.map(async (link) =>
createPartnerLink({
workspace: program.workspace as WorkspaceProps,
From 999687afd11dd50317fea0330f846f02ad4c8c28 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 13:41:10 +0530
Subject: [PATCH 24/31] Update import-partnerstack-modal.tsx
---
apps/web/ui/modals/import-partnerstack-modal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx
index 8d8c836d2c3..73ed42d1b49 100644
--- a/apps/web/ui/modals/import-partnerstack-modal.tsx
+++ b/apps/web/ui/modals/import-partnerstack-modal.tsx
@@ -98,6 +98,7 @@ function TokenForm({ onClose }: { onClose: () => void }) {
e.preventDefault();
if (!workspaceId || !publicKey || !secretKey) {
+ toast.error("Please fill in all required fields.");
return;
}
@@ -150,7 +151,6 @@ function TokenForm({ onClose }: { onClose: () => void }) {
type="password"
id="secretKey"
value={secretKey}
- autoFocus={!isMobile}
onChange={(e) => setSecretKey(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
From a23d8583d0450918c2d26ac3b28e1ae00ee2b99d Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Tue, 15 Jul 2025 13:49:12 +0530
Subject: [PATCH 25/31] Update import-customers.ts
---
apps/web/lib/partnerstack/import-customers.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index 7c49c8dc682..c07d0ab4f5f 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -219,7 +219,7 @@ async function createCustomer({
country: clickEvent.country,
clickedAt: new Date(customer.created_at),
createdAt: new Date(customer.created_at),
- externalId: customer.customer_key,
+ externalId: customer.customer_key || customer.email,
},
});
From d244a44faad2012055a05e5872499db690f26aa2 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Thu, 17 Jul 2025 13:00:41 +0530
Subject: [PATCH 26/31] Update import-partners.ts
---
apps/web/lib/partnerstack/import-partners.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts
index 3a0017425f0..5cd84e3c2be 100644
--- a/apps/web/lib/partnerstack/import-partners.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -136,7 +136,7 @@ async function createPartner({
});
// PS doesn't return the partner email address in the customers response
- // so we need to update keep a map of partner_key (PS) -> partner_id (Dub)
+ // so we need to keep a map of partner_key (PS) -> partner_id (Dub)
// and use it to identify the partner in the customers response
await redis.hset(`${PARTNER_IDS_KEY_PREFIX}:${program.id}`, {
[partner.key]: partnerId,
From 5f4219e989d6b13997fc3d191d920088b62ddb68 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Thu, 17 Jul 2025 13:16:15 +0530
Subject: [PATCH 27/31] Update import-customers.ts
---
apps/web/lib/partnerstack/import-customers.ts | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index c07d0ab4f5f..947b4c59320 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -55,6 +55,7 @@ export async function importCustomers(payload: PartnerStackImportPayload) {
break;
}
+ // Identify the Partner on Dub from PS partnership_key
const partnerKeys = [
...new Set(customers.map(({ partnership_key }) => partnership_key)),
];
@@ -158,10 +159,19 @@ async function createCustomer({
return;
}
- const customerFound = await prisma.customer.findFirst({
+ // Find the customer by email address
+ let customerFound = await prisma.customer.findFirst({
where: {
- projectId: workspace.id,
- email: customer.email,
+ OR: [
+ {
+ projectId: workspace.id,
+ email: customer.email,
+ },
+ {
+ projectId: workspace.id,
+ externalId: customer.customer_key,
+ },
+ ],
},
});
From d0a02801374dbc1f99c9f4d5e611eaac11101aee Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Thu, 17 Jul 2025 13:21:19 +0530
Subject: [PATCH 28/31] Update import-commissions.ts
---
apps/web/lib/partnerstack/import-commissions.ts | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index 0af5d0d0475..691b778a2b0 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -118,8 +118,16 @@ async function createCommission({
const customer = await prisma.customer.findFirst({
where: {
- projectId: workspaceId,
- email: commission.customer.email,
+ OR: [
+ {
+ projectId: workspaceId,
+ email: commission.customer.email,
+ },
+ {
+ projectId: workspaceId,
+ externalId: commission.customer.external_key,
+ },
+ ],
},
include: {
link: true,
From cf751d011fee0e903cad79579c0e92ff72e3159a Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Thu, 17 Jul 2025 13:48:42 +0530
Subject: [PATCH 29/31] Update import-customers.ts
---
apps/web/lib/partnerstack/import-customers.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts
index 947b4c59320..38b0f16c924 100644
--- a/apps/web/lib/partnerstack/import-customers.ts
+++ b/apps/web/lib/partnerstack/import-customers.ts
@@ -160,7 +160,7 @@ async function createCustomer({
}
// Find the customer by email address
- let customerFound = await prisma.customer.findFirst({
+ const customerFound = await prisma.customer.findFirst({
where: {
OR: [
{
From 73cfd952d0766d5339832f69fd4553566c9334a8 Mon Sep 17 00:00:00 2001
From: Kiran K
Date: Thu, 17 Jul 2025 13:48:45 +0530
Subject: [PATCH 30/31] Update import-partners.ts
---
apps/web/lib/partnerstack/import-partners.ts | 33 +++++++++++---------
1 file changed, 19 insertions(+), 14 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts
index 5cd84e3c2be..497d7a43abd 100644
--- a/apps/web/lib/partnerstack/import-partners.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -56,22 +56,16 @@ export async function importPartners(payload: PartnerStackImportPayload) {
break;
}
- const activePartners = partners.filter(
- ({ stats }) => stats.CUSTOMER_COUNT > 0,
+ await Promise.allSettled(
+ partners.map((partner) =>
+ createPartner({
+ program,
+ partner,
+ reward,
+ }),
+ ),
);
- if (activePartners.length > 0) {
- await Promise.allSettled(
- activePartners.map((partner) =>
- createPartner({
- program,
- partner,
- reward,
- }),
- ),
- );
- }
-
await new Promise((resolve) => setTimeout(resolve, 2000));
processedBatches++;
@@ -96,12 +90,23 @@ async function createPartner({
partner: PartnerStackPartner;
reward?: Pick;
}) {
+ if (partner.stats.CUSTOMER_COUNT === 0) {
+ console.log(`No leads found for partner ${partner.email}`);
+ return;
+ }
+
const countryCode = partner.address?.country
? Object.keys(COUNTRIES).find(
(key) => COUNTRIES[key] === partner.address?.country,
)
: null;
+ if (!countryCode && partner.address?.country) {
+ console.log(
+ `Country code not found for country ${partner.address.country}`,
+ );
+ }
+
const { id: partnerId } = await prisma.partner.upsert({
where: {
email: partner.email,
From 39ede7f7575818d6422a84d084a4d8441a5d0396 Mon Sep 17 00:00:00 2001
From: Steven Tey
Date: Thu, 17 Jul 2025 08:15:48 -0700
Subject: [PATCH 31/31] =?UTF-8?q?hold=20=E2=86=92=20fraud,=20small=20chang?=
=?UTF-8?q?e=20to=20source/target=20emails?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/lib/partnerstack/import-commissions.ts | 2 +-
.../partners/merge-accounts/send-verification-code-form.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts
index 691b778a2b0..7c2f8446aa8 100644
--- a/apps/web/lib/partnerstack/import-commissions.ts
+++ b/apps/web/lib/partnerstack/import-commissions.ts
@@ -13,7 +13,7 @@ const toDubStatus: Record<
PartnerStackCommission["reward_status"],
CommissionStatus
> = {
- hold: "pending",
+ hold: "fraud",
pending: "pending",
approved: "processed",
declined: "canceled",
diff --git a/apps/web/ui/partners/merge-accounts/send-verification-code-form.tsx b/apps/web/ui/partners/merge-accounts/send-verification-code-form.tsx
index ef2fc2f0268..9403ac7ae74 100644
--- a/apps/web/ui/partners/merge-accounts/send-verification-code-form.tsx
+++ b/apps/web/ui/partners/merge-accounts/send-verification-code-form.tsx
@@ -29,8 +29,8 @@ export function SendVerificationCodeForm({
formState: { isSubmitting },
} = useForm({
defaultValues: {
- sourceEmail: partner?.email || "",
- targetEmail: "",
+ sourceEmail: "",
+ targetEmail: partner?.email || "",
},
});