Skip to content

Commit cb12e4f

Browse files
committed
[server][dashboard] When starting a workspace but usage attribution is unclear, prompt for explicit user choice
1 parent bc6c018 commit cb12e4f

File tree

6 files changed

+198
-93
lines changed

6 files changed

+198
-93
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 { useContext, useEffect, useState } from "react";
8+
import { Team } from "@gitpod/gitpod-protocol";
9+
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
10+
import { getGitpodService } from "../service/service";
11+
import { TeamsContext } from "../teams/teams-context";
12+
import { UserContext } from "../user-context";
13+
import SelectableCardSolid from "../components/SelectableCardSolid";
14+
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
15+
16+
export function BillingAccountSelector(props: { onSelected?: () => void }) {
17+
const { user, setUser } = useContext(UserContext);
18+
const { teams } = useContext(TeamsContext);
19+
const [teamsWithBillingEnabled, setTeamsWithBillingEnabled] = useState<Team[] | undefined>();
20+
21+
useEffect(() => {
22+
if (!teams) {
23+
setTeamsWithBillingEnabled(undefined);
24+
return;
25+
}
26+
const teamsWithBilling: Team[] = [];
27+
Promise.all(
28+
teams.map(async (t) => {
29+
const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(t.id);
30+
if (subscriptionId) {
31+
teamsWithBilling.push(t);
32+
}
33+
}),
34+
).then(() => setTeamsWithBillingEnabled(teamsWithBilling));
35+
}, [teams]);
36+
37+
const setUsageAttributionTeam = async (team?: Team) => {
38+
if (!user) {
39+
return;
40+
}
41+
const usageAttributionId = AttributionId.render(
42+
team ? { kind: "team", teamId: team.id } : { kind: "user", userId: user.id },
43+
);
44+
await getGitpodService().server.setUsageAttribution(usageAttributionId);
45+
setUser(await getGitpodService().server.getLoggedInUser());
46+
if (props.onSelected) {
47+
props.onSelected();
48+
}
49+
};
50+
return (
51+
<>
52+
{teamsWithBillingEnabled === undefined && <Spinner className="m-2 h-5 w-5 animate-spin" />}
53+
{teamsWithBillingEnabled && (
54+
<div>
55+
<p>Bill all my usage to:</p>
56+
<div className="mt-4 flex space-x-3">
57+
<SelectableCardSolid
58+
className="w-36 h-32"
59+
title="(myself)"
60+
selected={
61+
!teamsWithBillingEnabled.find(
62+
(t) =>
63+
AttributionId.render({ kind: "team", teamId: t.id }) ===
64+
user?.usageAttributionId,
65+
)?.name
66+
}
67+
onClick={() => setUsageAttributionTeam(undefined)}
68+
>
69+
<div className="flex-grow flex items-end p-1"></div>
70+
</SelectableCardSolid>
71+
{teamsWithBillingEnabled.map((t) => (
72+
<SelectableCardSolid
73+
className="w-36 h-32"
74+
title={t.name}
75+
selected={
76+
!!teamsWithBillingEnabled.find(
77+
(t) =>
78+
AttributionId.render({ kind: "team", teamId: t.id }) ===
79+
user?.usageAttributionId,
80+
)?.name
81+
}
82+
onClick={() => setUsageAttributionTeam(t)}
83+
>
84+
<div className="flex-grow flex items-end p-1"></div>
85+
</SelectableCardSolid>
86+
))}
87+
</div>
88+
</div>
89+
)}
90+
</>
91+
);
92+
}

components/dashboard/src/settings/Billing.tsx

Lines changed: 2 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,85 +4,17 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { Team } from "@gitpod/gitpod-protocol";
8-
import { useContext, useEffect, useState } from "react";
9-
import { Link } from "react-router-dom";
10-
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
11-
import DropDown from "../components/DropDown";
12-
import { getGitpodService } from "../service/service";
13-
import { TeamsContext } from "../teams/teams-context";
14-
import { UserContext } from "../user-context";
157
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
8+
import { BillingAccountSelector } from "../components/BillingAccountSelector";
169

1710
export default function Billing() {
18-
const { user } = useContext(UserContext);
19-
const { teams } = useContext(TeamsContext);
20-
const [teamsWithBillingEnabled, setTeamsWithBillingEnabled] = useState<Team[] | undefined>();
21-
22-
useEffect(() => {
23-
if (!teams) {
24-
setTeamsWithBillingEnabled(undefined);
25-
return;
26-
}
27-
const teamsWithBilling: Team[] = [];
28-
Promise.all(
29-
teams.map(async (t) => {
30-
const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(t.id);
31-
if (subscriptionId) {
32-
teamsWithBilling.push(t);
33-
}
34-
}),
35-
).then(() => setTeamsWithBillingEnabled(teamsWithBilling));
36-
}, [teams]);
37-
38-
const setUsageAttributionTeam = async (team?: Team) => {
39-
if (!user) {
40-
return;
41-
}
42-
const usageAttributionId = team ? `team:${team.id}` : `user:${user.id}`;
43-
await getGitpodService().server.setUsageAttribution(usageAttributionId);
44-
};
45-
4611
return (
4712
<PageWithSettingsSubMenu title="Billing" subtitle="Usage-Based Billing.">
4813
<h3>Usage-Based Billing</h3>
4914
<h2 className="text-gray-500">Manage usage-based billing, spending limit, and payment method.</h2>
5015
<div className="mt-8">
5116
<h3>Billing Account</h3>
52-
{teamsWithBillingEnabled === undefined && <Spinner className="m-2 h-5 w-5 animate-spin" />}
53-
{teamsWithBillingEnabled && teamsWithBillingEnabled.length === 0 && (
54-
<div className="flex space-x-2">
55-
<span>
56-
<Link className="gp-link" to="/teams/new">
57-
Create a team
58-
</Link>{" "}
59-
to set up usage-based billing.
60-
</span>
61-
</div>
62-
)}
63-
{teamsWithBillingEnabled && teamsWithBillingEnabled.length > 0 && (
64-
<div className="flex space-x-2">
65-
<span>Bill all my usage to:</span>
66-
<DropDown
67-
activeEntry={
68-
teamsWithBillingEnabled.find((t) => `team:${t.id}` === user?.usageAttributionId)?.name
69-
}
70-
customClasses="w-32"
71-
renderAsLink={true}
72-
entries={[
73-
{
74-
title: "(myself)",
75-
onClick: () => setUsageAttributionTeam(undefined),
76-
},
77-
].concat(
78-
teamsWithBillingEnabled.map((t) => ({
79-
title: t.name,
80-
onClick: () => setUsageAttributionTeam(t),
81-
})),
82-
)}
83-
/>
84-
</div>
85-
)}
17+
<BillingAccountSelector />
8618
</div>
8719
</PageWithSettingsSubMenu>
8820
);

components/dashboard/src/start/CreateWorkspace.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import PrebuildLogs from "../components/PrebuildLogs";
2525
import CodeText from "../components/CodeText";
2626
import FeedbackComponent from "../feedback-form/FeedbackComponent";
2727
import { isGitpodIo } from "../utils";
28+
import { BillingAccountSelector } from "../components/BillingAccountSelector";
2829

2930
export interface CreateWorkspaceProps {
3031
contextUrl: string;
@@ -185,6 +186,19 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
185186
phase = StartPhase.Stopped;
186187
statusMessage = <LimitReachedOutOfHours />;
187188
break;
189+
case ErrorCodes.INVALID_COST_CENTER:
190+
// HACK: Hide the error (behind the modal)
191+
error = undefined;
192+
phase = StartPhase.Stopped;
193+
statusMessage = (
194+
<SelectCostCenterModal
195+
onSelected={() => {
196+
this.setState({ error: undefined });
197+
this.createWorkspace();
198+
}}
199+
/>
200+
);
201+
break;
188202
default:
189203
statusMessage = (
190204
<p className="text-base text-gitpod-red w-96">
@@ -290,6 +304,15 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
290304
}
291305
}
292306

307+
function SelectCostCenterModal(props: { onSelected?: () => void }) {
308+
return (
309+
<Modal visible={true} closeable={false} onClose={() => {}}>
310+
<h3>Choose Billing Team</h3>
311+
<BillingAccountSelector onSelected={props.onSelected} />
312+
</Modal>
313+
);
314+
}
315+
293316
function LimitReachedModal(p: { children: React.ReactNode }) {
294317
const { user } = useContext(UserContext);
295318
return (

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,18 @@ export namespace ErrorCodes {
3232
// 430 Repository not whitelisted (custom status code)
3333
export const REPOSITORY_NOT_WHITELISTED = 430;
3434

35+
// 450 Payment error
36+
export const PAYMENT_ERROR = 450;
37+
38+
// 455 Invalid cost center (custom status code)
39+
export const INVALID_COST_CENTER = 455;
40+
3541
// 460 Context Parse Error (custom status code)
3642
export const CONTEXT_PARSE_ERROR = 460;
3743

38-
// 461 Invalid gitpod yml
44+
// 461 Invalid gitpod yml (custom status code)
3945
export const INVALID_GITPOD_YML = 461;
4046

41-
// 450 Payment error
42-
export const PAYMENT_ERROR = 450;
43-
4447
// 470 User Blocked (custom status code)
4548
export const USER_BLOCKED = 470;
4649

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,20 +2010,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20102010
id: attributionId,
20112011
spendingLimit: this.defaultSpendingLimit,
20122012
});
2013-
2014-
// For all team members that didn't explicitly choose yet where their usage should be attributed to,
2015-
// we simplify the UX by automatically attributing their usage to this recently-upgraded team.
2016-
// Note: This default choice can be changed at any time by members in their personal billing settings.
2017-
const members = await this.teamDB.findMembersByTeam(teamId);
2018-
await Promise.all(
2019-
members.map(async (m) => {
2020-
const u = await this.userDB.findUserById(m.userId);
2021-
if (u && !u.usageAttributionId) {
2022-
u.usageAttributionId = attributionId;
2023-
await this.userDB.storeUser(u);
2024-
}
2025-
}),
2026-
);
20272013
} catch (error) {
20282014
log.error(`Failed to subscribe team '${teamId}' to Stripe`, error);
20292015
throw new ResponseError(ErrorCodes.INTERNAL_SERVER_ERROR, `Failed to subscribe team '${teamId}' to Stripe`);

components/server/src/user/user-service.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
WORKSPACE_TIMEOUT_DEFAULT_LONG,
1616
WORKSPACE_TIMEOUT_EXTENDED,
1717
WORKSPACE_TIMEOUT_EXTENDED_ALT,
18+
Team,
1819
} from "@gitpod/gitpod-protocol";
1920
import { CostCenterDB, ProjectDB, TeamDB, TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib";
2021
import { HostContextProvider } from "../auth/host-context-provider";
@@ -29,6 +30,8 @@ import { EmailAddressAlreadyTakenException, SelectAccountException } from "../au
2930
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
3031
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
3132
import { StripeService } from "../../ee/src/user/stripe-service";
33+
import { ResponseError } from "vscode-ws-jsonrpc";
34+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
3235

3336
export interface FindUserByIdentityStrResult {
3437
user: User;
@@ -213,6 +216,66 @@ export class UserService {
213216
return false;
214217
}
215218

219+
protected async findTeamUsageBasedSubscriptionId(team: Team): Promise<string | undefined> {
220+
const customer = await this.stripeService.findCustomerByTeamId(team.id);
221+
if (!customer) {
222+
return;
223+
}
224+
const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id);
225+
return subscription?.id;
226+
}
227+
228+
protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise<void> {
229+
const attribution = AttributionId.parse(usageAttributionId);
230+
if (attribution?.kind === "team") {
231+
const team = await this.teamDB.findTeamById(attribution.teamId);
232+
if (!team) {
233+
throw new ResponseError(
234+
ErrorCodes.INVALID_COST_CENTER,
235+
"The billing team you've selected no longer exists.",
236+
);
237+
}
238+
const members = await this.teamDB.findMembersByTeam(team.id);
239+
if (!members.find((m) => m.userId === user.id)) {
240+
throw new ResponseError(
241+
ErrorCodes.INVALID_COST_CENTER,
242+
"You're no longer a member of the selected billing team.",
243+
);
244+
}
245+
const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team);
246+
if (!subscriptionId) {
247+
throw new ResponseError(
248+
ErrorCodes.INVALID_COST_CENTER,
249+
"The billing team you've selected has no active subscription.",
250+
);
251+
}
252+
}
253+
}
254+
255+
protected async findSingleTeamWithUsageBasedBilling(user: User): Promise<Team | undefined> {
256+
// Find all the user's teams with usage-based billing enabled.
257+
const teams = await this.teamDB.findTeamsByUser(user.id);
258+
const teamsWithBilling: Team[] = [];
259+
await Promise.all(
260+
teams.map(async (team) => {
261+
const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team);
262+
if (subscriptionId) {
263+
teamsWithBilling.push(team);
264+
}
265+
}),
266+
);
267+
if (teamsWithBilling.length > 1) {
268+
// Multiple teams with usage-based billing enabled -- ask the user to make an explicit choice.
269+
throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "Multiple teams have billing enabled.");
270+
}
271+
if (teamsWithBilling.length === 1) {
272+
// Single team with usage-based billing enabled -- attribute all usage to it.
273+
return teamsWithBilling[0];
274+
}
275+
// No team with usage-based billing enabled.
276+
return undefined;
277+
}
278+
216279
/**
217280
* Identifies the team or user to which a workspace instance's running time should be attributed to
218281
* (e.g. for usage analytics or billing purposes).
@@ -231,12 +294,18 @@ export class UserService {
231294
async getWorkspaceUsageAttributionId(user: User, projectId?: string): Promise<string | undefined> {
232295
// A. Billing-based attribution
233296
if (this.config.enablePayment) {
234-
if (!user.usageAttributionId) {
235-
// No explicit user attribution ID yet -- attribute all usage to the user by default (regardless of project/team).
236-
return AttributionId.render({ kind: "user", userId: user.id });
297+
if (user.usageAttributionId) {
298+
await this.validateUsageAttributionId(user, user.usageAttributionId);
299+
// Return the user's explicit attribution ID.
300+
return user.usageAttributionId;
237301
}
238-
// Return the user's explicit attribution ID.
239-
return user.usageAttributionId;
302+
const billingTeam = await this.findSingleTeamWithUsageBasedBilling(user);
303+
if (billingTeam) {
304+
// Single team with usage-based billing enabled -- attribute all usage to it.
305+
return AttributionId.render({ kind: "team", teamId: billingTeam.id });
306+
}
307+
// Attribute all usage to the user by default (regardless of project/team).
308+
return AttributionId.render({ kind: "user", userId: user.id });
240309
}
241310

242311
// B. Project-based attribution

0 commit comments

Comments
 (0)