Skip to content

Commit e95ddcd

Browse files
jnsdlsMananTank
andauthored
[Dashboard] Feature: Integrate billing v2 init (#5432)
Co-authored-by: Manan Tank <[email protected]>
1 parent 7973d64 commit e95ddcd

File tree

90 files changed

+2007
-4176
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

90 files changed

+2007
-4176
lines changed

apps/dashboard/framer-rewrites.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
module.exports = [
33
// -- home
44
"/",
5+
"/pricing",
56
// -- product landing pages --
67
// -- connect
78
"/connect/sign-in",

apps/dashboard/next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
115115
};
116116

117117
const baseNextConfig: NextConfig = {
118+
serverExternalPackages: ["pino-pretty"],
118119
async headers() {
119120
return [
120121
{

apps/dashboard/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@
4848
"@radix-ui/react-tooltip": "1.1.4",
4949
"@sentry/nextjs": "8.38.0",
5050
"@shazow/whatsabi": "^0.16.0",
51-
"@stripe/react-stripe-js": "^2.8.1",
52-
"@stripe/stripe-js": "^3.5.0",
5351
"@tanstack/react-query": "5.60.2",
5452
"@tanstack/react-table": "^8.17.3",
5553
"@thirdweb-dev/service-utils": "workspace:*",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"use server";
2+
3+
import "server-only";
4+
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
5+
import { redirect } from "next/navigation";
6+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
7+
import type { ProductSKU } from "../lib/billing";
8+
9+
export type RedirectCheckoutOptions = {
10+
teamSlug: string;
11+
sku: ProductSKU;
12+
redirectPath?: string;
13+
metadata?: Record<string, string>;
14+
};
15+
export async function redirectToCheckout(
16+
options: RedirectCheckoutOptions,
17+
): Promise<{ status: number }> {
18+
if (!options.teamSlug) {
19+
return {
20+
status: 400,
21+
};
22+
}
23+
const token = await getAuthToken();
24+
25+
if (!token) {
26+
return {
27+
status: 401,
28+
};
29+
}
30+
31+
const res = await fetch(
32+
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
33+
{
34+
method: "POST",
35+
body: JSON.stringify({
36+
sku: options.sku,
37+
redirectTo: getAbsoluteUrlFromPath(
38+
options.redirectPath ||
39+
`/team/${options.teamSlug}/~/settings/billing`,
40+
).toString(),
41+
metadata: options.metadata || {},
42+
}),
43+
headers: {
44+
"Content-Type": "application/json",
45+
Authorization: `Bearer ${token}`,
46+
},
47+
},
48+
);
49+
if (!res.ok) {
50+
return {
51+
status: res.status,
52+
};
53+
}
54+
const json = await res.json();
55+
if (!json.result) {
56+
return {
57+
status: 500,
58+
};
59+
}
60+
61+
// redirect to the stripe checkout session
62+
redirect(json.result);
63+
}
64+
65+
export type BillingPortalOptions = {
66+
teamSlug: string | undefined;
67+
redirectPath?: string;
68+
};
69+
export async function redirectToBillingPortal(
70+
options: BillingPortalOptions,
71+
): Promise<{ status: number }> {
72+
if (!options.teamSlug) {
73+
return {
74+
status: 400,
75+
};
76+
}
77+
const token = await getAuthToken();
78+
if (!token) {
79+
return {
80+
status: 401,
81+
};
82+
}
83+
84+
const res = await fetch(
85+
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
86+
{
87+
method: "POST",
88+
body: JSON.stringify({
89+
redirectTo: getAbsoluteUrlFromPath(
90+
options.redirectPath ||
91+
`/team/${options.teamSlug}/~/settings/billing`,
92+
).toString(),
93+
}),
94+
headers: {
95+
"Content-Type": "application/json",
96+
Authorization: `Bearer ${token}`,
97+
},
98+
},
99+
);
100+
101+
if (!res.ok) {
102+
return {
103+
status: res.status,
104+
};
105+
}
106+
107+
const json = await res.json();
108+
109+
if (!json.result) {
110+
return {
111+
status: 500,
112+
};
113+
}
114+
115+
// redirect to the stripe billing portal
116+
redirect(json.result);
117+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
2+
import { API_SERVER_URL } from "../constants/env";
3+
import type { ProductSKU } from "../lib/billing";
4+
5+
type InvoiceLine = {
6+
// amount for this line item
7+
amount: number;
8+
// statement descriptor
9+
description: string | null;
10+
// the thirdweb product sku or null if it is not recognized
11+
thirdwebSku: ProductSKU | null;
12+
};
13+
14+
type Invoice = {
15+
// total amount excluding tax
16+
amount: number | null;
17+
// the ISO currency code (e.g. USD)
18+
currency: string;
19+
// the line items on the invoice
20+
lines: InvoiceLine[];
21+
};
22+
23+
export type TeamSubscription = {
24+
id: string;
25+
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
26+
status:
27+
| "incomplete"
28+
| "incomplete_expired"
29+
| "trialing"
30+
| "active"
31+
| "past_due"
32+
| "canceled"
33+
| "unpaid"
34+
| "paused";
35+
currentPeriodStart: string;
36+
currentPeriodEnd: string;
37+
trialStart: string | null;
38+
trialEnd: string | null;
39+
upcomingInvoice: Invoice;
40+
};
41+
42+
export async function getTeamSubscriptions(slug: string) {
43+
const token = await getAuthToken();
44+
45+
if (!token) {
46+
return null;
47+
}
48+
49+
const teamRes = await fetch(
50+
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
51+
{
52+
headers: {
53+
Authorization: `Bearer ${token}`,
54+
},
55+
},
56+
);
57+
58+
if (teamRes.ok) {
59+
return (await teamRes.json())?.result as TeamSubscription[];
60+
}
61+
return null;
62+
}

apps/dashboard/src/@/api/team.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export type Team = {
1313
deletedAt?: string;
1414
bannedAt?: string;
1515
image?: string;
16-
billingPlan: "pro" | "growth" | "free";
16+
billingPlan: "pro" | "growth" | "free" | "starter";
1717
billingStatus: "validPayment" | (string & {}) | null;
1818
billingEmail: string | null;
19+
growthTrialEligible: boolean | null;
1920
};
2021

2122
export async function getTeamBySlug(slug: string) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client";
2+
3+
import {
4+
type BillingPortalOptions,
5+
type RedirectCheckoutOptions,
6+
redirectToBillingPortal,
7+
redirectToCheckout,
8+
} from "../actions/billing";
9+
import { Button, type ButtonProps } from "./ui/button";
10+
11+
type CheckoutButtonProps = RedirectCheckoutOptions & ButtonProps;
12+
export function CheckoutButton({
13+
onClick,
14+
teamSlug,
15+
sku,
16+
metadata,
17+
redirectPath,
18+
children,
19+
...restProps
20+
}: CheckoutButtonProps) {
21+
return (
22+
<Button
23+
{...restProps}
24+
onClick={async (e) => {
25+
onClick?.(e);
26+
await redirectToCheckout({
27+
teamSlug,
28+
sku,
29+
metadata,
30+
redirectPath,
31+
});
32+
}}
33+
>
34+
{children}
35+
</Button>
36+
);
37+
}
38+
39+
type BillingPortalButtonProps = BillingPortalOptions & ButtonProps;
40+
export function BillingPortalButton({
41+
onClick,
42+
teamSlug,
43+
redirectPath,
44+
children,
45+
...restProps
46+
}: BillingPortalButtonProps) {
47+
return (
48+
<Button
49+
{...restProps}
50+
onClick={async (e) => {
51+
onClick?.(e);
52+
await redirectToBillingPortal({
53+
teamSlug,
54+
redirectPath,
55+
});
56+
}}
57+
>
58+
{children}
59+
</Button>
60+
);
61+
}

apps/dashboard/src/@/constants/env.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,16 @@ export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
3333
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
3434
// Comma-separated list of chain IDs to disable faucet for.
3535
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
36+
37+
export function getAbsoluteUrlFromPath(path: string) {
38+
const url = new URL(
39+
isProd
40+
? "https://thirdweb.com"
41+
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
42+
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
43+
: "http://localhost:3000") || "https://thirdweb-dev.com",
44+
);
45+
46+
url.pathname = path;
47+
return url;
48+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// keep in line with product SKUs in the backend
2+
export type ProductSKU =
3+
| "plan:starter"
4+
| "plan:growth"
5+
| "plan:custom"
6+
| "product:ecosystem_wallets"
7+
| "product:engine_standard"
8+
| "product:engine_premium"
9+
| "usage:storage"
10+
| "usage:in_app_wallet"
11+
| "usage:aa_sponsorship"
12+
| "usage:aa_sponsorship_op_grant"
13+
| null;

0 commit comments

Comments
 (0)