diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/activity.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/activity.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/activity.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/activity.tsx
diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/compliance-button.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/compliance-button.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/compliance-button.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/compliance-button.tsx
diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/page-client.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/page-client.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/page-client.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/page-client.tsx
diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/page.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/page.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/page.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/page.tsx
diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/payout-method-card.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/payout-method-card.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/payout-method-card.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/payout-method-card.tsx
diff --git a/apps/web/app/partners.dub.co/(dashboard)/settings/wallet/verify-phone-number.tsx b/apps/web/app/partners.dub.co/(dashboard)/settings/payouts/verify-phone-number.tsx
similarity index 100%
rename from apps/web/app/partners.dub.co/(dashboard)/settings/wallet/verify-phone-number.tsx
rename to apps/web/app/partners.dub.co/(dashboard)/settings/payouts/verify-phone-number.tsx
diff --git a/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/page.tsx b/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/page.tsx
deleted file mode 100644
index eb1e5e5c7dd..00000000000
--- a/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ConnectedDots4 } from "@dub/ui/src/icons";
-import { VerificationForm } from "./verification-form";
-
-export default function OnboardingVerify() {
- return (
-
-
-
-
-
- Verify your phone number
-
-
-
-
-
- );
-}
diff --git a/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/verification-form.tsx b/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/verification-form.tsx
deleted file mode 100644
index ce5a693fe66..00000000000
--- a/apps/web/app/partners.dub.co/(onboarding)/onboarding/verify/verification-form.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-"use client";
-
-import { resendVerificationCodeAction } from "@/lib/actions/partners/resend-verification-code";
-import { verifyPartnerAction } from "@/lib/actions/partners/verify-partner";
-import useDotsUser from "@/lib/swr/use-dots-user";
-import useRefreshSession from "@/lib/swr/use-refresh-session";
-import { Button, LoadingSpinner, useMediaQuery } from "@dub/ui";
-import { MobilePhone } from "@dub/ui/src/icons";
-import { cn } from "@dub/utils/src/functions";
-import { OTPInput } from "input-otp";
-import { useAction } from "next-safe-action/hooks";
-import { useRouter } from "next/navigation";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-
-export function VerificationForm() {
- const router = useRouter();
- const { isMobile } = useMediaQuery();
-
- useRefreshSession("defaultPartnerId");
-
- const {
- handleSubmit,
- reset,
- getValues,
- setValue,
- formState: { errors, isSubmitting, isSubmitSuccessful },
- } = useForm<{ code: string }>();
-
- const { executeAsync, isExecuting } = useAction(verifyPartnerAction, {
- onSuccess: () => {
- router.push("/programs");
- },
- onError: ({ error, input }) => {
- toast.error(error.serverError);
- reset(input);
- },
- });
-
- return (
-
- );
-}
-
-function ResendCode() {
- const { dotsUser } = useDotsUser();
- const { executeAsync, isExecuting } = useAction(
- resendVerificationCodeAction,
- {
- onSuccess: () => {
- toast.success("Code resent successfully");
- },
- },
- );
- return (
-
- {!dotsUser ? (
-
- ) : (
-
- +
- {dotsUser?.phone_number.country_code}
- {dotsUser?.phone_number.phone_number}
-
- )}
-
-
- );
-}
diff --git a/apps/web/lib/actions/partners/onboard-partner.ts b/apps/web/lib/actions/partners/onboard-partner.ts
index a71d10d7f73..5018cd4411f 100644
--- a/apps/web/lib/actions/partners/onboard-partner.ts
+++ b/apps/web/lib/actions/partners/onboard-partner.ts
@@ -1,11 +1,10 @@
"use server";
import { createId } from "@/lib/api/utils";
-import { createDotsUser } from "@/lib/dots/create-dots-user";
-import { sendVerificationToken } from "@/lib/dots/send-verification-token";
import { userIsInBeta } from "@/lib/edge-config";
import { completeProgramApplications } from "@/lib/partners/complete-program-applications";
import { storage } from "@/lib/storage";
+import { createConnectedAccount } from "@/lib/stripe/create-connected-account";
import { onboardPartnerSchema } from "@/lib/zod/schemas/partners";
import { prisma } from "@dub/prisma";
import { COUNTRY_PHONE_CODES, nanoid } from "@dub/utils";
@@ -30,35 +29,30 @@ export const onboardPartnerAction = authUserActionClient
const { name, email, image, country, phoneNumber, description } =
parsedInput;
- // Create the Dots user with DOTS_DEFAULT_APP_ID
- const [firstName, lastName] = name.split(" ");
- const countryCode = COUNTRY_PHONE_CODES[country] || null;
-
- if (!countryCode) {
- throw new Error("Invalid country code.");
+ if (!COUNTRY_PHONE_CODES[country]) {
+ throw new Error("Invalid country code or country not supported.");
}
- const dotsUserInfo = {
- firstName,
- lastName: lastName || firstName.slice(0, 1), // Dots requires a last name
+ // Create the Stripe connected account for the partner
+ const connectedAccount = await createConnectedAccount({
+ name,
email,
- countryCode: countryCode.toString(),
+ country,
phoneNumber,
- };
-
- const dotsUser = await createDotsUser({
- userInfo: dotsUserInfo,
});
- const partnerExists = await prisma.partner.findUnique({
- where: {
- dotsUserId: dotsUser.id,
- },
- });
+ // TODO:
+ // This needs testing. Not sure how Stripe handle this
- if (partnerExists) {
- throw new Error("This phone number is already in use.");
- }
+ // const partnerExists = await prisma.partner.findUnique({
+ // where: {
+ // stripeConnectId: connectedAccount.id,
+ // },
+ // });
+
+ // if (partnerExists) {
+ // throw new Error("This phone number is already in use.");
+ // }
const partnerId = createId({ prefix: "pn_" });
@@ -66,7 +60,7 @@ export const onboardPartnerAction = authUserActionClient
.upload(`partners/${partnerId}/image_${nanoid(7)}`, image)
.then(({ url }) => url);
- const [partner, _] = await Promise.all([
+ await Promise.all([
prisma.partner.create({
data: {
id: partnerId,
@@ -74,7 +68,7 @@ export const onboardPartnerAction = authUserActionClient
email,
country,
bio: description,
- dotsUserId: dotsUser.id,
+ stripeConnectId: connectedAccount.id,
image: imageUrl,
users: {
create: {
@@ -84,6 +78,7 @@ export const onboardPartnerAction = authUserActionClient
},
},
}),
+
prisma.user.update({
where: {
id: user.id,
@@ -92,15 +87,8 @@ export const onboardPartnerAction = authUserActionClient
defaultPartnerId: partnerId,
},
}),
- sendVerificationToken({
- dotsUserId: dotsUser.id,
- }),
]);
// Complete any outstanding program applications
waitUntil(completeProgramApplications(user.id));
-
- return {
- partnerId: partner.id,
- };
});
diff --git a/apps/web/lib/actions/partners/resend-verification-code.ts b/apps/web/lib/actions/partners/resend-verification-code.ts
deleted file mode 100644
index 85cd2ede62c..00000000000
--- a/apps/web/lib/actions/partners/resend-verification-code.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-"use server";
-
-import { sendVerificationToken } from "@/lib/dots/send-verification-token";
-import { authPartnerActionClient } from "../safe-action";
-
-// Resend verification code
-export const resendVerificationCodeAction = authPartnerActionClient.action(
- async ({ ctx }) => {
- const { partner } = ctx;
-
- if (!partner.dotsUserId) {
- throw new Error("Partner does not have a Dots user ID");
- }
-
- await sendVerificationToken({
- dotsUserId: partner.dotsUserId,
- });
-
- return {
- success: true,
- };
- },
-);
diff --git a/apps/web/lib/actions/partners/verify-partner.ts b/apps/web/lib/actions/partners/verify-partner.ts
deleted file mode 100644
index bb888d47cf4..00000000000
--- a/apps/web/lib/actions/partners/verify-partner.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-"use server";
-
-import { verifyUserWithToken } from "@/lib/dots/verify-user-with-token";
-import z from "@/lib/zod";
-import { authPartnerActionClient } from "../safe-action";
-
-// Verify partner phone number
-export const verifyPartnerAction = authPartnerActionClient
- .schema(z.object({ code: z.string() }))
- .action(async ({ ctx, parsedInput }) => {
- const { partner } = ctx;
- const { code } = parsedInput;
-
- if (!partner.dotsUserId) {
- throw new Error("Partner does not have a Dots user ID.");
- }
-
- try {
- await verifyUserWithToken({
- dotsUserId: partner.dotsUserId,
- token: code,
- });
- } catch (error) {
- throw new Error("Invalid code. Please try again.");
- }
-
- return {
- partnerId: partner.id,
- };
- });
diff --git a/apps/web/lib/api/workspaces.ts b/apps/web/lib/api/workspaces.ts
index d57618856ca..a04faa07f14 100644
--- a/apps/web/lib/api/workspaces.ts
+++ b/apps/web/lib/api/workspaces.ts
@@ -1,6 +1,5 @@
import { deleteDomainAndLinks } from "@/lib/api/domains";
import { storage } from "@/lib/storage";
-import { cancelSubscription } from "@/lib/stripe";
import { recordLink } from "@/lib/tinybird";
import { WorkspaceProps } from "@/lib/types";
import { formatRedisLink, redis } from "@/lib/upstash";
@@ -12,6 +11,7 @@ import {
R2_URL,
} from "@dub/utils";
import { waitUntil } from "@vercel/functions";
+import { cancelSubscription } from "../stripe/cancel-subscription";
export async function deleteWorkspace(
workspace: Pick,
diff --git a/apps/web/lib/middleware/partners.ts b/apps/web/lib/middleware/partners.ts
index 2021b8585cc..a8cb5894ff0 100644
--- a/apps/web/lib/middleware/partners.ts
+++ b/apps/web/lib/middleware/partners.ts
@@ -12,9 +12,9 @@ const UNAUTHENTICATED_PATHS = [
"/apply",
];
-const PARTNER_REDIRECTS = {
- "/settings/payouts": "/settings/wallet",
-};
+// const PARTNER_REDIRECTS = {
+// "/settings/payouts": "/settings/wallet",
+// };
export default async function PartnersMiddleware(req: NextRequest) {
const { path, fullPath } = parse(req);
@@ -45,9 +45,13 @@ export default async function PartnersMiddleware(req: NextRequest) {
return NextResponse.redirect(new URL("/onboarding", req.url));
} else if (path === "/" || path.startsWith("/pn_")) {
return NextResponse.redirect(new URL("/programs", req.url));
- } else if (PARTNER_REDIRECTS[path]) {
- return NextResponse.redirect(new URL(PARTNER_REDIRECTS[path], req.url));
- } else if (["/login", "/register"].some((p) => path.startsWith(p))) {
+ }
+
+ // else if (PARTNER_REDIRECTS[path]) {
+ // return NextResponse.redirect(new URL(PARTNER_REDIRECTS[path], req.url));
+ // }
+
+ else if (["/login", "/register"].some((p) => path.startsWith(p))) {
return NextResponse.redirect(new URL("/", req.url)); // Redirect authenticated users to dashboard
}
}
diff --git a/apps/web/lib/stripe/cancel-subscription.ts b/apps/web/lib/stripe/cancel-subscription.ts
new file mode 100644
index 00000000000..6fe0dbfeba3
--- /dev/null
+++ b/apps/web/lib/stripe/cancel-subscription.ts
@@ -0,0 +1,23 @@
+import { stripe } from ".";
+
+export async function cancelSubscription(customer?: string) {
+ if (!customer) return;
+
+ try {
+ const subscriptionId = await stripe.subscriptions
+ .list({
+ customer,
+ })
+ .then((res) => res.data[0].id);
+
+ return await stripe.subscriptions.update(subscriptionId, {
+ cancel_at_period_end: true,
+ cancellation_details: {
+ comment: "Customer deleted their Dub workspace.",
+ },
+ });
+ } catch (error) {
+ console.log("Error cancelling Stripe subscription", error);
+ return;
+ }
+}
diff --git a/apps/web/lib/stripe/create-connected-account.ts b/apps/web/lib/stripe/create-connected-account.ts
new file mode 100644
index 00000000000..e66887f504f
--- /dev/null
+++ b/apps/web/lib/stripe/create-connected-account.ts
@@ -0,0 +1,39 @@
+import { z } from "zod";
+import { stripe } from ".";
+import { onboardPartnerSchema } from "../zod/schemas/partners";
+
+export const createConnectedAccount = async ({
+ name,
+ email,
+ country,
+ phoneNumber,
+}: Pick<
+ z.infer,
+ "name" | "email" | "country" | "phoneNumber"
+>) => {
+ const [firstName, lastName] = name.split(" ");
+
+ // TODO:
+ // Handle the errors from the stripe API
+
+ return await stripe.accounts.create({
+ type: "express",
+ business_type: "individual",
+ email,
+ country,
+ individual: {
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone: phoneNumber,
+ },
+ capabilities: {
+ transfers: {
+ requested: true,
+ },
+ card_payments: {
+ requested: true,
+ },
+ },
+ });
+};
diff --git a/apps/web/lib/stripe/index.ts b/apps/web/lib/stripe/index.ts
index eb9fdf18ca7..65122b8cc1e 100644
--- a/apps/web/lib/stripe/index.ts
+++ b/apps/web/lib/stripe/index.ts
@@ -10,25 +10,3 @@ export const stripe = new Stripe(
},
},
);
-
-export async function cancelSubscription(customer?: string) {
- if (!customer) return;
-
- try {
- const subscriptionId = await stripe.subscriptions
- .list({
- customer,
- })
- .then((res) => res.data[0].id);
-
- return await stripe.subscriptions.update(subscriptionId, {
- cancel_at_period_end: true,
- cancellation_details: {
- comment: "Customer deleted their Dub workspace.",
- },
- });
- } catch (error) {
- console.log("Error cancelling Stripe subscription", error);
- return;
- }
-}
diff --git a/apps/web/scripts/stripe-connect.ts b/apps/web/scripts/stripe-connect.ts
new file mode 100644
index 00000000000..c87b82bafb9
--- /dev/null
+++ b/apps/web/scripts/stripe-connect.ts
@@ -0,0 +1,92 @@
+// Stripe Connect MVP
+
+import "dotenv-flow/config";
+
+import Stripe from "stripe";
+
+export const stripe = new Stripe(`${process.env.STRIPE_SECRET_KEY}`, {
+ apiVersion: "2022-11-15",
+ appInfo: {
+ name: "Dub.co",
+ version: "0.1.0",
+ },
+});
+
+const partner = {
+ stripeCustomerId: "cus_RNro3sEDvvLA1h",
+};
+
+const affiliate = {
+ country: "US",
+ firstName: "Affiliate",
+ lastName: "Mac",
+ email: "affiliate@example.com",
+ connectedAccountId: "acct_1PxvAwFZTaDk8h7W",
+};
+
+const payout = {
+ amount: 1500,
+ currency: "usd",
+};
+
+async function main() {
+ // await createExpressAccount();
+ // await createLoginLink();
+ // await createPayout();
+
+ const account = await stripe.accounts.retrieve(affiliate.connectedAccountId);
+
+ console.log("Account", account);
+}
+
+const createExpressAccount = async () => {
+ const account = await stripe.accounts.create({
+ type: "express",
+ business_type: "individual",
+ country: affiliate.country,
+ email: affiliate.email,
+ capabilities: {
+ transfers: {
+ requested: true,
+ },
+ card_payments: {
+ requested: true,
+ },
+ },
+ individual: {
+ first_name: affiliate.firstName,
+ last_name: affiliate.lastName,
+ email: affiliate.email,
+ },
+ });
+
+ console.log("Created express account for affiliate", account);
+};
+
+const createLoginLink = async () => {
+ const loginLink = await stripe.accounts.createLoginLink(
+ affiliate.connectedAccountId,
+ );
+
+ console.log("Login link", loginLink);
+};
+
+const createPayout = async () => {
+ const result = await stripe.paymentIntents.create({
+ amount: payout.amount,
+ currency: payout.currency,
+ description: "Payout for affiliate from Dub Partners",
+ customer: partner.stripeCustomerId, // Partner's Stripe Customer ID
+ payment_method: "pm_1QV6ZKFacAXKeDpJQJa2tLc8", // Partner's Stripe Payment Method ID
+ confirm: true,
+ confirmation_method: "automatic",
+ transfer_data: {
+ destination: affiliate.connectedAccountId, // To where the payout is sent
+ },
+ application_fee_amount: 100, // 1% fee from Dub
+ });
+
+ console.log("Created payout for affiliate", result);
+};
+
+main();
diff --git a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx
index 7a657c9b340..5586f9f6d25 100644
--- a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx
+++ b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx
@@ -117,9 +117,9 @@ const NAV_AREAS: SidebarNavAreas<{
exact: true,
},
{
- name: "Wallet",
+ name: "Payouts",
icon: MoneyBills2,
- href: "/settings/wallet",
+ href: "/settings/payouts",
},
{
name: "People",
diff --git a/packages/prisma/schema/partner.prisma b/packages/prisma/schema/partner.prisma
index 66507fad5b7..1d27fee3f45 100644
--- a/packages/prisma/schema/partner.prisma
+++ b/packages/prisma/schema/partner.prisma
@@ -10,17 +10,17 @@ enum PartnerRole {
}
model Partner {
- id String @id @default(cuid())
- name String
- email String? @unique
- image String?
- bio String? @db.LongText
- country String?
- status PartnerStatus @default(default)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- dotsUserId String? @unique
+ id String @id @default(cuid())
+ name String
+ email String? @unique
+ image String?
+ bio String? @db.LongText
+ country String?
+ status PartnerStatus @default(default)
+ stripeConnectId String? @unique
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ dotsUserId String? @unique // TODO: Remove this column
programs ProgramEnrollment[]
applications ProgramApplication[]