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 cf2131d0af0551..635f4c48720b5f 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; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 0cfe2d32ba5d2b..eb0a5de8e0afa8 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, @@ -2020,6 +2022,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. @@ -2029,7 +2040,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const u = await this.userDB.findUserById(m.userId); if (u && !u.additionalData?.usageAttributionId) { u.additionalData = u.additionalData || {}; - u.additionalData.usageAttributionId = `team:${teamId}`; + u.additionalData.usageAttributionId = attributionId; await this.userDB.updateUserPartial(u); } }), @@ -2057,6 +2068,34 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } + protected defaultSpendingLimit = 100; + 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 }); + const costCenter = await this.costCenterDB.findById(attributionId); + if (costCenter) { + return costCenter.spendingLimit; + } + // On demand creating a cost center for this team + await this.costCenterDB.storeEntry({ + id: attributionId, + spendingLimit: this.defaultSpendingLimit, + }); + return this.defaultSpendingLimit; + } + + async setSpendingLimitForTeam(ctx: TraceContext, teamId: string, spendingLimit: number): Promise { + const user = this.checkAndBlockUser("setSpendingLimitForTeam"); + await this.ensureIsUsageBasedFeatureFlagEnabled(user); + await this.guardTeamOperation(teamId, "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 fe5a5624c8eb7c..a53944b8cf7451 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -216,6 +216,8 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { identifyUser: { group: "default", points: 1 }, getIDEOptions: { group: "default", points: 1 }, getPrebuildEvents: { 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 f618f89f5862d8..a0cd71f18df06a 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`); + } // //#endregion