feat(gatekeeper): expire trail workspace plans

This commit is contained in:
Gergő Jedlicska
2024-12-10 19:20:02 +01:00
parent 1104211ad7
commit 45c626323f
10 changed files with 198 additions and 39 deletions
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = '22'
@@ -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[]>
+65 -9
View File
@@ -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 }