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({