feat(gatekeeper): expire trail workspace plans
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing'
|
||||
import { WorkspaceFeatureName } from '@/modules/gatekeeper/domain/workspacePricing'
|
||||
|
||||
export type CanWorkspaceAccessFeature = (args: {
|
||||
@@ -8,3 +9,7 @@ export type CanWorkspaceAccessFeature = (args: {
|
||||
export type WorkspaceFeatureAccessFunction = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<boolean>
|
||||
|
||||
export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
|
||||
numberOfDays: number
|
||||
}) => Promise<WorkspacePlan[]>
|
||||
|
||||
@@ -19,12 +19,15 @@ import {
|
||||
manageSubscriptionDownscaleFactory
|
||||
} from '@/modules/gatekeeper/services/subscriptions'
|
||||
import {
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory,
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
|
||||
upsertWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/workspaces/repositories/workspaces'
|
||||
import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
|
||||
import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
|
||||
import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus'
|
||||
|
||||
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
|
||||
getFeatureFlags()
|
||||
@@ -34,12 +37,11 @@ const initScopes = async () => {
|
||||
await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope })))
|
||||
}
|
||||
|
||||
const scheduleWorkspaceSubscriptionDownscale = () => {
|
||||
const scheduleExecution = scheduleExecutionFactory({
|
||||
acquireTaskLock: acquireTaskLockFactory({ db }),
|
||||
releaseTaskLock: releaseTaskLockFactory({ db })
|
||||
})
|
||||
|
||||
const scheduleWorkspaceSubscriptionDownscale = ({
|
||||
scheduleExecution
|
||||
}: {
|
||||
scheduleExecution: ScheduleExecution
|
||||
}) => {
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({
|
||||
@@ -66,7 +68,48 @@ const scheduleWorkspaceSubscriptionDownscale = () => {
|
||||
)
|
||||
}
|
||||
|
||||
let scheduledTask: cron.ScheduledTask | undefined = undefined
|
||||
const scheduleWorkspaceTrialEmails = ({
|
||||
scheduleExecution
|
||||
}: {
|
||||
scheduleExecution: ScheduleExecution
|
||||
}) => {
|
||||
// TODO: make this a daily thing
|
||||
const cronExpression = '*/5 * * * *'
|
||||
return scheduleExecution(cronExpression, 'WorkspaceTrialEmails', async () => {
|
||||
// await manageSubscriptionDownscale()
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleWorkspaceTrialExpiry = ({
|
||||
scheduleExecution,
|
||||
emit
|
||||
}: {
|
||||
scheduleExecution: ScheduleExecution
|
||||
emit: EventBusEmit
|
||||
}) => {
|
||||
const changeExpiredStatuses = changeExpiredTrialWorkspacePlanStatusesFactory({ db })
|
||||
const cronExpression = '*/5 * * * *'
|
||||
return scheduleExecution(cronExpression, 'WorkspaceTrialExpiry', async () => {
|
||||
const expiredWorkspacePlans = await changeExpiredStatuses({ numberOfDays: 31 })
|
||||
|
||||
if (expiredWorkspacePlans.length) {
|
||||
logger.info(
|
||||
{ workspaceIds: expiredWorkspacePlans.map((p) => p.workspaceId) },
|
||||
'Workspace trial expired for {workspaceIds}.'
|
||||
)
|
||||
await Promise.all(
|
||||
expiredWorkspacePlans.map(async (plan) => {
|
||||
emit({
|
||||
eventName: 'gatekeeper.workspace-trial-expired',
|
||||
payload: { workspaceId: plan.workspaceId }
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let scheduledTasks: cron.ScheduledTask[] = []
|
||||
let quitListeners: (() => void) | undefined = undefined
|
||||
|
||||
const gatekeeperModule: SpeckleModule = {
|
||||
@@ -89,7 +132,18 @@ const gatekeeperModule: SpeckleModule = {
|
||||
if (FF_BILLING_INTEGRATION_ENABLED) {
|
||||
app.use(getBillingRouter())
|
||||
|
||||
scheduledTask = scheduleWorkspaceSubscriptionDownscale()
|
||||
const eventBus = getEventBus()
|
||||
|
||||
const scheduleExecution = scheduleExecutionFactory({
|
||||
acquireTaskLock: acquireTaskLockFactory({ db }),
|
||||
releaseTaskLock: releaseTaskLockFactory({ db })
|
||||
})
|
||||
|
||||
scheduledTasks = [
|
||||
scheduleWorkspaceSubscriptionDownscale({ scheduleExecution }),
|
||||
scheduleWorkspaceTrialEmails({ scheduleExecution }),
|
||||
scheduleWorkspaceTrialExpiry({ scheduleExecution, emit: eventBus.emit })
|
||||
]
|
||||
|
||||
quitListeners = initializeEventListenersFactory({
|
||||
db,
|
||||
@@ -109,7 +163,9 @@ const gatekeeperModule: SpeckleModule = {
|
||||
},
|
||||
async shutdown() {
|
||||
if (quitListeners) quitListeners()
|
||||
if (scheduledTask) scheduledTask.stop()
|
||||
scheduledTasks.forEach((task) => {
|
||||
task.stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
export = gatekeeperModule
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
GetWorkspaceSubscriptions,
|
||||
UpsertTrialWorkspacePlan
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { ChangeExpiredTrialWorkspacePlanStatuses } from '@/modules/gatekeeper/domain/operations'
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const tables = {
|
||||
@@ -61,6 +62,17 @@ export const upsertTrialWorkspacePlanFactory = ({
|
||||
db: Knex
|
||||
}): UpsertTrialWorkspacePlan => upsertWorkspacePlanFactory({ db })
|
||||
|
||||
export const changeExpiredTrialWorkspacePlanStatusesFactory =
|
||||
({ db }: { db: Knex }): ChangeExpiredTrialWorkspacePlanStatuses =>
|
||||
async ({ numberOfDays }) => {
|
||||
return await tables
|
||||
.workspacePlans(db)
|
||||
.where({ status: 'trial' })
|
||||
.andWhereRaw(`"createdAt" + make_interval(days => ${numberOfDays}) < now()`)
|
||||
.update({ status: 'expired' })
|
||||
.returning('*')
|
||||
}
|
||||
|
||||
export const saveCheckoutSessionFactory =
|
||||
({ db }: { db: Knex }): SaveCheckoutSession =>
|
||||
async ({ checkoutSession }) => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
upsertPaidWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory,
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory,
|
||||
upsertTrialWorkspacePlanFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import {
|
||||
createTestSubscriptionData,
|
||||
@@ -28,6 +30,7 @@ const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({
|
||||
})
|
||||
const getWorkspacePlan = getWorkspacePlanFactory({ db })
|
||||
const upsertPaidWorkspacePlan = upsertPaidWorkspacePlanFactory({ db })
|
||||
const upsertTrialWorkspacePlan = upsertTrialWorkspacePlanFactory({ db })
|
||||
const saveCheckoutSession = saveCheckoutSessionFactory({ db })
|
||||
const deleteCheckoutSession = deleteCheckoutSessionFactory({ db })
|
||||
const getCheckoutSession = getCheckoutSessionFactory({ db })
|
||||
@@ -41,6 +44,9 @@ const getWorkspaceSubscriptionBySubscriptionId =
|
||||
const getSubscriptionsAboutToEndBillingCycle =
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db })
|
||||
|
||||
const changeExpiredTrialWorkspacePlanStatuses =
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory({ db })
|
||||
|
||||
describe('billing repositories @gatekeeper', () => {
|
||||
describe('workspacePlans', () => {
|
||||
describe('upsertPaidWorkspacePlanFactory creates a function, that', () => {
|
||||
@@ -85,6 +91,90 @@ describe('billing repositories @gatekeeper', () => {
|
||||
expect(storedWorkspacePlan).deep.equal(planUpdate)
|
||||
})
|
||||
})
|
||||
describe('changeExpiredTrialWorkspacePlanStatusesFactory creates a function, that', () => {
|
||||
it('ignores non trial plans', async () => {
|
||||
const workspace = await createAndStoreTestWorkspace()
|
||||
await upsertPaidWorkspacePlan({
|
||||
workspacePlan: {
|
||||
name: 'business',
|
||||
status: 'cancelationScheduled',
|
||||
workspaceId: workspace.id,
|
||||
createdAt: new Date(2023, 0, 1)
|
||||
}
|
||||
})
|
||||
|
||||
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
|
||||
numberOfDays: 1
|
||||
})
|
||||
expect(expiredPlans.map((p) => p.workspaceId).includes(workspace.id)).to.be
|
||||
.false
|
||||
})
|
||||
it('ignores non expired trial plans', async () => {
|
||||
const workspace = await createAndStoreTestWorkspace()
|
||||
await upsertTrialWorkspacePlan({
|
||||
workspacePlan: {
|
||||
name: 'starter',
|
||||
status: 'trial',
|
||||
workspaceId: workspace.id,
|
||||
createdAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
|
||||
numberOfDays: 1
|
||||
})
|
||||
expect(expiredPlans.map((p) => p.workspaceId).includes(workspace.id)).to.be
|
||||
.false
|
||||
})
|
||||
it('changes status to expired for expired trial plans', async () => {
|
||||
const workspace1 = await createAndStoreTestWorkspace()
|
||||
await upsertTrialWorkspacePlan({
|
||||
workspacePlan: {
|
||||
name: 'starter',
|
||||
status: 'trial',
|
||||
workspaceId: workspace1.id,
|
||||
createdAt: new Date(2023, 0, 1)
|
||||
}
|
||||
})
|
||||
|
||||
const workspace2 = await createAndStoreTestWorkspace()
|
||||
await upsertTrialWorkspacePlan({
|
||||
workspacePlan: {
|
||||
name: 'starter',
|
||||
status: 'trial',
|
||||
workspaceId: workspace2.id,
|
||||
createdAt: new Date(2023, 0, 1)
|
||||
}
|
||||
})
|
||||
|
||||
const workspace3 = await createAndStoreTestWorkspace()
|
||||
const workspace3Plan = {
|
||||
name: 'starter',
|
||||
status: 'trial',
|
||||
workspaceId: workspace3.id,
|
||||
createdAt: new Date()
|
||||
} as const
|
||||
await upsertTrialWorkspacePlan({
|
||||
workspacePlan: workspace3Plan
|
||||
})
|
||||
|
||||
const expiredPlans = await changeExpiredTrialWorkspacePlanStatuses({
|
||||
numberOfDays: 1
|
||||
})
|
||||
const expiredWorkspaceIds = expiredPlans.map((p) => p.workspaceId)
|
||||
expect(expiredWorkspaceIds.includes(workspace1.id)).to.be.true
|
||||
expect(expiredWorkspaceIds.includes(workspace2.id)).to.be.true
|
||||
expect(expiredWorkspaceIds.includes(workspace3.id)).to.be.false
|
||||
expiredPlans.forEach((expiredPlan) => {
|
||||
expect(expiredPlan.status).to.equal('expired')
|
||||
})
|
||||
|
||||
const storedWorkspacePlan = await getWorkspacePlan({
|
||||
workspaceId: workspace3.id
|
||||
})
|
||||
expect(storedWorkspacePlan).deep.equal(workspace3Plan)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('checkoutSessions', () => {
|
||||
describe('saveCheckoutSessionFactory creates a function that,', () => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const gatekeeperEventNamespace = 'gatekeeper' as const
|
||||
|
||||
const eventPrefix = `${gatekeeperEventNamespace}.` as const
|
||||
|
||||
export const GatekeeperEvents = {
|
||||
WorkspaceTrialExpired: `${eventPrefix}workspace-trial-expired`
|
||||
} as const
|
||||
|
||||
export type GatekeeperEventPayloads = {
|
||||
[GatekeeperEvents.WorkspaceTrialExpired]: { workspaceId: string }
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
WorkspaceEventsPayloads,
|
||||
workspaceEventNamespace
|
||||
} from '@/modules/workspacesCore/domain/events'
|
||||
import {
|
||||
gatekeeperEventNamespace,
|
||||
GatekeeperEventPayloads
|
||||
} from '@/modules/gatekeeperCore/domain/events'
|
||||
import { MaybeAsync } from '@speckle/shared'
|
||||
import { UnionToIntersection } from 'type-fest'
|
||||
|
||||
@@ -28,6 +32,7 @@ type TestEventsPayloads = {
|
||||
type EventsByNamespace = {
|
||||
test: TestEventsPayloads
|
||||
[workspaceEventNamespace]: WorkspaceEventsPayloads
|
||||
[gatekeeperEventNamespace]: GatekeeperEventPayloads
|
||||
[serverinvitesEventNamespace]: ServerInvitesEventsPayloads
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { initializeModuleEventEmitter } from '@/modules/shared/services/moduleEventEmitterSetup'
|
||||
import {
|
||||
WorkspaceEvents,
|
||||
WorkspaceEventsPayloads
|
||||
} from '@/modules/workspacesCore/domain/events'
|
||||
|
||||
const { emit, listen } = initializeModuleEventEmitter<WorkspaceEventsPayloads>({
|
||||
moduleName: 'workspaces'
|
||||
})
|
||||
|
||||
export const WorkspacesEmitter = { emit, listen, events: WorkspaceEvents }
|
||||
@@ -3,15 +3,15 @@ import { WorkspaceRoles } from '@speckle/shared'
|
||||
|
||||
export const workspaceEventNamespace = 'workspace' as const
|
||||
|
||||
const workspaceEventPrefix = `${workspaceEventNamespace}.` as const
|
||||
const eventPrefix = `${workspaceEventNamespace}.` as const
|
||||
|
||||
export const WorkspaceEvents = {
|
||||
Authorized: `${workspaceEventPrefix}authorized`,
|
||||
Created: `${workspaceEventPrefix}created`,
|
||||
Updated: `${workspaceEventPrefix}updated`,
|
||||
RoleDeleted: `${workspaceEventPrefix}role-deleted`,
|
||||
RoleUpdated: `${workspaceEventPrefix}role-updated`,
|
||||
JoinedFromDiscovery: `${workspaceEventPrefix}joined-from-discovery`
|
||||
Authorized: `${eventPrefix}authorized`,
|
||||
Created: `${eventPrefix}created`,
|
||||
Updated: `${eventPrefix}updated`,
|
||||
RoleDeleted: `${eventPrefix}role-deleted`,
|
||||
RoleUpdated: `${eventPrefix}role-updated`,
|
||||
JoinedFromDiscovery: `${eventPrefix}joined-from-discovery`
|
||||
} as const
|
||||
|
||||
export type WorkspaceEvents = (typeof WorkspaceEvents)[keyof typeof WorkspaceEvents]
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { initializeModuleEventEmitter } from '@/modules/shared/services/moduleEventEmitterSetup'
|
||||
import {
|
||||
WorkspaceEvents,
|
||||
WorkspaceEventsPayloads
|
||||
} from '@/modules/workspacesCore/domain/events'
|
||||
|
||||
const { emit, listen } = initializeModuleEventEmitter<WorkspaceEventsPayloads>({
|
||||
moduleName: 'workspaces'
|
||||
})
|
||||
|
||||
export const WorkspacesEmitter = { emit, listen, events: WorkspaceEvents }
|
||||
Reference in New Issue
Block a user