feat(gatekeeper): shedule subscription downscale
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user