Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/app/api/stripe/integration/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const relevantEvents = new Set([
"account.application.deauthorized",
]);

// POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration)
export const POST = withAxiom(
async (req: Request) => {
const buf = await req.text();
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/api/stripe/webhook/checkout-session-completed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import Stripe from "stripe";
export async function checkoutSessionCompleted(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;

if (checkoutSession.mode === "setup") {
return;
}

if (
checkoutSession.client_reference_id === null ||
checkoutSession.customer === null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { stripe } from "@/lib/stripe";
import { APP_DOMAIN } from "@dub/utils";
import { NextResponse } from "next/server";
import { z } from "zod";

export const GET = withWorkspace(async ({ workspace }) => {
if (!workspace.stripeId) {
Expand All @@ -20,17 +22,35 @@ export const GET = withWorkspace(async ({ workspace }) => {
}
});

export const POST = withWorkspace(async ({ workspace }) => {
const addPaymentMethodSchema = z.object({
method: z.enum(["card", "us_bank_account"]).optional(),
});

export const POST = withWorkspace(async ({ workspace, req }) => {
if (!workspace.stripeId) {
return NextResponse.json({ error: "Workspace does not have a Stripe ID" });
}

const { url } = await stripe.billingPortal.sessions.create({
const { method } = addPaymentMethodSchema.parse(await parseRequestBody(req));

if (!method) {
const { url } = await stripe.billingPortal.sessions.create({
customer: workspace.stripeId,
return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
flow_data: {
type: "payment_method_update",
},
});

return NextResponse.json({ url });
}

const { url } = await stripe.checkout.sessions.create({
mode: "setup",
customer: workspace.stripeId,
return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
flow_data: {
type: "payment_method_update",
},
payment_method_types: [method],
success_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
cancel_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
});

return NextResponse.json({ url });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import useWorkspace from "@/lib/swr/use-workspace";
import { PayoutsCount } from "@/lib/types";
import { usePayoutInvoiceSheet } from "@/ui/partners/payout-invoice-sheet";
import { PayoutStatus } from "@dub/prisma/client";
import { Button, buttonVariants, Tooltip } from "@dub/ui";
import { Button, buttonVariants, Tooltip, TooltipContent } from "@dub/ui";
import { cn, currencyFormatter } from "@dub/utils";
import Link from "next/link";

export function PayoutStats() {
const { slug } = useWorkspace();
const { slug, payoutMethodId } = useWorkspace();
const { payoutInvoiceSheet, setIsOpen } = usePayoutInvoiceSheet();

const { payoutsCount, loading } = usePayoutsCount<PayoutsCount[]>({
Expand Down Expand Up @@ -59,9 +59,15 @@ export function PayoutStats() {
onClick={() => setIsOpen(true)}
disabled={confirmButtonDisabled}
disabledTooltip={
confirmButtonDisabled
? "You have no pending payouts that match the minimum payout requirement for partners that have payouts enabled."
: undefined
confirmButtonDisabled ? (
"You have no pending payouts that match the minimum payout requirement for partners that have payouts enabled."
) : !payoutMethodId ? (
<TooltipContent
title="You must have a valid ACH bank account payment method to send payouts."
cta="Add payment method"
href={`/${slug}/settings/billing`}
/>
) : undefined
}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ export function PayoutTable() {
error,
isLoading,
} = useSWR<PayoutResponse[]>(
`/api/programs/${programId}/payouts${getQueryString({ workspaceId })}`,
`/api/programs/${programId}/payouts${getQueryString(
{ workspaceId },
{
ignore: ["payoutId"],
},
)}`,
fetcher,
{
keepPreviousData: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
import { CreditCard, GreekTemple, StripeLink } from "@dub/ui/icons";
import { Stripe } from "stripe";

export const PaymentMethodTypesList = (paymentMethod: Stripe.PaymentMethod) =>
export const PaymentMethodTypesList = (paymentMethod?: Stripe.PaymentMethod) =>
[
{
type: "card",
title: "Card",
icon: CreditCard,
description: paymentMethod.card
description: paymentMethod?.card
? `Connected ${paymentMethod.card.brand} ***${paymentMethod.card.last4}`
: "No card connected",
},
{
type: "us_bank_account",
title: "ACH",
icon: GreekTemple,
description: paymentMethod.us_bank_account
description: paymentMethod?.us_bank_account
? `Connected ${paymentMethod.us_bank_account.account_holder_type} account ending in ${paymentMethod.us_bank_account.last4}`
: "No ACH Debit connected",
},
Expand All @@ -26,7 +26,9 @@ export const PaymentMethodTypesList = (paymentMethod: Stripe.PaymentMethod) =>
title: "Link",
icon: StripeLink,
iconBgColor: "bg-green-100",
description: `Connected Link account ${paymentMethod.link?.email}`,
description: paymentMethod?.link
? `Connected Link account ${paymentMethod.link?.email}`
: "No Link account connected",
},
] satisfies {
type: Stripe.PaymentMethod.Type;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,45 @@
import usePaymentMethods from "@/lib/swr/use-payment-methods";
import useWorkspace from "@/lib/swr/use-workspace";
import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state";
import { Button, TooltipContent } from "@dub/ui";
import { CreditCard } from "@dub/ui/icons";
import ManageSubscriptionButton from "@/ui/workspaces/manage-subscription-button";
import { Badge, Button, CreditCard } from "@dub/ui";
import { cn } from "@dub/utils";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Stripe } from "stripe";
import { PaymentMethodTypesList } from "./payment-method-types";

export default function PaymentMethods() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { slug, stripeId } = useWorkspace();
const { stripeId, partnersEnabled } = useWorkspace();
const { paymentMethods } = usePaymentMethods();

const managePaymentMethods = async () => {
setIsLoading(true);
const { url } = await fetch(
`/api/workspaces/${slug}/billing/payment-methods`,
{
method: "POST",
},
).then((res) => res.json());
const regularPaymentMethods = paymentMethods?.filter(
(pm) => pm.type !== "us_bank_account",
);

router.push(url);
setIsLoading(false);
};
const achPaymentMethods = paymentMethods?.filter(
(pm) => pm.type === "us_bank_account",
);

return (
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="flex flex-col items-start justify-between gap-y-4 p-6 md:p-8 xl:flex-row">
<div className="flex flex-col items-center justify-between gap-y-4 p-6 md:p-8 xl:flex-row">
<div>
<h2 className="text-xl font-medium">Payment methods</h2>
<p className="text-balance text-sm leading-normal text-neutral-500">
Manage your payment methods on Dub
</p>
</div>
<Button
variant="secondary"
text="Manage"
className="h-8 w-fit"
disabledTooltip={
!stripeId && (
<TooltipContent
title="You must upgrade to a paid plan to manage your payment methods."
cta="Upgrade"
href={`/${slug}/upgrade`}
/>
)
}
onClick={managePaymentMethods}
loading={isLoading}
/>
{stripeId && (
<ManageSubscriptionButton text="Manage" className="w-fit" />
)}
</div>
<div className="grid gap-4 border-t border-neutral-200 p-6">
{paymentMethods ? (
paymentMethods.length > 0 ? (
paymentMethods.map((paymentMethod) => (
{regularPaymentMethods ? (
regularPaymentMethods.length > 0 ? (
regularPaymentMethods.map((paymentMethod) => (
<PaymentMethodCard
key={paymentMethod.id}
type={paymentMethod.type}
paymentMethod={paymentMethod}
/>
))
Expand All @@ -82,27 +62,60 @@ export default function PaymentMethods() {
<>
<PaymentMethodCardSkeleton />
<PaymentMethodCardSkeleton />
<PaymentMethodCardSkeleton />
</>
)}
</div>
{partnersEnabled && achPaymentMethods && (
<div className="grid gap-4 border-t border-neutral-200 p-6">
{achPaymentMethods.length > 0 ? (
achPaymentMethods.map((paymentMethod) => (
<PaymentMethodCard
key={paymentMethod.id}
type={paymentMethod.type}
paymentMethod={paymentMethod}
/>
))
) : (
<PaymentMethodCard type="us_bank_account" />
)}
</div>
)}
</div>
);
}

const PaymentMethodCard = ({
type,
paymentMethod,
}: {
paymentMethod: Stripe.PaymentMethod;
type: Stripe.PaymentMethod.Type;
paymentMethod?: Stripe.PaymentMethod;
}) => {
const { slug } = useWorkspace();
const [isLoading, setIsLoading] = useState(false);

const result = PaymentMethodTypesList(paymentMethod);

const {
title,
icon: Icon,
iconBgColor,
description,
} = PaymentMethodTypesList(paymentMethod).find(
(method) => method.type === paymentMethod.type,
) || PaymentMethodTypesList(paymentMethod)[0];
} = result.find((method) => method.type === type) || result[0];

const managePaymentMethods = async (method: string) => {
setIsLoading(true);
const { url } = await fetch(
`/api/workspaces/${slug}/billing/payment-methods`,
{
method: "POST",
body: JSON.stringify({ method }),
},
).then((res) => res.json());

window.open(url, "_blank");
setIsLoading(false);
};

return (
<div className="flex items-center justify-between rounded-lg border border-neutral-200 p-4">
Expand All @@ -116,10 +129,26 @@ const PaymentMethodCard = ({
<Icon className="size-6 text-neutral-700" />
</div>
<div>
<p className="font-medium text-neutral-900">{title}</p>
<div className="flex items-center gap-2">
<p className="font-medium text-neutral-900">{title}</p>
{type === "us_bank_account" && (
<Badge variant="neutral">
Recommended for Dub Partners payouts
</Badge>
)}
</div>
<p className="text-sm text-neutral-500">{description}</p>
</div>
</div>
{!paymentMethod && (
<Button
variant="primary"
className="h-8 w-fit"
text="Connect"
onClick={() => managePaymentMethods(type)}
loading={isLoading}
/>
)}
</div>
);
};
Expand Down
13 changes: 9 additions & 4 deletions apps/web/lib/swr/use-payouts-count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ export default function usePayoutsCount<T>(

const { data: payoutsCount, error } = useSWR<PayoutsCount[]>(
workspaceId &&
`/api/programs/${programId}/payouts/count${getQueryString({
...opts,
workspaceId,
})}`,
`/api/programs/${programId}/payouts/count${getQueryString(
{
...opts,
workspaceId,
},
{
ignore: ["payoutId"],
},
)}`,
fetcher,
);

Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/zod/schemas/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export const WorkspaceSchema = z
.describe(
"Whether the workspace has claimed a free .link domain. (dub.link/free)",
),
partnersEnabled: z
.boolean()
.describe("Whether the workspace has Dub Partners enabled."),

createdAt: z
.date()
Expand Down
Loading
Loading