Skip to content

Commit 5ce9684

Browse files
committed
[server][dashboard] Implement a new Team Billing where Owners can conveniently manage a paid plan for their Team
1 parent f77a1ef commit 5ce9684

File tree

14 files changed

+665
-19
lines changed

14 files changed

+665
-19
lines changed

components/dashboard/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/New
6262
const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/JoinTeam"));
6363
const Members = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/Members"));
6464
const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamSettings"));
65+
const TeamBilling = React.lazy(() => import(/* webpackPrefetch: true */ "./teams/TeamBilling"));
6566
const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/NewProject"));
6667
const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/ConfigureProject"));
6768
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ "./projects/Projects"));
@@ -447,6 +448,9 @@ function App() {
447448
if (maybeProject === "settings") {
448449
return <TeamSettings />;
449450
}
451+
if (maybeProject === "billing") {
452+
return <TeamBilling />;
453+
}
450454
if (resourceOrPrebuild === "prebuilds") {
451455
return <Prebuilds />;
452456
}

components/dashboard/src/Menu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default function Menu() {
5454
"projects",
5555
"members",
5656
"settings",
57+
"billing",
5758
// admin sub-pages
5859
"users",
5960
"workspaces",
@@ -188,7 +189,7 @@ export default function Menu() {
188189
teamSettingsList.push({
189190
title: "Settings",
190191
link: `/t/${team.slug}/settings`,
191-
alternatives: getTeamSettingsMenu(team).flatMap((e) => e.link),
192+
alternatives: getTeamSettingsMenu({ team, showPaymentUI }).flatMap((e) => e.link),
192193
});
193194
}
194195

components/dashboard/src/chargebee/chargebee-client.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,23 @@ export interface OpenPortalParams {
4242
export class ChargebeeClient {
4343
constructor(protected readonly client: chargebee.Client) {}
4444

45-
static async getOrCreate(): Promise<ChargebeeClient> {
45+
static async getOrCreate(teamId?: string): Promise<ChargebeeClient> {
4646
const create = async () => {
4747
const chargebeeClient = await ChargebeeClientProvider.get();
4848
const client = new ChargebeeClient(chargebeeClient);
49-
client.createPortalSession();
49+
client.createPortalSession(teamId);
5050
return client;
5151
};
5252

5353
const w = window as any;
5454
const _gp = w._gp || (w._gp = {});
55-
const chargebeeClient = _gp.chargebeeClient || (_gp.chargebeeClient = await create());
56-
return chargebeeClient;
55+
if (teamId) {
56+
if (!_gp.chargebeeClients) {
57+
_gp.chargebeeClients = {};
58+
}
59+
return _gp.chargebeeClients[teamId] || (_gp.chargebeeClients[teamId] = await create());
60+
}
61+
return _gp.chargebeeClient || (_gp.chargebeeClient = await create());
5762
}
5863

5964
checkout(
@@ -82,10 +87,10 @@ export class ChargebeeClient {
8287
});
8388
}
8489

85-
createPortalSession() {
90+
createPortalSession(teamId?: string) {
8691
const paymentServer = getGitpodService().server;
8792
this.client.setPortalSession(async () => {
88-
return paymentServer.createPortalSession();
93+
return teamId ? paymentServer.createTeamPortalSession(teamId) : paymentServer.createPortalSession();
8994
});
9095
}
9196

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { TeamMemberInfo } from "@gitpod/gitpod-protocol";
8+
import { Currency, Plan, Plans } from "@gitpod/gitpod-protocol/lib/plans";
9+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
10+
import { useContext, useEffect, useState } from "react";
11+
import { useLocation } from "react-router";
12+
import { ChargebeeClient } from "../chargebee/chargebee-client";
13+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
14+
import Card from "../components/Card";
15+
import DropDown from "../components/DropDown";
16+
import PillLabel from "../components/PillLabel";
17+
import SolidCard from "../components/SolidCard";
18+
import { ReactComponent as CheckSvg } from "../images/check.svg";
19+
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
20+
import { PaymentContext } from "../payment-context";
21+
import { getGitpodService } from "../service/service";
22+
import { getCurrentTeam, TeamsContext } from "./teams-context";
23+
import { getTeamSettingsMenu } from "./TeamSettings";
24+
25+
type PendingPlan = Plan & { pendingSince: number };
26+
27+
export default function TeamBilling() {
28+
const { teams } = useContext(TeamsContext);
29+
const location = useLocation();
30+
const team = getCurrentTeam(location, teams);
31+
const [members, setMembers] = useState<TeamMemberInfo[]>([]);
32+
const [teamSubscription, setTeamSubscription] = useState<TeamSubscription2 | undefined>();
33+
const { showPaymentUI, currency, setCurrency } = useContext(PaymentContext);
34+
const [pendingTeamPlan, setPendingTeamPlan] = useState<PendingPlan | undefined>();
35+
const [pollTeamSubscriptionTimeout, setPollTeamSubscriptionTimeout] = useState<NodeJS.Timeout | undefined>();
36+
37+
useEffect(() => {
38+
if (!team) {
39+
return;
40+
}
41+
(async () => {
42+
const [memberInfos, subscription] = await Promise.all([
43+
getGitpodService().server.getTeamMembers(team.id),
44+
getGitpodService().server.getTeamSubscription(team.id),
45+
]);
46+
setMembers(memberInfos);
47+
setTeamSubscription(subscription);
48+
})();
49+
}, [team]);
50+
51+
useEffect(() => {
52+
setPendingTeamPlan(undefined);
53+
if (!team) {
54+
return;
55+
}
56+
try {
57+
const pendingTeamPlanString = window.localStorage.getItem(`pendingPlanForTeam${team.id}`);
58+
if (!pendingTeamPlanString) {
59+
return;
60+
}
61+
const pending = JSON.parse(pendingTeamPlanString);
62+
setPendingTeamPlan(pending);
63+
} catch (error) {
64+
console.error("Could not load pending team plan", team.id, error);
65+
}
66+
}, [team]);
67+
68+
useEffect(() => {
69+
if (!pendingTeamPlan || !team) {
70+
return;
71+
}
72+
if (teamSubscription && teamSubscription.planId === pendingTeamPlan.chargebeeId) {
73+
// The purchase was successful!
74+
window.localStorage.removeItem(`pendingPlanForTeam${team.id}`);
75+
clearTimeout(pollTeamSubscriptionTimeout!);
76+
setPendingTeamPlan(undefined);
77+
return;
78+
}
79+
if (pendingTeamPlan.pendingSince + 1000 * 60 * 5 < Date.now()) {
80+
// Pending team plans expire after 5 minutes
81+
window.localStorage.removeItem(`pendingPlanForTeam${team.id}`);
82+
clearTimeout(pollTeamSubscriptionTimeout!);
83+
setPendingTeamPlan(undefined);
84+
return;
85+
}
86+
if (!pollTeamSubscriptionTimeout) {
87+
// Refresh team subscription in 5 seconds in order to poll for purchase confirmation
88+
const timeout = setTimeout(async () => {
89+
const ts = await getGitpodService().server.getTeamSubscription(team.id);
90+
setTeamSubscription(ts);
91+
setPollTeamSubscriptionTimeout(undefined);
92+
}, 5000);
93+
setPollTeamSubscriptionTimeout(timeout);
94+
}
95+
return function cleanup() {
96+
clearTimeout(pollTeamSubscriptionTimeout!);
97+
};
98+
}, [pendingTeamPlan, pollTeamSubscriptionTimeout, team, teamSubscription]);
99+
100+
const availableTeamPlans = Plans.getAvailableTeamPlans(currency || "USD").filter((p) => p.type !== "student");
101+
102+
const checkout = async (plan: Plan) => {
103+
if (!team || members.length < 1) {
104+
return;
105+
}
106+
const chargebeeClient = await ChargebeeClient.getOrCreate(team.id);
107+
await new Promise((resolve, reject) => {
108+
chargebeeClient.checkout((paymentServer) => paymentServer.teamCheckout(team.id, plan.chargebeeId), {
109+
success: resolve,
110+
error: reject,
111+
});
112+
});
113+
const pending = {
114+
...plan,
115+
pendingSince: Date.now(),
116+
};
117+
setPendingTeamPlan(pending);
118+
window.localStorage.setItem(`pendingPlanForTeam${team.id}`, JSON.stringify(pending));
119+
};
120+
121+
const isLoading = members.length === 0;
122+
const teamPlan = pendingTeamPlan || Plans.getById(teamSubscription?.planId);
123+
124+
return (
125+
<PageWithSubMenu
126+
subMenu={getTeamSettingsMenu({ team, showPaymentUI })}
127+
title="Billing"
128+
subtitle="Manage team billing and plans."
129+
>
130+
<h3>{!teamPlan ? "No billing plan" : "Plan"}</h3>
131+
<h2 className="text-gray-500">
132+
{!teamPlan ? (
133+
<div className="flex space-x-1">
134+
<span>Select a new billing plan for this team. Currency:</span>
135+
<DropDown
136+
contextMenuWidth="w-32"
137+
activeEntry={currency}
138+
entries={[
139+
{
140+
title: "EUR",
141+
onClick: () => setCurrency("EUR"),
142+
},
143+
{
144+
title: "USD",
145+
onClick: () => setCurrency("USD"),
146+
},
147+
]}
148+
/>
149+
</div>
150+
) : (
151+
<span>
152+
This team is currently on the <strong>{teamPlan.name}</strong> plan.
153+
</span>
154+
)}
155+
</h2>
156+
<div className="mt-4 space-x-4 flex">
157+
{isLoading && (
158+
<>
159+
<SolidCard>
160+
<div className="w-full h-full flex flex-col items-center justify-center">
161+
<Spinner className="h-5 w-5 animate-spin" />
162+
</div>
163+
</SolidCard>
164+
<SolidCard>
165+
<div className="w-full h-full flex flex-col items-center justify-center">
166+
<Spinner className="h-5 w-5 animate-spin" />
167+
</div>
168+
</SolidCard>
169+
</>
170+
)}
171+
{!isLoading && !teamPlan && (
172+
<>
173+
{availableTeamPlans.map((tp) => (
174+
<>
175+
<SolidCard
176+
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700"
177+
onClick={() => checkout(tp)}
178+
>
179+
<div className="px-2 py-5 flex-grow flex flex-col">
180+
<div className="font-medium text-base">{tp.name}</div>
181+
<div className="font-semibold text-gray-500 text-sm">Unlimited hours</div>
182+
<div className="mt-8 font-semibold text-sm">Includes:</div>
183+
<div className="flex flex-col items-start text-sm">
184+
<span className="inline-flex space-x-1">
185+
<CheckSvg fill="currentColor" className="self-center mt-1" />
186+
<span>Public &amp; Private Repositories</span>
187+
</span>
188+
<span className="inline-flex space-x-1">
189+
<CheckSvg fill="currentColor" className="self-center mt-1" />
190+
<span>4 Parallel Workspaces</span>
191+
</span>
192+
<span className="inline-flex space-x-1">
193+
<CheckSvg fill="currentColor" className="self-center mt-1" />
194+
<span>30 min Inactivity Timeout</span>
195+
</span>
196+
</div>
197+
<div className="flex-grow flex flex-col items-end justify-end">
198+
<PillLabel type="warn" className="font-semibold normal-case text-sm">
199+
{members.length} x {Currency.getSymbol(tp.currency)}
200+
{tp.pricePerMonth} = {Currency.getSymbol(tp.currency)}
201+
{members.length * tp.pricePerMonth} per month
202+
</PillLabel>
203+
</div>
204+
</div>
205+
</SolidCard>
206+
</>
207+
))}
208+
</>
209+
)}
210+
{!isLoading && teamPlan && (
211+
<>
212+
<Card>
213+
<div className="px-2 py-5 flex-grow flex flex-col">
214+
<div className="font-bold text-base">{teamPlan.name}</div>
215+
<div className="font-semibold text-gray-500 text-sm">Unlimited hours</div>
216+
<div className="mt-8 font-semibold text-sm">Includes:</div>
217+
<div className="flex flex-col items-start text-sm">
218+
<span className="inline-flex space-x-1">
219+
<CheckSvg fill="currentColor" className="self-center mt-1" />
220+
<span>Public &amp; Private Repositories</span>
221+
</span>
222+
<span className="inline-flex space-x-1">
223+
<CheckSvg fill="currentColor" className="self-center mt-1" />
224+
<span>4 Parallel Workspaces</span>
225+
</span>
226+
<span className="inline-flex space-x-1">
227+
<CheckSvg fill="currentColor" className="self-center mt-1" />
228+
<span>30 min Inactivity Timeout</span>
229+
</span>
230+
</div>
231+
<div className="flex-grow flex flex-col items-end justify-end"></div>
232+
</div>
233+
</Card>
234+
{!teamSubscription ? (
235+
<SolidCard>
236+
<div className="w-full h-full flex flex-col items-center justify-center">
237+
<Spinner className="h-5 w-5 animate-spin" />
238+
</div>
239+
</SolidCard>
240+
) : (
241+
<SolidCard>
242+
<div className="px-2 py-5 flex-grow flex flex-col">
243+
<div className="font-medium text-base text-gray-400">Members</div>
244+
<div className="font-semibold text-base text-gray-600">{members.length}</div>
245+
<div className="mt-8 font-medium text-base text-gray-400">Next invoice on</div>
246+
<div className="font-semibold text-base text-gray-600">
247+
{guessNextInvoiceDate(teamSubscription.startDate).toDateString()}
248+
</div>
249+
<div className="flex-grow flex flex-col items-end justify-end">
250+
<button
251+
onClick={() => {
252+
if (team) {
253+
ChargebeeClient.getOrCreate(team.id).then((chargebeeClient) =>
254+
chargebeeClient.openPortal(),
255+
);
256+
}
257+
}}
258+
className="m-0"
259+
>
260+
Manage Billing or Cancel
261+
</button>
262+
</div>
263+
</div>
264+
</SolidCard>
265+
)}
266+
</>
267+
)}
268+
</div>
269+
</PageWithSubMenu>
270+
);
271+
}
272+
273+
function guessNextInvoiceDate(startDate: string): Date {
274+
const now = new Date();
275+
const date = new Date(startDate);
276+
while (date < now) {
277+
date.setMonth(date.getMonth() + 1);
278+
}
279+
return date;
280+
}

0 commit comments

Comments
 (0)