From ce4ca59282340d764aaf887743719fc8fb9db593 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Mon, 15 Aug 2022 15:24:53 +0000 Subject: [PATCH 1/7] [server] Improved BillingMode tests --- .../dashboard/src/teams/TeamBilling.tsx | 301 +++++++++--------- .../ee/src/billing/billing-mode.spec.db.ts | 79 +++-- 2 files changed, 210 insertions(+), 170 deletions(-) diff --git a/components/dashboard/src/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx index c0b1e578fd1197..d564979b70f42c 100644 --- a/components/dashboard/src/teams/TeamBilling.tsx +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -28,7 +28,7 @@ import { UserContext } from "../user-context"; type PendingPlan = Plan & { pendingSince: number }; export default function TeamBilling() { - const { user } = useContext(UserContext); + const { user, userBillingMode } = useContext(UserContext); const { teams } = useContext(TeamsContext); const location = useLocation(); const team = getCurrentTeam(location, teams); @@ -151,163 +151,172 @@ 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(userBillingMode); + return ( + + {showUBP && } + {!showUBP && renderTeamBilling()} ); } 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..105c66f5b8d181 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(team: boolean = false) { + function stripeSubscription() { return { id: "stripe-123", - customer: team ? stripeTeamCustomerId : stripeCustomerId, - isTeam: team, + customer: stripeCustomerId, + }; + } + + function stripeTeamSubscription() { + return { + id: "stripe-123", + 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; @@ -193,7 +219,7 @@ class BillingModeSpec { config: { enablePayment: true, usageBasedPricingEnabled: false, - subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + subscriptions: [teamSubscription(Plans.TEAM_PROFESSIONAL_EUR)], }, expectation: { mode: "chargebee", @@ -205,7 +231,7 @@ class BillingModeSpec { 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", @@ -220,7 +246,7 @@ class BillingModeSpec { usageBasedPricingEnabled: true, subscriptions: [ subscription(Plans.PERSONAL_EUR, cancellationDate, endDate), - subscription(Plans.TEAM_PROFESSIONAL_EUR), + teamSubscription(Plans.TEAM_PROFESSIONAL_EUR), ], }, expectation: { @@ -236,7 +262,7 @@ class BillingModeSpec { usageBasedPricingEnabled: true, subscriptions: [ subscription(Plans.PERSONAL_EUR, cancellationDate, endDate), - subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate), + teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, endDate), ], }, expectation: { @@ -252,9 +278,9 @@ class BillingModeSpec { 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", @@ -270,7 +296,7 @@ class BillingModeSpec { usageBasedPricingEnabled: true, subscriptions: [ subscription(Plans.PERSONAL_EUR, cancellationDate, cancellationDate), - subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate), + teamSubscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate), ], }, expectation: { @@ -285,7 +311,7 @@ class BillingModeSpec { 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(), }, @@ -357,7 +383,7 @@ class BillingModeSpec { config: { enablePayment: true, usageBasedPricingEnabled: false, - subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR)], }, expectation: { mode: "chargebee", @@ -369,7 +395,7 @@ class BillingModeSpec { config: { enablePayment: true, usageBasedPricingEnabled: true, - subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR)], + subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR)], }, expectation: { mode: "chargebee", @@ -377,12 +403,12 @@ class BillingModeSpec { }, // team: transition chargebee -> UBB { - name: "team: chargbee paid (cancelled)", + name: "team: chargbee 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", @@ -407,7 +433,7 @@ class BillingModeSpec { 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", @@ -419,7 +445,7 @@ class BillingModeSpec { config: { enablePayment: true, usageBasedPricingEnabled: true, - subscriptions: [subscription(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)], + subscriptions: [teamSubscription2(Plans.TEAM_PROFESSIONAL_EUR, cancellationDate, cancellationDate)], stripeSubscription: stripeSubscription(), }, expectation: { @@ -498,9 +524,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 +535,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 +553,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 +575,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); From 90eb5f59e842518547392f8db48d2e5510edc9e7 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Mon, 15 Aug 2022 15:32:07 +0000 Subject: [PATCH 2/7] [server] Add test case for cancelled old TeamSubscription --- .../server/ee/src/billing/billing-mode.spec.db.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 105c66f5b8d181..7eb5999347665e 100644 --- a/components/server/ee/src/billing/billing-mode.spec.db.ts +++ b/components/server/ee/src/billing/billing-mode.spec.db.ts @@ -415,6 +415,18 @@ class BillingModeSpec { canUpgradeToUBB: true, }, }, + { + name: "team: chargbee 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", From e5fec663aff43f3e54215b7e55d151261e59599d Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 16 Aug 2022 09:16:01 +0000 Subject: [PATCH 3/7] [dashboard, server] WorkspaceClass: make usable based on BillingMode --- components/dashboard/src/settings/Preferences.tsx | 7 +++++-- components/gitpod-protocol/src/billing-mode.ts | 6 +++++- components/server/src/workspace/workspace-starter.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) 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/gitpod-protocol/src/billing-mode.ts b/components/gitpod-protocol/src/billing-mode.ts index 630932aaa1a3a7..4cc9ed09190ce1 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"; } 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; From 8f2730063438053c402abccc940c8bd22964f0ee Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 16 Aug 2022 10:28:20 +0000 Subject: [PATCH 4/7] [server] BillingMode: Only paid UBP team seats are "greedy" --- .../gitpod-protocol/src/billing-mode.ts | 3 ++ .../ee/src/billing/billing-mode.spec.db.ts | 30 ++++++++++--------- .../server/ee/src/billing/billing-mode.ts | 18 +++++++---- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/components/gitpod-protocol/src/billing-mode.ts b/components/gitpod-protocol/src/billing-mode.ts index 4cc9ed09190ce1..d32ca5335a828f 100644 --- a/components/gitpod-protocol/src/billing-mode.ts +++ b/components/gitpod-protocol/src/billing-mode.ts @@ -56,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 7eb5999347665e..e930aca31d87dc 100644 --- a/components/server/ee/src/billing/billing-mode.spec.db.ts +++ b/components/server/ee/src/billing/billing-mode.spec.db.ts @@ -202,7 +202,7 @@ class BillingModeSpec { }, // user: chargebee { - name: "user: chargbee paid personal", + name: "user: chargebee paid personal", subject: user(), config: { enablePayment: true, @@ -214,7 +214,7 @@ class BillingModeSpec { }, }, { - name: "user: chargbee paid team seat", + name: "user: chargebee paid team seat", subject: user(), config: { enablePayment: true, @@ -226,7 +226,7 @@ class BillingModeSpec { }, }, { - name: "user: chargbee paid personal + team seat", + name: "user: chargebee paid personal + team seat", subject: user(), config: { enablePayment: true, @@ -239,7 +239,7 @@ 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, @@ -255,7 +255,7 @@ 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, @@ -271,7 +271,7 @@ 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, @@ -289,7 +289,7 @@ 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, @@ -304,7 +304,7 @@ 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, @@ -378,7 +378,7 @@ class BillingModeSpec { }, // team: chargebee { - name: "team: chargbee paid", + name: "team: chargebee paid", subject: team(), config: { enablePayment: true, @@ -390,7 +390,7 @@ class BillingModeSpec { }, }, { - name: "team: chargbee paid (UBB)", + name: "team: chargebee paid (UBB)", subject: team(), config: { enablePayment: true, @@ -403,7 +403,7 @@ class BillingModeSpec { }, // team: transition chargebee -> UBB { - name: "team: chargbee paid (TeamSubscription2, cancelled)", + name: "team: chargebee paid (TeamSubscription2, cancelled)", subject: team(), config: { enablePayment: true, @@ -416,7 +416,7 @@ class BillingModeSpec { }, }, { - name: "team: chargbee paid (old TeamSubscription, cancelled)", + name: "team: chargebee paid (old TeamSubscription, cancelled)", subject: team(), config: { enablePayment: true, @@ -440,7 +440,7 @@ class BillingModeSpec { }, }, { - name: "team: stripe free, chargbee (inactive)", + name: "team: stripe free, chargebee (inactive)", subject: team(), config: { enablePayment: true, @@ -452,7 +452,7 @@ class BillingModeSpec { }, }, { - name: "team: stripe paid, chargbee (inactive)", + name: "team: stripe paid, chargebee (inactive)", subject: team(), config: { enablePayment: true, @@ -462,6 +462,7 @@ class BillingModeSpec { }, expectation: { mode: "usage-based", + paid: true, }, }, { @@ -474,6 +475,7 @@ class BillingModeSpec { }, expectation: { mode: "usage-based", + paid: true, }, }, ]; diff --git a/components/server/ee/src/billing/billing-mode.ts b/components/server/ee/src/billing/billing-mode.ts index 8458116023e776..4b68c0ebd1e0a7 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 hasUbbPaidTeamSeat = 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 (hasUbbPaidTeamSeat || hasUbbPersonal) { + // UBB is greedy: once a user has at least a team seat, 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.findCustomerByUserId(team.id); + if (customer) { + const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id); + if (subscription) { + result.paid = true; + } + } + return result; } } From 8ce23dc5ee40765e64d265459bf5ad748693a02b Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 16 Aug 2022 11:48:42 +0000 Subject: [PATCH 5/7] [server] Treat "no CostCenter found" the same as "spending limit reached" --- .../server/ee/src/billing/billing-service.ts | 8 ++++++-- components/server/src/user/user-service.ts | 14 +++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) 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) { From 319c7284ca6ef32dfaf20cf6b201daef90b2ab8c Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 16 Aug 2022 16:01:27 +0000 Subject: [PATCH 6/7] [server] BillingMode: Use findCustomerByTeamId --- components/server/ee/src/billing/billing-mode.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/server/ee/src/billing/billing-mode.ts b/components/server/ee/src/billing/billing-mode.ts index 4b68c0ebd1e0a7..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 hasUbbPaidTeamSeat = teamsModes.some((tm) => tm.mode === "usage-based" && !!tm.paid); + 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 (hasUbbPaidTeamSeat || hasUbbPersonal) { - // UBB is greedy: 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; @@ -198,7 +198,7 @@ export class BillingModesImpl implements BillingModes { // 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.findCustomerByUserId(team.id); + const customer = await this.stripeSvc.findCustomerByTeamId(team.id); if (customer) { const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id); if (subscription) { From 6929af673587d08a3658e8cb3cfec453e0138bff Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Wed, 17 Aug 2022 09:07:33 +0000 Subject: [PATCH 7/7] [dashboard] Fix BillingMode UI glitches --- components/dashboard/src/Menu.tsx | 8 +++++--- components/dashboard/src/teams/TeamBilling.tsx | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) 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/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx index d564979b70f42c..ee1e18b02c8cca 100644 --- a/components/dashboard/src/teams/TeamBilling.tsx +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -28,7 +28,7 @@ import { UserContext } from "../user-context"; type PendingPlan = Plan & { pendingSince: number }; export default function TeamBilling() { - const { user, userBillingMode } = useContext(UserContext); + const { user } = useContext(UserContext); const { teams } = useContext(TeamsContext); const location = useLocation(); const team = getCurrentTeam(location, teams); @@ -308,15 +308,23 @@ export default function TeamBilling() { ); } - const showUBP = BillingMode.showUsageBasedBilling(userBillingMode); + const showUBP = BillingMode.showUsageBasedBilling(teamBillingMode); return ( - {showUBP && } - {!showUBP && renderTeamBilling()} + {teamBillingMode === undefined ? ( +
+ +
+ ) : ( + <> + {showUBP && } + {!showUBP && renderTeamBilling()} + + )}
); }