Skip to content

Commit 080d381

Browse files
committed
[server] Implement EntitlementService based on BillingMode
1 parent a1b7c3c commit 080d381

12 files changed

+457
-270
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import {
8+
User,
9+
WorkspaceInstance,
10+
WorkspaceTimeoutDuration,
11+
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
12+
} from "@gitpod/gitpod-protocol";
13+
import { UsageServiceClient } from "@gitpod/usage-api/lib/usage/v1/usage_grpc_pb";
14+
import { inject, injectable } from "inversify";
15+
import { EntitlementService } from "../../../src/billing/entitlement-service";
16+
import { MayStartWorkspaceResult } from "../user/eligibility-service";
17+
18+
@injectable()
19+
export class EntitlementServiceUBB implements EntitlementService {
20+
@inject(UsageServiceClient) protected readonly usageService: UsageServiceClient;
21+
22+
async mayStartWorkspace(
23+
user: User,
24+
date: Date,
25+
runningInstances: Promise<WorkspaceInstance[]>,
26+
): Promise<MayStartWorkspaceResult> {
27+
// TODO(gpl) Implement!
28+
return { enoughCredits: true };
29+
}
30+
31+
async getDefaultWorkspaceTimeout(user: User, date: Date): Promise<WorkspaceTimeoutDuration> {
32+
// TODO(gpl) Implement!#
33+
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
34+
}
35+
36+
async maySetTimeout(user: User, date: Date): Promise<boolean> {
37+
// TODO(gpl) Implement!
38+
return false;
39+
}
40+
41+
async hasFixedWorkspaceResources(user: User, date: Date): Promise<boolean> {
42+
// TODO(gpl) Implement!
43+
return false;
44+
}
45+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { TeamDB, TeamSubscription2DB, TeamSubscriptionDB } from "@gitpod/gitpod-db/lib";
8+
import { Accounting, SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting";
9+
import {
10+
User,
11+
WorkspaceInstance,
12+
WorkspaceTimeoutDuration,
13+
WORKSPACE_TIMEOUT_DEFAULT_LONG,
14+
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
15+
} from "@gitpod/gitpod-protocol";
16+
import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
17+
import { MAX_PARALLEL_WORKSPACES, Plans } from "@gitpod/gitpod-protocol/lib/plans";
18+
import { millisecondsToHours } from "@gitpod/gitpod-protocol/lib/util/timeutil";
19+
import { inject, injectable } from "inversify";
20+
import { EntitlementService } from "../../../src/billing/entitlement-service";
21+
import { Config } from "../../../src/config";
22+
import { AccountStatementProvider, CachedAccountStatement } from "../user/account-statement-provider";
23+
import { HitParallelWorkspaceLimit, MayStartWorkspaceResult } from "../user/eligibility-service";
24+
25+
@injectable()
26+
export class EntitlementServiceChargebee implements EntitlementService {
27+
@inject(Config) protected readonly config: Config;
28+
@inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService;
29+
@inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider;
30+
@inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB;
31+
@inject(TeamDB) protected readonly teamDB: TeamDB;
32+
@inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB;
33+
34+
async mayStartWorkspace(
35+
user: User,
36+
date: Date,
37+
runningInstances: Promise<WorkspaceInstance[]>,
38+
): Promise<MayStartWorkspaceResult> {
39+
const hasHitParallelWorkspaceLimit = async (): Promise<HitParallelWorkspaceLimit | undefined> => {
40+
const max = await this.getMaxParallelWorkspaces(user);
41+
const instances = (await runningInstances).filter((i) => i.status.phase !== "preparing");
42+
const current = instances.length; // >= parallelWorkspaceAllowance;
43+
if (current >= max) {
44+
return {
45+
current,
46+
max,
47+
};
48+
} else {
49+
return undefined;
50+
}
51+
};
52+
const [enoughCredits, hitParallelWorkspaceLimit] = await Promise.all([
53+
this.checkEnoughCreditForWorkspaceStart(user.id, date, runningInstances),
54+
hasHitParallelWorkspaceLimit(),
55+
]);
56+
57+
return {
58+
enoughCredits: !!enoughCredits,
59+
hitParallelWorkspaceLimit,
60+
};
61+
}
62+
63+
/**
64+
* Returns the maximum number of parallel workspaces a user can run at the same time.
65+
* @param user
66+
* @param date The date for which we want to know whether the user is allowed to set a timeout (depends on active subscription)
67+
*/
68+
protected async getMaxParallelWorkspaces(user: User, date: Date = new Date()): Promise<number> {
69+
// if payment is not enabled users can start as many parallel workspaces as they want
70+
if (!this.config.enablePayment) {
71+
return MAX_PARALLEL_WORKSPACES;
72+
}
73+
74+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
75+
return subscriptions.map((s) => Plans.getParallelWorkspacesById(s.planId)).reduce((p, v) => Math.max(p, v));
76+
}
77+
78+
protected async checkEnoughCreditForWorkspaceStart(
79+
userId: string,
80+
date: Date,
81+
runningInstances: Promise<WorkspaceInstance[]>,
82+
): Promise<boolean> {
83+
// As retrieving a full AccountStatement is expensive we want to cache it as much as possible.
84+
const cachedAccountStatement = this.accountStatementProvider.getCachedStatement();
85+
const lowerBound = this.getRemainingUsageHoursLowerBound(cachedAccountStatement, date.toISOString());
86+
if (lowerBound && (lowerBound === "unlimited" || lowerBound > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS)) {
87+
return true;
88+
}
89+
90+
const remainingUsageHours = await this.accountStatementProvider.getRemainingUsageHours(
91+
userId,
92+
date.toISOString(),
93+
runningInstances,
94+
);
95+
return remainingUsageHours > Accounting.MINIMUM_CREDIT_FOR_OPEN_IN_HOURS;
96+
}
97+
98+
/**
99+
* Tries to calculate the lower bound of remaining usage hours based on cached AccountStatements
100+
* with the goal to improve workspace startup times.
101+
*/
102+
protected getRemainingUsageHoursLowerBound(
103+
cachedStatement: CachedAccountStatement | undefined,
104+
date: string,
105+
): RemainingHours | undefined {
106+
if (!cachedStatement) {
107+
return undefined;
108+
}
109+
if (cachedStatement.remainingHours === "unlimited") {
110+
return "unlimited";
111+
}
112+
113+
const diffInMillis = new Date(cachedStatement.endDate).getTime() - new Date(date).getTime();
114+
const maxPossibleUsage = millisecondsToHours(diffInMillis) * MAX_PARALLEL_WORKSPACES;
115+
return cachedStatement.remainingHours - maxPossibleUsage;
116+
}
117+
118+
async maySetTimeout(user: User, date: Date = new Date()): Promise<boolean> {
119+
if (!this.config.enablePayment) {
120+
// when payment is disabled users can do everything
121+
return true;
122+
}
123+
124+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
125+
const eligblePlans = [
126+
Plans.PROFESSIONAL_EUR,
127+
Plans.PROFESSIONAL_USD,
128+
Plans.PROFESSIONAL_STUDENT_EUR,
129+
Plans.PROFESSIONAL_STUDENT_USD,
130+
Plans.TEAM_PROFESSIONAL_EUR,
131+
Plans.TEAM_PROFESSIONAL_USD,
132+
Plans.TEAM_PROFESSIONAL_STUDENT_EUR,
133+
Plans.TEAM_PROFESSIONAL_STUDENT_USD,
134+
].map((p) => p.chargebeeId);
135+
136+
return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
137+
}
138+
139+
async getDefaultWorkspaceTimeout(user: User, date: Date = new Date()): Promise<WorkspaceTimeoutDuration> {
140+
if (await this.maySetTimeout(user, date)) {
141+
return WORKSPACE_TIMEOUT_DEFAULT_LONG;
142+
} else {
143+
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
144+
}
145+
}
146+
147+
async hasFixedWorkspaceResources(user: User, date: Date = new Date()): Promise<boolean> {
148+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(user, date.toISOString());
149+
const eligblePlans = [
150+
Plans.PROFESSIONAL_EUR,
151+
Plans.PROFESSIONAL_USD,
152+
Plans.TEAM_PROFESSIONAL_EUR,
153+
Plans.TEAM_PROFESSIONAL_USD,
154+
].map((p) => p.chargebeeId);
155+
156+
return subscriptions.filter((s) => eligblePlans.includes(s.planId!)).length > 0;
157+
}
158+
159+
async userGetsMoreResources(user: User): Promise<boolean> {
160+
const subscriptions = await this.subscriptionService.getNotYetCancelledSubscriptions(
161+
user,
162+
new Date().toISOString(),
163+
);
164+
const eligiblePlans = [Plans.TEAM_PROFESSIONAL_EUR, Plans.TEAM_PROFESSIONAL_USD].map((p) => p.chargebeeId);
165+
166+
const relevantSubscriptions = subscriptions.filter((s) => eligiblePlans.includes(s.planId!));
167+
if (relevantSubscriptions.length === 0) {
168+
// user has no subscription that grants "more resources"
169+
return false;
170+
}
171+
172+
// some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources
173+
const excludeFromMoreResources = await Promise.all(
174+
relevantSubscriptions.map(async (s): Promise<boolean> => {
175+
if (s.teamMembershipId) {
176+
const team = await this.teamDB.findTeamByMembershipId(s.teamMembershipId);
177+
if (!team) {
178+
return true;
179+
}
180+
const ts2 = await this.teamSubscription2Db.findForTeam(team.id, new Date().toISOString());
181+
if (!ts2) {
182+
return true;
183+
}
184+
return ts2.excludeFromMoreResources;
185+
}
186+
if (!s.teamSubscriptionSlotId) {
187+
return false;
188+
}
189+
const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId);
190+
return !!ts?.excludeFromMoreResources;
191+
}),
192+
);
193+
if (excludeFromMoreResources.every((b) => b)) {
194+
// if all TS the user is part of are marked this way, we deny that privilege
195+
return false;
196+
}
197+
198+
return true;
199+
}
200+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { UserDB } from "@gitpod/gitpod-db/lib";
8+
import {
9+
User,
10+
WorkspaceInstance,
11+
WorkspaceTimeoutDuration,
12+
WORKSPACE_TIMEOUT_DEFAULT_LONG,
13+
WORKSPACE_TIMEOUT_DEFAULT_SHORT,
14+
} from "@gitpod/gitpod-protocol";
15+
import { LicenseEvaluator } from "@gitpod/licensor/lib";
16+
import { Feature } from "@gitpod/licensor/lib/api";
17+
import { inject, injectable } from "inversify";
18+
import { BillingModes } from "../../../src/billing/billing-mode";
19+
import { EntitlementService } from "../../../src/billing/entitlement-service";
20+
import { MayStartWorkspaceResult } from "../user/eligibility-service";
21+
import { EntitlementServiceUBB } from "./entintlement-service-ubb";
22+
import { EntitlementServiceChargebee } from "./entitlement-service-chargebee";
23+
24+
@injectable()
25+
export class EntitlementServiceImpl implements EntitlementService {
26+
@inject(BillingModes) protected readonly billingModes: BillingModes;
27+
@inject(EntitlementServiceChargebee) protected readonly chargebee: EntitlementServiceChargebee;
28+
@inject(EntitlementServiceUBB) protected readonly ubb: EntitlementServiceUBB;
29+
@inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator;
30+
@inject(UserDB) protected readonly userDb: UserDB;
31+
32+
async mayStartWorkspace(
33+
user: User,
34+
date: Date,
35+
runningInstances: Promise<WorkspaceInstance[]>,
36+
): Promise<MayStartWorkspaceResult> {
37+
const billingMode = await this.billingModes.getBillingModeForUser(user);
38+
switch (billingMode.mode) {
39+
case "none":
40+
return { enoughCredits: true };
41+
case "chargebee":
42+
return this.chargebee.mayStartWorkspace(user, date, runningInstances);
43+
case "usage-based":
44+
return this.ubb.mayStartWorkspace(user, date, runningInstances);
45+
}
46+
}
47+
48+
async getDefaultWorkspaceTimeout(user: User, date: Date): Promise<WorkspaceTimeoutDuration> {
49+
const billingMode = await this.billingModes.getBillingModeForUser(user);
50+
switch (billingMode.mode) {
51+
case "none":
52+
// TODO(gpl) Extract into EntitlementServiceLicense
53+
// the self-hosted case
54+
const userCount = await this.userDb.getUserCount(true);
55+
if (!this.licenseEvaluator.isEnabled(Feature.FeatureSetTimeout, userCount)) {
56+
return WORKSPACE_TIMEOUT_DEFAULT_SHORT;
57+
}
58+
59+
return WORKSPACE_TIMEOUT_DEFAULT_LONG;
60+
case "chargebee":
61+
return this.chargebee.getDefaultWorkspaceTimeout(user, date);
62+
case "usage-based":
63+
return this.ubb.getDefaultWorkspaceTimeout(user, date);
64+
}
65+
}
66+
67+
async maySetTimeout(user: User, date: Date): Promise<boolean> {
68+
const billingMode = await this.billingModes.getBillingModeForUser(user);
69+
switch (billingMode.mode) {
70+
case "none":
71+
return true;
72+
case "chargebee":
73+
return this.chargebee.maySetTimeout(user, date);
74+
case "usage-based":
75+
return this.ubb.maySetTimeout(user, date);
76+
}
77+
}
78+
79+
async hasFixedWorkspaceResources(user: User, date: Date): Promise<boolean> {
80+
const billingMode = await this.billingModes.getBillingModeForUser(user);
81+
switch (billingMode.mode) {
82+
case "none":
83+
return true;
84+
case "chargebee":
85+
return this.chargebee.hasFixedWorkspaceResources(user, date);
86+
case "usage-based":
87+
return this.ubb.hasFixedWorkspaceResources(user, date);
88+
}
89+
}
90+
}

components/server/ee/src/container-module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ import { UserCounter } from "./user/user-counter";
6363
import { BitbucketServerApp } from "./prebuilds/bitbucket-server-app";
6464
import { BillingModes } from "../../src/billing/billing-mode";
6565
import { BillingModesImpl } from "./billing/billing-mode";
66+
import { EntitlementService } from "../../src/billing/entitlement-service";
67+
import { EntitlementServiceImpl } from "./billing/entitlement-service";
68+
import { EntitlementServiceChargebee } from "./billing/entitlement-service-chargebee";
69+
import { EntitlementServiceUBB } from "./billing/entintlement-service-ubb";
6670

6771
export const productionEEContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
6872
rebind(Server).to(ServerEE).inSingletonScope();
@@ -125,5 +129,8 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
125129
bind(ChargebeeService).toSelf().inSingletonScope();
126130
bind(StripeService).toSelf().inSingletonScope();
127131

128-
bind(BillingModes).to(BillingModesImpl).inSingletonScope();
132+
rebind(BillingModes).to(BillingModesImpl).inSingletonScope();
133+
rebind(EntitlementService).to(EntitlementServiceImpl).inSingletonScope();
134+
bind(EntitlementServiceChargebee).toSelf().inSingletonScope();
135+
bind(EntitlementServiceUBB).toSelf().inSingletonScope();
129136
});

0 commit comments

Comments
 (0)