feat(gatekeeper): intoduce the enterprise plan (#4882)

* feat(gatekeeper): intoduce the enterprise plan

* chore(server): remove more "magic strings"

* test(shared): fix plan tests with enterprise case

* Small change to format plan name

---------

Co-authored-by: Mike Tasset <mike.tasset@gmail.com>
This commit is contained in:
Gergő Jedlicska
2025-06-05 11:07:59 +02:00
committed by GitHub
parent e8738bac02
commit 72ecb9197b
17 changed files with 154 additions and 113 deletions
@@ -26,7 +26,8 @@ export const formatName = (plan?: MaybeNullOrUndefined<WorkspacePlans>) => {
[WorkspacePlans.TeamUnlimitedInvoiced]: 'Starter (Invoiced)',
[WorkspacePlans.Pro]: 'Business',
[WorkspacePlans.ProUnlimited]: 'Business',
[WorkspacePlans.ProUnlimitedInvoiced]: 'Business (Invoiced)'
[WorkspacePlans.ProUnlimitedInvoiced]: 'Business (Invoiced)',
[WorkspacePlans.Enterprise]: 'Enterprise'
}
return formattedPlanNames[plan]
}
@@ -4931,6 +4931,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -64,6 +64,7 @@ enum WorkspacePlans {
pro
proUnlimited
proUnlimitedInvoiced
enterprise
}
enum WorkspacePlanStatuses {
@@ -10,7 +10,7 @@ import {
upsertWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared'
import { WorkspacePlans, WorkspacePlanStatuses } from '@speckle/shared'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
@@ -18,8 +18,9 @@ const command: CommandModule<
unknown,
{
workspaceSlugOrId: string
status: PaidWorkspacePlanStatuses
plan: PaidWorkspacePlans
// you need to know what you are doing, status and plan pairing validity is not ensured here
status: WorkspacePlanStatuses
plan: WorkspacePlans
}
> = {
command: 'set-plan <workspaceSlugOrId> [plan] [status]',
@@ -32,8 +33,8 @@ const command: CommandModule<
plan: {
describe: 'Plan to set the status for',
type: 'string',
default: PaidWorkspacePlans.Team,
choices: [PaidWorkspacePlans.Team, PaidWorkspacePlans.Pro]
default: WorkspacePlans.Team,
choices: Object.values(WorkspacePlans)
},
status: {
describe: 'Status to set for the workspace plan',
@@ -4954,6 +4954,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -4934,6 +4934,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -1,7 +1,12 @@
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { authorizeResolver } from '@/modules/shared'
import { Roles, throwUncoveredError, WorkspacePlanFeatures } from '@speckle/shared'
import {
Roles,
throwUncoveredError,
WorkspacePlanFeatures,
WorkspacePlans
} from '@speckle/shared'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
@@ -71,19 +76,20 @@ export = FF_GATEKEEPER_MODULE_ENABLED
if (!workspacePlan) return null
let paymentMethod: WorkspacePaymentMethod
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
paymentMethod = WorkspacePaymentMethod.Billing
break
case 'unlimited':
case 'academia':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Free:
paymentMethod = WorkspacePaymentMethod.Unpaid
break
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
paymentMethod = WorkspacePaymentMethod.Invoice
break
default:
@@ -248,17 +254,18 @@ export = FF_GATEKEEPER_MODULE_ENABLED
if (subscription) {
let purchased = 0
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'free':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Free:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
// not stripe paid plans and old plans do not have seats available
break
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
purchased = getTotalSeatsCountByPlanFactory({
getWorkspacePlanProductId
})({
@@ -19,7 +19,8 @@ import {
import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
throwUncoveredError
throwUncoveredError,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
@@ -78,16 +79,17 @@ export const handleSubscriptionUpdateFactory =
if (status) {
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -146,21 +148,22 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory =
// if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
// If viewer seat type, we don't need to do anything
if (seatType === WorkspaceSeatType.Viewer) {
return
} else {
break
}
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Free:
case WorkspacePlans.Enterprise:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -15,7 +15,7 @@ import {
} from '@/modules/gatekeeper/errors/billing'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { Logger } from '@/observability/logging'
import { throwUncoveredError } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlans } from '@speckle/shared'
import { cloneDeep, isEqual } from 'lodash'
type DownscaleWorkspaceSubscription = (args: {
@@ -41,16 +41,17 @@ export const downscaleWorkspaceSubscriptionFactory =
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case 'free':
case WorkspacePlans.Free:
case WorkspacePlans.Academia:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
case WorkspacePlans.Unlimited:
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
@@ -27,7 +27,8 @@ import { EventBusEmit } from '@/modules/shared/services/eventBus'
import {
PaidWorkspacePlans,
throwUncoveredError,
WorkspacePlanBillingIntervals
WorkspacePlanBillingIntervals,
WorkspacePlans
} from '@speckle/shared'
import { cloneDeep } from 'lodash'
@@ -68,16 +69,17 @@ export const upgradeWorkspaceSubscriptionFactory =
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
switch (workspacePlan.name) {
case 'unlimited':
case 'academia':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'free': // Upgrade from free is handled through startCheckout since it is from free to paid
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Enterprise:
case WorkspacePlans.Free: // Upgrade from free is handled through startCheckout since it is from free to paid
throw new WorkspaceNotPaidPlanError()
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
break
default:
throwUncoveredError(workspacePlan)
@@ -9,7 +9,8 @@ const WorkspacePlansUpgradeMapping: Record<WorkspacePlans, WorkspacePlans[]> = {
teamUnlimitedInvoiced: [],
pro: ['pro', 'proUnlimited'],
proUnlimited: ['proUnlimited'],
proUnlimitedInvoiced: []
proUnlimitedInvoiced: [],
enterprise: []
}
export const isUpgradeWorkspacePlanValid = ({
@@ -6,7 +6,7 @@ import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { GetWorkspace } from '@/modules/workspaces/domain/operations'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { throwUncoveredError, WorkspacePlan } from '@speckle/shared'
import { throwUncoveredError, WorkspacePlan, WorkspacePlans } from '@speckle/shared'
export const updateWorkspacePlanFactory =
({
@@ -35,10 +35,10 @@ export const updateWorkspacePlanFactory =
const createdAt = new Date()
const updatedAt = new Date()
switch (name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
switch (status) {
case 'valid':
case 'cancelationScheduled':
@@ -53,11 +53,12 @@ export const updateWorkspacePlanFactory =
}
break
case 'free':
case 'academia':
case 'unlimited':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case WorkspacePlans.Free:
case WorkspacePlans.Academia:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Enterprise:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
switch (status) {
case 'valid':
await upsertWorkspacePlan({
@@ -116,6 +116,7 @@ import {
} from '@/modules/workspaces/services/retrieval'
import {
Roles,
WorkspacePlans,
WorkspaceRoles,
removeNullOrUndefinedKeys,
throwUncoveredError
@@ -707,10 +708,10 @@ export = FF_WORKSPACES_MODULE_ENABLED
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
if (workspacePlan) {
switch (workspacePlan.name) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
switch (workspacePlan.status) {
case 'cancelationScheduled':
case 'valid':
@@ -721,11 +722,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
default:
throwUncoveredError(workspacePlan)
}
case 'free':
case 'unlimited':
case 'academia':
case 'proUnlimitedInvoiced':
case 'teamUnlimitedInvoiced':
case WorkspacePlans.Free:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.TeamUnlimitedInvoiced:
break
default:
throwUncoveredError(workspacePlan)
@@ -4935,6 +4935,7 @@ export type WorkspacePlanUsage = {
export const WorkspacePlans = {
Academia: 'academia',
Enterprise: 'enterprise',
Free: 'free',
Pro: 'pro',
ProUnlimited: 'proUnlimited',
@@ -148,6 +148,17 @@ export const WorkspacePaidPlanConfigs: {
export const WorkspaceUnpaidPlanConfigs: {
[plan in UnpaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = {
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
@@ -18,7 +18,8 @@ describe('plan helpers', () => {
proUnlimitedInvoiced: false,
team: false,
teamUnlimited: true,
teamUnlimitedInvoiced: false
teamUnlimitedInvoiced: false,
enterprise: false
}
it.each(Object.entries(planCases))(
'plan %s include the paid unlimited projects addon -> %s',
@@ -41,7 +42,8 @@ describe('plan helpers', () => {
proUnlimitedInvoiced: false,
team: true,
teamUnlimited: true,
teamUnlimitedInvoiced: false
teamUnlimitedInvoiced: false,
enterprise: false
}
it.each(Object.entries(planCases))(
'is plan %s available self served -> %s',
+31 -27
View File
@@ -13,6 +13,7 @@ export type PaidWorkspacePlans =
export const UnpaidWorkspacePlans = <const>{
TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced',
ProUnlimitedInvoiced: 'proUnlimitedInvoiced',
Enterprise: 'enterprise',
Unlimited: 'unlimited',
Academia: 'academia',
Free: 'free'
@@ -32,16 +33,17 @@ export const doesPlanIncludeUnlimitedProjectsAddon = (
plan: WorkspacePlans
): boolean => {
switch (plan) {
case 'teamUnlimited':
case 'proUnlimited':
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.ProUnlimited:
return true
case 'free':
case 'team':
case 'pro':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.Free:
case WorkspacePlans.Team:
case WorkspacePlans.Pro:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default:
@@ -51,16 +53,17 @@ export const doesPlanIncludeUnlimitedProjectsAddon = (
export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
switch (plan) {
case 'free':
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Free:
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
return true
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default:
@@ -70,16 +73,17 @@ export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
export const isPaidPlan = (plan: WorkspacePlans): boolean => {
switch (plan) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
case WorkspacePlans.Team:
case WorkspacePlans.TeamUnlimited:
case WorkspacePlans.Pro:
case WorkspacePlans.ProUnlimited:
return true
case 'free':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
case WorkspacePlans.Free:
case WorkspacePlans.TeamUnlimitedInvoiced:
case WorkspacePlans.ProUnlimitedInvoiced:
case WorkspacePlans.Unlimited:
case WorkspacePlans.Academia:
case WorkspacePlans.Enterprise:
return false
default: