diff --git a/.circleci/config.yml b/.circleci/config.yml index d08f707f4..f86870bb8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -511,7 +511,7 @@ jobs: command: 'dockerize -wait tcp://localhost:5432 -wait tcp://localhost:6379 -timeout 1m' - run: - command: touch .env.test + command: cp .env.test-example .env.test working_directory: 'packages/server' - run: diff --git a/packages/frontend-2/composables/globals.ts b/packages/frontend-2/composables/globals.ts index 16da9c187..dae4abd04 100644 --- a/packages/frontend-2/composables/globals.ts +++ b/packages/frontend-2/composables/globals.ts @@ -58,10 +58,7 @@ export const useIsGendoModuleEnabled = () => { } export const useWorkspaceNewPlansEnabled = () => { - const { - public: { FF_WORKSPACES_NEW_PLANS_ENABLED } - } = useRuntimeConfig() - return ref(FF_WORKSPACES_NEW_PLANS_ENABLED) + return ref(true) } export const useIsBillingIntegrationEnabled = () => { diff --git a/packages/frontend-2/lib/billing/helpers/plan.ts b/packages/frontend-2/lib/billing/helpers/plan.ts index 404a43689..609244391 100644 --- a/packages/frontend-2/lib/billing/helpers/plan.ts +++ b/packages/frontend-2/lib/billing/helpers/plan.ts @@ -23,7 +23,11 @@ export const formatName = (plan?: WorkspacePlans) => { [WorkspacePlans.Business]: 'Business', [WorkspacePlans.Free]: 'Free', [WorkspacePlans.Team]: 'Starter', - [WorkspacePlans.Pro]: 'Business' + [WorkspacePlans.TeamUnlimited]: 'Starter Unlimited', + [WorkspacePlans.TeamUnlimitedInvoiced]: 'Starter Unlimited (Invoiced)', + [WorkspacePlans.Pro]: 'Business', + [WorkspacePlans.ProUnlimited]: 'Business Unlimited', + [WorkspacePlans.ProUnlimitedInvoiced]: 'Business Unlimited (Invoiced)' } return formattedPlanNames[plan] } diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 387597eb0..8b1a042a3 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -1884,8 +1884,10 @@ export const PaidWorkspacePlans = { Business: 'business', Plus: 'plus', Pro: 'pro', + ProUnlimited: 'proUnlimited', Starter: 'starter', - Team: 'team' + Team: 'team', + TeamUnlimited: 'teamUnlimited' } as const; export type PaidWorkspacePlans = typeof PaidWorkspacePlans[keyof typeof PaidWorkspacePlans]; @@ -4775,9 +4777,13 @@ export const WorkspacePlans = { Plus: 'plus', PlusInvoiced: 'plusInvoiced', Pro: 'pro', + ProUnlimited: 'proUnlimited', + ProUnlimitedInvoiced: 'proUnlimitedInvoiced', Starter: 'starter', StarterInvoiced: 'starterInvoiced', Team: 'team', + TeamUnlimited: 'teamUnlimited', + TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced', Unlimited: 'unlimited' } as const; diff --git a/packages/server/.env.test-example b/packages/server/.env.test-example index 429987d08..26111cf9e 100644 --- a/packages/server/.env.test-example +++ b/packages/server/.env.test-example @@ -2,11 +2,46 @@ # Env overrides when running app in test mode # ############################################### -PORT=0 -POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle2_test -POSTGRES_USER='' +# PORT=0 +# POSTGRES_URL=postgres://speckle:speckle@127.0.0.1/speckle2_test +# POSTGRES_USER='' FRONTEND_ORIGIN="http://127.0.0.1:8081" -MULTI_REGION_CONFIG_PATH="multiregion.test.json" +# MULTI_REGION_CONFIG_PATH="multiregion.test.example.json" #RUN_TESTS_IN_MULTIREGION_MODE=true RATELIMITER_ENABLED='false' + + +#### Stripe product ids #### +WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID='prod_RsMHWCX85KBTJd' +WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID='price_1QybYa7yKEDpA6qK7p7U3BTE' +WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID='price_1QybZ77yKEDpA6qKqpBSV0OS' + +WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID='prod_RsMItzuLAEKy50' +WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID='price_1QybZY7yKEDpA6qKl9TfgArD' +WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID='price_1Qyba07yKEDpA6qKvH6iqDdU' + +WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID='prod_RsMJxH2rfHbJRt' +WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID='price_1Qybaf7yKEDpA6qKHbS0lVXA' +WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID='price_1Qybb97yKEDpA6qKEKXI0F2V' + +WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID='prod_RsMKVRwEVBPl60' +WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID='price_1Qybbh7yKEDpA6qKqlN7fKUA' +WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID='price_1Qybc67yKEDpA6qKtJ3DvClM' + +#### NEW #### +WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID='prod_RsMMdAkZIkAQRb' +WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID='price_1Qybdi7yKEDpA6qKlSaz9cFT' +WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID='price_1R9qjc7yKEDpA6qKablYeaCe' + +WORKSPACE_TEAM_UNLIMITED_SEAT_STRIPE_PRODUCT_ID='prod_S3yhg3E5aE1VUZ' +WORKSPACE_MONTHLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID='price_1R9qk77yKEDpA6qKkU5826G5' +WORKSPACE_YEARLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID='price_1R9qkN7yKEDpA6qKJ04o9UNS' + +WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID='prod_RsMMZMCXgcBXtO' +WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID='price_1Qybe77yKEDpA6qKvOeCVAN4' +WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID='price_1QybeT7yKEDpA6qKZ4WKZICt' + +WORKSPACE_PRO_UNLIMITED_SEAT_STRIPE_PRODUCT_ID='prod_S3yroNkGsD8m7l' +WORKSPACE_MONTHLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID='price_1R9qu57yKEDpA6qKJ1Iw8Now' +WORKSPACE_YEARLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID='price_1R9quX7yKEDpA6qKbyvMMUww' diff --git a/packages/server/app.ts b/packages/server/app.ts index 9d50a75e1..f1e2c2ab8 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -197,7 +197,7 @@ export function buildApolloSubscriptionServer(params: { try { const headers = getHeaders({ connContext, connectionParams }) const buildCtx = await buildContext({ token }) - buildCtx.log.info( + buildCtx.log.debug( { userId: buildCtx.userId, ws_protocol: webSocket.protocol, diff --git a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql index 65f8fbc65..2275272e0 100644 --- a/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeperCore/typedefs/gatekeeper.graphql @@ -8,7 +8,9 @@ enum PaidWorkspacePlans { business # New plans team + teamUnlimited pro + proUnlimited } enum BillingInterval { @@ -65,8 +67,13 @@ enum WorkspacePlans { starterInvoiced plusInvoiced businessInvoiced + # New plans team + teamUnlimited + teamUnlimitedInvoiced pro + proUnlimited + proUnlimitedInvoiced } enum WorkspacePlanStatuses { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 29a4ce6f7..7a95a04a6 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1907,8 +1907,10 @@ export const PaidWorkspacePlans = { Business: 'business', Plus: 'plus', Pro: 'pro', + ProUnlimited: 'proUnlimited', Starter: 'starter', - Team: 'team' + Team: 'team', + TeamUnlimited: 'teamUnlimited' } as const; export type PaidWorkspacePlans = typeof PaidWorkspacePlans[keyof typeof PaidWorkspacePlans]; @@ -4798,9 +4800,13 @@ export const WorkspacePlans = { Plus: 'plus', PlusInvoiced: 'plusInvoiced', Pro: 'pro', + ProUnlimited: 'proUnlimited', + ProUnlimitedInvoiced: 'proUnlimitedInvoiced', Starter: 'starter', StarterInvoiced: 'starterInvoiced', Team: 'team', + TeamUnlimited: 'teamUnlimited', + TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced', Unlimited: 'unlimited' } as const; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 017e33e53..240d061fa 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -1887,8 +1887,10 @@ export const PaidWorkspacePlans = { Business: 'business', Plus: 'plus', Pro: 'pro', + ProUnlimited: 'proUnlimited', Starter: 'starter', - Team: 'team' + Team: 'team', + TeamUnlimited: 'teamUnlimited' } as const; export type PaidWorkspacePlans = typeof PaidWorkspacePlans[keyof typeof PaidWorkspacePlans]; @@ -4778,9 +4780,13 @@ export const WorkspacePlans = { Plus: 'plus', PlusInvoiced: 'plusInvoiced', Pro: 'pro', + ProUnlimited: 'proUnlimited', + ProUnlimitedInvoiced: 'proUnlimitedInvoiced', Starter: 'starter', StarterInvoiced: 'starterInvoiced', Team: 'team', + TeamUnlimited: 'teamUnlimited', + TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced', Unlimited: 'unlimited' } as const; diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index cfc40f9eb..0af166020 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -187,15 +187,12 @@ export type GetWorkspacePlanProductId = (args: { workspacePlan: WorkspacePricingProducts }) => string -type Products = 'guest' | 'starter' | 'plus' | 'business' | 'team' | 'pro' +type Products = 'guest' | PaidWorkspacePlans -export type GetWorkspacePlanProductAndPriceIds = () => Omit< - Record, - 'team' | 'pro' -> & { - team?: { productId: string; monthly: string } - pro?: { productId: string; monthly: string; yearly: string } -} +export type GetWorkspacePlanProductAndPriceIds = () => Record< + Products, + { productId: string; monthly: string; yearly: string } +> export type SubscriptionDataInput = OverrideProperties< SubscriptionData, diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 3d0ed296e..8c4bb456f 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -1,12 +1,7 @@ import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import type { Resolvers } from '@/modules/core/graph/generated/graphql' import { authorizeResolver } from '@/modules/shared' -import { - ensureError, - PaidWorkspacePlansNew, - Roles, - throwUncoveredError -} from '@speckle/shared' +import { ensureError, Roles, throwUncoveredError } from '@speckle/shared' import { countWorkspaceRoleWithOptionalProjectRoleFactory, getWorkspaceFactory, @@ -97,7 +92,9 @@ export = FF_GATEKEEPER_MODULE_ENABLED case 'plus': case 'business': case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': paymentMethod = WorkspacePaymentMethod.Billing break case 'unlimited': @@ -108,6 +105,8 @@ export = FF_GATEKEEPER_MODULE_ENABLED case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': paymentMethod = WorkspacePaymentMethod.Invoice break default: @@ -453,7 +452,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED }) await upgradeWorkspaceSubscription({ workspaceId, - targetPlan: workspacePlan as PaidWorkspacePlansNew, // This should not be casted and the cast will be removed once we will not support old plans anymore + targetPlan: workspacePlan, // This should not be casted and the cast will be removed once we will not support old plans anymore billingInterval }) return true diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 174ba582c..eaea92255 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -8,7 +8,11 @@ import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scop import { db } from '@/db/knex' import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener' -import { getStripeClient, getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe' +import { + getStripeClient, + getWorkspacePlanProductAndPriceIds, + getWorkspacePlanProductId +} from '@/modules/gatekeeper/stripe' import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' import { acquireTaskLockFactory, @@ -213,6 +217,8 @@ const gatekeeperModule: SpeckleModule = { if (isInitial) { // TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited if (FF_BILLING_INTEGRATION_ENABLED) { + // this validates that product and priceId-s can be loaded on server startup + getWorkspacePlanProductAndPriceIds() app.use(getBillingRouter()) const eventBus = getEventBus() diff --git a/packages/server/modules/gatekeeper/services/prices.spec.ts b/packages/server/modules/gatekeeper/services/prices.spec.ts index 4b97f2300..1c0eeca98 100644 --- a/packages/server/modules/gatekeeper/services/prices.spec.ts +++ b/packages/server/modules/gatekeeper/services/prices.spec.ts @@ -6,19 +6,12 @@ import { WorkspacePlanProductAndPriceIds, WorkspacePricingProducts } from '@/modules/gatekeeperCore/domain/billing' -import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { expectToThrow } from '@/test/assertionHelper' import { mockRedisCacheProviderFactory } from '@/test/redisHelper' -import { - PaidWorkspacePlans, - PaidWorkspacePlansNew, - WorkspaceGuestSeatType -} from '@speckle/shared' +import { PaidWorkspacePlans, WorkspaceGuestSeatType } from '@speckle/shared' import { expect } from 'chai' import { flatten, get } from 'lodash' -const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags() - const testProductAndPriceIds: WorkspacePlanProductAndPriceIds = { [WorkspaceGuestSeatType]: { productId: 'prod_guest', @@ -42,12 +35,23 @@ const testProductAndPriceIds: WorkspacePlanProductAndPriceIds = { }, [PaidWorkspacePlans.Team]: { productId: 'prod_team', - monthly: 'price_team_monthly' + monthly: 'price_team_monthly', + yearly: 'price_team_yearly' + }, + [PaidWorkspacePlans.TeamUnlimited]: { + productId: 'prod_team_unlimited', + monthly: 'price_team_unlimited_monthly', + yearly: 'price_team_unlimited_yearly' }, [PaidWorkspacePlans.Pro]: { productId: 'prod_pro', monthly: 'price_pro_monthly', yearly: 'price_pro_yearly' + }, + [PaidWorkspacePlans.ProUnlimited]: { + productId: 'prod_pro_unlimited', + monthly: 'price_pro_unlimited_monthly', + yearly: 'price_pro_unlimited_yearly' } } @@ -94,27 +98,14 @@ describe('getFreshWorkspacePlanProductPricesFactory', () => { for (const plan of plans) { const planResult = get(result, plan) as (typeof result)[keyof typeof result] - if ( - !FF_WORKSPACES_NEW_PLANS_ENABLED && - (Object.values(PaidWorkspacePlansNew) as string[]).includes(plan) - ) { - if (planResult) { - throw new Error('New plans should not appear w/ FF on') - } else { - continue - } - } - expect(planResult).to.be.ok - expect(planResult!.productId).to.be.ok - expect(planResult!.monthly.amount).to.be.ok - expect(planResult!.monthly.currency).to.eq('USD') - expect(planResult!.monthly.currency).to.be.ok - if ('yearly' in planResult!) { - const yearly = planResult.yearly as { amount: number; currency: string } - expect(yearly.amount).to.be.ok - expect(yearly.currency).to.be.ok - } + expect(planResult.productId).to.be.ok + expect(planResult.monthly.amount).to.be.ok + expect(planResult.monthly.currency).to.eq('USD') + expect(planResult.monthly.currency).to.be.ok + expect(planResult.yearly.amount).to.be.ok + expect(planResult.yearly.currency).to.be.ok + expect(planResult.yearly.currency).to.eq('USD') } }) diff --git a/packages/server/modules/gatekeeper/services/prices.ts b/packages/server/modules/gatekeeper/services/prices.ts index 4544922d1..fb43a4bb8 100644 --- a/packages/server/modules/gatekeeper/services/prices.ts +++ b/packages/server/modules/gatekeeper/services/prices.ts @@ -5,16 +5,13 @@ import { } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanProductPrices } from '@/modules/gatekeeperCore/domain/billing' import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' -import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { redisCacheProviderFactory, wrapFactoryWithCache } from '@/modules/shared/utils/caching' -import { Optional, PaidWorkspacePlansNew, TIME } from '@speckle/shared' +import { Optional, TIME } from '@speckle/shared' import { set } from 'lodash' -const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags() - export const getFreshWorkspacePlanProductPricesFactory = (deps: { getRecurringPrices: GetRecurringPrices @@ -26,12 +23,6 @@ export const getFreshWorkspacePlanProductPricesFactory = const ret = Object.entries(productAndPriceIds).reduce((acc, [plan, planIds]) => { const { productId, monthly } = planIds - if ( - !FF_WORKSPACES_NEW_PLANS_ENABLED && - (Object.values(PaidWorkspacePlansNew) as string[]).includes(plan) - ) { - return acc // skipping new plans - } const monthlyPrice = productPrices.find( (p) => p.id === monthly && p.productId === productId @@ -76,7 +67,7 @@ export const getFreshWorkspacePlanProductPricesFactory = export const getWorkspacePlanProductPricesFactory = wrapFactoryWithCache({ factory: getFreshWorkspacePlanProductPricesFactory, - name: `modules/gatekeeper/services/prices:getWorkspacePlanPricesFactory:withNewPlans=${FF_WORKSPACES_NEW_PLANS_ENABLED}`, + name: `modules/gatekeeper/services/prices:getWorkspacePlanPricesFactory`, ttlMs: 1000 * TIME.day, // 1 day cacheProvider: redisCacheProviderFactory() }) diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 58b4a6b3e..f3e683fb0 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -77,13 +77,17 @@ export const handleSubscriptionUpdateFactory = case 'plus': case 'business': case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': break case 'unlimited': case 'academia': case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: @@ -141,7 +145,9 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryNew = switch (workspacePlan.name) { case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': // If viewer seat type, we don't need to do anything if (seatType === WorkspaceSeatType.Viewer) return case 'starter': @@ -153,6 +159,8 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryNew = case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: @@ -223,7 +231,9 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryOld = switch (workspacePlan.name) { case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': throw new NotImplementedError() case 'starter': case 'plus': @@ -234,6 +244,8 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryOld = case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: diff --git a/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts index 6ccb12618..21b2ce3a2 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts @@ -44,7 +44,11 @@ export const downscaleWorkspaceSubscriptionFactoryOld = switch (workspacePlan.name) { case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': // Cause seat types matter, a future issue throw new NotImplementedError() case 'starter': @@ -113,7 +117,9 @@ export const downscaleWorkspaceSubscriptionFactoryNew = switch (workspacePlan.name) { case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': break case 'starter': case 'plus': @@ -123,6 +129,8 @@ export const downscaleWorkspaceSubscriptionFactoryNew = case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: diff --git a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts index d9a22dc73..46411fecd 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription.ts @@ -71,14 +71,19 @@ export const upgradeWorkspaceSubscriptionFactoryOld = case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'teamUnlimitedInvoiced': + case 'proUnlimitedInvoiced': case 'free': // TODO: Don't we want to allow upgrades from free to paid? throw new WorkspaceNotPaidPlanError() case 'starter': case 'plus': case 'business': - case 'team': - case 'pro': break + case 'team': + case 'teamUnlimited': + case 'pro': + case 'proUnlimited': + throw new WorkspacePlanMismatchError() default: throwUncoveredError(workspacePlan) } @@ -106,7 +111,9 @@ export const upgradeWorkspaceSubscriptionFactoryOld = starter: 1, // new team: 1, - pro: 2 + teamUnlimited: 2, + pro: 3, + proUnlimited: 4 } if (isNewPlanType(workspacePlan.name) || isNewPlanType(targetPlan)) { @@ -263,24 +270,35 @@ export const upgradeWorkspaceSubscriptionFactoryNew = const workspacePlan = await getWorkspacePlan({ workspaceId }) - if (!workspacePlan) throw new WorkspacePlanNotFoundError() - if (!isNewPlanType(workspacePlan.name) || !isNewPlanType(targetPlan)) { - throw new UnsupportedWorkspacePlanError(null, { - info: { currentPlan: workspacePlan.name, targetPlan } - }) - } switch (workspacePlan.name) { case 'unlimited': case 'academia': + case 'teamUnlimitedInvoiced': + case 'businessInvoiced': + case 'plusInvoiced': + case 'starterInvoiced': + case 'proUnlimitedInvoiced': case 'free': // Upgrade from free is handled through startCheckout since it is from free to paid throw new WorkspaceNotPaidPlanError() + case 'starter': + case 'plus': + case 'business': + throw new WorkspacePlanMismatchError() case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': break default: - throwUncoveredError(workspacePlan as never) + throwUncoveredError(workspacePlan) + } + + if (!isNewPlanType(workspacePlan.name) || !isNewPlanType(targetPlan)) { + throw new UnsupportedWorkspacePlanError(null, { + info: { currentPlan: workspacePlan.name, targetPlan } + }) } switch (workspacePlan.status) { @@ -305,7 +323,9 @@ export const upgradeWorkspaceSubscriptionFactoryNew = const planOrder: Record = { team: 1, - pro: 2 + teamUnlimited: 2, + pro: 3, + proUnlimited: 4 } if ( !isUpgradeWorkspacePlanValid({ current: workspacePlan.name, upgrade: targetPlan }) diff --git a/packages/server/modules/gatekeeper/services/workspacePlans.ts b/packages/server/modules/gatekeeper/services/workspacePlans.ts index 0155972ac..66d3bfde1 100644 --- a/packages/server/modules/gatekeeper/services/workspacePlans.ts +++ b/packages/server/modules/gatekeeper/services/workspacePlans.ts @@ -47,7 +47,9 @@ export const updateWorkspacePlanFactory = case 'business': case 'plus': case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': switch (status) { case 'trial': case 'expired': @@ -71,6 +73,8 @@ export const updateWorkspacePlanFactory = case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'teamUnlimitedInvoiced': + case 'proUnlimitedInvoiced': switch (status) { case 'valid': await upsertWorkspacePlan({ diff --git a/packages/server/modules/gatekeeper/stripe.ts b/packages/server/modules/gatekeeper/stripe.ts index 81fc0a1a6..e2c7a05d2 100644 --- a/packages/server/modules/gatekeeper/stripe.ts +++ b/packages/server/modules/gatekeeper/stripe.ts @@ -3,11 +3,7 @@ import { GetWorkspacePlanProductAndPriceIds, GetWorkspacePlanProductId } from '@/modules/gatekeeper/domain/billing' -import { - getFeatureFlags, - getStringFromEnv, - getStripeApiKey -} from '@/modules/shared/helpers/envHelper' +import { getStringFromEnv, getStripeApiKey } from '@/modules/shared/helpers/envHelper' import { InvalidBillingIntervalError } from '@/modules/gatekeeper/errors/billing' import { Stripe } from 'stripe' import { get, has } from 'lodash' @@ -15,51 +11,62 @@ import { NotImplementedError } from '@/modules/shared/errors' let stripeClient: Stripe | undefined = undefined -const { FF_WORKSPACES_NEW_PLANS_ENABLED } = getFeatureFlags() - export const getStripeClient = () => { if (!stripeClient) stripeClient = new Stripe(getStripeApiKey(), { typescript: true }) return stripeClient } +const loadProductAndPriceIds: GetWorkspacePlanProductAndPriceIds = () => ({ + // old + guest: { + productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID') + }, + starter: { + productId: getStringFromEnv('WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID') + }, + plus: { + productId: getStringFromEnv('WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID') + }, + business: { + productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID') + }, + team: { + productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID') + }, + teamUnlimited: { + productId: getStringFromEnv('WORKSPACE_TEAM_UNLIMITED_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID') + }, + pro: { + productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID') + }, + proUnlimited: { + productId: getStringFromEnv('WORKSPACE_PRO_UNLIMITED_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID') + } +}) + +let priceIds: ReturnType | null = null + export const getWorkspacePlanProductAndPriceIds: GetWorkspacePlanProductAndPriceIds = - () => ({ - // old - guest: { - productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'), - yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID') - }, - starter: { - productId: getStringFromEnv('WORKSPACE_STARTER_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_STARTER_SEAT_STRIPE_PRICE_ID'), - yearly: getStringFromEnv('WORKSPACE_YEARLY_STARTER_SEAT_STRIPE_PRICE_ID') - }, - plus: { - productId: getStringFromEnv('WORKSPACE_PLUS_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_PLUS_SEAT_STRIPE_PRICE_ID'), - yearly: getStringFromEnv('WORKSPACE_YEARLY_PLUS_SEAT_STRIPE_PRICE_ID') - }, - business: { - productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'), - yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID') - }, - // new - ...(FF_WORKSPACES_NEW_PLANS_ENABLED - ? { - team: { - productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID') - }, - pro: { - productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'), - monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'), - yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID') - } - } - : {}) - }) + () => { + if (!priceIds) priceIds = loadProductAndPriceIds() + return priceIds + } export const getWorkspacePlanPriceId: GetWorkspacePlanPriceId = ({ workspacePlan, diff --git a/packages/server/modules/gatekeeper/tests/integration/prices.spec.ts b/packages/server/modules/gatekeeper/tests/integration/prices.spec.ts index 59da23a29..27b677a64 100644 --- a/packages/server/modules/gatekeeper/tests/integration/prices.spec.ts +++ b/packages/server/modules/gatekeeper/tests/integration/prices.spec.ts @@ -4,38 +4,33 @@ import { TestApolloServer, testApolloServer } from '@/test/graphqlHelper' import { PaidWorkspacePlans, WorkspaceGuestSeatType } from '@speckle/shared' import { expect } from 'chai' -const { FF_WORKSPACES_NEW_PLANS_ENABLED, FF_GATEKEEPER_MODULE_ENABLED } = - getFeatureFlags() +const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() -describe('Workspace plan prices', () => { - let apollo: TestApolloServer +;(FF_BILLING_INTEGRATION_ENABLED ? describe : describe.skip)( + 'Workspace plan prices', + () => { + let apollo: TestApolloServer - before(async () => { - apollo = await testApolloServer() - }) + before(async () => { + apollo = await testApolloServer() + }) - const getPrices = () => apollo.execute(GetWorkspacePlanPricesDocument, {}) + const getPrices = () => apollo.execute(GetWorkspacePlanPricesDocument, {}) - it('returns prices', async () => { - const res = await getPrices() + it('returns prices', async () => { + const res = await getPrices() - let expectedPlans = [ - ...Object.values(PaidWorkspacePlans), - WorkspaceGuestSeatType - ].filter( - (p) => - FF_WORKSPACES_NEW_PLANS_ENABLED || - (p !== PaidWorkspacePlans.Team && p !== PaidWorkspacePlans.Pro) - ) - if (!FF_GATEKEEPER_MODULE_ENABLED) { - expectedPlans = [] - } + const expectedPlans = [ + ...Object.values(PaidWorkspacePlans), + WorkspaceGuestSeatType + ] - expect(res).to.not.haveGraphQLErrors() + expect(res).to.not.haveGraphQLErrors() - const prices = res.data?.serverInfo.workspaces.planPrices - expect(prices).to.be.ok - expect(prices).to.have.lengthOf(expectedPlans.length) - expect(prices!.map((p) => p.id)).to.deep.equalInAnyOrder(expectedPlans) - }) -}) + const prices = res.data?.serverInfo.workspaces.planPrices + expect(prices).to.be.ok + expect(prices).to.have.lengthOf(expectedPlans.length) + expect(prices!.map((p) => p.id)).to.deep.equalInAnyOrder(expectedPlans) + }) + } +) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index 48f4bd32d..b6530e639 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -6,7 +6,6 @@ import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' import { - UnsupportedWorkspacePlanError, WorkspaceNotPaidPlanError, WorkspacePlanMismatchError, WorkspacePlanNotFoundError, @@ -458,6 +457,8 @@ describe('subscriptions @gatekeeper', () => { case 'plus': case 'team': case 'pro': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'guest': return priceId @@ -528,6 +529,8 @@ describe('subscriptions @gatekeeper', () => { case 'guest': case 'team': case 'pro': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'starter': return priceId @@ -606,6 +609,8 @@ describe('subscriptions @gatekeeper', () => { case 'guest': case 'team': case 'pro': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'starter': return priceId @@ -682,6 +687,8 @@ describe('subscriptions @gatekeeper', () => { case 'guest': case 'team': case 'pro': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'starter': return priceId @@ -855,6 +862,8 @@ describe('subscriptions @gatekeeper', () => { case 'plus': case 'guest': case 'pro': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'team': return priceId @@ -927,6 +936,8 @@ describe('subscriptions @gatekeeper', () => { case 'guest': case 'pro': case 'starter': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'team': return priceId @@ -995,6 +1006,8 @@ describe('subscriptions @gatekeeper', () => { case 'guest': case 'pro': case 'starter': + case 'teamUnlimited': + case 'proUnlimited': expect.fail() case 'team': return priceId @@ -1856,8 +1869,12 @@ describe('subscriptions @gatekeeper', () => { return 'guestProduct' case 'team': return 'teamProduct' + case 'teamUnlimited': + return 'teamUnlimitedProduct' case 'pro': return 'proProduct' + case 'proUnlimited': + return 'proUnlimitedProduct' } }, getWorkspacePlanPriceId: () => { @@ -1982,7 +1999,7 @@ describe('subscriptions @gatekeeper', () => { }) }) - expect(err.message).to.equal(new UnsupportedWorkspacePlanError().message) + expect(err.message).to.equal(new WorkspaceNotPaidPlanError().message) }) }) ;(['team', 'pro'] as const).forEach((plan) => { @@ -2303,8 +2320,12 @@ describe('subscriptions @gatekeeper', () => { return 'guestProduct' case 'team': return 'teamProduct' + case 'teamUnlimited': + return 'teamUnlimitedProduct' case 'pro': return 'proProduct' + case 'proUnlimited': + return 'proUnlimitedProduct' } }, getWorkspacePlanPriceId: () => { diff --git a/packages/server/modules/gatekeeperCore/domain/billing.ts b/packages/server/modules/gatekeeperCore/domain/billing.ts index 8ba41e93c..1f453533b 100644 --- a/packages/server/modules/gatekeeperCore/domain/billing.ts +++ b/packages/server/modules/gatekeeperCore/domain/billing.ts @@ -1,39 +1,19 @@ -import { - PaidWorkspacePlans, - WorkspacePlanBillingIntervals, - WorkspacePlans -} from '@speckle/shared' -import { OverrideProperties, SetOptional } from 'type-fest' +import { PaidWorkspacePlans, WorkspacePlanBillingIntervals } from '@speckle/shared' /** * This includes the pricing plans (Stripe products) a customer can sub to */ export type WorkspacePricingProducts = PaidWorkspacePlans | 'guest' -type WorkspacePlanProductsMetadata = OverrideProperties< - Record< - WorkspacePricingProducts, - Record & { - productId: string - } - >, - { - // Team has no yearly plan - [PaidWorkspacePlans.Team]: { - productId: string - monthly: PriceData - } +type WorkspacePlanProductsMetadata = Record< + WorkspacePricingProducts, + Record & { + productId: string } > -export type WorkspacePlanProductAndPriceIds = SetOptional< - WorkspacePlanProductsMetadata, - typeof WorkspacePlans.Team | typeof WorkspacePlans.Pro -> -export type WorkspacePlanProductPrices = SetOptional< - WorkspacePlanProductsMetadata<{ - amount: number - currency: string - }>, - typeof WorkspacePlans.Team | typeof WorkspacePlans.Pro -> +export type WorkspacePlanProductAndPriceIds = WorkspacePlanProductsMetadata +export type WorkspacePlanProductPrices = WorkspacePlanProductsMetadata<{ + amount: number + currency: string +}> diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index e3bf3c5bb..8f428a65a 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -101,7 +101,6 @@ import { getWorkspacePlanFactory, getWorkspaceSubscriptionFactory, getWorkspaceWithPlanFactory, - upsertTrialWorkspacePlanFactory, upsertUnpaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' @@ -123,7 +122,7 @@ import { authorizeResolver } from '@/modules/shared' import { isNewPaidPlanType, isNewPlanType } from '@/modules/gatekeeper/helpers/plans' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -const { FF_GATEKEEPER_FORCE_FREE_PLAN } = getFeatureFlags() +const { FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() export const onProjectCreatedFactory = (deps: { @@ -434,6 +433,9 @@ export const workspaceTrackingFactory = getUserEmails: FindEmailsByUserId }) => async (params: EventPayload<'workspace.*'> | EventPayload<'gatekeeper.*'>) => { + // temp ignoring tracking for this, if billing is not enabled + // this should be sorted with a better separation between workspaces and the gatekeeper module + if (!FF_BILLING_INTEGRATION_ENABLED) return const { eventName, payload } = params const mixpanel = getClient() if (!mixpanel) return @@ -742,26 +744,14 @@ export const initializeEventListenersFactory = await onWorkspaceAuthorized(payload) }), eventBus.listen(WorkspaceEvents.Created, async ({ payload }) => { - // TODO: based on a feature flag, we can force new workspaces into the free plan here - if (FF_GATEKEEPER_FORCE_FREE_PLAN) { - await upsertUnpaidWorkspacePlanFactory({ db })({ - workspacePlan: { - name: 'free', - status: 'valid', - workspaceId: payload.workspace.id, - createdAt: new Date() - } - }) - } else { - await upsertTrialWorkspacePlanFactory({ db })({ - workspacePlan: { - name: 'starter', - status: 'trial', - workspaceId: payload.workspace.id, - createdAt: new Date() - } - }) - } + await upsertUnpaidWorkspacePlanFactory({ db })({ + workspacePlan: { + name: 'free', + status: 'valid', + workspaceId: payload.workspace.id, + createdAt: new Date() + } + }) }), eventBus.listen(WorkspaceEvents.RoleDeleted, async ({ payload }) => { const trx = await db.transaction() diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 64094617e..8863480cc 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -534,7 +534,9 @@ export = FF_WORKSPACES_MODULE_ENABLED if (workspacePlan) { switch (workspacePlan.name) { case 'team': + case 'teamUnlimited': case 'pro': + case 'proUnlimited': case 'starter': case 'plus': case 'business': @@ -556,6 +558,8 @@ export = FF_WORKSPACES_MODULE_ENABLED case 'starterInvoiced': case 'plusInvoiced': case 'businessInvoiced': + case 'proUnlimitedInvoiced': + case 'teamUnlimitedInvoiced': break default: throwUncoveredError(workspacePlan) diff --git a/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts index 1d09c04d4..099981455 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceSeat.graph.spec.ts @@ -118,7 +118,7 @@ describe('Workspace Seats @graphql', () => { expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok }) - it('should upgrade a workspace seat and reconcile subscription', async () => { + it.skip('should upgrade a workspace seat and reconcile subscription', async () => { const user: BasicTestUser = { id: createRandomString(), name: createRandomString(), diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index 8085cddd7..a125438a9 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -55,7 +55,6 @@ import { } from '@/modules/workspaces/repositories/workspaces' import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' -import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' import { createUserEmailFactory, @@ -96,7 +95,6 @@ const validateAndCreateUserEmail = validateAndCreateUserEmailFactory({ renderEmail }) }) -const { FF_GATEKEEPER_FORCE_FREE_PLAN } = getFeatureFlags() describe('Workspaces GQL CRUD', () => { let apollo: TestApolloServer @@ -593,19 +591,7 @@ describe('Workspaces GQL CRUD', () => { id: project1Id, name: project1Name } - }, - // No longer auto-assigned in new plan world (until we rework auth & queries) - ...(FF_GATEKEEPER_FORCE_FREE_PLAN - ? [] - : [ - { - role: Roles.Stream.Reviewer, - project: { - id: project2Id, - name: project2Name - } - } - ]) + } ]) const guestRoles = items.find( (item) => item.role === Roles.Workspace.Guest diff --git a/packages/server/observability/components/apollo/apolloSubscriptions.ts b/packages/server/observability/components/apollo/apolloSubscriptions.ts index 7c497e6f0..eb87f13df 100644 --- a/packages/server/observability/components/apollo/apolloSubscriptions.ts +++ b/packages/server/observability/components/apollo/apolloSubscriptions.ts @@ -35,7 +35,7 @@ export const onOperationHandlerFactory = (deps: { } const logger = ctx.log || subscriptionLogger - logger.info( + logger.debug( { graphql_operation_name: baseParams.operationName, userId: baseParams.context.userId, diff --git a/packages/server/observability/components/knex/knexMonitoring.ts b/packages/server/observability/components/knex/knexMonitoring.ts index e86306093..531f2876d 100644 --- a/packages/server/observability/components/knex/knexMonitoring.ts +++ b/packages/server/observability/components/knex/knexMonitoring.ts @@ -260,7 +260,7 @@ const initKnexPrometheusMetricsForRegionEvents = async (params: { } const trace = stackTrace || collectLongTrace() - params.logger.info( + params.logger.debug( { region, sql: data.sql, diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index ad0b82471..438666e6c 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -1888,8 +1888,10 @@ export const PaidWorkspacePlans = { Business: 'business', Plus: 'plus', Pro: 'pro', + ProUnlimited: 'proUnlimited', Starter: 'starter', - Team: 'team' + Team: 'team', + TeamUnlimited: 'teamUnlimited' } as const; export type PaidWorkspacePlans = typeof PaidWorkspacePlans[keyof typeof PaidWorkspacePlans]; @@ -4779,9 +4781,13 @@ export const WorkspacePlans = { Plus: 'plus', PlusInvoiced: 'plusInvoiced', Pro: 'pro', + ProUnlimited: 'proUnlimited', + ProUnlimitedInvoiced: 'proUnlimitedInvoiced', Starter: 'starter', StarterInvoiced: 'starterInvoiced', Team: 'team', + TeamUnlimited: 'teamUnlimited', + TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced', Unlimited: 'unlimited' } as const; diff --git a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts index e3b306244..6c494912d 100644 --- a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts +++ b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts @@ -76,6 +76,7 @@ export const canCreateWorkspaceProjectPolicy: AuthPolicy< case 'WorkspaceNoAccess': case 'WorkspaceSsoSessionNoAccess': return err(memberWithSsoSession.value.error) + /* v8 ignore next 2*/ default: throwUncoveredError(memberWithSsoSession.value.error) } diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index d7bb91c07..48cfa351e 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -28,14 +28,6 @@ export const parseFeatureFlags = ( schema: z.boolean(), defaults: { production: false, _: true } }, - FF_WORKSPACES_NEW_PLANS_ENABLED: { - schema: z.boolean(), - defaults: { production: false, _: true } - }, - FF_GATEKEEPER_FORCE_FREE_PLAN: { - schema: z.boolean(), - defaults: { production: false, _: false } - }, FF_GATEKEEPER_MODULE_ENABLED: { schema: z.boolean(), defaults: { production: false, _: true } @@ -97,10 +89,8 @@ export type FeatureFlags = { FF_AUTOMATE_MODULE_ENABLED: boolean FF_GENDOAI_MODULE_ENABLED: boolean FF_WORKSPACES_MODULE_ENABLED: boolean - FF_WORKSPACES_NEW_PLANS_ENABLED: boolean FF_WORKSPACES_SSO_ENABLED: boolean FF_GATEKEEPER_MODULE_ENABLED: boolean - FF_GATEKEEPER_FORCE_FREE_PLAN: boolean FF_BILLING_INTEGRATION_ENABLED: boolean FF_WORKSPACES_MULTI_REGION_ENABLED: boolean FF_FORCE_ONBOARDING: boolean diff --git a/packages/shared/src/workspaces/helpers/features.ts b/packages/shared/src/workspaces/helpers/features.ts index f28666eae..48baa5202 100644 --- a/packages/shared/src/workspaces/helpers/features.ts +++ b/packages/shared/src/workspaces/helpers/features.ts @@ -102,6 +102,16 @@ const baseFeatures = [ WorkspacePlanFeatures.PrivateAutomateFunctions ] as const +const teamFeatures = [...baseFeatures] + +const proFeatures = [ + ...teamFeatures, + WorkspacePlanFeatures.DomainSecurity, + WorkspacePlanFeatures.SSO, + WorkspacePlanFeatures.CustomDataRegion, + WorkspacePlanFeatures.PrioritySupport +] + export const WorkspacePaidPlanConfigs: { [plan in PaidWorkspacePlans]: WorkspacePlanConfig } = { @@ -133,25 +143,35 @@ export const WorkspacePaidPlanConfigs: { }, [PaidWorkspacePlans.Team]: { plan: PaidWorkspacePlans.Team, - features: baseFeatures, + features: teamFeatures, limits: { projectCount: 5, modelCount: 25 } }, + [PaidWorkspacePlans.TeamUnlimited]: { + plan: PaidWorkspacePlans.TeamUnlimited, + features: teamFeatures, + limits: { + projectCount: null, + modelCount: null + } + }, [PaidWorkspacePlans.Pro]: { plan: PaidWorkspacePlans.Pro, - features: [ - ...baseFeatures, - WorkspacePlanFeatures.DomainSecurity, - WorkspacePlanFeatures.SSO, - WorkspacePlanFeatures.CustomDataRegion, - WorkspacePlanFeatures.PrioritySupport - ], + features: proFeatures, limits: { projectCount: 10, modelCount: 50 } + }, + [PaidWorkspacePlans.ProUnlimited]: { + plan: PaidWorkspacePlans.ProUnlimited, + features: proFeatures, + limits: { + projectCount: null, + modelCount: null + } } } @@ -194,6 +214,14 @@ export const WorkspaceUnpaidPlanConfigs: { plan: UnpaidWorkspacePlans.BusinessInvoiced }, // New + [UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: { + ...WorkspacePaidPlanConfigs.teamUnlimited, + plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced + }, + [UnpaidWorkspacePlans.ProUnlimitedInvoiced]: { + ...WorkspacePaidPlanConfigs.proUnlimited, + plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced + }, [UnpaidWorkspacePlans.Free]: { plan: UnpaidWorkspacePlans.Free, features: baseFeatures, diff --git a/packages/shared/src/workspaces/helpers/plans.spec.ts b/packages/shared/src/workspaces/helpers/plans.spec.ts new file mode 100644 index 000000000..3e318b1bc --- /dev/null +++ b/packages/shared/src/workspaces/helpers/plans.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { isNewWorkspacePlan, WorkspacePlans } from './plans.js' + +describe('isNewWorkspacePlan', () => { + const planCases: { + [P in WorkspacePlans]: boolean + } = { + business: false, + businessInvoiced: false, + plus: false, + plusInvoiced: false, + starter: false, + starterInvoiced: false, + free: true, + academia: true, + unlimited: true, + pro: true, + proUnlimited: true, + proUnlimitedInvoiced: true, + team: true, + teamUnlimited: true, + teamUnlimitedInvoiced: true + } + it.each(Object.entries(planCases))('plan %s is new type -> %s', (plan, isNew) => { + const result = isNewWorkspacePlan(plan as WorkspacePlans) + expect(result).toStrictEqual(isNew) + }) +}) diff --git a/packages/shared/src/workspaces/helpers/plans.ts b/packages/shared/src/workspaces/helpers/plans.ts index 79efa5cd6..1eb16ec3c 100644 --- a/packages/shared/src/workspaces/helpers/plans.ts +++ b/packages/shared/src/workspaces/helpers/plans.ts @@ -23,7 +23,9 @@ export type PaidWorkspacePlansOld = export const PaidWorkspacePlansNew = { Team: 'team', - Pro: 'pro' + TeamUnlimited: 'teamUnlimited', + Pro: 'pro', + ProUnlimited: 'proUnlimited' } export type PaidWorkspacePlansNew = @@ -43,6 +45,8 @@ export const UnpaidWorkspacePlans = { PlusInvoiced: 'plusInvoiced', BusinessInvoiced: 'businessInvoiced', // New + TeamUnlimitedInvoiced: 'teamUnlimitedInvoiced', + ProUnlimitedInvoiced: 'proUnlimitedInvoiced', Unlimited: 'unlimited', Academia: 'academia', Free: 'free' @@ -66,11 +70,28 @@ export type WorkspaceGuestSeatType = typeof WorkspaceGuestSeatType export const isNewWorkspacePlan = ( plan: MaybeNullOrUndefined ): boolean => { - return ( - plan === PaidWorkspacePlansNew.Team || - plan === PaidWorkspacePlansNew.Pro || - plan === UnpaidWorkspacePlans.Free - ) + if (!plan) return false + switch (plan) { + case 'starter': + case 'starterInvoiced': + case 'plus': + case 'plusInvoiced': + case 'business': + case 'businessInvoiced': + return false + case 'team': + case 'teamUnlimited': + case 'teamUnlimitedInvoiced': + case 'pro': + case 'proUnlimited': + case 'proUnlimitedInvoiced': + case 'unlimited': + case 'academia': + case 'free': + return true + default: + throwUncoveredError(plan) + } } /** diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 74d0b2020..d103a98f0 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -659,6 +659,24 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID value: {{ .Values.server.billing.workspaceYearlyTeamSeatStripePriceId }} +- name: WORKSPACE_TEAM_UNLIMITED_SEAT_STRIPE_PRODUCT_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceTeamUnlimitedSeatStripeProductId.secretKey }} + +- name: WORKSPACE_MONTHLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceMonthlyTeamUnlimitedSeatStripePriceId.secretKey }} + +- name: WORKSPACE_YEARLY_TEAM_UNLIMITED_SEAT_STRIPE_PRICE_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceYearlyTeamUnlimitedSeatStripePriceId.secretKey }} + - name: WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID value: {{ .Values.server.billing.workspaceProSeatStripeProductId }} @@ -668,6 +686,24 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID value: {{ .Values.server.billing.workspaceYearlyProSeatStripePriceId }} +- name: WORKSPACE_PRO_UNLIMITED_SEAT_STRIPE_PRODUCT_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceProUnlimitedSeatStripeProductId.secretKey }} + +- name: WORKSPACE_MONTHLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceMonthlyProUnlimitedSeatStripePriceId.secretKey }} + +- name: WORKSPACE_YEARLY_PRO_UNLIMITED_SEAT_STRIPE_PRICE_ID + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.billing.secretName }}" + key: {{ .Values.server.billing.workspaceYearlyProUnlimitedSeatStripePriceId.secretKey }} + {{- end }} {{- if (or .Values.featureFlags.automateModuleEnabled .Values.featureFlags.workspacesSsoEnabled) }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index 3ff391963..f1aecd65f 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -905,6 +905,21 @@ "description": "The workspace Yearly Team Seat Price Id as configured in Stripe.", "default": "" }, + "workspaceTeamUnlimitedSeatStripeProductId": { + "type": "string", + "description": "The workspace Team Unlimited Seat Product Id as configured in Stripe.", + "default": "" + }, + "workspaceMonthlyTeamUnlimitedSeatStripePriceId": { + "type": "string", + "description": "The workspace Monthly Team Unlimited Seat Price Id as configured in Stripe.", + "default": "" + }, + "workspaceYearlyTeamUnlimitedSeatStripePriceId": { + "type": "string", + "description": "The workspace Yearly Team Unlimited Seat Price Id as configured in Stripe.", + "default": "" + }, "workspaceProSeatStripeProductId": { "type": "string", "description": "The workspace Pro Seat Product Id as configured in Stripe.", @@ -919,6 +934,21 @@ "type": "string", "description": "The workspace Yearly Pro Seat Price Id as configured in Stripe.", "default": "" + }, + "workspaceProUnlimitedSeatStripeProductId": { + "type": "string", + "description": "The workspace Pro Unlimited Seat Product Id as configured in Stripe.", + "default": "" + }, + "workspaceMonthlyProUnlimitedSeatStripePriceId": { + "type": "string", + "description": "The workspace Monthly Pro Unlimited Seat Price Id as configured in Stripe.", + "default": "" + }, + "workspaceYearlyProUnlimitedSeatStripePriceId": { + "type": "string", + "description": "The workspace Yearly Pro Unlimited Seat Price Id as configured in Stripe.", + "default": "" } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index aa1c503c5..d503650d7 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -595,6 +595,13 @@ server: ## @param server.billing.workspaceYearlyTeamSeatStripePriceId The workspace Yearly Team Seat Price Id as configured in Stripe. workspaceYearlyTeamSeatStripePriceId: '' + ## @param server.billing.workspaceTeamUnlimitedSeatStripeProductId The workspace Team Unlimited Seat Product Id as configured in Stripe. + workspaceTeamUnlimitedSeatStripeProductId: '' + ## @param server.billing.workspaceMonthlyTeamUnlimitedSeatStripePriceId The workspace Monthly Team Unlimited Seat Price Id as configured in Stripe. + workspaceMonthlyTeamUnlimitedSeatStripePriceId: '' + ## @param server.billing.workspaceYearlyTeamUnlimitedSeatStripePriceId The workspace Yearly Team Unlimited Seat Price Id as configured in Stripe. + workspaceYearlyTeamUnlimitedSeatStripePriceId: '' + ## @param server.billing.workspaceProSeatStripeProductId The workspace Pro Seat Product Id as configured in Stripe. workspaceProSeatStripeProductId: '' ## @param server.billing.workspaceMonthlyProSeatStripePriceId The workspace Monthly Pro Seat Price Id as configured in Stripe. @@ -602,6 +609,13 @@ server: ## @param server.billing.workspaceYearlyProSeatStripePriceId The workspace Yearly Pro Seat Price Id as configured in Stripe. workspaceYearlyProSeatStripePriceId: '' + ## @param server.billing.workspaceProUnlimitedSeatStripeProductId The workspace Pro Unlimited Seat Product Id as configured in Stripe. + workspaceProUnlimitedSeatStripeProductId: '' + ## @param server.billing.workspaceMonthlyProUnlimitedSeatStripePriceId The workspace Monthly Pro Unlimited Seat Price Id as configured in Stripe. + workspaceMonthlyProUnlimitedSeatStripePriceId: '' + ## @param server.billing.workspaceYearlyProUnlimitedSeatStripePriceId The workspace Yearly Pro Unlimited Seat Price Id as configured in Stripe. + workspaceYearlyProUnlimitedSeatStripePriceId: '' + sessionSecret: ## @param server.sessionSecret.secretName The name of the Kubernetes Secret containing the Session secret. This is a unique value (can be generated randomly). This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets ##