Skip to content

Commit 81b41f1

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

File tree

14 files changed

+669
-19
lines changed

14 files changed

+669
-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: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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, PlanType } from "@gitpod/gitpod-protocol/lib/plans";
9+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
10+
import React, { 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+
const featuresByPlanType: { [type in PlanType]?: Array<React.ReactNode> } = {
125+
// Team Professional
126+
"professional-new": [
127+
<span>Public &amp; Private Repositories</span>,
128+
<span>8 Parallel Workspaces</span>,
129+
<span>30 min Inactivity Timeout</span>,
130+
],
131+
// Team Unleaashed
132+
professional: [
133+
<span>Public &amp; Private Repositories</span>,
134+
<span>16 Parallel Workspaces</span>,
135+
<span>1 hr Inactivity Timeout</span>,
136+
<span>3 hr Timeout Boost</span>,
137+
],
138+
};
139+
140+
return (
141+
<PageWithSubMenu
142+
subMenu={getTeamSettingsMenu({ team, showPaymentUI })}
143+
title="Billing"
144+
subtitle="Manage team billing and plans."
145+
>
146+
<h3>{!teamPlan ? "No billing plan" : "Plan"}</h3>
147+
<h2 className="text-gray-500">
148+
{!teamPlan ? (
149+
<div className="flex space-x-1">
150+
<span>Select a new billing plan for this team. Currency:</span>
151+
<DropDown
152+
contextMenuWidth="w-32"
153+
activeEntry={currency}
154+
entries={[
155+
{
156+
title: "EUR",
157+
onClick: () => setCurrency("EUR"),
158+
},
159+
{
160+
title: "USD",
161+
onClick: () => setCurrency("USD"),
162+
},
163+
]}
164+
/>
165+
</div>
166+
) : (
167+
<span>
168+
This team is currently on the <strong>{teamPlan.name}</strong> plan.
169+
</span>
170+
)}
171+
</h2>
172+
<div className="mt-4 space-x-4 flex">
173+
{isLoading && (
174+
<>
175+
<SolidCard>
176+
<div className="w-full h-full flex flex-col items-center justify-center">
177+
<Spinner className="h-5 w-5 animate-spin" />
178+
</div>
179+
</SolidCard>
180+
<SolidCard>
181+
<div className="w-full h-full flex flex-col items-center justify-center">
182+
<Spinner className="h-5 w-5 animate-spin" />
183+
</div>
184+
</SolidCard>
185+
</>
186+
)}
187+
{!isLoading && !teamPlan && (
188+
<>
189+
{availableTeamPlans.map((tp) => (
190+
<>
191+
<SolidCard
192+
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700"
193+
onClick={() => checkout(tp)}
194+
>
195+
<div className="px-2 py-5 flex-grow flex flex-col">
196+
<div className="font-medium text-base">{tp.name}</div>
197+
<div className="font-semibold text-gray-500 text-sm">Unlimited hours</div>
198+
<div className="mt-8 font-semibold text-sm">Includes:</div>
199+
<div className="flex flex-col items-start text-sm">
200+
{(featuresByPlanType[tp.type] || []).map((f) => (
201+
<span className="inline-flex space-x-1">
202+
<CheckSvg fill="currentColor" className="self-center mt-1" />
203+
{f}
204+
</span>
205+
))}
206+
</div>
207+
<div className="flex-grow flex flex-col items-end justify-end">
208+
<PillLabel type="warn" className="font-semibold normal-case text-sm">
209+
{members.length} x {Currency.getSymbol(tp.currency)}
210+
{tp.pricePerMonth} = {Currency.getSymbol(tp.currency)}
211+
{members.length * tp.pricePerMonth} per month
212+
</PillLabel>
213+
</div>
214+
</div>
215+
</SolidCard>
216+
</>
217+
))}
218+
</>
219+
)}
220+
{!isLoading && teamPlan && (
221+
<>
222+
<Card>
223+
<div className="px-2 py-5 flex-grow flex flex-col">
224+
<div className="font-bold text-base">{teamPlan.name}</div>
225+
<div className="font-semibold text-gray-500 text-sm">Unlimited hours</div>
226+
<div className="mt-8 font-semibold text-sm">Includes:</div>
227+
<div className="flex flex-col items-start text-sm">
228+
{(featuresByPlanType[teamPlan.type] || []).map((f) => (
229+
<span className="inline-flex space-x-1">
230+
<CheckSvg fill="currentColor" className="self-center mt-1" />
231+
{f}
232+
</span>
233+
))}
234+
</div>
235+
<div className="flex-grow flex flex-col items-end justify-end"></div>
236+
</div>
237+
</Card>
238+
{!teamSubscription ? (
239+
<SolidCard>
240+
<div className="w-full h-full flex flex-col items-center justify-center">
241+
<Spinner className="h-5 w-5 animate-spin" />
242+
</div>
243+
</SolidCard>
244+
) : (
245+
<SolidCard>
246+
<div className="px-2 py-5 flex-grow flex flex-col">
247+
<div className="font-medium text-base text-gray-400">Members</div>
248+
<div className="font-semibold text-base text-gray-600">{members.length}</div>
249+
<div className="mt-8 font-medium text-base text-gray-400">Next invoice on</div>
250+
<div className="font-semibold text-base text-gray-600">
251+
{guessNextInvoiceDate(teamSubscription.startDate).toDateString()}
252+
</div>
253+
<div className="flex-grow flex flex-col items-end justify-end">
254+
<button
255+
onClick={() => {
256+
if (team) {
257+
ChargebeeClient.getOrCreate(team.id).then((chargebeeClient) =>
258+
chargebeeClient.openPortal(),
259+
);
260+
}
261+
}}
262+
className="m-0"
263+
>
264+
Manage Billing or Cancel
265+
</button>
266+
</div>
267+
</div>
268+
</SolidCard>
269+
)}
270+
</>
271+
)}
272+
</div>
273+
</PageWithSubMenu>
274+
);
275+
}
276+
277+
function guessNextInvoiceDate(startDate: string): Date {
278+
const now = new Date();
279+
const date = new Date(startDate);
280+
while (date < now) {
281+
date.setMonth(date.getMonth() + 1);
282+
}
283+
return date;
284+
}

0 commit comments

Comments
 (0)