diff --git a/components/dashboard/src/components/BillingAccountSelector.tsx b/components/dashboard/src/components/BillingAccountSelector.tsx new file mode 100644 index 00000000000000..d4ec6db40dd362 --- /dev/null +++ b/components/dashboard/src/components/BillingAccountSelector.tsx @@ -0,0 +1,92 @@ +/** + * 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 { useContext, useEffect, useState } from "react"; +import { Team } from "@gitpod/gitpod-protocol"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { getGitpodService } from "../service/service"; +import { TeamsContext } from "../teams/teams-context"; +import { UserContext } from "../user-context"; +import SelectableCardSolid from "../components/SelectableCardSolid"; +import { ReactComponent as Spinner } from "../icons/Spinner.svg"; + +export function BillingAccountSelector(props: { onSelected?: () => void }) { + const { user, setUser } = useContext(UserContext); + const { teams } = useContext(TeamsContext); + const [teamsWithBillingEnabled, setTeamsWithBillingEnabled] = useState(); + + useEffect(() => { + if (!teams) { + setTeamsWithBillingEnabled(undefined); + return; + } + const teamsWithBilling: Team[] = []; + Promise.all( + teams.map(async (t) => { + const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(t.id); + if (subscriptionId) { + teamsWithBilling.push(t); + } + }), + ).then(() => setTeamsWithBillingEnabled(teamsWithBilling)); + }, [teams]); + + const setUsageAttributionTeam = async (team?: Team) => { + if (!user) { + return; + } + const usageAttributionId = AttributionId.render( + team ? { kind: "team", teamId: team.id } : { kind: "user", userId: user.id }, + ); + await getGitpodService().server.setUsageAttribution(usageAttributionId); + setUser(await getGitpodService().server.getLoggedInUser()); + if (props.onSelected) { + props.onSelected(); + } + }; + return ( + <> + {teamsWithBillingEnabled === undefined && } + {teamsWithBillingEnabled && ( +
+

Bill all my usage to:

+
+ + AttributionId.render({ kind: "team", teamId: t.id }) === + user?.usageAttributionId, + )?.name + } + onClick={() => setUsageAttributionTeam(undefined)} + > +
+
+ {teamsWithBillingEnabled.map((t) => ( + + AttributionId.render({ kind: "team", teamId: t.id }) === + user?.usageAttributionId, + )?.name + } + onClick={() => setUsageAttributionTeam(t)} + > +
+
+ ))} +
+
+ )} + + ); +} diff --git a/components/dashboard/src/settings/Billing.tsx b/components/dashboard/src/settings/Billing.tsx index 8b05e521ecbcd0..fe5c880a39d20c 100644 --- a/components/dashboard/src/settings/Billing.tsx +++ b/components/dashboard/src/settings/Billing.tsx @@ -4,85 +4,17 @@ * See License-AGPL.txt in the project root for license information. */ -import { Team } from "@gitpod/gitpod-protocol"; -import { useContext, useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { ReactComponent as Spinner } from "../icons/Spinner.svg"; -import DropDown from "../components/DropDown"; -import { getGitpodService } from "../service/service"; -import { TeamsContext } from "../teams/teams-context"; -import { UserContext } from "../user-context"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; +import { BillingAccountSelector } from "../components/BillingAccountSelector"; export default function Billing() { - const { user } = useContext(UserContext); - const { teams } = useContext(TeamsContext); - const [teamsWithBillingEnabled, setTeamsWithBillingEnabled] = useState(); - - useEffect(() => { - if (!teams) { - setTeamsWithBillingEnabled(undefined); - return; - } - const teamsWithBilling: Team[] = []; - Promise.all( - teams.map(async (t) => { - const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(t.id); - if (subscriptionId) { - teamsWithBilling.push(t); - } - }), - ).then(() => setTeamsWithBillingEnabled(teamsWithBilling)); - }, [teams]); - - const setUsageAttributionTeam = async (team?: Team) => { - if (!user) { - return; - } - const usageAttributionId = team ? `team:${team.id}` : `user:${user.id}`; - await getGitpodService().server.setUsageAttribution(usageAttributionId); - }; - return (

Usage-Based Billing

Manage usage-based billing, spending limit, and payment method.

Billing Account

- {teamsWithBillingEnabled === undefined && } - {teamsWithBillingEnabled && teamsWithBillingEnabled.length === 0 && ( -
- - - Create a team - {" "} - to set up usage-based billing. - -
- )} - {teamsWithBillingEnabled && teamsWithBillingEnabled.length > 0 && ( -
- Bill all my usage to: - `team:${t.id}` === user?.usageAttributionId)?.name - } - customClasses="w-32" - renderAsLink={true} - entries={[ - { - title: "(myself)", - onClick: () => setUsageAttributionTeam(undefined), - }, - ].concat( - teamsWithBillingEnabled.map((t) => ({ - title: t.name, - onClick: () => setUsageAttributionTeam(t), - })), - )} - /> -
- )} +
); diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index 5db5824e8dc559..26d775aacd8adf 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -25,6 +25,7 @@ import PrebuildLogs from "../components/PrebuildLogs"; import CodeText from "../components/CodeText"; import FeedbackComponent from "../feedback-form/FeedbackComponent"; import { isGitpodIo } from "../utils"; +import { BillingAccountSelector } from "../components/BillingAccountSelector"; export interface CreateWorkspaceProps { contextUrl: string; @@ -185,6 +186,19 @@ export default class CreateWorkspace extends React.Component; break; + case ErrorCodes.INVALID_COST_CENTER: + // HACK: Hide the error (behind the modal) + error = undefined; + phase = StartPhase.Stopped; + statusMessage = ( + { + this.setState({ error: undefined }); + this.createWorkspace(); + }} + /> + ); + break; default: statusMessage = (

@@ -290,6 +304,15 @@ export default class CreateWorkspace extends React.Component void }) { + return ( + {}}> +

Choose Billing Team

+ + + ); +} + function LimitReachedModal(p: { children: React.ReactNode }) { const { user } = useContext(UserContext); return ( diff --git a/components/gitpod-protocol/src/messaging/error.ts b/components/gitpod-protocol/src/messaging/error.ts index 2b6dda0f61a619..c4338ebd74ed82 100644 --- a/components/gitpod-protocol/src/messaging/error.ts +++ b/components/gitpod-protocol/src/messaging/error.ts @@ -32,15 +32,18 @@ export namespace ErrorCodes { // 430 Repository not whitelisted (custom status code) export const REPOSITORY_NOT_WHITELISTED = 430; + // 450 Payment error + export const PAYMENT_ERROR = 450; + + // 455 Invalid cost center (custom status code) + export const INVALID_COST_CENTER = 455; + // 460 Context Parse Error (custom status code) export const CONTEXT_PARSE_ERROR = 460; - // 461 Invalid gitpod yml + // 461 Invalid gitpod yml (custom status code) export const INVALID_GITPOD_YML = 461; - // 450 Payment error - export const PAYMENT_ERROR = 450; - // 470 User Blocked (custom status code) export const USER_BLOCKED = 470; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 792f1d3c41c1ba..90525bac6aafe8 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -2010,20 +2010,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl { id: attributionId, spendingLimit: this.defaultSpendingLimit, }); - - // For all team members that didn't explicitly choose yet where their usage should be attributed to, - // we simplify the UX by automatically attributing their usage to this recently-upgraded team. - // Note: This default choice can be changed at any time by members in their personal billing settings. - const members = await this.teamDB.findMembersByTeam(teamId); - await Promise.all( - members.map(async (m) => { - const u = await this.userDB.findUserById(m.userId); - if (u && !u.usageAttributionId) { - u.usageAttributionId = attributionId; - await this.userDB.storeUser(u); - } - }), - ); } catch (error) { log.error(`Failed to subscribe team '${teamId}' to Stripe`, error); throw new ResponseError(ErrorCodes.INTERNAL_SERVER_ERROR, `Failed to subscribe team '${teamId}' to Stripe`); diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 5c613b8cc1e86c..419cdb3b2bc301 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -15,6 +15,7 @@ import { WORKSPACE_TIMEOUT_DEFAULT_LONG, WORKSPACE_TIMEOUT_EXTENDED, WORKSPACE_TIMEOUT_EXTENDED_ALT, + Team, } from "@gitpod/gitpod-protocol"; import { CostCenterDB, ProjectDB, TeamDB, TermsAcceptanceDB, UserDB } from "@gitpod/gitpod-db/lib"; import { HostContextProvider } from "../auth/host-context-provider"; @@ -29,6 +30,8 @@ import { EmailAddressAlreadyTakenException, SelectAccountException } from "../au import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { StripeService } from "../../ee/src/user/stripe-service"; +import { ResponseError } from "vscode-ws-jsonrpc"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; export interface FindUserByIdentityStrResult { user: User; @@ -191,6 +194,66 @@ export class UserService { } } + protected async findTeamUsageBasedSubscriptionId(team: Team): Promise { + const customer = await this.stripeService.findCustomerByTeamId(team.id); + if (!customer) { + return; + } + const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); + return subscription?.id; + } + + protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise { + const attribution = AttributionId.parse(usageAttributionId); + if (attribution?.kind === "team") { + const team = await this.teamDB.findTeamById(attribution.teamId); + if (!team) { + throw new ResponseError( + ErrorCodes.INVALID_COST_CENTER, + "The billing team you've selected no longer exists.", + ); + } + const members = await this.teamDB.findMembersByTeam(team.id); + if (!members.find((m) => m.userId === user.id)) { + throw new ResponseError( + ErrorCodes.INVALID_COST_CENTER, + "You're no longer a member of the selected billing team.", + ); + } + const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team); + if (!subscriptionId) { + throw new ResponseError( + ErrorCodes.INVALID_COST_CENTER, + "The billing team you've selected has no active subscription.", + ); + } + } + } + + protected async findSingleTeamWithUsageBasedBilling(user: User): Promise { + // Find all the user's teams with usage-based billing enabled. + const teams = await this.teamDB.findTeamsByUser(user.id); + const teamsWithBilling: Team[] = []; + await Promise.all( + teams.map(async (team) => { + const subscriptionId = await this.findTeamUsageBasedSubscriptionId(team); + if (subscriptionId) { + teamsWithBilling.push(team); + } + }), + ); + if (teamsWithBilling.length > 1) { + // Multiple teams with usage-based billing enabled -- ask the user to make an explicit choice. + throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "Multiple teams have billing enabled."); + } + if (teamsWithBilling.length === 1) { + // Single team with usage-based billing enabled -- attribute all usage to it. + return teamsWithBilling[0]; + } + // No team with usage-based billing enabled. + return undefined; + } + /** * Identifies the team or user to which a workspace instance's running time should be attributed to * (e.g. for usage analytics or billing purposes). @@ -209,12 +272,18 @@ export class UserService { async getWorkspaceUsageAttributionId(user: User, projectId?: string): Promise { // A. Billing-based attribution if (this.config.enablePayment) { - if (!user.usageAttributionId) { - // No explicit user attribution ID yet -- attribute all usage to the user by default (regardless of project/team). - return AttributionId.render({ kind: "user", userId: user.id }); + if (user.usageAttributionId) { + await this.validateUsageAttributionId(user, user.usageAttributionId); + // Return the user's explicit attribution ID. + return user.usageAttributionId; } - // Return the user's explicit attribution ID. - return user.usageAttributionId; + const billingTeam = await this.findSingleTeamWithUsageBasedBilling(user); + if (billingTeam) { + // Single team with usage-based billing enabled -- attribute all usage to it. + return AttributionId.render({ kind: "team", teamId: billingTeam.id }); + } + // Attribute all usage to the user by default (regardless of project/team). + return AttributionId.render({ kind: "user", userId: user.id }); } // B. Project-based attribution