feat(gatekeeper): shedule subscription downscale

This commit is contained in:
Gergő Jedlicska
2024-10-28 18:58:13 +01:00
parent a80be1ca89
commit d6dad6609a
4 changed files with 122 additions and 2 deletions
@@ -59,8 +59,10 @@ enum WorkspacePlans {
enum WorkspacePlanStatuses {
valid
paymentFailed
cancelationScheduled
canceled
trial
expired
}
type WorkspacePlan {
@@ -117,7 +117,7 @@ const subscriptionProduct = z.object({
quantity: z.number()
})
type SubscriptionProduct = z.infer<typeof subscriptionProduct>
export type SubscriptionProduct = z.infer<typeof subscriptionProduct>
export const subscriptionData = z.object({
subscriptionId: z.string().min(1),
@@ -147,6 +147,8 @@ export type GetWorkspaceSubscription = (args: {
workspaceId: string
}) => Promise<WorkspaceSubscription | null>
export type GetWorkspaceSubscriptions = () => Promise<WorkspaceSubscription[]>
export type GetWorkspaceSubscriptionBySubscriptionId = (args: {
subscriptionId: string
}) => Promise<WorkspaceSubscription | null>
@@ -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
@@ -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 })
}
}