Skip to content

Commit 16d815b

Browse files
committed
add /subscriptions endpoint
1 parent 181f3a8 commit 16d815b

File tree

14 files changed

+592
-90
lines changed

14 files changed

+592
-90
lines changed

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,40 @@ export async function getStripeCheckoutLink(slug: string, sku: string) {
3939
link: null,
4040
} as const;
4141
}
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/@/constants/env.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ export function getAbsoluteUrlFromPath(path: string) {
3838
const url = new URL(
3939
isProd
4040
? "https://thirdweb.com"
41-
: `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` ||
42-
"https://thirdweb-dev.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",
4344
);
4445

4546
url.pathname = path;
File renamed without changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { getStripeBillingPortalLink } from "@/api/team-billing";
2+
import { RedirectType, notFound, redirect } from "next/navigation";
3+
4+
interface PageParams {
5+
team_slug: string;
6+
}
7+
8+
interface PageProps {
9+
params: Promise<PageParams>;
10+
}
11+
12+
export default async function TeamBillingPortalLink(props: PageProps) {
13+
const params = await props.params;
14+
// get the stripe checkout link for the team + sku from the API
15+
// this returns a status code and a link (if success)
16+
// 200: success
17+
// 400: invalid params
18+
// 401: user not authenticated
19+
// 403: user not allowed to subscribe (not admin)
20+
// 500: something random else went wrong
21+
const { link, status } = await getStripeBillingPortalLink(params.team_slug);
22+
23+
console.log("status", status);
24+
25+
if (link) {
26+
// we want to REPLACE so when the user navigates BACK the do not end up back here but on the previous page
27+
redirect(link, RedirectType.replace);
28+
}
29+
30+
switch (status) {
31+
case 400: {
32+
return <div>Invalid Params</div>;
33+
}
34+
case 401: {
35+
return <div>User not authenticated</div>;
36+
}
37+
case 403: {
38+
return <div>User not allowed to subscribe</div>;
39+
}
40+
41+
// default case
42+
default: {
43+
// todo handle this better
44+
notFound();
45+
}
46+
}
47+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { addDays } from "date-fns";
3+
import {
4+
createDashboardAccountStub,
5+
teamStub,
6+
teamSubscriptionsStub,
7+
} from "stories/stubs";
8+
import {
9+
BadgeContainer,
10+
mobileViewport,
11+
} from "../../../../../../../../stories/utils";
12+
import { PlanInfoCard } from "./PlanInfoCard";
13+
14+
const meta = {
15+
title: "Billing/PlanInfoCard",
16+
component: Story,
17+
parameters: {
18+
nextjs: {
19+
appDirectory: true,
20+
},
21+
},
22+
} satisfies Meta<typeof Story>;
23+
24+
export default meta;
25+
type Story = StoryObj<typeof meta>;
26+
27+
export const Desktop: Story = {
28+
args: {},
29+
};
30+
31+
export const Mobile: Story = {
32+
args: {},
33+
parameters: {
34+
viewport: mobileViewport("iphone14"),
35+
},
36+
};
37+
38+
function Story() {
39+
const team = teamStub("foo", "growth");
40+
const zeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth");
41+
const trialPlanZeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth", {
42+
trialEnd: addDays(new Date(), 7).toISOString(),
43+
});
44+
45+
const subsWith1Usage = teamSubscriptionsStub("plan:growth", {
46+
usage: {
47+
storage: {
48+
amount: 10000,
49+
quantity: 4,
50+
},
51+
},
52+
});
53+
54+
const subsWith4Usage = teamSubscriptionsStub("plan:growth", {
55+
usage: {
56+
storage: {
57+
amount: 10000,
58+
quantity: 4,
59+
},
60+
aaSponsorshipAmount: {
61+
amount: 7500,
62+
quantity: 4,
63+
},
64+
aaSponsorshipOpGrantAmount: {
65+
amount: 2500,
66+
quantity: 4,
67+
},
68+
inAppWalletAmount: {
69+
amount: 40000,
70+
quantity: 100,
71+
},
72+
},
73+
});
74+
75+
const account = createDashboardAccountStub("foo");
76+
77+
return (
78+
<div className="container flex max-w-[1130px] flex-col gap-12 lg:p-10">
79+
<BadgeContainer label="On-demand Subscriptions with 0 usage">
80+
<PlanInfoCard
81+
team={team}
82+
subscriptions={zeroUsageOnDemandSubs}
83+
account={account}
84+
/>
85+
</BadgeContainer>
86+
87+
<BadgeContainer label="Trial Plan - On-demand Subscriptions with 0 usage">
88+
<PlanInfoCard
89+
team={team}
90+
subscriptions={trialPlanZeroUsageOnDemandSubs}
91+
account={account}
92+
/>
93+
</BadgeContainer>
94+
95+
<BadgeContainer label="On-demand Subscriptions with 1 usage">
96+
<PlanInfoCard
97+
team={team}
98+
subscriptions={subsWith1Usage}
99+
account={account}
100+
/>
101+
</BadgeContainer>
102+
103+
<BadgeContainer label="On-demand Subscriptions with 4 usage">
104+
<PlanInfoCard
105+
team={team}
106+
subscriptions={subsWith4Usage}
107+
account={account}
108+
/>
109+
</BadgeContainer>
110+
</div>
111+
);
112+
}

0 commit comments

Comments
 (0)