diff --git a/components/dashboard/src/teams/TeamUsageBasedBilling.tsx b/components/dashboard/src/teams/TeamUsageBasedBilling.tsx index 93669ed06a492b..2647ea7a2b6b7f 100644 --- a/components/dashboard/src/teams/TeamUsageBasedBilling.tsx +++ b/components/dashboard/src/teams/TeamUsageBasedBilling.tsx @@ -28,6 +28,8 @@ export default function TeamUsageBasedBilling() { const [pendingStripeSubscription, setPendingStripeSubscription] = useState(); const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState(); const [stripePortalUrl, setStripePortalUrl] = useState(); + const [showUpdateLimitModal, setShowUpdateLimitModal] = useState(false); + const [spendingLimit, setSpendingLimit] = useState(); useEffect(() => { if (!team) { @@ -54,6 +56,8 @@ export default function TeamUsageBasedBilling() { (async () => { const portalUrl = await getGitpodService().server.getStripePortalUrlForTeam(team.id); setStripePortalUrl(portalUrl); + const spendingLimit = await getGitpodService().server.getSpendingLimitForTeam(team.id); + setSpendingLimit(spendingLimit); })(); }, [team, stripeSubscriptionId]); @@ -135,30 +139,50 @@ export default function TeamUsageBasedBilling() { return <>; } + const showSpinner = isLoading || pendingStripeSubscription; + const showUpgradeBilling = !showSpinner && !stripeSubscriptionId; + const showManageBilling = !showSpinner && !!stripeSubscriptionId; + + const doUpdateLimit = async (newLimit: number) => { + if (!team) { + return; + } + const oldLimit = spendingLimit; + setSpendingLimit(newLimit); + try { + await getGitpodService().server.setSpendingLimitForTeam(team.id, newLimit); + } catch (error) { + setSpendingLimit(oldLimit); + console.error(error); + alert(error?.message || "Failed to update spending limit. See console for error message."); + } + setShowUpdateLimitModal(false); + }; + return (

Usage-Based Billing

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

-
-
-
Billing
- {(isLoading || pendingStripeSubscription) && ( - <> - - - )} - {!isLoading && !pendingStripeSubscription && !stripeSubscriptionId && ( - <> -
- Inactive -
- - - )} - {!isLoading && !pendingStripeSubscription && !!stripeSubscriptionId && ( - <> +
+ {showSpinner && ( +
+
Billing
+ +
+ )} + {showUpgradeBilling && ( +
+
Billing
+
Inactive
+ +
+ )} + {showManageBilling && ( +
+
+
Billing
Active
@@ -167,11 +191,27 @@ export default function TeamUsageBasedBilling() { Manage Billing → - - )} -
+
+
+
Spending Limit
+
+ {spendingLimit || "–"} +
+ +
+
+ )}
{showBillingSetupModal && setShowBillingSetupModal(false)} />} + {showUpdateLimitModal && ( + setShowUpdateLimitModal(false)} + onUpdate={(newLimit) => doUpdateLimit(newLimit)} + /> + )}
); } @@ -182,6 +222,49 @@ function getStripeAppearance(isDark?: boolean): Appearance { }; } +function UpdateLimitModal(props: { + currentValue: number | undefined; + onClose: () => void; + onUpdate: (newLimit: number) => {}; +}) { + const [newLimit, setNewLimit] = useState(props.currentValue); + + return ( + +

Update Limit

+
+

Set up a spending limit on a monthly basis.

+ + +
+ setNewLimit(parseInt(e.target.value || "1", 10))} + /> +
+
+
+ +
+
+ ); +} + function BillingSetupModal(props: { onClose: () => void }) { const { isDark } = useContext(ThemeContext); const [stripePromise, setStripePromise] = useState | undefined>(); @@ -243,7 +326,7 @@ function CreditCardInputForm() { } } catch (error) { console.error(error); - alert(error); + alert(error?.message || "Failed to submit form. See console for error message."); } finally { setIsLoading(false); } diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 2c1d26f277ab43..551cdd67089d6a 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -290,6 +290,8 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, findStripeSubscriptionIdForTeam(teamId: string): Promise; subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise; getStripePortalUrlForTeam(teamId: string): Promise; + getSpendingLimitForTeam(teamId: string): Promise; + setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise; listBilledUsage(attributionId: string): Promise; setUsageAttribution(usageAttribution: string): Promise; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 56b5b902dd6806..54bb6985808b71 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -65,7 +65,7 @@ import { LicenseKeySource } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { PrebuildManager } from "../prebuilds/prebuild-manager"; -import { LicenseDB } from "@gitpod/gitpod-db/lib"; +import { CostCenterDB, LicenseDB } from "@gitpod/gitpod-db/lib"; import { GuardedCostCenter, ResourceAccessGuard, ResourceAccessOp } from "../../../src/auth/resource-access"; import { AccountStatement, CreditAlert, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol"; @@ -152,6 +152,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl { @inject(CachingUsageServiceClientProvider) protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider; + @inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB; + initialize( client: GitpodClient | undefined, user: User | undefined, @@ -2001,6 +2003,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } + protected defaultSpendingLimit = 100; async subscribeTeamToStripe( ctx: TraceContext, teamId: string, @@ -2017,6 +2020,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl { customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId); } await this.stripeService.createSubscriptionForCustomer(customer.id, currency); + + const attributionId = AttributionId.render({ kind: "team", teamId }); + + // Creating a cost center for this team + await this.costCenterDB.storeEntry({ + 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. @@ -2025,7 +2037,8 @@ export class GitpodServerEEImpl extends GitpodServerImpl { members.map(async (m) => { const u = await this.userDB.findUserById(m.userId); if (u && !u.usageAttributionId) { - await this.userService.setUsageAttribution(u, `team:${teamId}`); + u.usageAttributionId = attributionId; + await this.userDB.storeUser(u); } }), ); @@ -2052,6 +2065,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } + async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise { + const user = this.checkAndBlockUser("getSpendingLimitForTeam"); + await this.ensureIsUsageBasedFeatureFlagEnabled(user); + await this.guardTeamOperation(teamId, "get"); + + const attributionId = AttributionId.render({ kind: "team", teamId }); + await this.guardCostCenterAccess(ctx, user.id, attributionId, "get"); + + const costCenter = await this.costCenterDB.findById(attributionId); + if (costCenter) { + return costCenter.spendingLimit; + } + return undefined; + } + + async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise { + const user = this.checkAndBlockUser("setSpendingLimitForTeam"); + await this.ensureIsUsageBasedFeatureFlagEnabled(user); + await this.guardTeamOperation(teamId, "update"); + if (typeof spendingLimit !== "number" || spendingLimit < 0) { + throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unexpected `spendingLimit` value."); + } + const attributionId = AttributionId.render({ kind: "team", teamId }); + await this.guardCostCenterAccess(ctx, user.id, attributionId, "update"); + + await this.costCenterDB.storeEntry({ + id: AttributionId.render({ kind: "team", teamId }), + spendingLimit, + }); + } + async listBilledUsage(ctx: TraceContext, attributionId: string): Promise { traceAPIParams(ctx, { attributionId }); const user = this.checkAndBlockUser("listBilledUsage"); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index fb3ac06f80ca3c..756973c6ec07ca 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -217,6 +217,8 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { getIDEOptions: { group: "default", points: 1 }, getPrebuildEvents: { group: "default", points: 1 }, setUsageAttribution: { group: "default", points: 1 }, + getSpendingLimitForTeam: { group: "default", points: 1 }, + setSpendingLimitForTeam: { group: "default", points: 1 }, }; return { diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 10193d877f6b09..016cbee7c74dc9 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -3194,6 +3194,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async listBilledUsage(ctx: TraceContext, attributionId: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } + async getSpendingLimitForTeam(ctx: TraceContext, teamId: string): Promise { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } + async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise { + throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); + } async setUsageAttribution(ctx: TraceContext, usageAttributionId: string): Promise { const user = this.checkAndBlockUser("setUsageAttribution");