Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gatekeeper): expire trial workspace plans #3669

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
node = '22'
5 changes: 5 additions & 0 deletions packages/server/modules/gatekeeper/domain/operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { WorkspacePlan } from '@/modules/gatekeeper/domain/billing'
import { WorkspaceFeatureName } from '@/modules/gatekeeper/domain/workspacePricing'

export type CanWorkspaceAccessFeature = (args: {
Expand All @@ -8,3 +9,7 @@ export type CanWorkspaceAccessFeature = (args: {
export type WorkspaceFeatureAccessFunction = (args: {
workspaceId: string
}) => Promise<boolean>

export type ChangeExpiredTrialWorkspacePlanStatuses = (args: {
numberOfDays: number
}) => Promise<WorkspacePlan[]>
74 changes: 65 additions & 9 deletions packages/server/modules/gatekeeper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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({
Expand All @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highlighting just in case (appears to be deferred for another PR)

})
}

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 = {
Expand All @@ -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,
Expand All @@ -109,7 +163,9 @@ const gatekeeperModule: SpeckleModule = {
},
async shutdown() {
if (quitListeners) quitListeners()
if (scheduledTask) scheduledTask.stop()
scheduledTasks.forEach((task) => {
task.stop()
})
}
}
export = gatekeeperModule
12 changes: 12 additions & 0 deletions packages/server/modules/gatekeeper/repositories/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory,
getWorkspaceSubscriptionsPastBillingCycleEndFactory
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
changeExpiredTrialWorkspacePlanStatusesFactory,
upsertTrialWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import {
createTestSubscriptionData,
Expand All @@ -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 })
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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,', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/server/modules/gatekeeperCore/domain/events.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
5 changes: 5 additions & 0 deletions packages/server/modules/shared/services/eventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -28,6 +32,7 @@ type TestEventsPayloads = {
type EventsByNamespace = {
test: TestEventsPayloads
[workspaceEventNamespace]: WorkspaceEventsPayloads
[gatekeeperEventNamespace]: GatekeeperEventPayloads
[serverinvitesEventNamespace]: ServerInvitesEventsPayloads
}

Expand Down
11 changes: 0 additions & 11 deletions packages/server/modules/workspaces/events/emitter.ts

This file was deleted.

14 changes: 7 additions & 7 deletions packages/server/modules/workspacesCore/domain/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 0 additions & 11 deletions packages/server/modules/workspacesCore/services/eventEmitter.ts

This file was deleted.