diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index b645df3e8238d6..faeff38fe3d83c 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -372,8 +372,7 @@ function SpendingLimitReachedModal(p: { hints: any }) { const [attributedTeam, setAttributedTeam] = useState(); useEffect(() => { - const attributionId: AttributionId | undefined = - p.hints && p.hints.attributionId && AttributionId.parse(p.hints.attributionId); + const attributionId: AttributionId | undefined = p.hints && p.hints.attributionId; if (attributionId) { // setAttributionId(attributionId); if (attributionId.kind === "team") { diff --git a/components/server/ee/src/billing/billing-service.ts b/components/server/ee/src/billing/billing-service.ts new file mode 100644 index 00000000000000..d1579b458c37f4 --- /dev/null +++ b/components/server/ee/src/billing/billing-service.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the Gitpod Enterprise Source Code License, + * See License.enterprise.txt in the project root folder. + */ + +import { CostCenterDB } from "@gitpod/gitpod-db/lib"; +import { User } from "@gitpod/gitpod-protocol"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { BillableSession, BillableSessionRequest, SortOrder } from "@gitpod/gitpod-protocol/lib/usage"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { CachingUsageServiceClientProvider, UsageService } from "@gitpod/usage-api/lib/usage/v1/sugar"; +import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; +import { inject, injectable } from "inversify"; +import { UserService } from "../../../src/user/user-service"; + +export interface SpendingLimitReachedResult { + reached: boolean; + almostReached?: boolean; + attributionId: AttributionId; +} + +@injectable() +export class BillingService { + @inject(UserService) protected readonly userService: UserService; + @inject(CostCenterDB) protected readonly costCenterDB: CostCenterDB; + @inject(CachingUsageServiceClientProvider) + protected readonly usageServiceClientProvider: CachingUsageServiceClientProvider; + + async checkSpendingLimitReached(user: User): Promise { + const attributionId = await this.userService.getWorkspaceUsageAttributionId(user); + const costCenter = !!attributionId && (await this.costCenterDB.findById(AttributionId.render(attributionId))); + if (!costCenter) { + const err = new Error("No CostCenter found"); + log.error({ userId: user.id }, err.message, err, { attributionId }); + throw err; + } + + const allSessions = await this.listBilledUsage({ + attributionId: AttributionId.render(attributionId), + startedTimeOrder: SortOrder.Descending, + }); + const totalUsage = allSessions.map((s) => s.credits).reduce((a, b) => a + b, 0); + if (totalUsage >= costCenter.spendingLimit) { + return { + reached: true, + attributionId, + }; + } else if (totalUsage > costCenter.spendingLimit * 0.8) { + return { + reached: false, + almostReached: true, + attributionId, + }; + } + return { + reached: false, + attributionId, + }; + } + + // TODO (gpl): Replace this with call to stripeService.getInvoice() + async listBilledUsage(req: BillableSessionRequest): Promise { + const { attributionId, startedTimeOrder, from, to } = req; + let timestampFrom; + let timestampTo; + + if (from) { + timestampFrom = Timestamp.fromDate(new Date(from)); + } + if (to) { + timestampTo = Timestamp.fromDate(new Date(to)); + } + const usageClient = this.usageServiceClientProvider.getDefault(); + const response = await usageClient.listBilledUsage( + {}, + attributionId, + startedTimeOrder as number, + timestampFrom, + timestampTo, + ); + const sessions = response.getSessionsList().map((s) => UsageService.mapBilledSession(s)); + return sessions; + } +} diff --git a/components/server/ee/src/billing/entitlement-service-chargebee.ts b/components/server/ee/src/billing/entitlement-service-chargebee.ts index 46d91d9d322796..3967c0404c24d2 100644 --- a/components/server/ee/src/billing/entitlement-service-chargebee.ts +++ b/components/server/ee/src/billing/entitlement-service-chargebee.ts @@ -17,10 +17,13 @@ import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol" import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans"; import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil"; import { inject, injectable } from "inversify"; -import { EntitlementService } from "../../../src/billing/entitlement-service"; +import { + EntitlementService, + HitParallelWorkspaceLimit, + MayStartWorkspaceResult, +} from "../../../src/billing/entitlement-service"; import { Config } from "../../../src/config"; import { AccountStatementProvider, CachedAccountStatement } from "../user/account-statement-provider"; -import { HitParallelWorkspaceLimit, MayStartWorkspaceResult } from "../user/eligibility-service"; @injectable() export class EntitlementServiceChargebee implements EntitlementService { @@ -54,8 +57,13 @@ export class EntitlementServiceChargebee implements EntitlementService { hasHitParallelWorkspaceLimit(), ]); + const result = enoughCredits && !hitParallelWorkspaceLimit; + + console.log("mayStartWorkspace > hitParallelWorkspaceLimit " + hitParallelWorkspaceLimit); + return { - enoughCredits: !!enoughCredits, + mayStart: result, + oufOfCredits: !enoughCredits, hitParallelWorkspaceLimit, }; } @@ -79,6 +87,7 @@ export class EntitlementServiceChargebee implements EntitlementService { const cachedAccountStatement = this.accountStatementProvider.getCachedStatement(); const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString()); if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) { + console.log("checkEnoughCreditForWorkspaceStart > unlimited"); return true; } @@ -87,6 +96,7 @@ export class EntitlementServiceChargebee implements EntitlementService { date.toISOString(), runningInstances, ); + console.log("checkEnoughCreditForWorkspaceStart > remainingUsageHours " + remainingUsageHours); return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS; } diff --git a/components/server/ee/src/billing/entitlement-service-license.ts b/components/server/ee/src/billing/entitlement-service-license.ts index a55f1bedb89bfd..863d33da71c74c 100644 --- a/components/server/ee/src/billing/entitlement-service-license.ts +++ b/components/server/ee/src/billing/entitlement-service-license.ts @@ -15,9 +15,8 @@ import { import { LicenseEvaluator } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { inject, injectable } from "inversify"; -import { EntitlementService } from "../../../src/billing/entitlement-service"; +import { EntitlementService, MayStartWorkspaceResult } from "../../../src/billing/entitlement-service"; import { Config } from "../../../src/config"; -import { MayStartWorkspaceResult } from "../user/eligibility-service"; @injectable() export class EntitlementServiceLicense implements EntitlementService { @@ -31,7 +30,7 @@ export class EntitlementServiceLicense implements EntitlementService { runningInstances: Promise, ): Promise { // if payment is not enabled users can start as many parallel workspaces as they want - return { enoughCredits: true }; + return { mayStart: true }; } async maySetTimeout(user: User, date: Date): Promise { diff --git a/components/server/ee/src/billing/entitlement-service-ubp.ts b/components/server/ee/src/billing/entitlement-service-ubp.ts new file mode 100644 index 00000000000000..d1f20543ca37c4 --- /dev/null +++ b/components/server/ee/src/billing/entitlement-service-ubp.ts @@ -0,0 +1,104 @@ +/** + * 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 { UserDB } from "@gitpod/gitpod-db/lib"; +import { + User, + WorkspaceInstance, + WorkspaceTimeoutDuration, + WORKSPACE_TIMEOUT_DEFAULT_LONG, + WORKSPACE_TIMEOUT_DEFAULT_SHORT, +} from "@gitpod/gitpod-protocol"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { inject, injectable } from "inversify"; +import { + EntitlementService, + HitParallelWorkspaceLimit, + MayStartWorkspaceResult, +} from "../../../src/billing/entitlement-service"; +import { Config } from "../../../src/config"; +import { BillingModes } from "./billing-mode"; +import { BillingService } from "./billing-service"; + +const MAX_PARALLEL_WORKSPACES_FREE = 4; +const MAX_PARALLEL_WORKSPACES_PAID = 16; + +/** + * EntitlementService implementation for Usage-Based Pricing (UBP) + */ +@injectable() +export class EntitlementServiceUBP implements EntitlementService { + @inject(Config) protected readonly config: Config; + @inject(UserDB) protected readonly userDb: UserDB; + @inject(BillingModes) protected readonly billingModes: BillingModes; + @inject(BillingService) protected readonly billingService: BillingService; + + async mayStartWorkspace( + user: User, + date: Date, + runningInstances: Promise, + ): Promise { + const hasHitParallelWorkspaceLimit = async (): Promise => { + const max = await this.getMaxParallelWorkspaces(user, date); + const current = (await runningInstances).filter((i) => i.status.phase !== "preparing").length; + if (current >= max) { + return { + current, + max, + }; + } else { + return undefined; + } + }; + const [spendingLimitReachedOnCostCenter, hitParallelWorkspaceLimit] = await Promise.all([ + this.checkSpendingLimitReached(user, date), + hasHitParallelWorkspaceLimit(), + ]); + const result = !spendingLimitReachedOnCostCenter && !hitParallelWorkspaceLimit; + return { + mayStart: result, + spendingLimitReachedOnCostCenter, + hitParallelWorkspaceLimit, + }; + } + + protected async checkSpendingLimitReached(user: User, date: Date): Promise { + const result = await this.billingService.checkSpendingLimitReached(user); + if (result.reached) { + return result.attributionId; + } + return undefined; + } + + protected async getMaxParallelWorkspaces(user: User, date: Date): Promise { + if (await this.hasPaidSubscription(user, date)) { + return MAX_PARALLEL_WORKSPACES_PAID; + } else { + return MAX_PARALLEL_WORKSPACES_FREE; + } + } + + async maySetTimeout(user: User, date: Date): Promise { + return this.hasPaidSubscription(user, date); + } + + async getDefaultWorkspaceTimeout(user: User, date: Date): Promise { + if (await this.hasPaidSubscription(user, date)) { + return WORKSPACE_TIMEOUT_DEFAULT_LONG; + } else { + return WORKSPACE_TIMEOUT_DEFAULT_SHORT; + } + } + + async userGetsMoreResources(user: User, date: Date = new Date()): Promise { + return this.hasPaidSubscription(user, date); + } + + protected async hasPaidSubscription(user: User, date: Date): Promise { + // TODO(gpl) UBP personal: implement! + return true; + } +} diff --git a/components/server/ee/src/billing/entitlement-service.ts b/components/server/ee/src/billing/entitlement-service.ts index 2dce08d63e27fe..f71b5f6fb0b3fd 100644 --- a/components/server/ee/src/billing/entitlement-service.ts +++ b/components/server/ee/src/billing/entitlement-service.ts @@ -4,52 +4,110 @@ * See License-AGPL.txt in the project root for license information. */ -import { User, WorkspaceInstance, WorkspaceTimeoutDuration } from "@gitpod/gitpod-protocol"; +import { + User, + WorkspaceInstance, + WorkspaceTimeoutDuration, + WORKSPACE_TIMEOUT_DEFAULT_LONG, +} from "@gitpod/gitpod-protocol"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { inject, injectable } from "inversify"; -import { EntitlementService } from "../../../src/billing/entitlement-service"; +import { EntitlementService, MayStartWorkspaceResult } from "../../../src/billing/entitlement-service"; import { Config } from "../../../src/config"; -import { MayStartWorkspaceResult } from "../user/eligibility-service"; +import { BillingModes } from "./billing-mode"; import { EntitlementServiceChargebee } from "./entitlement-service-chargebee"; import { EntitlementServiceLicense } from "./entitlement-service-license"; +import { EntitlementServiceUBP } from "./entitlement-service-ubp"; /** * The default implementation for the Enterprise Edition (EE). It decides based on config which ruleset to choose for each call. + * + * As a last safety net for rolling this out, it swallows all errors and turns them into log statements. */ @injectable() export class EntitlementServiceImpl implements EntitlementService { @inject(Config) protected readonly config: Config; - @inject(EntitlementServiceChargebee) protected readonly etsChargebee: EntitlementServiceChargebee; - @inject(EntitlementServiceLicense) protected readonly etsLicense: EntitlementServiceLicense; + @inject(BillingModes) protected readonly billingModes: BillingModes; + @inject(EntitlementServiceChargebee) protected readonly chargebee: EntitlementServiceChargebee; + @inject(EntitlementServiceLicense) protected readonly license: EntitlementServiceLicense; + @inject(EntitlementServiceUBP) protected readonly ubp: EntitlementServiceUBP; async mayStartWorkspace( user: User, - date: Date, + date: Date = new Date(), runningInstances: Promise, ): Promise { - if (!this.config.enablePayment) { - return await this.etsLicense.mayStartWorkspace(user, date, runningInstances); + try { + const billingMode = await this.billingModes.getBillingModeForUser(user, date); + let result; + switch (billingMode.mode) { + case "none": + result = await this.license.mayStartWorkspace(user, date, runningInstances); + break; + case "chargebee": + result = await this.chargebee.mayStartWorkspace(user, date, runningInstances); + break; + case "usage-based": + result = await this.ubp.mayStartWorkspace(user, date, runningInstances); + break; + default: + throw new Error("Unsupported billing mode: " + (billingMode as any).mode); // safety net + } + return result; + } catch (err) { + log.error({ userId: user.id }, "EntitlementService error: mayStartWorkspace", err); + throw err; } - return await this.etsChargebee.mayStartWorkspace(user, date, runningInstances); } - async maySetTimeout(user: User, date: Date): Promise { - if (!this.config.enablePayment) { - return await this.etsLicense.maySetTimeout(user, date); + async maySetTimeout(user: User, date: Date = new Date()): Promise { + try { + const billingMode = await this.billingModes.getBillingModeForUser(user, date); + switch (billingMode.mode) { + case "none": + return this.license.maySetTimeout(user, date); + case "chargebee": + return this.chargebee.maySetTimeout(user, date); + case "usage-based": + return this.ubp.maySetTimeout(user, date); + } + } catch (err) { + log.error({ userId: user.id }, "EntitlementService error: maySetTimeout", err); + return true; } - return await this.etsChargebee.maySetTimeout(user, date); } - async getDefaultWorkspaceTimeout(user: User, date: Date): Promise { - if (!this.config.enablePayment) { - return await this.etsLicense.getDefaultWorkspaceTimeout(user, date); + async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise { + try { + const billingMode = await this.billingModes.getBillingModeForUser(user, date); + switch (billingMode.mode) { + case "none": + return this.license.getDefaultWorkspaceTimeout(user, date); + case "chargebee": + return this.chargebee.getDefaultWorkspaceTimeout(user, date); + case "usage-based": + return this.ubp.getDefaultWorkspaceTimeout(user, date); + } + } catch (err) { + log.error({ userId: user.id }, "EntitlementService error: getDefaultWorkspaceTimeout", err); + return WORKSPACE_TIMEOUT_DEFAULT_LONG; } - return await this.etsChargebee.getDefaultWorkspaceTimeout(user, date); } - async userGetsMoreResources(user: User): Promise { - if (!this.config.enablePayment) { - return await this.etsLicense.userGetsMoreResources(user); + async userGetsMoreResources(user: User, date: Date = new Date()): Promise { + try { + const billingMode = await this.billingModes.getBillingModeForUser(user, date); + switch (billingMode.mode) { + case "none": + return this.license.userGetsMoreResources(user); + case "chargebee": + return this.chargebee.userGetsMoreResources(user); + case "usage-based": + return this.ubp.userGetsMoreResources(user); + } + } catch (err) { + log.error({ userId: user.id }, "EntitlementService error: userGetsMoreResources", err); + return true; } - return await this.etsChargebee.userGetsMoreResources(user); } } diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 3c756be60c7f78..93d8fa1a72a747 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -64,6 +64,8 @@ import { EntitlementServiceChargebee } from "./billing/entitlement-service-charg import { BillingModes, BillingModesImpl } from "./billing/billing-mode"; import { EntitlementServiceLicense } from "./billing/entitlement-service-license"; import { EntitlementServiceImpl } from "./billing/entitlement-service"; +import { EntitlementServiceUBP } from "./billing/entitlement-service-ubp"; +import { BillingService } from "./billing/billing-service"; export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { rebind(Server).to(ServerEE).inSingletonScope(); @@ -127,7 +129,10 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(EntitlementServiceChargebee).toSelf().inSingletonScope(); bind(EntitlementServiceLicense).toSelf().inSingletonScope(); + bind(EntitlementServiceUBP).toSelf().inSingletonScope(); bind(EntitlementServiceImpl).toSelf().inSingletonScope(); rebind(EntitlementService).to(EntitlementServiceImpl).inSingletonScope(); bind(BillingModes).to(BillingModesImpl).inSingletonScope(); + + bind(BillingService).toSelf().inSingletonScope(); }); diff --git a/components/server/ee/src/user/eligibility-service.ts b/components/server/ee/src/user/eligibility-service.ts index 69eb15ca990d57..6c196d855b72b5 100644 --- a/components/server/ee/src/user/eligibility-service.ts +++ b/components/server/ee/src/user/eligibility-service.ts @@ -15,16 +15,6 @@ import { EMailDomainService } from "../auth/email-domain-service"; import fetch from "node-fetch"; import { Config } from "../../../src/config"; -export interface MayStartWorkspaceResult { - hitParallelWorkspaceLimit?: HitParallelWorkspaceLimit; - enoughCredits: boolean; -} - -export interface HitParallelWorkspaceLimit { - max: number; - current: number; -} - /** * Response from the GitHub Education Student Developer / Faculty Member Pack. * The flags `student` and `faculty` are mutually exclusive (the cannot both become `true`). diff --git a/components/server/ee/src/user/user-service.ts b/components/server/ee/src/user/user-service.ts index 91580a5c66ba73..0f80236321ec63 100644 --- a/components/server/ee/src/user/user-service.ts +++ b/components/server/ee/src/user/user-service.ts @@ -20,11 +20,9 @@ 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 { EntitlementService } from "../../../src/billing/entitlement-service"; export class UserServiceEE extends UserService { @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; - @inject(EntitlementService) protected readonly entitlementService: EntitlementService; @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; @inject(OssAllowListDB) protected readonly OssAllowListDb: OssAllowListDB; @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index f7d0d1dd3dcdc8..07c46a14506f45 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -46,7 +46,6 @@ import { FindPrebuildsParams, TeamMemberRole, WORKSPACE_TIMEOUT_DEFAULT_SHORT, - WorkspaceType, PrebuildEvent, } from "@gitpod/gitpod-protocol"; import { ResponseError } from "vscode-jsonrpc"; @@ -72,7 +71,7 @@ import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositor import { EligibilityService } from "../user/eligibility-service"; import { AccountStatementProvider } from "../user/account-statement-provider"; import { GithubUpgradeURL, PlanCoupon } from "@gitpod/gitpod-protocol/lib/payment-protocol"; -import { BillableSession, BillableSessionRequest, SortOrder } from "@gitpod/gitpod-protocol/lib/usage"; +import { BillableSession, BillableSessionRequest } from "@gitpod/gitpod-protocol/lib/usage"; import { AssigneeIdentityIdentifier, TeamSubscription, @@ -107,13 +106,13 @@ import { BitbucketAppSupport } from "../bitbucket/bitbucket-app-support"; import { URL } from "url"; import { UserCounter } from "../user/user-counter"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; -import { CachingUsageServiceClientProvider } from "@gitpod/usage-api/lib/usage/v1/sugar"; -import * as usage from "@gitpod/usage-api/lib/usage/v1/usage_pb"; +import { CachingUsageServiceClientProvider, UsageService } from "@gitpod/usage-api/lib/usage/v1/sugar"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import { EntitlementService } from "../../../src/billing/entitlement-service"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; import { BillingModes } from "../billing/billing-mode"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { BillingService } from "../billing/billing-service"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -160,6 +159,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { @inject(EntitlementService) protected readonly entitlementService: EntitlementService; @inject(BillingModes) protected readonly billingModes: BillingModes; + @inject(BillingService) protected readonly billingService: BillingService; initialize( client: GitpodClient | undefined, @@ -256,39 +256,31 @@ export class GitpodServerEEImpl extends GitpodServerImpl { ): Promise { await super.mayStartWorkspace(ctx, user, runningInstances); - // TODO(at) replace the naive implementation based on usage service - // with a proper call check against the upcoming invoice. - // For now this should just enable the work on fronend. - if (await this.isUsageBasedFeatureFlagEnabled(user)) { - // dummy implementation to test frontend bits - const attributionId = await this.userService.getWorkspaceUsageAttributionId(user); - const costCenter = !!attributionId && (await this.costCenterDB.findById(attributionId)); - if (costCenter) { - const allSessions = await this.listBilledUsage(ctx, { - attributionId, - startedTimeOrder: SortOrder.Descending, - }); - const totalUsage = allSessions.map((s) => s.credits).reduce((a, b) => a + b, 0); - - if (totalUsage >= costCenter.spendingLimit) { - throw new ResponseError( - ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, - "Increase spending limit and try again.", - { - attributionId: user.usageAttributionId, - }, - ); - } - } + let result; + try { + result = await this.entitlementService.mayStartWorkspace(user, new Date(), runningInstances); + } catch (error) { + throw new ResponseError(ErrorCodes.INTERNAL_SERVER_ERROR, `Error in Entitlement Service.`); } - - const result = await this.entitlementService.mayStartWorkspace(user, new Date(), runningInstances); - if (!result.enoughCredits) { + log.info("mayStartWorkspace", { result }); + if (result.mayStart) { + return; // green light from entitlement service + } + if (!!result.oufOfCredits) { throw new ResponseError( ErrorCodes.NOT_ENOUGH_CREDIT, `Not enough monthly workspace hours. Please upgrade your account to get more hours for your workspaces.`, ); } + if (!!result.spendingLimitReachedOnCostCenter) { + throw new ResponseError( + ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED, + "Increase spending limit and try again.", + { + attributionId: result.spendingLimitReachedOnCostCenter, + }, + ); + } if (!!result.hitParallelWorkspaceLimit) { throw new ResponseError( ErrorCodes.TOO_MANY_RUNNING_WORKSPACES, @@ -2142,24 +2134,17 @@ export class GitpodServerEEImpl extends GitpodServerImpl { async getNotifications(ctx: TraceContext): Promise { const result = await super.getNotifications(ctx); const user = this.checkAndBlockUser("getNotifications"); - if (user.usageAttributionId) { - // This change doesn't matter much because the listBilledUsage() call - // will be removed anyway in https://github.com/gitpod-io/gitpod/issues/11692 - const request = { - attributionId: user.usageAttributionId, - startedTimeOrder: SortOrder.Descending, - }; - const allSessions = await this.listBilledUsage(ctx, request); - const totalUsage = allSessions.map((s) => s.credits).reduce((a, b) => a + b, 0); - const costCenter = await this.costCenterDB.findById(user.usageAttributionId); + + const billingMode = await this.billingModes.getBillingModeForUser(user, new Date()); + if (billingMode.mode === "usage-based") { + const limit = await this.billingService.checkSpendingLimitReached(user); + const costCenter = await this.costCenterDB.findById(AttributionId.render(limit.attributionId)); if (costCenter) { - if (totalUsage > costCenter.spendingLimit) { + if (limit.reached) { result.unshift("The spending limit is reached."); - } else if (totalUsage > costCenter.spendingLimit * 0.8) { + } else if (limit.almostReached) { result.unshift("The spending limit is almost reached."); } - } else { - log.warn("No costcenter found.", { userId: user.id, attributionId: user.usageAttributionId }); } } return result; @@ -2188,7 +2173,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { timestampFrom, timestampTo, ); - const sessions = response.getSessionsList().map((s) => this.mapBilledSession(s)); + const sessions = response.getSessionsList().map((s) => UsageService.mapBilledSession(s)); return sessions; } @@ -2229,28 +2214,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl { await this.guardAccess({ kind: "costCenter", /*subject: costCenter,*/ owner }, operation); } - protected mapBilledSession(s: usage.BilledSession): BillableSession { - function mandatory(v: T, m: (v: T) => string = (s) => "" + s): string { - if (!v) { - throw new Error(`Empty value in usage.BilledSession for instanceId '${s.getInstanceId()}'`); - } - return m(v); - } - return { - attributionId: mandatory(s.getAttributionId()), - userId: s.getUserId() || undefined, - teamId: s.getTeamId() || undefined, - projectId: s.getProjectId() || undefined, - workspaceId: mandatory(s.getWorkspaceId()), - instanceId: mandatory(s.getInstanceId()), - workspaceType: mandatory(s.getWorkspaceType()) as WorkspaceType, - workspaceClass: s.getWorkspaceClass(), - startTime: mandatory(s.getStartTime(), (t) => t!.toDate().toISOString()), - endTime: s.getEndTime()?.toDate().toISOString(), - credits: s.getCredits(), // optional - }; - } - async getBillingModeForUser(ctx: TraceContextWithSpan): Promise { traceAPIParams(ctx, {}); diff --git a/components/server/src/billing/entitlement-service.ts b/components/server/src/billing/entitlement-service.ts index d7093cab222bf4..47eff95862c516 100644 --- a/components/server/src/billing/entitlement-service.ts +++ b/components/server/src/billing/entitlement-service.ts @@ -10,8 +10,24 @@ import { WorkspaceTimeoutDuration, WORKSPACE_TIMEOUT_DEFAULT_SHORT, } from "@gitpod/gitpod-protocol"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { injectable } from "inversify"; -import { MayStartWorkspaceResult } from "../../ee/src/user/eligibility-service"; + +export interface MayStartWorkspaceResult { + mayStart: boolean; + + hitParallelWorkspaceLimit?: HitParallelWorkspaceLimit; + + oufOfCredits?: boolean; + + /** Usage-Based Pricing: AttributionId of the CostCenter that reached it's spending limit */ + spendingLimitReachedOnCostCenter?: AttributionId; +} + +export interface HitParallelWorkspaceLimit { + max: number; + current: number; +} export const EntitlementService = Symbol("EntitlementService"); export interface EntitlementService { @@ -59,7 +75,7 @@ export class CommunityEntitlementService implements EntitlementService { date: Date, runningInstances: Promise, ): Promise { - return { enoughCredits: true }; + return { mayStart: true }; } async maySetTimeout(user: User, date: Date): Promise { diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 419cdb3b2bc301..56a8410ae7af01 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -269,35 +269,35 @@ export class UserService { * @param user * @param projectId */ - async getWorkspaceUsageAttributionId(user: User, projectId?: string): Promise { + async getWorkspaceUsageAttributionId(user: User, projectId?: string): Promise { // A. Billing-based attribution if (this.config.enablePayment) { if (user.usageAttributionId) { await this.validateUsageAttributionId(user, user.usageAttributionId); // Return the user's explicit attribution ID. - return user.usageAttributionId; + return AttributionId.parse(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 }); + return { 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 }); + return { kind: "user", userId: user.id }; } // B. Project-based attribution if (!projectId) { // No project -- attribute to the user. - return AttributionId.render({ kind: "user", userId: user.id }); + return { kind: "user", userId: user.id }; } 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 AttributionId.render({ kind: "user", userId: user.id }); + return { kind: "user", userId: user.id }; } // Attribute workspace usage to the team that currently owns this project. - return AttributionId.render({ kind: "team", teamId: project.teamId }); + return { kind: "team", teamId: project.teamId }; } async setUsageAttribution(user: User, usageAttributionId: string | undefined): Promise { diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index f57d84eb766e29..596da61db29442 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1047,7 +1047,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { // make sure we've checked that the user has enough credit before consuming any resources. // Be sure to check this before prebuilds and create workspace, too! let context = await contextPromise; - await Promise.all([this.mayStartWorkspace(ctx, user, runningInstancesPromise)]); + await this.mayStartWorkspace(ctx, user, runningInstancesPromise); if (SnapshotContext.is(context)) { // TODO(janx): Remove snapshot access tracking once we're certain that enforcing repository read access doesn't disrupt the snapshot UX. diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index e1f365ab9c501b..38fa9ff1fd1ade 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -119,6 +119,7 @@ import { WorkspaceClusterImagebuilderClientProvider } from "./workspace-cluster- import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; import { WorkspaceClasses } from "./workspace-classes"; import { EntitlementService } from "../billing/entitlement-service"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; export interface StartWorkspaceOptions { rethrow?: boolean; @@ -835,7 +836,7 @@ export class WorkspaceStarter { phase: "preparing", }, configuration, - usageAttributionId, + usageAttributionId: usageAttributionId && AttributionId.render(usageAttributionId), workspaceClass, }; if (WithReferrerContext.is(workspace.context)) { diff --git a/components/usage-api/typescript/src/usage/v1/sugar.ts b/components/usage-api/typescript/src/usage/v1/sugar.ts index fb4d1e9f6f2a95..0db54133620b96 100644 --- a/components/usage-api/typescript/src/usage/v1/sugar.ts +++ b/components/usage-api/typescript/src/usage/v1/sugar.ts @@ -9,11 +9,13 @@ import { BillingServiceClient } from "./billing_grpc_pb"; import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing"; import * as opentracing from "opentracing"; import { Metadata } from "@grpc/grpc-js"; -import { ListBilledUsageRequest, ListBilledUsageResponse } from "./usage_pb"; +import { BilledSession, ListBilledUsageRequest, ListBilledUsageResponse } from "./usage_pb"; import { injectable, inject, optional } from "inversify"; import { createClientCallMetricsInterceptor, IClientCallMetrics } from "@gitpod/gitpod-protocol/lib/util/grpc"; import * as grpc from "@grpc/grpc-js"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; +import { BillableSession } from "@gitpod/gitpod-protocol/lib/usage"; +import { WorkspaceType } from "@gitpod/gitpod-protocol"; export const UsageServiceClientProvider = Symbol("UsageServiceClientProvider"); export const BillingServiceClientProvider = Symbol("BillingServiceClientProvider"); @@ -139,7 +141,13 @@ export class PromisifiedUsageServiceClient { ); } - public async listBilledUsage(_ctx: TraceContext, attributionId: string, order: ListBilledUsageRequest.Ordering, from?: Timestamp, to?: Timestamp): Promise { + public async listBilledUsage( + _ctx: TraceContext, + attributionId: string, + order: ListBilledUsageRequest.Ordering, + from?: Timestamp, + to?: Timestamp, + ): Promise { const ctx = TraceContext.childContext(`/usage-service/listBilledUsage`, _ctx); try { @@ -182,6 +190,30 @@ export class PromisifiedUsageServiceClient { } } +export namespace UsageService { + export function mapBilledSession(s: BilledSession): BillableSession { + function mandatory(v: T, m: (v: T) => string = (s) => "" + s): string { + if (!v) { + throw new Error(`Empty value in usage.BilledSession for instanceId '${s.getInstanceId()}'`); + } + return m(v); + } + return { + attributionId: mandatory(s.getAttributionId()), + userId: s.getUserId() || undefined, + teamId: s.getTeamId() || undefined, + projectId: s.getProjectId() || undefined, + workspaceId: mandatory(s.getWorkspaceId()), + instanceId: mandatory(s.getInstanceId()), + workspaceType: mandatory(s.getWorkspaceType()) as WorkspaceType, + workspaceClass: s.getWorkspaceClass(), + startTime: mandatory(s.getStartTime(), (t) => t!.toDate().toISOString()), + endTime: s.getEndTime()?.toDate().toISOString(), + credits: s.getCredits(), // optional + }; + } +} + export class PromisifiedBillingServiceClient { constructor(public readonly client: BillingServiceClient, protected readonly interceptor: grpc.Interceptor[]) {}