From d6dad6609a91d57868331fc17afc2ca3e16fc583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 28 Oct 2024 18:58:13 +0100 Subject: [PATCH] feat(gatekeeper): shedule subscription downscale --- .../gatekeeper/typedefs/gatekeeper.graphql | 2 + .../modules/gatekeeper/domain/billing.ts | 4 +- packages/server/modules/gatekeeper/index.ts | 28 ++++++ .../gatekeeper/services/subscriptions.ts | 90 ++++++++++++++++++- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql index a1aad81ab..e3f7e884c 100644 --- a/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql +++ b/packages/server/assets/gatekeeper/typedefs/gatekeeper.graphql @@ -59,8 +59,10 @@ enum WorkspacePlans { enum WorkspacePlanStatuses { valid paymentFailed + cancelationScheduled canceled trial + expired } type WorkspacePlan { diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 3ee6c87e7..3414c9f6d 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -117,7 +117,7 @@ const subscriptionProduct = z.object({ quantity: z.number() }) -type SubscriptionProduct = z.infer +export type SubscriptionProduct = z.infer export const subscriptionData = z.object({ subscriptionId: z.string().min(1), @@ -147,6 +147,8 @@ export type GetWorkspaceSubscription = (args: { workspaceId: string }) => Promise +export type GetWorkspaceSubscriptions = () => Promise + export type GetWorkspaceSubscriptionBySubscriptionId = (args: { subscriptionId: string }) => Promise diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 7dca178ab..0cfe0efed 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,3 +1,4 @@ +import cron from 'node-cron' import { moduleLogger } from '@/logging/logging' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' @@ -8,6 +9,11 @@ import { db } from '@/db/knex' import { gatekeeperScopes } from '@/modules/gatekeeper/scopes' import { initializeEventListenersFactory } from '@/modules/gatekeeper/events/eventListener' import { getStripeClient } from '@/modules/gatekeeper/stripe' +import { scheduleExecutionFactory } from '@/modules/core/services/taskScheduler' +import { + acquireTaskLockFactory, + releaseTaskLockFactory +} from '@/modules/core/repositories/scheduledTasks' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -17,6 +23,25 @@ const initScopes = async () => { await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope }))) } +const scheduleWorkspaceSubscriptionDownscale = () => { + const scheduleExecution = scheduleExecutionFactory({ + acquireTaskLock: acquireTaskLockFactory({ db }), + releaseTaskLock: releaseTaskLockFactory({ db }) + }) + + const cronExpression = '*/10 * * * * *' + return scheduleExecution( + cronExpression, + 'WorkspaceSubscriptionDownscale', + async () => { + moduleLogger.info('Starting workspace subscription downscale scan') + // await cleanOrphanedWebhookConfigs() + moduleLogger.info('Finished cleanup') + } + ) +} + +let scheduledTask: cron.ScheduledTask | undefined = undefined let quitListeners: (() => void) | undefined = undefined const gatekeeperModule: SpeckleModule = { @@ -39,6 +64,8 @@ const gatekeeperModule: SpeckleModule = { if (FF_BILLING_INTEGRATION_ENABLED) { app.use(getBillingRouter()) + scheduledTask = scheduleWorkspaceSubscriptionDownscale() + quitListeners = initializeEventListenersFactory({ db, stripe: getStripeClient() @@ -57,6 +84,7 @@ const gatekeeperModule: SpeckleModule = { }, async shutdown() { if (quitListeners) quitListeners() + if (scheduledTask) scheduledTask.stop() } } export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index dcca63737..c22d3db4b 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -4,6 +4,7 @@ import { GetWorkspacePlanProductId, GetWorkspaceSubscription, GetWorkspaceSubscriptionBySubscriptionId, + GetWorkspaceSubscriptions, PaidWorkspacePlanStatuses, ReconcileSubscriptionData, SubscriptionData, @@ -11,6 +12,7 @@ import { UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription } from '@/modules/gatekeeper/domain/billing' +import { WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' import { WorkspacePlanMismatchError, WorkspacePlanNotFoundError, @@ -18,7 +20,7 @@ import { } from '@/modules/gatekeeper/errors/billing' import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared' -import { cloneDeep, sum } from 'lodash' +import { cloneDeep, isEqual, sum } from 'lodash' export const handleSubscriptionUpdateFactory = ({ @@ -170,3 +172,89 @@ export const addWorkspaceSubscriptionSeatIfNeededFactory = } await reconcileSubscriptionData({ subscriptionData, applyProrotation: true }) } + +const mutateSubscriptionDataWithNewValidSeatNumbers = ({ + seatCount, + workspacePlan, + getWorkspacePlanProductId, + subscriptionData +}: { + seatCount: number + workspacePlan: WorkspacePricingPlans + getWorkspacePlanProductId: GetWorkspacePlanProductId + subscriptionData: SubscriptionData +}): void => { + const productId = getWorkspacePlanProductId({ workspacePlan }) + const product = subscriptionData.products.find( + (product) => product.productId === productId + ) + if (seatCount < 0) throw new Error('Invalid seat count, cannot be negative') + + if (seatCount === 0 && product === undefined) return + if (product !== undefined && product.quantity >= seatCount) { + product.quantity = seatCount + } else { + throw new Error('Invalid subscription state') + } +} + +export const downscaleWorkspaceSubscriptionsFactory = + ({ + getWorkspaceSubscriptions, + getWorkspacePlan, + countWorkspaceRole, + getWorkspacePlanProductId, + reconcileSubscriptionData + }: { + getWorkspaceSubscriptions: GetWorkspaceSubscriptions + getWorkspacePlan: GetWorkspacePlan + countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole + getWorkspacePlanProductId: GetWorkspacePlanProductId + reconcileSubscriptionData: ReconcileSubscriptionData + }) => + async () => { + const workspaceSubscriptions = await getWorkspaceSubscriptions() + for (const workspaceSubscription of workspaceSubscriptions) { + const workspaceId = workspaceSubscription.workspaceId + workspaceSubscription.subscriptionData + + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + case 'business': + break + case 'unlimited': + case 'academia': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + const [guestCount, memberCount, adminCount] = await Promise.all([ + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }), + countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' }) + ]) + + const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: guestCount, + workspacePlan: 'guest', + getWorkspacePlanProductId, + subscriptionData + }) + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: memberCount + adminCount, + workspacePlan: workspacePlan.name, + getWorkspacePlanProductId, + subscriptionData + }) + + if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) + await reconcileSubscriptionData({ subscriptionData, applyProrotation: false }) + } + }