Skip to content

Commit c73b1bc

Browse files
committed
[server][dashboard] Allow (new) Teams to buy and manage (legacy) Team Plans
1 parent 2604a0b commit c73b1bc

File tree

13 files changed

+204
-10
lines changed

13 files changed

+204
-10
lines changed

components/dashboard/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/New
4545
const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam'));
4646
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
4747
const TeamSettings = React.lazy(() => import(/* webpackPrefetch: true */ './teams/TeamSettings'));
48+
const TeamPlans = React.lazy(() => import(/* webpackPrefetch: true */ './teams/TeamPlans'));
4849
const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/NewProject'));
4950
const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ConfigureProject'));
5051
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
@@ -366,6 +367,9 @@ function App() {
366367
if (maybeProject === "settings") {
367368
return <TeamSettings />;
368369
}
370+
if (maybeProject === "plans") {
371+
return <TeamPlans />;
372+
}
369373
if (resourceOrPrebuild === "prebuilds") {
370374
return <Prebuilds />;
371375
}

components/dashboard/src/Menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default function Menu() {
4343
const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?");
4444
const projectSlug = (() => {
4545
const resource = match?.params?.segment2;
46-
if (resource && ![/* team sub-pages */ "projects", "members", "settings", /* admin sub-pages */ "users", "workspaces"].includes(resource)) {
46+
if (resource && ![/* team sub-pages */ "projects", "members", "settings", "plans", /* admin sub-pages */ "users", "workspaces"].includes(resource)) {
4747
return resource;
4848
}
4949
})();
@@ -148,7 +148,7 @@ export default function Menu() {
148148
teamSettingsList.push({
149149
title: 'Settings',
150150
link: `/t/${team.slug}/settings`,
151-
alternatives: getTeamSettingsMenu(team).flatMap(e => e.link),
151+
alternatives: getTeamSettingsMenu({ team, showPaymentUI }).flatMap(e => e.link),
152152
})
153153
}
154154

components/dashboard/src/settings/Plans.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ export default function () {
430430
? <progress value={currentPlan.hoursPerMonth - accountStatement.remainingHours} max={currentPlan.hoursPerMonth} />
431431
: <progress value="0" max="100" />}
432432
<p className="text-sm">
433-
<a className={`text-blue-light hover:underline" ${isChargebeeCustomer ? '' : 'invisible'}`} href="javascript:void(0)" onClick={() => { ChargebeeClient.getOrCreate().then(chargebeeClient => chargebeeClient.openPortal()); }}>Billing</a>
433+
<a className={`gp-link ${isChargebeeCustomer ? '' : 'invisible'}`} href="javascript:void(0)" onClick={() => { ChargebeeClient.getOrCreate().then(chargebeeClient => chargebeeClient.openPortal()); }}>Billing</a>
434434
{!!accountStatement && Plans.isFreePlan(currentPlan.chargebeeId) && <span className="pl-6">{currency === 'EUR'
435435
? <>€ / <a className="text-blue-light hover:underline" href="javascript:void(0)" onClick={() => setCurrency('USD')}>$</a></>
436436
: <><a className="text-blue-light hover:underline" href="javascript:void(0)" onClick={() => setCurrency('EUR')}></a> / $</>}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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, Plans } from "@gitpod/gitpod-protocol/lib/plans";
9+
import { useContext, useEffect, useState } from "react";
10+
import { useLocation } from "react-router";
11+
import { ChargebeeClient } from "../chargebee/chargebee-client";
12+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
13+
import SelectableCard from "../components/SelectableCard";
14+
import { PaymentContext } from "../payment-context";
15+
import { getGitpodService } from "../service/service";
16+
import { getCurrentTeam, TeamsContext } from "./teams-context";
17+
import { getTeamSettingsMenu } from "./TeamSettings";
18+
19+
export default function TeamPlans() {
20+
const { teams } = useContext(TeamsContext);
21+
const location = useLocation();
22+
const team = getCurrentTeam(location, teams);
23+
const [ members, setMembers ] = useState<TeamMemberInfo[]>([]);
24+
const { showPaymentUI, currency, setCurrency } = useContext(PaymentContext);
25+
26+
useEffect(() => {
27+
if (!team) {
28+
return;
29+
}
30+
(async () => {
31+
const infos = await getGitpodService().server.getTeamMembers(team.id);
32+
setMembers(infos);
33+
})();
34+
}, [ team ]);
35+
36+
const availableTeamPlans = Plans.getAvailableTeamPlans(currency || 'USD');
37+
38+
const checkout = async (chargebeePlanId: string) => {
39+
if (!team || members.length < 1) {
40+
return;
41+
}
42+
const chargebeeClient = await ChargebeeClient.getOrCreate();
43+
await new Promise((resolve, reject) => {
44+
chargebeeClient.checkout(paymentServer => paymentServer.teamCheckout(team.id, chargebeePlanId), {
45+
success: resolve,
46+
error: reject,
47+
});
48+
});
49+
}
50+
51+
return <PageWithSubMenu subMenu={getTeamSettingsMenu({ team, showPaymentUI })} title="Plans" subtitle="Manage team plans and billing.">
52+
<p className="text-sm">
53+
<a className={`gp-link ${isChargebeeCustomer ? '' : 'invisible'}`} href="javascript:void(0)" onClick={() => { ChargebeeClient.getOrCreate().then(chargebeeClient => chargebeeClient.openPortal()); }}>Billing</a>
54+
<span className="pl-6">{currency === 'EUR'
55+
? <>€ / <a className="gp-link" href="javascript:void(0)" onClick={() => setCurrency('USD')}>$</a></>
56+
: <><a className="gp-link" href="javascript:void(0)" onClick={() => setCurrency('EUR')}></a> / $</>}
57+
</span>
58+
</p>
59+
<div className="mt-4 space-x-4 flex">
60+
<SelectableCard className="w-36 h-32" title="Free" selected={true} onClick={() => {}}>
61+
{members.length} x {Currency.getSymbol(currency || 'USD')}0 = {Currency.getSymbol(currency || 'USD')}0
62+
</SelectableCard>
63+
{availableTeamPlans.map(tp => <SelectableCard className="w-36 h-32" title={tp.name} selected={false} onClick={() => checkout(tp.chargebeeId)}>
64+
{members.length} x {Currency.getSymbol(tp.currency)}{tp.pricePerMonth} = {Currency.getSymbol(tp.currency)}{members.length * tp.pricePerMonth}
65+
</SelectableCard>)}
66+
</div>
67+
</PageWithSubMenu>;
68+
}

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,24 @@ import { Redirect, useLocation } from "react-router";
1010
import CodeText from "../components/CodeText";
1111
import ConfirmationModal from "../components/ConfirmationModal";
1212
import { PageWithSubMenu } from "../components/PageWithSubMenu";
13+
import { PaymentContext } from "../payment-context";
1314
import { getGitpodService, gitpodHostUrl } from "../service/service";
1415
import { UserContext } from "../user-context";
1516
import { getCurrentTeam, TeamsContext } from "./teams-context";
1617

17-
export function getTeamSettingsMenu(team?: Team) {
18+
export function getTeamSettingsMenu(params: { team?: Team, showPaymentUI?: boolean }) {
19+
const { team, showPaymentUI } = params;
1820
return [
1921
{
2022
title: 'General',
2123
link: [`/t/${team?.slug}/settings`],
2224
},
25+
...(showPaymentUI ? [
26+
{
27+
title: 'Paid Plans',
28+
link: [`/t/${team?.slug}/plans`],
29+
},
30+
] : []),
2331
];
2432
}
2533

@@ -31,6 +39,7 @@ export default function TeamSettings() {
3139
const { user } = useContext(UserContext);
3240
const location = useLocation();
3341
const team = getCurrentTeam(location, teams);
42+
const { showPaymentUI } = useContext(PaymentContext);
3443

3544
const close = () => setModal(false);
3645

@@ -55,7 +64,7 @@ export default function TeamSettings() {
5564
};
5665

5766
return <>
58-
<PageWithSubMenu subMenu={getTeamSettingsMenu(team)} title="Settings" subtitle="Manage general team settings.">
67+
<PageWithSubMenu subMenu={getTeamSettingsMenu({ team, showPaymentUI })} title="Settings" subtitle="Manage general team settings.">
5968
<h3>Delete Team</h3>
6069
<p className="text-base text-gray-500 pb-4 max-w-2xl">Deleting this team will also remove all associated data with this team, including projects and workspaces. Deleted teams cannot be restored!</p>
6170
<button className="danger secondary" onClick={() => setModal(true)}>Delete Team</button>

components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ export class TeamSubscriptionHandler implements EventHandler<chargebee.Subscript
3838

3939
async handleSingleEvent(event: chargebee.Event<chargebee.SubscriptionEventV2>): Promise<boolean> {
4040
const chargebeeSubscription = event.content.subscription;
41-
const userId = chargebeeSubscription.customer_id;
41+
const customerId = chargebeeSubscription.customer_id;
4242
const eventType = event.event_type;
4343

4444
const logContext = this.userContext(event);
4545
log.info(logContext, `Start TeamSubscriptionHandler.handleSingleEvent`, { eventType });
4646
try {
47-
await this.mapToTeamSubscription(userId, eventType, chargebeeSubscription);
47+
await this.mapToTeamSubscription(customerId, eventType, chargebeeSubscription);
4848
} catch (error) {
4949
log.error(logContext, "Error in TeamSubscriptionHandler.handleSingleEvent", error);
5050
throw error;
@@ -53,10 +53,11 @@ export class TeamSubscriptionHandler implements EventHandler<chargebee.Subscript
5353
return true;
5454
}
5555

56-
async mapToTeamSubscription(userId: string, eventType: chargebee.EventType, chargebeeSubscription: chargebee.Subscription) {
56+
async mapToTeamSubscription(customerId: string, eventType: chargebee.EventType, chargebeeSubscription: chargebee.Subscription) {
57+
// TODO(janx) Handle `customerId.startsWith('team:')`
5758
await this.db.transaction(async (db) => {
5859
const subs = await db.findTeamSubscriptions({
59-
userId,
60+
userId: customerId,
6061
paymentReference: chargebeeSubscription.id
6162
});
6263
if (subs.length === 0) {
@@ -67,7 +68,7 @@ export class TeamSubscriptionHandler implements EventHandler<chargebee.Subscript
6768
}
6869

6970
const ts = TeamSubscription.create({
70-
userId,
71+
userId: customerId,
7172
paymentReference: chargebeeSubscription.id,
7273
planId: chargebeeSubscription.plan_id,
7374
startDate: getStartDate(chargebeeSubscription),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { Entity, Column, PrimaryColumn, Index } from "typeorm";
8+
9+
import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
10+
11+
import { TypeORM } from "../../typeorm/typeorm";
12+
import { Transformer } from "../../typeorm/transformer";
13+
14+
@Entity()
15+
@Index("ind_team_paymentReference", ["teamId", "paymentReference"])
16+
@Index("ind_team_startdate", ["teamId", "startDate"])
17+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
18+
export class DBTeamSubscription2 implements TeamSubscription2 {
19+
20+
@PrimaryColumn("uuid")
21+
id: string;
22+
23+
@Column(TypeORM.UUID_COLUMN_TYPE)
24+
teamId: string;
25+
26+
@Column()
27+
paymentReference: string;
28+
29+
@Column()
30+
startDate: string;
31+
32+
@Column({
33+
default: '',
34+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED
35+
})
36+
endDate?: string;
37+
38+
@Column()
39+
planId: string;
40+
41+
@Column('int')
42+
quantity: number;
43+
44+
@Column({
45+
default: '',
46+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED
47+
})
48+
cancellationDate?: string;
49+
50+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
51+
@Column()
52+
deleted: boolean;
53+
}

components/gitpod-db/src/typeorm/team-subscription-db-impl.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TeamSubscription, TeamSubscriptionSlot } from "@gitpod/gitpod-protocol/
1111

1212
import { TeamSubscriptionDB } from "../team-subscription-db";
1313
import { DBTeamSubscription } from "./entity/db-team-subscription";
14+
import { DBTeamSubscription2 } from "./entity/db-team-subscription-2";
1415
import { DBTeamSubscriptionSlot } from "./entity/db-team-subscription-slot";
1516
import { TypeORM } from "./typeorm";
1617

@@ -33,6 +34,10 @@ export class TeamSubscriptionDBImpl implements TeamSubscriptionDB {
3334
return (await this.getEntityManager()).getRepository(DBTeamSubscription);
3435
}
3536

37+
protected async getRepo2(): Promise<Repository<DBTeamSubscription2>> {
38+
return (await this.getEntityManager()).getRepository(DBTeamSubscription2);
39+
}
40+
3641
protected async getSlotsRepo(): Promise<Repository<DBTeamSubscriptionSlot>> {
3742
return (await this.getEntityManager()).getRepository(DBTeamSubscriptionSlot);
3843
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
223223
getChargebeeSiteId(): Promise<string>;
224224
createPortalSession(): Promise<{}>;
225225
checkout(planId: string, planQuantity?: number): Promise<{}>;
226+
teamCheckout(teamId: string, planId: string): Promise<{}>;
226227
getAvailableCoupons(): Promise<PlanCoupon[]>;
227228
getAppliedCoupons(): Promise<PlanCoupon[]>;
228229

components/gitpod-protocol/src/team-subscription-protocol.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ export interface TeamSubscription {
2020
deleted?: boolean;
2121
}
2222

23+
export interface TeamSubscription2 {
24+
id: string;
25+
teamId: string;
26+
planId: string;
27+
startDate: string;
28+
endDate?: string;
29+
/** The Chargebee subscription id */
30+
paymentReference: string;
31+
cancellationDate?: string;
32+
}
33+
2334
export namespace TeamSubscription {
2435
export const create = (ts: Omit<TeamSubscription, 'id'>): TeamSubscription => {
2536
const withId = ts as TeamSubscription;

0 commit comments

Comments
 (0)