Skip to content

Commit c335b08

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

File tree

2 files changed

+63
-19
lines changed

2 files changed

+63
-19
lines changed

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: 63 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";
@@ -211,6 +212,57 @@ export class UserService {
211212
return false;
212213
}
213214

215+
protected async findTeamUsageBasedSubscriptionId(team: Team): Promise<string | undefined> {
216+
const customer = await this.stripeService.findCustomerByTeamId(team.id);
217+
if (!customer) {
218+
return;
219+
}
220+
const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id);
221+
return subscription?.id;
222+
}
223+
224+
protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise<void> {
225+
const attribution = AttributionId.parse(usageAttributionId);
226+
if (attribution?.kind === "team") {
227+
const team = await this.teamDB.findTeamById(attribution.teamId);
228+
if (!team) {
229+
throw new Error("Selected team does not exist!");
230+
}
231+
const members = await this.teamDB.findMembersByTeam(team.id);
232+
if (!members.find((m) => m.userId === user.id)) {
233+
throw new Error("User is not part of selected team!");
234+
}
235+
const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team);
236+
if (!subscriptionId) {
237+
throw new Error("Selected team has no subscription!");
238+
}
239+
}
240+
}
241+
242+
protected async findSingleTeamWithUsageBasedBilling(user: User): Promise<Team | undefined> {
243+
// Find all the user's teams with usage-based billing enabled.
244+
const teams = await this.teamDB.findTeamsByUser(user.id);
245+
const teamsWithBilling: Team[] = [];
246+
await Promise.all(
247+
teams.map(async (team) => {
248+
const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team);
249+
if (subscriptionId) {
250+
teamsWithBilling.push(team);
251+
}
252+
}),
253+
);
254+
if (teamsWithBilling.length > 1) {
255+
// Multiple teams with usage-based billing enabled -- ask the user to make an explicit choice.
256+
throw new Error("Multiple billing teams! Please choose one");
257+
}
258+
if (teamsWithBilling.length === 1) {
259+
// Single team with usage-based billing enabled -- attribute all usage to it.
260+
return teamsWithBilling[0];
261+
}
262+
// No team with usage-based billing enabled.
263+
return undefined;
264+
}
265+
214266
/**
215267
* Identifies the team or user to which a workspace instance's running time should be attributed to
216268
* (e.g. for usage analytics or billing purposes).
@@ -229,12 +281,18 @@ export class UserService {
229281
async getWorkspaceUsageAttributionId(user: User, projectId?: string): Promise<string | undefined> {
230282
// A. Billing-based attribution
231283
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 });
284+
if (user.usageAttributionId) {
285+
await this.validateUsageAttributionId(user, user.usageAttributionId);
286+
// Return the user's explicit attribution ID.
287+
return user.usageAttributionId;
235288
}
236-
// Return the user's explicit attribution ID.
237-
return user.usageAttributionId;
289+
const billingTeam = await this.findSingleTeamWithUsageBasedBilling(user);
290+
if (billingTeam) {
291+
// Single team with usage-based billing enabled -- attribute all usage to it.
292+
return AttributionId.render({ kind: "team", teamId: billingTeam.id });
293+
}
294+
// Attribute all usage to the user by default (regardless of project/team).
295+
return AttributionId.render({ kind: "user", userId: user.id });
238296
}
239297

240298
// B. Project-based attribution

0 commit comments

Comments
 (0)