diff --git a/components/dashboard/src/settings/Billing.tsx b/components/dashboard/src/settings/Billing.tsx
index 778fecb060194c..f26819e37d3dd7 100644
--- a/components/dashboard/src/settings/Billing.tsx
+++ b/components/dashboard/src/settings/Billing.tsx
@@ -4,14 +4,40 @@
* See License-AGPL.txt in the project root for license information.
*/
-import { useContext } from "react";
+import { Team } from "@gitpod/gitpod-protocol";
+import { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
+import DropDown from "../components/DropDown";
import { PageWithSubMenu } from "../components/PageWithSubMenu";
import { PaymentContext } from "../payment-context";
+import { getGitpodService } from "../service/service";
+import { TeamsContext } from "../teams/teams-context";
+import { UserContext } from "../user-context";
import getSettingsMenu from "./settings-menu";
export default function Billing() {
+ const { user } = useContext(UserContext);
const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext);
+ const { teams } = useContext(TeamsContext);
+ const [teamsWithBillingEnabled, setTeamsWithBillingEnabled] = useState([]);
+
+ const userFullName = user?.fullName || user?.name || "...";
+
+ useEffect(() => {
+ if (!teams) {
+ setTeamsWithBillingEnabled([]);
+ return;
+ }
+ const teamsWithBilling: Team[] = [];
+ Promise.all(
+ teams.map(async (t) => {
+ const customerId = await getGitpodService().server.findStripeCustomerIdForTeam(t.id);
+ if (customerId) {
+ teamsWithBilling.push(t);
+ }
+ }),
+ ).then(() => setTeamsWithBillingEnabled(teamsWithBilling));
+ }, [teams]);
return (
{" "}
to set up usage-based billing.
+
+ Bill all usage to:
+ {},
+ },
+ ].concat(
+ teamsWithBillingEnabled.map((t) => ({
+ title: t.name,
+ onClick: () => {},
+ })),
+ )}
+ />
+
);
}
diff --git a/components/gitpod-db/src/typeorm/entity/db-workspace-instance.ts b/components/gitpod-db/src/typeorm/entity/db-workspace-instance.ts
index 3afa156176040d..fec4e758e7fa1a 100644
--- a/components/gitpod-db/src/typeorm/entity/db-workspace-instance.ts
+++ b/components/gitpod-db/src/typeorm/entity/db-workspace-instance.ts
@@ -102,4 +102,10 @@ export class DBWorkspaceInstance implements WorkspaceInstance {
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
workspaceClass?: string;
+
+ @Column({
+ default: "",
+ transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
+ })
+ attributedTeamId?: string;
}
diff --git a/components/gitpod-db/src/typeorm/migration/1654847406624-WorkspaceInstanceAttributedTeam.ts b/components/gitpod-db/src/typeorm/migration/1654847406624-WorkspaceInstanceAttributedTeam.ts
new file mode 100644
index 00000000000000..3b3a8dddb7f626
--- /dev/null
+++ b/components/gitpod-db/src/typeorm/migration/1654847406624-WorkspaceInstanceAttributedTeam.ts
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2022 Gitpod GmbH. All rights reserved.
+ * Licensed under the GNU Affero General Public License (AGPL).
+ * See License-AGPL.txt in the project root for license information.
+ */
+
+import { MigrationInterface, QueryRunner } from "typeorm";
+import { columnExists } from "./helper/helper";
+
+const TABLE_NAME = "d_b_workspace_instance";
+const COLUMN_NAME = "attributedTeamId";
+
+export class WorkspaceInstanceAttributedTeam1654847406624 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) {
+ await queryRunner.query(
+ `ALTER TABLE ${TABLE_NAME} ADD COLUMN ${COLUMN_NAME} char(36) NOT NULL DEFAULT '', ALGORITHM=INPLACE, LOCK=NONE`,
+ );
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ if (await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME)) {
+ await queryRunner.query(
+ `ALTER TABLE ${TABLE_NAME} DROP COLUMN ${COLUMN_NAME}, ALGORITHM=INPLACE, LOCK=NONE`,
+ );
+ }
+ }
+}
diff --git a/components/gitpod-db/src/workspace-db.spec.db.ts b/components/gitpod-db/src/workspace-db.spec.db.ts
index d5ebd9810693c2..7aa0a8a3717af1 100644
--- a/components/gitpod-db/src/workspace-db.spec.db.ts
+++ b/components/gitpod-db/src/workspace-db.spec.db.ts
@@ -66,6 +66,7 @@ class WorkspaceDBSpec {
ideImage: "unknown",
},
deleted: false,
+ attributedTeamId: undefined,
};
readonly wsi2: WorkspaceInstance = {
workspaceId: this.ws.id,
@@ -88,6 +89,7 @@ class WorkspaceDBSpec {
ideImage: "unknown",
},
deleted: false,
+ attributedTeamId: undefined,
};
readonly ws2: Workspace = {
id: "2",
@@ -125,6 +127,7 @@ class WorkspaceDBSpec {
ideImage: "unknown",
},
deleted: false,
+ attributedTeamId: undefined,
};
readonly ws3: Workspace = {
@@ -162,6 +165,7 @@ class WorkspaceDBSpec {
ideImage: "unknown",
},
deleted: false,
+ attributedTeamId: undefined,
};
async before() {
diff --git a/components/gitpod-protocol/src/workspace-instance.ts b/components/gitpod-protocol/src/workspace-instance.ts
index ea5e28f6ef1c4b..1cf2a0482ce112 100644
--- a/components/gitpod-protocol/src/workspace-instance.ts
+++ b/components/gitpod-protocol/src/workspace-instance.ts
@@ -62,6 +62,13 @@ export interface WorkspaceInstance {
* resources that are provided to the workspace.
*/
workspaceClass?: string;
+
+ /**
+ * Identifies the team to which this instance's runtime should be attributed to
+ * (e.g. for usage analytics or billing purposes).
+ * If unset, the usage should be attributed to the workspace's owner (ws.ownerId).
+ */
+ attributedTeamId?: string;
}
// WorkspaceInstanceStatus describes the current state of a workspace instance
diff --git a/components/server/ee/src/user/stripe-service.ts b/components/server/ee/src/user/stripe-service.ts
index c99e7f2eb74ec0..a163b52114a509 100644
--- a/components/server/ee/src/user/stripe-service.ts
+++ b/components/server/ee/src/user/stripe-service.ts
@@ -50,6 +50,13 @@ export class StripeService {
return result.data[0];
}
+ async findCustomersByTeamIds(teamIds: string[]): Promise> {
+ const result = await this.getStripe().customers.search({
+ query: teamIds.map((teamId) => `metadata['teamId']:'${teamId}'`).join(" OR "),
+ });
+ return result.data;
+ }
+
async createCustomerForUser(user: User, setupIntentId: string): Promise {
if (await this.findCustomerByUserId(user.id)) {
throw new Error(`A Stripe customer already exists for user '${user.id}'`);
diff --git a/components/server/ee/src/user/user-service.ts b/components/server/ee/src/user/user-service.ts
index 6c12c6cea95127..f891471f7f69a3 100644
--- a/components/server/ee/src/user/user-service.ts
+++ b/components/server/ee/src/user/user-service.ts
@@ -5,7 +5,15 @@
*/
import { UserService, CheckSignUpParams, CheckTermsParams } from "../../../src/user/user-service";
-import { User, WorkspaceTimeoutDuration, WORKSPACE_TIMEOUT_EXTENDED, WORKSPACE_TIMEOUT_EXTENDED_ALT, WORKSPACE_TIMEOUT_DEFAULT_LONG, WORKSPACE_TIMEOUT_DEFAULT_SHORT } from "@gitpod/gitpod-protocol";
+import {
+ User,
+ WorkspaceTimeoutDuration,
+ WORKSPACE_TIMEOUT_EXTENDED,
+ WORKSPACE_TIMEOUT_EXTENDED_ALT,
+ WORKSPACE_TIMEOUT_DEFAULT_LONG,
+ WORKSPACE_TIMEOUT_DEFAULT_SHORT,
+ Project,
+} from "@gitpod/gitpod-protocol";
import { inject } from "inversify";
import { LicenseEvaluator } from "@gitpod/licensor/lib";
import { Feature } from "@gitpod/licensor/lib/api";
@@ -15,6 +23,8 @@ import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/account
import { OssAllowListDB } from "@gitpod/gitpod-db/lib/oss-allowlist-db";
import { HostContextProvider } from "../../../src/auth/host-context-provider";
import { Config } from "../../../src/config";
+import { TeamDB } from "@gitpod/gitpod-db/lib";
+import { StripeService } from "./stripe-service";
export class UserServiceEE extends UserService {
@inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator;
@@ -23,6 +33,8 @@ export class UserServiceEE extends UserService {
@inject(OssAllowListDB) protected readonly OssAllowListDb: OssAllowListDB;
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
@inject(Config) protected readonly config: Config;
+ @inject(TeamDB) protected readonly teamDb: TeamDB;
+ @inject(StripeService) protected readonly stripeService: StripeService;
async getDefaultWorkspaceTimeout(user: User, date: Date): Promise {
if (this.config.enablePayment) {
@@ -73,6 +85,33 @@ export class UserServiceEE extends UserService {
return false;
}
+ async getWorkspaceUsageAttributionTeamId(user: User, projectId?: string): Promise {
+ let project: Project | undefined;
+ if (projectId) {
+ project = await this.projectDb.findProjectById(projectId);
+ }
+ if (!this.config.enablePayment) {
+ // If the project is owned by a team, we attribute workspace usage to that team.
+ // Otherwise, we return `undefined` to attribute to the user (default).
+ return project?.teamId;
+ }
+ // If payment is enabled, we attribute workspace usage to a team that has billing enabled.
+ const teams = await this.teamDb.findTeamsByUser(user.id);
+ const customers = await this.stripeService.findCustomersByTeamIds(teams.map((t) => t.id));
+ if (customers.length === 0) {
+ // No teams with billing enabled, fall back to user attribution.
+ return undefined;
+ }
+ // TODO(janx): Allow users to pick a "cost center" team, and use it here.
+
+ // If there are multiple teams with billing enabled, we prefer the one owning the project if possible.
+ if (project?.teamId && customers.find((c) => c.metadata.teamId === project!.teamId)) {
+ return project.teamId;
+ }
+ // Otherwise, we just pick the first team with billing enabled.
+ return customers[0].metadata.teamId;
+ }
+
async checkSignUp(params: CheckSignUpParams) {
// todo@at: check if we need an optimization for SaaS here. used to be a no-op there.
diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts
index e35a97cc5792bd..7f9232fb2140f2 100644
--- a/components/server/src/user/user-service.ts
+++ b/components/server/src/user/user-service.ts
@@ -16,7 +16,7 @@ import {
WORKSPACE_TIMEOUT_EXTENDED,
WORKSPACE_TIMEOUT_EXTENDED_ALT,
} from "@gitpod/gitpod-protocol";
-import { TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib";
+import { ProjectDB, TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib";
import { HostContextProvider } from "../auth/host-context-provider";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { Config } from "../config";
@@ -63,6 +63,7 @@ export class UserService {
@inject(Config) protected readonly config: Config;
@inject(TermsAcceptanceDB) protected readonly termsAcceptanceDb: TermsAcceptanceDB;
@inject(TermsProvider) protected readonly termsProvider: TermsProvider;
+ @inject(ProjectDB) protected readonly projectDb: ProjectDB;
/**
* Takes strings in the form of / and returns the matching User
@@ -205,6 +206,28 @@ export class UserService {
return false;
}
+ /**
+ * Identifies the team to which a workspace instance's running time should be attributed to
+ * (e.g. for usage analytics or billing purposes).
+ * If no specific team is identified, the usage will be attributed to the user instead (default).
+ *
+ * @param user
+ * @param projectId
+ */
+ async getWorkspaceUsageAttributionTeamId(user: User, projectId?: string): Promise {
+ if (!projectId) {
+ // No project -- attribute to the user.
+ return undefined;
+ }
+ const project = await this.projectDb.findProjectById(projectId);
+ if (!project?.teamId) {
+ // The project doesn't exist, or it isn't owned by a team -- attribute to the user.
+ return undefined;
+ }
+ // Attribute workspace usage to the team that currently owns this project.
+ return project.teamId;
+ }
+
/**
* This might throw `AuthException`s.
*
diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts
index 91f7872ff201e5..9488698289c0b2 100644
--- a/components/server/src/workspace/workspace-starter.ts
+++ b/components/server/src/workspace/workspace-starter.ts
@@ -633,9 +633,9 @@ export class WorkspaceStarter {
delete ideConfig.ideOptions.options["code-latest"];
delete ideConfig.ideOptions.options["code-desktop-insiders"];
- const migratted = migrationIDESettings(user);
- if (user.additionalData?.ideSettings && migratted) {
- user.additionalData.ideSettings = migratted;
+ const migrated = migrationIDESettings(user);
+ if (user.additionalData?.ideSettings && migrated) {
+ user.additionalData.ideSettings = migrated;
}
const ideChoice = user.additionalData?.ideSettings?.defaultIde;
@@ -709,6 +709,8 @@ export class WorkspaceStarter {
configuration.featureFlags = featureFlags;
}
+ const attributedTeamId = await this.userService.getWorkspaceUsageAttributionTeamId(user, workspace.projectId);
+
const now = new Date().toISOString();
const instance: WorkspaceInstance = {
id: uuidv4(),
@@ -722,6 +724,7 @@ export class WorkspaceStarter {
phase: "preparing",
},
configuration,
+ attributedTeamId,
};
if (WithReferrerContext.is(workspace.context)) {
this.analytics.track({