Skip to content

Commit d540448

Browse files
committed
integrate billing v2 init
1 parent e0eea33 commit d540448

Some content is hidden

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

59 files changed

+1459
-2143
lines changed

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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import "server-only";
2+
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
3+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
4+
5+
export async function getStripeCheckoutLink(slug: string, sku: string) {
6+
const token = await getAuthToken();
7+
8+
if (!token) {
9+
return {
10+
status: 401,
11+
link: null,
12+
};
13+
}
14+
15+
const res = await fetch(
16+
`${API_SERVER_URL}/v1/teams/${slug}/checkout/create-link`,
17+
{
18+
method: "POST",
19+
body: JSON.stringify({
20+
sku: decodeURIComponent(sku),
21+
redirectTo: getAbsoluteUrlFromPath(
22+
`/team/${slug}/~/settings/billing`,
23+
).toString(),
24+
}),
25+
headers: {
26+
"Content-Type": "application/json",
27+
Authorization: `Bearer ${token}`,
28+
},
29+
},
30+
);
31+
if (res.ok) {
32+
return {
33+
status: 200,
34+
link: (await res.json())?.result as string,
35+
} as const;
36+
}
37+
return {
38+
status: res.status,
39+
link: null,
40+
} as const;
41+
}
42+
43+
export async function getStripeBillingPortalLink(slug: string) {
44+
const token = await getAuthToken();
45+
46+
if (!token) {
47+
return {
48+
status: 401,
49+
link: null,
50+
};
51+
}
52+
53+
const res = await fetch(
54+
`${API_SERVER_URL}/v1/teams/${slug}/checkout/create-session-link`,
55+
{
56+
method: "POST",
57+
body: JSON.stringify({
58+
redirectTo: getAbsoluteUrlFromPath(
59+
`/team/${slug}/~/settings/billing`,
60+
).toString(),
61+
}),
62+
headers: {
63+
"Content-Type": "application/json",
64+
Authorization: `Bearer ${token}`,
65+
},
66+
},
67+
);
68+
if (res.ok) {
69+
return {
70+
status: 200,
71+
link: (await res.json())?.result as string,
72+
} as const;
73+
}
74+
return {
75+
status: res.status,
76+
link: null,
77+
} as const;
78+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
2+
import { API_SERVER_URL } from "../constants/env";
3+
4+
// keep in line with product SKUs in the backend
5+
type ProductSKU =
6+
| "plan:starter"
7+
| "plan:growth"
8+
| "plan:custom"
9+
| "product:ecosystem_wallets"
10+
| "product:engine_standard"
11+
| "product:engine_premium"
12+
| "usage:storage"
13+
| "usage:in_app_wallet"
14+
| "usage:aa_sponsorship"
15+
| "usage:aa_sponsorship_op_grant"
16+
| null;
17+
18+
type InvoiceLine = {
19+
// amount for this line item
20+
amount: number;
21+
// statement descriptor
22+
description: string | null;
23+
// the thirdweb product sku or null if it is not recognized
24+
thirdwebSku: ProductSKU | null;
25+
};
26+
27+
type Invoice = {
28+
// total amount excluding tax
29+
amount: number | null;
30+
// the ISO currency code (e.g. USD)
31+
currency: string;
32+
// the line items on the invoice
33+
lines: InvoiceLine[];
34+
};
35+
36+
export type TeamSubscription = {
37+
id: string;
38+
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
39+
status:
40+
| "incomplete"
41+
| "incomplete_expired"
42+
| "trialing"
43+
| "active"
44+
| "past_due"
45+
| "canceled"
46+
| "unpaid"
47+
| "paused";
48+
currentPeriodStart: string;
49+
currentPeriodEnd: string;
50+
trialStart: string | null;
51+
trialEnd: string | null;
52+
upcomingInvoice: Invoice;
53+
};
54+
55+
export async function getTeamSubscriptions(slug: string) {
56+
const token = await getAuthToken();
57+
58+
if (!token) {
59+
return null;
60+
}
61+
62+
const teamRes = await fetch(
63+
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
64+
{
65+
headers: {
66+
Authorization: `Bearer ${token}`,
67+
},
68+
},
69+
);
70+
71+
if (teamRes.ok) {
72+
return (await teamRes.json())?.result as TeamSubscription[];
73+
}
74+
return null;
75+
}
76+
77+
// util fn:
78+
79+
export function parseThirdwebSKU(sku: ProductSKU) {
80+
if (!sku) {
81+
return null;
82+
}
83+
switch (sku) {
84+
case "plan:starter":
85+
return "Starter Plan";
86+
case "plan:growth":
87+
return "Growth Plan";
88+
case "plan:custom":
89+
return "Custom Plan";
90+
case "product:ecosystem_wallets":
91+
return "Ecosystem Wallets";
92+
case "product:engine_standard":
93+
return "Engine Standard";
94+
case "product:engine_premium":
95+
return "Engine Premium";
96+
case "usage:storage":
97+
return "Storage";
98+
case "usage:in_app_wallet":
99+
return "In-App Wallet";
100+
case "usage:aa_sponsorship":
101+
return "AA Sponsorship";
102+
case "usage:aa_sponsorship_op_grant":
103+
return "AA Sponsorship Op Grant";
104+
default:
105+
return null;
106+
}
107+
}

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) {

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+
}

apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts

Lines changed: 1 addition & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const accountPlan = {
3030
} as const;
3131

3232
type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
33-
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
33+
type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
3434

3535
export type AuthorizedWallet = {
3636
id: string;
@@ -498,51 +498,6 @@ export function useUpdateAccount() {
498498
});
499499
}
500500

501-
export function useUpdateAccountPlan(waitForWebhook?: boolean) {
502-
const { user } = useLoggedInUser();
503-
const queryClient = useQueryClient();
504-
505-
return useMutation({
506-
mutationFn: async (input: { plan: string; feedback?: string }) => {
507-
invariant(user?.address, "walletAddress is required");
508-
509-
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/plan`, {
510-
method: "PUT",
511-
512-
headers: {
513-
"Content-Type": "application/json",
514-
},
515-
body: JSON.stringify(input),
516-
});
517-
518-
const json = await res.json();
519-
520-
if (json.error) {
521-
throw new Error(json.error.message);
522-
}
523-
524-
// Wait for account plan to update via stripe webhook
525-
// TODO: find a better way to notify the client that the plan has been updated
526-
if (waitForWebhook) {
527-
await new Promise((resolve) => setTimeout(resolve, 1000 * 10));
528-
}
529-
530-
return json.data;
531-
},
532-
onSuccess: async () => {
533-
return Promise.all([
534-
// invalidate usage data as limits are different
535-
queryClient.invalidateQueries({
536-
queryKey: accountKeys.me(user?.address as string),
537-
}),
538-
queryClient.invalidateQueries({
539-
queryKey: accountKeys.usage(user?.address as string),
540-
}),
541-
]);
542-
},
543-
});
544-
}
545-
546501
export function useUpdateNotifications() {
547502
const { user } = useLoggedInUser();
548503
const queryClient = useQueryClient();
@@ -681,40 +636,6 @@ export function useResendEmailConfirmation() {
681636
});
682637
}
683638

684-
export function useCreatePaymentMethod() {
685-
const { user } = useLoggedInUser();
686-
const queryClient = useQueryClient();
687-
688-
return useMutation({
689-
mutationFn: async (paymentMethodId: string) => {
690-
invariant(user?.address, "walletAddress is required");
691-
692-
const res = await fetch(`${THIRDWEB_API_HOST}/v1/account/paymentMethod`, {
693-
method: "POST",
694-
695-
headers: {
696-
"Content-Type": "application/json",
697-
},
698-
body: JSON.stringify({
699-
paymentMethodId,
700-
}),
701-
});
702-
const json = await res.json();
703-
704-
if (json.error) {
705-
throw new Error(json.error.message);
706-
}
707-
708-
return json.data;
709-
},
710-
onSuccess: () => {
711-
return queryClient.invalidateQueries({
712-
queryKey: accountKeys.me(user?.address as string),
713-
});
714-
},
715-
});
716-
}
717-
718639
export function useApiKeys() {
719640
const { user, isLoggedIn } = useLoggedInUser();
720641
return useQuery({

apps/dashboard/src/app/account/settings/getAccount.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ import { getAuthToken } from "../../api/lib/getAuthToken";
44

55
export async function getAccount() {
66
const authToken = await getAuthToken();
7-
const apiServerURL = new URL(API_SERVER_URL);
87

9-
apiServerURL.pathname = "/v1/account/me";
10-
11-
const res = await fetch(apiServerURL, {
8+
const res = await fetch(`${API_SERVER_URL}/v1/account/me`, {
129
method: "GET",
1310
headers: {
1411
Authorization: `Bearer ${authToken}`,

apps/dashboard/src/app/components/TeamPlanBadge.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import type { Team } from "@/api/team";
12
import { Badge } from "@/components/ui/badge";
23
import { cn } from "@/lib/utils";
34

45
export function TeamPlanBadge(props: {
5-
plan: "free" | "growth" | "pro";
6+
plan: Team["billingPlan"];
67
className?: string;
78
}) {
89
return (
910
<Badge
1011
variant={
11-
props.plan === "free"
12+
props.plan === "free" || props.plan === "starter"
1213
? "secondary"
1314
: props.plan === "growth"
1415
? "success"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Note: this path is meant specifically for subscribing to a plan or product, it renders no UI and instead will redirect the user to stripe UI to complete the subscription process.
2+
3+
Why have this at all? Because this way we can have a public link that will (after login complete) redirect the user to the correct subscrtiption page.

0 commit comments

Comments
 (0)