From 45c626323ffda42a1da4efbc2b7673d863a964ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 10 Dec 2024 19:20:02 +0100 Subject: [PATCH] feat(gatekeeper): expire trail workspace plans --- mise.toml | 2 + .../modules/gatekeeper/domain/operations.ts | 5 + packages/server/modules/gatekeeper/index.ts | 74 +++++++++++++-- .../gatekeeper/repositories/billing.ts | 12 +++ .../intergration/billingRepositories.spec.ts | 92 ++++++++++++++++++- .../modules/gatekeeperCore/domain/events.ts | 11 +++ .../modules/shared/services/eventBus.ts | 5 + .../modules/workspaces/events/emitter.ts | 11 --- .../modules/workspacesCore/domain/events.ts | 14 +-- .../workspacesCore/services/eventEmitter.ts | 11 --- 10 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 mise.toml create mode 100644 packages/server/modules/gatekeeperCore/domain/events.ts delete mode 100644 packages/server/modules/workspaces/events/emitter.ts delete mode 100644 packages/server/modules/workspacesCore/services/eventEmitter.ts diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..46aafd5e8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = '22' \ No newline at end of file diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts index 78cf4fef1..c4da4dc7d 100644 --- a/packages/server/modules/gatekeeper/domain/operations.ts +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -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 + +export type ChangeExpiredTrialWorkspacePlanStatuses = (args: { + numberOfDays: number +}) => Promise diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 45e9032bf..a49430039 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -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 diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 5fb44ba03..eb9c38771 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -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 }) => { diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 03f12477b..8b0cf6403 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -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,', () => { diff --git a/packages/server/modules/gatekeeperCore/domain/events.ts b/packages/server/modules/gatekeeperCore/domain/events.ts new file mode 100644 index 000000000..4f7511bdc --- /dev/null +++ b/packages/server/modules/gatekeeperCore/domain/events.ts @@ -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 } +} diff --git a/packages/server/modules/shared/services/eventBus.ts b/packages/server/modules/shared/services/eventBus.ts index 31351098a..2ea5529e3 100644 --- a/packages/server/modules/shared/services/eventBus.ts +++ b/packages/server/modules/shared/services/eventBus.ts @@ -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 } diff --git a/packages/server/modules/workspaces/events/emitter.ts b/packages/server/modules/workspaces/events/emitter.ts deleted file mode 100644 index 8406a7974..000000000 --- a/packages/server/modules/workspaces/events/emitter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { initializeModuleEventEmitter } from '@/modules/shared/services/moduleEventEmitterSetup' -import { - WorkspaceEvents, - WorkspaceEventsPayloads -} from '@/modules/workspacesCore/domain/events' - -const { emit, listen } = initializeModuleEventEmitter({ - moduleName: 'workspaces' -}) - -export const WorkspacesEmitter = { emit, listen, events: WorkspaceEvents } diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 2ae6b2eea..40700be33 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -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] diff --git a/packages/server/modules/workspacesCore/services/eventEmitter.ts b/packages/server/modules/workspacesCore/services/eventEmitter.ts deleted file mode 100644 index 8406a7974..000000000 --- a/packages/server/modules/workspacesCore/services/eventEmitter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { initializeModuleEventEmitter } from '@/modules/shared/services/moduleEventEmitterSetup' -import { - WorkspaceEvents, - WorkspaceEventsPayloads -} from '@/modules/workspacesCore/domain/events' - -const { emit, listen } = initializeModuleEventEmitter({ - moduleName: 'workspaces' -}) - -export const WorkspacesEmitter = { emit, listen, events: WorkspaceEvents }