Skip to content

Commit 76098d7

Browse files
committed
[server] When starting a workspace but usage attribution is unclear, prompt for explicit user choice
1 parent 3218c48 commit 76098d7

File tree

6 files changed

+181
-93
lines changed

6 files changed

+181
-93
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 { Team } from "@gitpod/gitpod-protocol";
8+
import { useContext, useEffect, useState } from "react";
9+
import { Link } from "react-router-dom";
10+
import { getGitpodService } from "../service/service";
11+
import { TeamsContext } from "../teams/teams-context";
12+
import { UserContext } from "../user-context";
13+
import DropDown from "../components/DropDown";
14+
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
15+
16+
export function BillingAccountSelector() {
17+
const { user } = 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 = team ? `team:${team.id}` : `user:${user.id}`;
42+
await getGitpodService().server.setUsageAttribution(usageAttributionId);
43+
};
44+
return (
45+
<>
46+
{teamsWithBillingEnabled === undefined && <Spinner className="m-2 h-5 w-5 animate-spin" />}
47+
{teamsWithBillingEnabled && teamsWithBillingEnabled.length === 0 && (
48+
<div className="flex space-x-2">
49+
<span>
50+
<Link className="gp-link" to="/teams/new">
51+
Create a team
52+
</Link>{" "}
53+
to set up usage-based billing.
54+
</span>
55+
</div>
56+
)}
57+
{teamsWithBillingEnabled && teamsWithBillingEnabled.length > 0 && (
58+
<div className="flex space-x-2">
59+
<span>Bill all my usage to:</span>
60+
<DropDown
61+
activeEntry={
62+
teamsWithBillingEnabled.find((t) => `team:${t.id}` === user?.usageAttributionId)?.name
63+
}
64+
customClasses="w-32"
65+
renderAsLink={true}
66+
entries={[
67+
{
68+
title: "(myself)",
69+
onClick: () => setUsageAttributionTeam(undefined),
70+
},
71+
].concat(
72+
teamsWithBillingEnabled.map((t) => ({
73+
title: t.name,
74+
onClick: () => setUsageAttributionTeam(t),
75+
})),
76+
)}
77+
/>
78+
</div>
79+
)}
80+
</>
81+
);
82+
}

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: 16 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,12 @@ 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 = <SelectCostCenterModal />;
194+
break;
188195
default:
189196
statusMessage = (
190197
<p className="text-base text-gitpod-red w-96">
@@ -290,6 +297,15 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
290297
}
291298
}
292299

300+
function SelectCostCenterModal() {
301+
return (
302+
<Modal visible={true} closeable={false} onClose={() => {}}>
303+
<h3>Choose Billing Team</h3>
304+
<BillingAccountSelector />
305+
</Modal>
306+
);
307+
}
308+
293309
function LimitReachedModal(p: { children: React.ReactNode }) {
294310
const { user } = useContext(UserContext);
295311
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
@@ -2008,20 +2008,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20082008
id: attributionId,
20092009
spendingLimit: this.defaultSpendingLimit,
20102010
});
2011-
2012-
// For all team members that didn't explicitly choose yet where their usage should be attributed to,
2013-
// we simplify the UX by automatically attributing their usage to this recently-upgraded team.
2014-
// Note: This default choice can be changed at any time by members in their personal billing settings.
2015-
const members = await this.teamDB.findMembersByTeam(teamId);
2016-
await Promise.all(
2017-
members.map(async (m) => {
2018-
const u = await this.userDB.findUserById(m.userId);
2019-
if (u && !u.usageAttributionId) {
2020-
u.usageAttributionId = attributionId;
2021-
await this.userDB.storeUser(u);
2022-
}
2023-
}),
2024-
);
20252011
} catch (error) {
20262012
log.error(`Failed to subscribe team '${teamId}' to Stripe`, error);
20272013
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;
@@ -211,6 +214,66 @@ export class UserService {
211214
return false;
212215
}
213216

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

240309
// B. Project-based attribution

0 commit comments

Comments
 (0)