-
-
-
-
-
-
{title}
- {paymentMethod &&
- (type === "us_bank_account" || paymentMethod.link?.email) && (
-
- Connected
-
- )}
+ <>
+ {AddPaymentMethodModal}
+
+
+
+
+
+
+
+
-
{description}
-
- {!paymentMethod && (
+
-
+
+
+ >
);
};
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx
index 71b1ccc6598..a5e2b6d1ec4 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx
@@ -59,7 +59,9 @@ export default function PlanUsage() {
status: "approved",
});
- const payoutFees = plan ? PAYOUT_FEES[plan.toLowerCase()]?.ach : null;
+ const payoutFees = plan
+ ? PAYOUT_FEES[plan.toLowerCase()]?.direct_debit
+ : null;
const { data: tags } = useTagsCount();
const { users } = useUsers();
diff --git a/apps/web/lib/actions/partners/confirm-payouts.ts b/apps/web/lib/actions/partners/confirm-payouts.ts
index 087b89989ed..69893fd39ca 100644
--- a/apps/web/lib/actions/partners/confirm-payouts.ts
+++ b/apps/web/lib/actions/partners/confirm-payouts.ts
@@ -1,6 +1,7 @@
"use server";
import { qstash } from "@/lib/cron";
+import { PAYMENT_METHOD_TYPES } from "@/lib/partners/constants";
import { CUTOFF_PERIOD_ENUM } from "@/lib/partners/cutoff-period";
import { stripe } from "@/lib/stripe";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
@@ -13,8 +14,7 @@ const confirmPayoutsSchema = z.object({
cutoffPeriod: CUTOFF_PERIOD_ENUM,
});
-const allowedPaymentMethods = ["us_bank_account", "card", "link"];
-
+// Confirm payouts
export const confirmPayoutsAction = authActionClient
.schema(confirmPayoutsSchema)
.action(async ({ parsedInput, ctx }) => {
@@ -35,9 +35,11 @@ export const confirmPayoutsAction = authActionClient
throw new Error("Invalid payout method.");
}
- if (!allowedPaymentMethods.includes(paymentMethod.type)) {
+ if (!PAYMENT_METHOD_TYPES.includes(paymentMethod.type)) {
throw new Error(
- "We only support ACH and Card for now. Please update your payout method to one of these.",
+ `We only support ${PAYMENT_METHOD_TYPES.join(
+ ", ",
+ )} for now. Please update your payout method to one of these.`,
);
}
diff --git a/apps/web/lib/partners/constants.ts b/apps/web/lib/partners/constants.ts
index 81c66711984..02d987ccb9b 100644
--- a/apps/web/lib/partners/constants.ts
+++ b/apps/web/lib/partners/constants.ts
@@ -1,22 +1,73 @@
+import Stripe from "stripe";
+import { PaymentMethodOption } from "../types";
+
export const PAYOUTS_SHEET_ITEMS_LIMIT = 10;
export const REFERRALS_EMBED_EARNINGS_LIMIT = 8;
export const CUSTOMER_PAGE_EVENTS_LIMIT = 8;
export const PAYOUT_FEES = {
business: {
- ach: 0.05,
- card: 0.08,
+ direct_debit: 0.05,
+ card: 0.1,
},
advanced: {
- ach: 0.05,
+ direct_debit: 0.05,
card: 0.08,
},
enterprise: {
- ach: 0.03,
+ direct_debit: 0.03,
card: 0.06,
},
-};
-
-export const DUB_MIN_PAYOUT_AMOUNT_CENTS = 10000; // 100 USD
+} as const;
+export const DUB_MIN_PAYOUT_AMOUNT_CENTS = 10000;
export const PAYOUT_FAILURE_FEE_CENTS = 1000; // 10 USD
+
+// Direct debit payment types for Partner payout
+export const DIRECT_DEBIT_PAYMENT_TYPES_INFO: {
+ type: Stripe.PaymentMethod.Type;
+ location: string;
+ title: string;
+ icon: string;
+ option: PaymentMethodOption;
+}[] = [
+ {
+ type: "us_bank_account",
+ location: "US",
+ title: "ACH",
+ icon: "https://hatscripts.github.io/circle-flags/flags/us.svg",
+ option: {},
+ },
+ {
+ type: "acss_debit",
+ location: "CA",
+ title: "ACSS Debit",
+ icon: "https://hatscripts.github.io/circle-flags/flags/ca.svg",
+ option: {
+ currency: "cad",
+ mandate_options: {
+ payment_schedule: "sporadic",
+ transaction_type: "business",
+ },
+ },
+ },
+ {
+ type: "sepa_debit",
+ location: "EU",
+ title: "SEPA Debit",
+ icon: "https://hatscripts.github.io/circle-flags/flags/eu.svg",
+ option: {},
+ },
+];
+
+export const DIRECT_DEBIT_PAYMENT_METHOD_TYPES: Stripe.PaymentMethod.Type[] = [
+ "us_bank_account",
+ "acss_debit",
+ "sepa_debit",
+];
+
+export const PAYMENT_METHOD_TYPES: Stripe.PaymentMethod.Type[] = [
+ "card",
+ "link",
+ ...DIRECT_DEBIT_PAYMENT_METHOD_TYPES,
+];
diff --git a/apps/web/lib/payment-methods.ts b/apps/web/lib/payment-methods.ts
new file mode 100644
index 00000000000..bb20e872dcb
--- /dev/null
+++ b/apps/web/lib/payment-methods.ts
@@ -0,0 +1,31 @@
+import Stripe from "stripe";
+import {
+ DIRECT_DEBIT_PAYMENT_METHOD_TYPES,
+ PAYOUT_FEES,
+} from "./partners/constants";
+
+export const calculatePayoutFee = ({
+ paymentMethod,
+ plan,
+}: {
+ paymentMethod: Stripe.PaymentMethod.Type;
+ plan: string | undefined;
+}) => {
+ if (!paymentMethod) {
+ return null;
+ }
+
+ const planType = plan?.split(" ")[0] ?? "business";
+
+ if (!Object.keys(PAYOUT_FEES).includes(planType)) {
+ return null;
+ }
+
+ if (["link", "card"].includes(paymentMethod)) {
+ return PAYOUT_FEES[planType].card;
+ }
+
+ if (DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod)) {
+ return PAYOUT_FEES[planType].direct_debit;
+ }
+};
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index 0f9a0cb799e..7d9eaa716f1 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -478,3 +478,11 @@ export type ProgramData = z.infer
;
export type ProgramMetrics = z.infer;
export type PayoutMethod = "stripe" | "paypal";
+
+export type PaymentMethodOption = {
+ currency?: string;
+ mandate_options?: {
+ payment_schedule?: string;
+ transaction_type?: string;
+ };
+};
diff --git a/apps/web/ui/modals/add-payment-method-modal.tsx b/apps/web/ui/modals/add-payment-method-modal.tsx
new file mode 100644
index 00000000000..f353cc3ba87
--- /dev/null
+++ b/apps/web/ui/modals/add-payment-method-modal.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { DIRECT_DEBIT_PAYMENT_TYPES_INFO } from "@/lib/partners/constants";
+import useWorkspace from "@/lib/swr/use-workspace";
+import { X } from "@/ui/shared/icons";
+import { AnimatedSizeContainer, GreekTemple, Modal } from "@dub/ui";
+import { useRouter } from "next/navigation";
+import { CSSProperties, Dispatch, SetStateAction, useState } from "react";
+import { toast } from "sonner";
+import Stripe from "stripe";
+
+function AddPaymentMethodModal({
+ showAddPaymentMethodModal,
+ setShowAddPaymentMethodModal,
+}: {
+ showAddPaymentMethodModal: boolean;
+ setShowAddPaymentMethodModal: Dispatch>;
+}) {
+ return (
+
+
+
+ );
+}
+
+function AddPaymentMethodModalInner({
+ setShowAddPaymentMethodModal,
+}: {
+ setShowAddPaymentMethodModal: Dispatch>;
+}) {
+ const router = useRouter();
+ const { slug } = useWorkspace();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const addPaymentMethod = async (type: Stripe.PaymentMethod.Type) => {
+ setIsLoading(true);
+
+ const response = await fetch(
+ `/api/workspaces/${slug}/billing/payment-methods`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ method: type,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ setIsLoading(false);
+ toast.error("Failed to add payment method. Please try again.");
+ return;
+ }
+
+ const data = (await response.json()) as { url: string };
+
+ router.push(data.url);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Connect your bank account
+
+
+ Select your bank’s location to connect your bank account.
+
+
+
+
+ {DIRECT_DEBIT_PAYMENT_TYPES_INFO.map(
+ ({ type, location, title, icon: Icon }, index) => (
+
+ ),
+ )}
+
+
+
+
+ );
+}
+
+export function useAddPaymentMethodModal() {
+ const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] =
+ useState(false);
+
+ return {
+ setShowAddPaymentMethodModal,
+ AddPaymentMethodModal: (
+
+ ),
+ };
+}
diff --git a/apps/web/ui/partners/payout-invoice-sheet.tsx b/apps/web/ui/partners/payout-invoice-sheet.tsx
index 62d09fb8ef6..297a2ea91e3 100644
--- a/apps/web/ui/partners/payout-invoice-sheet.tsx
+++ b/apps/web/ui/partners/payout-invoice-sheet.tsx
@@ -1,9 +1,10 @@
import { confirmPayoutsAction } from "@/lib/actions/partners/confirm-payouts";
-import { PAYOUT_FEES } from "@/lib/partners/constants";
+import { DIRECT_DEBIT_PAYMENT_METHOD_TYPES } from "@/lib/partners/constants";
import {
CUTOFF_PERIOD,
CUTOFF_PERIOD_TYPES,
} from "@/lib/partners/cutoff-period";
+import { calculatePayoutFee } from "@/lib/payment-methods";
import { mutatePrefix } from "@/lib/swr/mutate";
import usePaymentMethods from "@/lib/swr/use-payment-methods";
import useWorkspace from "@/lib/swr/use-workspace";
@@ -34,45 +35,57 @@ import {
import { useAction } from "next-safe-action/hooks";
import { Fragment, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
+import Stripe from "stripe";
import useSWR from "swr";
+const PAYMENT_METHODS = Object.freeze({
+ link: {
+ label: "link",
+ type: "link",
+ icon: CreditCard,
+ duration: "Instantly",
+ },
+ card: {
+ label: "card",
+ type: "card",
+ icon: CreditCard,
+ duration: "Instantly",
+ },
+ us_bank_account: {
+ label: "ACH",
+ type: "us_bank_account",
+ icon: GreekTemple,
+ duration: "4 business days",
+ },
+ acss_debit: {
+ label: "ACSS Debit",
+ type: "acss_debit",
+ icon: GreekTemple,
+ duration: "5 business days",
+ },
+ sepa_debit: {
+ label: "SEPA Debit",
+ type: "sepa_debit",
+ icon: GreekTemple,
+ duration: "5 business days",
+ },
+});
+
+type SelectPaymentMethod =
+ (typeof PAYMENT_METHODS)[keyof typeof PAYMENT_METHODS] & {
+ id: string;
+ fee: number;
+ };
+
function PayoutInvoiceSheetContent() {
- const { id: workspaceId, slug, plan, defaultProgramId } = useWorkspace();
const { queryParams } = useRouterStuff();
+ const { id: workspaceId, slug, plan, defaultProgramId } = useWorkspace();
+
const { paymentMethods, loading: paymentMethodsLoading } =
usePaymentMethods();
- const paymentMethodsTypes = Object.freeze({
- link: {
- label: "link",
- type: "link",
- icon: CreditCard,
- fee: PAYOUT_FEES[plan?.split(" ")[0] ?? "business"].card,
- duration: "Instantly",
- },
- card: {
- label: "card",
- type: "card",
- icon: CreditCard,
- fee: PAYOUT_FEES[plan?.split(" ")[0] ?? "business"].card,
- duration: "Instantly",
- },
- us_bank_account: {
- label: "ACH",
- type: "us_bank_account",
- icon: GreekTemple,
- fee: PAYOUT_FEES[plan?.split(" ")[0] ?? "business"].ach,
- duration: "4 business days",
- },
- });
-
- type PaymentMethodWithFee =
- (typeof paymentMethodsTypes)[keyof typeof paymentMethodsTypes] & {
- id: string;
- };
-
const [selectedPaymentMethod, setSelectedPaymentMethod] =
- useState(null);
+ useState(null);
const [cutoffPeriod, setCutoffPeriod] =
useState("today");
@@ -104,35 +117,52 @@ function PayoutInvoiceSheetContent() {
},
});
- // Set the first payment method as the selected payment method
- useEffect(() => {
- if (!paymentMethods || !paymentMethods.length) {
- return;
- }
+ const finalPaymentMethods = useMemo(
+ () =>
+ paymentMethods?.map((pm) => {
+ const paymentMethod = PAYMENT_METHODS[pm.type];
- if (!selectedPaymentMethod) {
- const firstPaymentMethod = paymentMethods[0];
- setSelectedPaymentMethod({
- ...paymentMethodsTypes[firstPaymentMethod.type],
- id: firstPaymentMethod.id,
- });
- }
- }, [paymentMethods, selectedPaymentMethod]);
+ const base = {
+ ...paymentMethod,
+ id: pm.id,
+ fee: calculatePayoutFee({
+ paymentMethod: pm.type,
+ plan,
+ }),
+ };
- const paymentMethodsWithFee = useMemo(
- () =>
- paymentMethods?.map((pm) => ({
- ...paymentMethodsTypes[pm.type],
- id: pm.id,
- title: pm.link
- ? `Link – ${truncate(pm.link.email, 24)}`
- : pm.card
- ? `${capitalize(pm.card?.brand)} **** ${pm.card?.last4}`
- : `ACH **** ${pm.us_bank_account?.last4}`,
- })),
- [paymentMethods],
+ if (pm.link) {
+ return {
+ ...base,
+ title: `Link – ${truncate(pm.link.email, 16)}`,
+ };
+ }
+
+ if (pm.card) {
+ return {
+ ...base,
+ title: `${capitalize(pm.card.brand)} **** ${pm.card.last4}`,
+ };
+ }
+
+ return {
+ ...base,
+ title: `${paymentMethod.label} **** ${pm[paymentMethod.type]?.last4}`,
+ };
+ }),
+ [paymentMethods, plan],
);
+ useEffect(() => {
+ if (
+ !selectedPaymentMethod &&
+ finalPaymentMethods &&
+ finalPaymentMethods.length > 0
+ ) {
+ setSelectedPaymentMethod(finalPaymentMethods[0]);
+ }
+ }, [finalPaymentMethods, selectedPaymentMethod]);
+
const amount = useMemo(
() =>
eligiblePayouts?.reduce((acc, payout) => {
@@ -162,13 +192,13 @@ function PayoutInvoiceSheetContent() {
value={selectedPaymentMethod?.id || ""}
onChange={(e) =>
setSelectedPaymentMethod(
- paymentMethodsWithFee?.find(
+ finalPaymentMethods?.find(
(pm) => pm.id === e.target.value,
) || null,
)
}
>
- {paymentMethodsWithFee?.map(({ id, title }) => (
+ {finalPaymentMethods?.map(({ id, title }) => (
@@ -237,7 +267,7 @@ function PayoutInvoiceSheetContent() {
),
tooltipContent: selectedPaymentMethod ? (