Skip to content

EntitlementService Usage-Based Pricing #11936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions components/dashboard/src/start/CreateWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,7 @@ function SpendingLimitReachedModal(p: { hints: any }) {
const [attributedTeam, setAttributedTeam] = useState<Team | undefined>();

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") {
Expand Down
85 changes: 85 additions & 0 deletions components/server/ee/src/billing/billing-service.ts
Original file line number Diff line number Diff line change
@@ -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<SpendingLimitReachedResult> {
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<BillableSession[]> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,7 +30,7 @@ export class EntitlementServiceLicense implements EntitlementService {
runningInstances: Promise<WorkspaceInstance[]>,
): Promise<MayStartWorkspaceResult> {
// 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<boolean> {
Expand Down
104 changes: 104 additions & 0 deletions components/server/ee/src/billing/entitlement-service-ubp.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceInstance[]>,
): Promise<MayStartWorkspaceResult> {
const hasHitParallelWorkspaceLimit = async (): Promise<HitParallelWorkspaceLimit | undefined> => {
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<AttributionId | undefined> {
const result = await this.billingService.checkSpendingLimitReached(user);
if (result.reached) {
return result.attributionId;
}
return undefined;
}

protected async getMaxParallelWorkspaces(user: User, date: Date): Promise<number> {
if (await this.hasPaidSubscription(user, date)) {
return MAX_PARALLEL_WORKSPACES_PAID;
} else {
return MAX_PARALLEL_WORKSPACES_FREE;
}
}

async maySetTimeout(user: User, date: Date): Promise<boolean> {
return this.hasPaidSubscription(user, date);
}

async getDefaultWorkspaceTimeout(user: User, date: Date): Promise<WorkspaceTimeoutDuration> {
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<boolean> {
return this.hasPaidSubscription(user, date);
}

protected async hasPaidSubscription(user: User, date: Date): Promise<boolean> {
// TODO(gpl) UBP personal: implement!
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: As currently implemented, it reads like everyone on Usage-Based gets XL workspaces.

}
}
100 changes: 79 additions & 21 deletions components/server/ee/src/billing/entitlement-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceInstance[]>,
): Promise<MayStartWorkspaceResult> {
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<boolean> {
if (!this.config.enablePayment) {
return await this.etsLicense.maySetTimeout(user, date);
async maySetTimeout(user: User, date: Date = new Date()): Promise<boolean> {
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<WorkspaceTimeoutDuration> {
if (!this.config.enablePayment) {
return await this.etsLicense.getDefaultWorkspaceTimeout(user, date);
async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise<WorkspaceTimeoutDuration> {
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<boolean> {
if (!this.config.enablePayment) {
return await this.etsLicense.userGetsMoreResources(user);
async userGetsMoreResources(user: User, date: Date = new Date()): Promise<boolean> {
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);
}
}
Loading