- Members
+
+ {teamPlan.name}
-
- {members.length}
+
+ Unlimited hours
-
- Next invoice on
+
Includes:
+
+ {(featuresByPlanType[teamPlan.type] || []).map((f) => (
+
+
+ {f}
+
+ ))}
-
- {guessNextInvoiceDate(teamSubscription.startDate).toDateString()}
+
+
+
+ {!teamSubscription ? (
+
+
+
-
-
+
+ ) : (
+
+
+
+ Members
+
+
+ {members.length}
+
+
+ Next invoice on
+
+
+ {guessNextInvoiceDate(teamSubscription.startDate).toDateString()}
+
+
+
+
-
-
- )}
- >
- )}
-
-
- Team Billing automatically adds all members to the plan.{" "}
-
- Learn more
-
-
+
+ )}
+ >
+ )}
+
+
+ Team Billing automatically adds all members to the plan.{" "}
+
+ Learn more
+
+
+ >
+ );
+ }
+
+ const showUBP = BillingMode.showUsageBasedBilling(teamBillingMode);
+ return (
+
+ {teamBillingMode === undefined ? (
+
+
+
+ ) : (
+ <>
+ {showUBP && }
+ {!showUBP && renderTeamBilling()}
+ >
+ )}
);
}
diff --git a/components/gitpod-protocol/src/billing-mode.ts b/components/gitpod-protocol/src/billing-mode.ts
index 630932aaa1a3a7..d32ca5335a828f 100644
--- a/components/gitpod-protocol/src/billing-mode.ts
+++ b/components/gitpod-protocol/src/billing-mode.ts
@@ -26,7 +26,11 @@ export namespace BillingMode {
);
}
- export function canSetWorkspaceClass(billingMode: BillingMode): boolean {
+ export function canSetWorkspaceClass(billingMode?: BillingMode): boolean {
+ if (!billingMode) {
+ return false;
+ }
+
// if has any Stripe subscription, either directly or per team
return billingMode.mode === "usage-based";
}
@@ -52,6 +56,9 @@ interface Chargebee {
interface UsageBased {
mode: "usage-based";
+ /** True iff this is a team, and is based on a paid plan. Currently only set for teams! */
+ paid?: boolean;
+
/** User is already converted, but is member with at least one Chargebee-based "Team Plan" */
hasChargebeeTeamPlan?: boolean;
diff --git a/components/server/ee/src/billing/billing-mode.spec.db.ts b/components/server/ee/src/billing/billing-mode.spec.db.ts
index a328c60c674f77..e930aca31d87dc 100644
--- a/components/server/ee/src/billing/billing-mode.spec.db.ts
+++ b/components/server/ee/src/billing/billing-mode.spec.db.ts
@@ -108,8 +108,9 @@ class BillingModeSpec {
};
}
- function subscription(plan: Plan, cancellationDate?: string, endDate?: string): Subscription {
- return Subscription.create({
+ type TestSubscription = Subscription & { type: "personal" | "team-old" | "team2" };
+ function subscription(plan: Plan, cancellationDate?: string, endDate?: string): TestSubscription {
+ const s = Subscription.create({
startDate: creationDate,
userId,
planId: plan.chargebeeId,
@@ -117,13 +118,38 @@ class BillingModeSpec {
cancellationDate,
endDate: endDate || cancellationDate,
});
+ return {
+ ...s,
+ type: "personal",
+ };
+ }
+ function teamSubscription(plan: Plan, cancellationDate?: string, endDate?: string): TestSubscription {
+ const s = subscription(plan, cancellationDate, endDate);
+ return {
+ ...s,
+ type: "team-old",
+ };
+ }
+ function teamSubscription2(plan: Plan, cancellationDate?: string, endDate?: string): TestSubscription {
+ const s = teamSubscription(plan, cancellationDate, endDate);
+ return {
+ ...s,
+ type: "team2",
+ };
+ }
+
+ function stripeSubscription() {
+ return {
+ id: "stripe-123",
+ customer: stripeCustomerId,
+ };
}
- function stripeSubscription(team: boolean = false) {
+ function stripeTeamSubscription() {
return {
id: "stripe-123",
- customer: team ? stripeTeamCustomerId : stripeCustomerId,
- isTeam: team,
+ customer: stripeTeamCustomerId,
+ isTeam: true,
};
}
@@ -133,8 +159,8 @@ class BillingModeSpec {
config: {
enablePayment: boolean;
usageBasedPricingEnabled: boolean;
- subscriptions?: Subscription[];
- stripeSubscription?: StripeSubscription & { isTeam: boolean };
+ subscriptions?: TestSubscription[];
+ stripeSubscription?: StripeSubscription & { isTeam?: boolean };
};
expectation: BillingMode;
only?: true;
@@ -176,7 +202,7 @@ class BillingModeSpec {
},
// user: chargebee
{
- name: "user: chargbee paid personal",
+ name: "user: chargebee paid personal",
subject: user(),
config: {
enablePayment: true,
@@ -188,24 +214,24 @@ class BillingModeSpec {
},
},
{
- name: "user: chargbee paid team seat",
+ name: "user: chargebee paid team seat",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: false,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)],
+ subscriptions: [teamSubscription(Plans.TEAM_PROFESSIONAL_EUR)],
},
expectation: {
mode: "chargebee",
},
},
{
- name: "user: chargbee paid personal + team seat",
+ name: "user: chargebee paid personal + team seat",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: false,
- subscriptions: [subscription(Plans.PERSONAL_EUR), subscription(Plans.TEAM_PROFESSIONAL_EUR)],
+ subscriptions: [subscription(Plans.PERSONAL_EUR), teamSubscription(Plans.TEAM_PROFESSIONAL_EUR)],
},
expectation: {
mode: "chargebee",
@@ -213,14 +239,14 @@ class BillingModeSpec {
},
// user: transition chargebee -> UBB
{
- name: "user: chargbee paid personal (cancelled) + team seat",
+ name: "user: chargebee paid personal (cancelled) + team seat",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
subscriptions: [
subscription(Plans.PERSONAL_EUR, cancellationDate, endDate),
- subscription(Plans.TEAM_PROFESSIONAL_EUR),
+ teamSubscription(Plans.TEAM_PROFESSIONAL_EUR),
],
},
expectation: {
@@ -229,14 +255,14 @@ class BillingModeSpec {
},
},
{
- name: "user: chargbee paid personal (cancelled) + team seat (cancelled)",
+ name: "user: chargebee paid personal (cancelled) + team seat (cancelled)",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
subscriptions: [
subscription(Plans.PERSONAL_EUR, cancellationDate, endDate),
- subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate),
+ teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate),
],
},
expectation: {
@@ -245,16 +271,16 @@ class BillingModeSpec {
},
},
{
- name: "user: chargbee paid personal (cancelled) + team seat (active) + stripe",
+ name: "user: chargebee paid personal (cancelled) + team seat (active) + stripe",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
subscriptions: [
subscription(Plans.PERSONAL_EUR, cancellationDate, endDate),
- subscription(Plans.TEAM_PROFESSIONAL_EUR),
+ teamSubscription(Plans.TEAM_PROFESSIONAL_EUR),
],
- stripeSubscription: stripeSubscription(true),
+ stripeSubscription: stripeTeamSubscription(),
},
expectation: {
mode: "usage-based",
@@ -263,14 +289,14 @@ class BillingModeSpec {
},
// user: usage-based
{
- name: "user: stripe free, chargbee paid personal (inactive) + team seat (inactive)",
+ name: "user: stripe free, chargebee paid personal (inactive) + team seat (inactive)",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
subscriptions: [
subscription(Plans.PERSONAL_EUR, cancellationDate, cancellationDate),
- subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate),
+ teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate),
],
},
expectation: {
@@ -278,14 +304,14 @@ class BillingModeSpec {
},
},
{
- name: "user: stripe paid, chargbee paid personal (inactive) + team seat (inactive)",
+ name: "user: stripe paid, chargebee paid personal (inactive) + team seat (inactive)",
subject: user(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
subscriptions: [
subscription(Plans.PERSONAL_EUR, cancellationDate, cancellationDate),
- subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate),
+ teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate),
],
stripeSubscription: stripeSubscription(),
},
@@ -352,24 +378,24 @@ class BillingModeSpec {
},
// team: chargebee
{
- name: "team: chargbee paid",
+ name: "team: chargebee paid",
subject: team(),
config: {
enablePayment: true,
usageBasedPricingEnabled: false,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)],
+ subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR)],
},
expectation: {
mode: "chargebee",
},
},
{
- name: "team: chargbee paid (UBB)",
+ name: "team: chargebee paid (UBB)",
subject: team(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)],
+ subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR)],
},
expectation: {
mode: "chargebee",
@@ -377,18 +403,30 @@ class BillingModeSpec {
},
// team: transition chargebee -> UBB
{
- name: "team: chargbee paid (cancelled)",
+ name: "team: chargebee paid (TeamSubscription2, cancelled)",
subject: team(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate)],
+ subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate)],
},
expectation: {
mode: "chargebee",
canUpgradeToUBB: true,
},
},
+ {
+ name: "team: chargebee paid (old TeamSubscription, cancelled)",
+ subject: team(),
+ config: {
+ enablePayment: true,
+ usageBasedPricingEnabled: true,
+ subscriptions: [teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate)],
+ },
+ expectation: {
+ mode: "usage-based",
+ },
+ },
// team: usage-based
{
name: "team: usage-based free",
@@ -402,28 +440,29 @@ class BillingModeSpec {
},
},
{
- name: "team: stripe free, chargbee (inactive)",
+ name: "team: stripe free, chargebee (inactive)",
subject: team(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)],
+ subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)],
},
expectation: {
mode: "usage-based",
},
},
{
- name: "team: stripe paid, chargbee (inactive)",
+ name: "team: stripe paid, chargebee (inactive)",
subject: team(),
config: {
enablePayment: true,
usageBasedPricingEnabled: true,
- subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)],
+ subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)],
stripeSubscription: stripeSubscription(),
},
expectation: {
mode: "usage-based",
+ paid: true,
},
},
{
@@ -436,6 +475,7 @@ class BillingModeSpec {
},
expectation: {
mode: "usage-based",
+ paid: true,
},
},
];
@@ -498,9 +538,9 @@ class BillingModeSpec {
throw new Error(`${test.name}: Invalid test data: expected membership for team to exist!`);
}
teamMembershipId = membership.id;
+ teamId = team.id;
if (isTeam) {
attributionId = { kind: "team", teamId: team.id };
- teamId = team.id;
}
}
if (!attributionId) {
@@ -509,7 +549,10 @@ class BillingModeSpec {
for (const sub of test.config.subscriptions || []) {
const plan = Plans.getById(sub.planId!);
if (plan?.team) {
- if (teamId) {
+ if (sub.type === "team2") {
+ if (!teamId) {
+ throw new Error("Cannot create TeamSubscription2 without teamId!");
+ }
// TeamSubscription2 - only relevant for teams (for BillingMode)
const ts2 = TeamSubscription2.create({
teamId,
@@ -524,7 +567,7 @@ class BillingModeSpec {
await teamSubscription2DB.storeEntry(ts2);
sub.teamMembershipId = teamMembershipId;
await accountingDB.storeSubscription(sub);
- } else {
+ } else if (sub.type === "team-old") {
// TeamSubscription - only relevant for users (for BillingMode)
const ts = TeamSubscription.create({
userId,
@@ -546,6 +589,8 @@ class BillingModeSpec {
await teamSubscriptionDB.storeSlot(slot);
sub.teamSubscriptionSlotId = slot.id;
await accountingDB.storeSubscription(sub);
+ } else {
+ throw new Error("Bad test data: team plan of wrong type!");
}
} else {
await accountingDB.storeSubscription(sub);
diff --git a/components/server/ee/src/billing/billing-mode.ts b/components/server/ee/src/billing/billing-mode.ts
index 8458116023e776..889e89cbb6436a 100644
--- a/components/server/ee/src/billing/billing-mode.ts
+++ b/components/server/ee/src/billing/billing-mode.ts
@@ -135,12 +135,12 @@ export class BillingModesImpl implements BillingModes {
// 3. Check team memberships/plans
// UBB overrides wins if there is _any_. But if there is none, use the existing Chargebee subscription.
const teamsModes = await Promise.all(teams.map((t) => this.getBillingModeForTeam(t, now)));
- const hasUbbTeam = teamsModes.some((tm) => tm.mode === "usage-based");
+ const hasUbbPaidTeam = teamsModes.some((tm) => tm.mode === "usage-based" && !!tm.paid);
const hasCbTeam = teamsModes.some((tm) => tm.mode === "chargebee");
const hasCbTeamSeat = cbTeamSubscriptions.length > 0;
- if (hasUbbTeam || hasUbbPersonal) {
- // UBB is gready: once a user has at least a team seat, they should benefit from it!
+ if (hasUbbPaidTeam || hasUbbPersonal) {
+ // UBB is greedy: once a user has at least a paid team membership, they should benefit from it!
const result: BillingMode = { mode: "usage-based" };
if (hasCbTeam) {
result.hasChargebeeTeamPlan = true;
@@ -196,7 +196,15 @@ export class BillingModesImpl implements BillingModes {
return { mode: "chargebee" };
}
- // 3. If not: we don't even have to check for a team subscription
- return { mode: "usage-based" };
+ // 3. Now we're usage-based. We only have to figure out whether we have a plan yet or not.
+ const result: BillingMode = { mode: "usage-based" };
+ const customer = await this.stripeSvc.findCustomerByTeamId(team.id);
+ if (customer) {
+ const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id);
+ if (subscription) {
+ result.paid = true;
+ }
+ }
+ return result;
}
}
diff --git a/components/server/ee/src/billing/billing-service.ts b/components/server/ee/src/billing/billing-service.ts
index d1579b458c37f4..1294fa8844a79e 100644
--- a/components/server/ee/src/billing/billing-service.ts
+++ b/components/server/ee/src/billing/billing-service.ts
@@ -29,11 +29,15 @@ export class BillingService {
async checkSpendingLimitReached(user: User): Promise
{
const attributionId = await this.userService.getWorkspaceUsageAttributionId(user);
- const costCenter = !!attributionId && (await this.costCenterDB.findById(AttributionId.render(attributionId)));
+ const costCenter = 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;
+ // Technially we do not have any spending limit set, yet. But sending users down the "reached" path will fix this issues as well.
+ return {
+ reached: true,
+ attributionId,
+ };
}
const allSessions = await this.listBilledUsage({
diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts
index 56a8410ae7af01..a067c06c1ff11b 100644
--- a/components/server/src/user/user-service.ts
+++ b/components/server/src/user/user-service.ts
@@ -203,9 +203,12 @@ export class UserService {
return subscription?.id;
}
- protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise {
+ protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise {
const attribution = AttributionId.parse(usageAttributionId);
- if (attribution?.kind === "team") {
+ if (!attribution) {
+ throw new ResponseError(ErrorCodes.INVALID_COST_CENTER, "The billing team id configured is invalid.");
+ }
+ if (attribution.kind === "team") {
const team = await this.teamDB.findTeamById(attribution.teamId);
if (!team) {
throw new ResponseError(
@@ -228,6 +231,7 @@ export class UserService {
);
}
}
+ return attribution;
}
protected async findSingleTeamWithUsageBasedBilling(user: User): Promise {
@@ -268,14 +272,14 @@ export class UserService {
*
* @param user
* @param projectId
+ * @returns The validated AttributionId
*/
- 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 AttributionId.parse(user.usageAttributionId);
+ return await this.validateUsageAttributionId(user, user.usageAttributionId);
}
const billingTeam = await this.findSingleTeamWithUsageBasedBilling(user);
if (billingTeam) {
diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts
index d1ef806f1e378d..c9d8fae9932333 100644
--- a/components/server/src/workspace/workspace-starter.ts
+++ b/components/server/src/workspace/workspace-starter.ts
@@ -123,6 +123,7 @@ import { BillingModes } from "../../ee/src/billing/billing-mode";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { CachingBillingServiceClientProvider } from "@gitpod/usage-api/lib/usage/v1/sugar";
import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb";
+import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
export interface StartWorkspaceOptions {
rethrow?: boolean;
@@ -838,8 +839,10 @@ export class WorkspaceStarter {
user: user,
teams: userTeams,
});
+ const usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId);
+ const billingMode = await this.billingModes.getBillingMode(usageAttributionId, new Date());
- if (classesEnabled) {
+ if (classesEnabled || BillingMode.canSetWorkspaceClass(billingMode)) {
// this is either the first time we start the workspace or the workspace was started
// before workspace classes and does not have a class yet
workspaceClass = await getWorkspaceClassForInstance(
@@ -872,8 +875,6 @@ export class WorkspaceStarter {
configuration.featureFlags = featureFlags;
}
- const usageAttributionId = await this.userService.getWorkspaceUsageAttributionId(user, workspace.projectId);
-
const now = new Date().toISOString();
const instance: WorkspaceInstance = {
id: uuidv4(),
@@ -1424,7 +1425,8 @@ export class WorkspaceStarter {
}
const volumeSnapshotId =
-((SnapshotContext.is(workspace.context) || WithPrebuild.is(workspace.context)) && !!workspace.context.snapshotBucketId)
+ (SnapshotContext.is(workspace.context) || WithPrebuild.is(workspace.context)) &&
+ !!workspace.context.snapshotBucketId
? workspace.context.snapshotBucketId
: lastValidWorkspaceInstanceId;