diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 3f9db9b9bd4bcb..19b7012a463326 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -152,9 +152,6 @@ export default function Menu() { server.isStudent().then((v) => () => setIsStudent(v)), server.isChargebeeCustomer().then((v) => () => setIsChargebeeCustomer(v)), ]).then((setters) => setters.forEach((s) => s())); - - // Refresh billing mode - refreshUserBillingMode(); }, []); useEffect(() => { @@ -163,6 +160,11 @@ export default function Menu() { } }, [team]); + useEffect(() => { + // Refresh billing mode + refreshUserBillingMode(); + }, [teams]); + const teamOrUserSlug = !!team ? "/t/" + team.slug : "/projects"; const leftMenu: Entry[] = (() => { // Project menu diff --git a/components/dashboard/src/settings/Preferences.tsx b/components/dashboard/src/settings/Preferences.tsx index 0dc3940615bc11..d3abb801bef45c 100644 --- a/components/dashboard/src/settings/Preferences.tsx +++ b/components/dashboard/src/settings/Preferences.tsx @@ -14,11 +14,12 @@ import SelectIDE from "./SelectIDE"; import SelectWorkspaceClass from "./selectClass"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; type Theme = "light" | "dark" | "system"; export default function Preferences() { - const { user } = useContext(UserContext); + const { user, userBillingMode } = useContext(UserContext); const { setIsDark } = useContext(ThemeContext); const { showWorkspaceClassesUI } = useContext(FeatureFlagContext); @@ -56,7 +57,9 @@ export default function Preferences() {

Editor

Choose the editor for opening workspaces.

- +

Theme

Early bird or night owl? Choose your side.

diff --git a/components/dashboard/src/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx index c0b1e578fd1197..ee1e18b02c8cca 100644 --- a/components/dashboard/src/teams/TeamBilling.tsx +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -151,163 +151,180 @@ export default function TeamBilling() { return ; } - return ( - - -

{!teamPlan ? "Select Team Plan" : "Team Plan"}

-

- {!teamPlan ? ( -
- Currency: - setCurrency("EUR"), - }, - { - title: "USD", - onClick: () => setCurrency("USD"), - }, - ]} - /> -
- ) : ( - - This team is currently on the {teamPlan.name} plan. - - )} -

-
- {isLoading && ( - <> - -
- -
-
- -
- -
-
- - )} - {!isLoading && !teamPlan && ( - <> - {availableTeamPlans.map((tp) => ( - <> - -
-
- {tp.name} -
-
- Unlimited hours -
-
- - {members.length} x {Currency.getSymbol(tp.currency)} - {tp.pricePerMonth} = {Currency.getSymbol(tp.currency)} - {members.length * tp.pricePerMonth} per month - -
-
Includes:
-
- {(featuresByPlanType[tp.type] || []).map((f) => ( - - - {f} - - ))} -
-
- -
-
-
- - ))} - - )} - {!isLoading && teamPlan && ( - <> - -
-
- {teamPlan.name} -
-
- Unlimited hours -
-
Includes:
-
- {(featuresByPlanType[teamPlan.type] || []).map((f) => ( - - - {f} - - ))} -
-
-
-
- {!teamSubscription ? ( + function renderTeamBilling(): JSX.Element { + return ( + <> +

{!teamPlan ? "Select Team Plan" : "Team Plan"}

+

+ {!teamPlan ? ( +
+ Currency: + setCurrency("EUR"), + }, + { + title: "USD", + onClick: () => setCurrency("USD"), + }, + ]} + /> +
+ ) : ( + + This team is currently on the {teamPlan.name} plan. + + )} +

+
+ {isLoading && ( + <>
- ) : ( +
+ +
+
+ + )} + {!isLoading && !teamPlan && ( + <> + {availableTeamPlans.map((tp) => ( + <> + +
+
+ {tp.name} +
+
+ Unlimited hours +
+
+ + {members.length} x {Currency.getSymbol(tp.currency)} + {tp.pricePerMonth} = {Currency.getSymbol(tp.currency)} + {members.length * tp.pricePerMonth} per month + +
+
Includes:
+
+ {(featuresByPlanType[tp.type] || []).map((f) => ( + + + {f} + + ))} +
+
+ +
+
+
+ + ))} + + )} + {!isLoading && teamPlan && ( + <> +
-
- 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;