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
163 changes: 1 addition & 162 deletions apps/dashboard/src/@/actions/billing.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,7 @@
"use server";
import "server-only";

import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";

export type GetBillingCheckoutUrlOptions = {
teamSlug: string;
sku: ProductSKU;
redirectUrl: string;
metadata?: Record<string, string>;
};

export async function getBillingCheckoutUrl(
options: GetBillingCheckoutUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();

if (!token) {
return {
status: 401,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
{
method: "POST",
body: JSON.stringify({
sku: options.sku,
redirectTo: options.redirectUrl,
metadata: options.metadata || {},
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return {
status: res.status,
};
}

const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}

return {
status: 200,
url: json.result as string,
};
}

export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl;

export async function getPlanCancelUrl(options: {
teamId: string;
redirectUrl: string;
}): Promise<{ status: number; url?: string }> {
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamId}/checkout/cancel-plan-link`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
redirectTo: options.redirectUrl,
}),
},
);

if (!res.ok) {
return {
status: res.status,
};
}

const json = await res.json();

if (!json.result) {
return {
status: 500,
};
}

return {
status: 200,
url: json.result as string,
};
}
import { API_SERVER_URL } from "../constants/env";

export async function reSubscribePlan(options: {
teamId: string;
Expand Down Expand Up @@ -141,58 +35,3 @@ export async function reSubscribePlan(options: {
status: 200,
};
}
export type GetBillingPortalUrlOptions = {
teamSlug: string | undefined;
redirectUrl: string;
};

export async function getBillingPortalUrl(
options: GetBillingPortalUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
{
method: "POST",
body: JSON.stringify({
redirectTo: options.redirectUrl,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);

if (!res.ok) {
return {
status: res.status,
};
}

const json = await res.json();

if (!json.result) {
return {
status: 500,
};
}

return {
status: 200,
url: json.result as string,
};
}

export type GetBillingPortalUrlAction = typeof getBillingPortalUrl;
157 changes: 40 additions & 117 deletions apps/dashboard/src/@/components/billing.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,51 @@
"use client";

import { useMutation } from "@tanstack/react-query";
import { AlertTriangleIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import type {
GetBillingCheckoutUrlAction,
GetBillingCheckoutUrlOptions,
GetBillingPortalUrlAction,
GetBillingPortalUrlOptions,
} from "../actions/billing";
import {
buildBillingPortalUrl,
buildCheckoutUrl,
} from "../../app/(app)/(stripe)/utils/build-url";
import type { Team } from "../api/team";
import type { ProductSKU } from "../lib/billing";
import { cn } from "../lib/utils";
import { Spinner } from "./ui/Spinner/Spinner";
import { Button, type ButtonProps } from "./ui/button";

type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
export function CheckoutButton(props: {
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
billingStatus: Team["billingStatus"];
};

export function CheckoutButton({
teamSlug,
sku,
metadata,
getBillingCheckoutUrl,
children,
buttonProps,
billingStatus,
}: CheckoutButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingCheckoutUrl({
teamSlug,
sku,
metadata,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});

const errorMessage = "Failed to open checkout page";

teamSlug: string;
sku: Exclude<ProductSKU, null>;
}) {
return (
<div className="flex w-full flex-col items-center gap-2">
{/* show warning if the team has an invalid payment method */}
{billingStatus === "invalidPayment" && (
<BillingWarning teamSlug={teamSlug} />
{props.billingStatus === "invalidPayment" && (
<BillingWarning teamSlug={props.teamSlug} />
)}
<Button
{...buttonProps}
className={cn(buttonProps?.className, "w-full gap-2")}
{...props.buttonProps}
asChild
className={cn(props.buttonProps?.className, "w-full gap-2")}
disabled={
// disable button if the team has an invalid payment method
// api will return 402 error if the team has an invalid payment method
billingStatus === "invalidPayment" ||
getUrlMutation.isPending ||
buttonProps?.disabled
props.billingStatus === "invalidPayment" ||
props.buttonProps?.disabled
}
onClick={async (e) => {
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}

const tab = window.open(res.url, "_blank");

if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
props.buttonProps?.onClick?.(e);
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
<Link
target="_blank"
href={buildCheckoutUrl({
teamSlug: props.teamSlug,
sku: props.sku,
})}
>
{props.children}
</Link>
</Button>
</div>
);
Expand All @@ -107,66 +69,27 @@ function BillingWarning({ teamSlug }: { teamSlug: string }) {
);
}

type BillingPortalButtonProps = Omit<
GetBillingPortalUrlOptions,
"redirectUrl"
> & {
getBillingPortalUrl: GetBillingPortalUrlAction;
export function BillingPortalButton(props: {
teamSlug: string;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};

export function BillingPortalButton({
teamSlug,
children,
getBillingPortalUrl,
buttonProps,
}: BillingPortalButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingPortalUrl({
teamSlug,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});

const errorMessage = "Failed to open billing portal";

}) {
return (
<Button
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
{...props.buttonProps}
className={cn(props.buttonProps?.className, "gap-2")}
disabled={props.buttonProps?.disabled}
asChild
onClick={async (e) => {
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess(res) {
if (!res.url) {
toast.error(errorMessage);
return;
}

const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
props.buttonProps?.onClick?.(e);
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
<Link
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
target="_blank"
>
{props.children}
</Link>
</Button>
);
}

function getAbsoluteUrl(path: string) {
const url = new URL(window.location.origin);
url.pathname = path;
return url.toString();
}
Loading
Loading