Skip to content

Commit 270cfbb

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

File tree

4 files changed

+85
-23
lines changed

4 files changed

+85
-23
lines changed

components/dashboard/src/start/CreateWorkspace.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
185185
phase = StartPhase.Stopped;
186186
statusMessage = <LimitReachedOutOfHours />;
187187
break;
188+
case ErrorCodes.INVALID_COST_CENTER:
189+
phase = StartPhase.Stopped;
190+
statusMessage = <p>Invalid cost center: {String(error)}</p>;
191+
break;
188192
default:
189193
statusMessage = (
190194
<p className="text-base text-gitpod-red w-96">

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)