From 96bfedefe8cbea10342e95cef2892568f544dcf2 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 18 Mar 2025 13:05:19 +0100 Subject: [PATCH 1/6] chore(gatekeeper): take end billing cycle date from stripe --- .../server/modules/gatekeeper/clients/stripe.ts | 3 ++- .../server/modules/gatekeeper/domain/billing.ts | 10 +++++----- .../server/modules/gatekeeper/services/checkout.ts | 13 +------------ packages/server/modules/gatekeeper/tests/helpers.ts | 7 +++++-- .../modules/workspaces/tests/helpers/creation.ts | 5 ++++- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 6f8b26d28..c73127c89 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -66,6 +66,7 @@ export const parseSubscriptionData = ( cancelAt: stripeSubscription.cancel_at ? new Date(stripeSubscription.cancel_at * 1000) : null, + currentPeriodEnd: stripeSubscription.current_period_end, products: stripeSubscription.items.data.map((subscriptionItem) => { const productId = typeof subscriptionItem.price.product === 'string' @@ -84,7 +85,7 @@ export const parseSubscriptionData = ( } }) } - return subscriptionData + return SubscriptionData.parse(subscriptionData) } // this should be a reconcile subscriptions, we keep an accurate state in the DB diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 9795b5822..7e3a2dd94 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -113,7 +113,7 @@ const subscriptionProduct = z.object({ export type SubscriptionProduct = z.infer -export const subscriptionData = z.object({ +export const SubscriptionData = z.object({ subscriptionId: z.string().min(1), customerId: z.string().min(1), cancelAt: z.date().nullable(), @@ -127,8 +127,11 @@ export const subscriptionData = z.object({ z.literal('unpaid'), z.literal('paused') ]), - products: subscriptionProduct.array() + products: subscriptionProduct.array(), + currentPeriodEnd: z.coerce.date() }) +// this abstracts the stripe sub data +export type SubscriptionData = z.infer export const calculateSubscriptionSeats = ({ subscriptionData, @@ -147,9 +150,6 @@ export const calculateSubscriptionSeats = ({ return { guest: guestProduct?.quantity || 0, plan: planProduct?.quantity || 0 } } -// this abstracts the stripe sub data -export type SubscriptionData = z.infer - export type UpsertWorkspaceSubscription = (args: { workspaceSubscription: WorkspaceSubscription }) => Promise diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 1d6ab0f5b..f28030db1 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -66,18 +66,7 @@ export const completeCheckoutSessionFactory = const subscriptionData = await getSubscriptionData({ subscriptionId }) - const currentBillingCycleEnd = new Date() - switch (checkoutSession.billingInterval) { - case 'monthly': - currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 1) - break - case 'yearly': - currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 12) - break - - default: - throwUncoveredError(checkoutSession.billingInterval) - } + const currentBillingCycleEnd = subscriptionData.currentPeriodEnd const workspaceSubscription = { createdAt: new Date(), diff --git a/packages/server/modules/gatekeeper/tests/helpers.ts b/packages/server/modules/gatekeeper/tests/helpers.ts index b55bb5205..6fec9ef02 100644 --- a/packages/server/modules/gatekeeper/tests/helpers.ts +++ b/packages/server/modules/gatekeeper/tests/helpers.ts @@ -8,7 +8,9 @@ import { assign } from 'lodash' export const createTestSubscriptionData = ( overrides: Partial = {} ): SubscriptionData => { - const defaultValues: SubscriptionData = { + const aMonthFromNow = new Date() + aMonthFromNow.setMonth(new Date().getMonth() + 1) + const defaultValues = { cancelAt: null, customerId: cryptoRandomString({ length: 10 }), products: [ @@ -20,7 +22,8 @@ export const createTestSubscriptionData = ( } ], status: 'active', - subscriptionId: cryptoRandomString({ length: 10 }) + subscriptionId: cryptoRandomString({ length: 10 }), + currentPeriodEnd: aMonthFromNow.toISOString() } return assign(defaultValues, overrides) } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index c71de513b..0fd4b7171 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -201,6 +201,8 @@ export const createTestWorkspace = async ( } if (addSubscription) { + const aMonthFromNow = new Date() + aMonthFromNow.setMonth(new Date().getMonth() + 1) await upsertSubscription({ workspaceSubscription: { workspaceId: newWorkspace.id, @@ -213,7 +215,8 @@ export const createTestWorkspace = async ( customerId: cryptoRandomString({ length: 10 }), cancelAt: null, status: 'active', - products: [] + products: [], + currentPeriodEnd: aMonthFromNow } } }) From 194a1fe6074d97b2e35d58b8fbcab8d71079a067 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Thu, 20 Mar 2025 16:29:32 +0100 Subject: [PATCH 2/6] feat(gatekeeper): downscale new plans --- packages/server/modules/gatekeeper/index.ts | 47 +++- .../gatekeeper/repositories/billing.ts | 45 +++- .../gatekeeper/services/subscriptions.ts | 123 +-------- .../manageSubscriptionDownscale.ts | 240 ++++++++++++++++++ .../intergration/billingRepositories.spec.ts | 19 +- .../tests/unit/subscriptions.spec.ts | 204 +++++++++++++-- 6 files changed, 523 insertions(+), 155 deletions(-) create mode 100644 packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 88b1d1fd6..6ee4a6a90 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -14,23 +14,23 @@ import { acquireTaskLockFactory, releaseTaskLockFactory } from '@/modules/core/repositories/scheduledTasks' -import { - downscaleWorkspaceSubscriptionFactory, - manageSubscriptionDownscaleFactory -} from '@/modules/gatekeeper/services/subscriptions' import { changeExpiredTrialWorkspacePlanStatusesFactory, getWorkspacePlanByProjectIdFactory, getWorkspacePlanFactory, getWorkspacesByPlanAgeFactory, - getWorkspaceSubscriptionsPastBillingCycleEndFactory, + getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans, + getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans, upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' import { countWorkspaceRoleWithOptionalProjectRoleFactory, getWorkspaceCollaboratorsFactory } from '@/modules/workspaces/repositories/workspaces' -import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' +import { + getSubscriptionDataFactory, + reconcileWorkspaceSubscriptionFactory +} from '@/modules/gatekeeper/clients/stripe' import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus' import { sendWorkspaceTrialExpiresEmailFactory } from '@/modules/gatekeeper/services/trialEmails' @@ -42,6 +42,13 @@ import coreModule from '@/modules/core/index' import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license' +import { + downscaleWorkspaceSubscriptionFactoryNew, + downscaleWorkspaceSubscriptionFactoryOld, + manageSubscriptionDownscaleFactoryNew, + manageSubscriptionDownscaleFactoryOld +} from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale' +import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -58,16 +65,31 @@ const scheduleWorkspaceSubscriptionDownscale = ({ }) => { const stripe = getStripeClient() - const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({ - downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({ + const manageSubscriptionDownscaleOld = manageSubscriptionDownscaleFactoryOld({ + downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryOld({ countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }), getWorkspacePlan: getWorkspacePlanFactory({ db }), reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }), getWorkspacePlanProductId }), - getWorkspaceSubscriptions: getWorkspaceSubscriptionsPastBillingCycleEndFactory({ - db + getWorkspaceSubscriptions: + getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({ + db + }), + updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + }) + const manageSubscriptionDownscaleNew = manageSubscriptionDownscaleFactoryNew({ + downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryNew({ + countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), + reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }), + getWorkspacePlanProductId }), + getWorkspaceSubscriptions: + getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans({ + db + }), + getSubscriptionData: getSubscriptionDataFactory({ stripe }), updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) }) @@ -76,7 +98,10 @@ const scheduleWorkspaceSubscriptionDownscale = ({ cronExpression, 'WorkspaceSubscriptionDownscale', async (_scheduledTime, { logger }) => { - await manageSubscriptionDownscale({ logger }) + await Promise.all([ + manageSubscriptionDownscaleOld({ logger }), // Only takes old plans subscriptions + manageSubscriptionDownscaleNew({ logger }) // Only takes new plans subscriptions + ]) } ) } diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index 23651121b..c7663eee1 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -27,6 +27,7 @@ import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing' import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper' import { Workspace } from '@/modules/workspacesCore/domain/types' import { Workspaces } from '@/modules/workspacesCore/helpers/db' +import { PaidWorkspacePlansNew, PaidWorkspacePlansOld } from '@speckle/shared' import { Knex } from 'knex' import { omit } from 'lodash' @@ -212,15 +213,55 @@ export const getWorkspaceSubscriptionBySubscriptionIdFactory = return subscription ?? null } -export const getWorkspaceSubscriptionsPastBillingCycleEndFactory = +const newPlans = Object.values(PaidWorkspacePlansNew) +const oldPlans = Object.values(PaidWorkspacePlansOld) + +export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans = ({ db }: { db: Knex }): GetWorkspaceSubscriptions => async () => { const cycleEnd = new Date() cycleEnd.setMinutes(cycleEnd.getMinutes() + 5) return await tables .workspaceSubscriptions(db) - .select() + .join( + WorkspacePlans.name, + WorkspacePlans.col.workspaceId, + 'workspace_subscriptions.workspaceId' + ) + .whereIn(WorkspacePlans.col.name, oldPlans) .where('currentBillingCycleEnd', '<', cycleEnd) + .select([ + 'workspace_subscriptions.workspaceId', + 'workspace_subscriptions.createdAt', + 'workspace_subscriptions.updatedAt', + 'workspace_subscriptions.currentBillingCycleEnd', + 'workspace_subscriptions.billingInterval', + 'workspace_subscriptions.subscriptionData' + ]) + } + +export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans = + ({ db }: { db: Knex }): GetWorkspaceSubscriptions => + async () => { + const cycleEnd = new Date() + cycleEnd.setMinutes(cycleEnd.getMinutes() + 5) + return await tables + .workspaceSubscriptions(db) + .join( + WorkspacePlans.name, + WorkspacePlans.col.workspaceId, + 'workspace_subscriptions.workspaceId' + ) + .whereIn(WorkspacePlans.col.name, newPlans) + .where('currentBillingCycleEnd', '<', cycleEnd) + .select([ + 'workspace_subscriptions.workspaceId', + 'workspace_subscriptions.createdAt', + 'workspace_subscriptions.updatedAt', + 'workspace_subscriptions.currentBillingCycleEnd', + 'workspace_subscriptions.billingInterval', + 'workspace_subscriptions.subscriptionData' + ]) } export const getWorkspacePlanByProjectIdFactory = diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index a069545fd..6440c5e7d 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -1,18 +1,15 @@ -import type { Logger } from '@/observability/logging' import { GetWorkspacePlan, GetWorkspacePlanPriceId, GetWorkspacePlanProductId, GetWorkspaceSubscription, GetWorkspaceSubscriptionBySubscriptionId, - GetWorkspaceSubscriptions, ReconcileSubscriptionData, SubscriptionData, SubscriptionDataInput, UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription, - WorkspaceSeatType, - WorkspaceSubscription + WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanMismatchError, @@ -27,9 +24,7 @@ import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared' -import { cloneDeep, isEqual, sum } from 'lodash' -import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers' -import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd' +import { cloneDeep, sum } from 'lodash' import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' export const handleSubscriptionUpdateFactory = @@ -297,117 +292,3 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryOld = prorationBehavior: 'create_prorations' }) } - -type DownscaleWorkspaceSubscription = (args: { - workspaceSubscription: WorkspaceSubscription -}) => Promise - -export const downscaleWorkspaceSubscriptionFactory = - ({ - getWorkspacePlan, - countWorkspaceRole, - getWorkspacePlanProductId, - reconcileSubscriptionData - }: { - getWorkspacePlan: GetWorkspacePlan - countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole - getWorkspacePlanProductId: GetWorkspacePlanProductId - reconcileSubscriptionData: ReconcileSubscriptionData - }): DownscaleWorkspaceSubscription => - async ({ workspaceSubscription }) => { - const workspaceId = workspaceSubscription.workspaceId - - const workspacePlan = await getWorkspacePlan({ workspaceId }) - if (!workspacePlan) throw new WorkspacePlanNotFoundError() - - switch (workspacePlan.name) { - case 'team': - case 'pro': - // Cause seat types matter, a future issue - throw new NotImplementedError() - case 'starter': - case 'plus': - case 'business': - break - case 'unlimited': - case 'academia': - case 'starterInvoiced': - case 'plusInvoiced': - case 'businessInvoiced': - case 'free': - throw new WorkspacePlanMismatchError() - default: - throwUncoveredError(workspacePlan) - } - - if (workspacePlan.status === 'canceled') return false - - // TODO: Guests will be able to have a paid seat - 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, prorationBehavior: 'none' }) - return true - } - return false - } - -export const manageSubscriptionDownscaleFactory = - ({ - getWorkspaceSubscriptions, - downscaleWorkspaceSubscription, - updateWorkspaceSubscription - }: { - getWorkspaceSubscriptions: GetWorkspaceSubscriptions - downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription - updateWorkspaceSubscription: UpsertWorkspaceSubscription - }) => - async (context: { logger: Logger }) => { - const { logger } = context - const subscriptions = await getWorkspaceSubscriptions() - for (const workspaceSubscription of subscriptions) { - const log = logger.child({ workspaceId: workspaceSubscription.workspaceId }) - try { - const subDownscaled = await downscaleWorkspaceSubscription({ - workspaceSubscription - }) - if (subDownscaled) { - log.info( - 'Downscaled workspace subscription to match the current workspace team' - ) - } else { - log.info('Did not need to downscale the workspace subscription') - } - } catch (err) { - log.error({ err }, 'Failed to downscale workspace subscription') - } - const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription }) - const updatedWorkspaceSubscription = { - ...workspaceSubscription, - currentBillingCycleEnd: newBillingCycleEnd - } - await updateWorkspaceSubscription({ - workspaceSubscription: updatedWorkspaceSubscription - }) - log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') - } - } diff --git a/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts new file mode 100644 index 000000000..6ccb12618 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale.ts @@ -0,0 +1,240 @@ +import { + GetSubscriptionData, + GetWorkspacePlan, + GetWorkspacePlanProductId, + GetWorkspaceSubscriptions, + ReconcileSubscriptionData, + UpsertWorkspaceSubscription, + WorkspaceSubscription +} from '@/modules/gatekeeper/domain/billing' +import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' +import { + WorkspacePlanMismatchError, + WorkspacePlanNotFoundError +} from '@/modules/gatekeeper/errors/billing' +import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd' +import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers' +import { NotImplementedError } from '@/modules/shared/errors' +import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' +import { Logger } from '@/observability/logging' +import { throwUncoveredError } from '@speckle/shared' +import { cloneDeep, isEqual } from 'lodash' + +type DownscaleWorkspaceSubscription = (args: { + workspaceSubscription: WorkspaceSubscription +}) => Promise + +export const downscaleWorkspaceSubscriptionFactoryOld = + ({ + getWorkspacePlan, + countWorkspaceRole, + getWorkspacePlanProductId, + reconcileSubscriptionData + }: { + getWorkspacePlan: GetWorkspacePlan + countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole + getWorkspacePlanProductId: GetWorkspacePlanProductId + reconcileSubscriptionData: ReconcileSubscriptionData + }): DownscaleWorkspaceSubscription => + async ({ workspaceSubscription }) => { + const workspaceId = workspaceSubscription.workspaceId + + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + // Cause seat types matter, a future issue + throw new NotImplementedError() + case 'starter': + case 'plus': + case 'business': + break + case 'unlimited': + case 'academia': + case 'starterInvoiced': + case 'plusInvoiced': + case 'businessInvoiced': + case 'free': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + if (workspacePlan.status === 'canceled') return false + + // TODO: Guests will be able to have a paid seat + 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, prorationBehavior: 'none' }) + return true + } + return false + } + +export const downscaleWorkspaceSubscriptionFactoryNew = + ({ + getWorkspacePlan, + countSeatsByTypeInWorkspace, + getWorkspacePlanProductId, + reconcileSubscriptionData + }: { + getWorkspacePlan: GetWorkspacePlan + countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace + getWorkspacePlanProductId: GetWorkspacePlanProductId + reconcileSubscriptionData: ReconcileSubscriptionData + }): DownscaleWorkspaceSubscription => + async ({ workspaceSubscription }) => { + const workspaceId = workspaceSubscription.workspaceId + + const workspacePlan = await getWorkspacePlan({ workspaceId }) + if (!workspacePlan) throw new WorkspacePlanNotFoundError() + + switch (workspacePlan.name) { + case 'team': + case 'pro': + break + case 'starter': + case 'plus': + case 'business': + case 'unlimited': + case 'academia': + case 'starterInvoiced': + case 'plusInvoiced': + case 'businessInvoiced': + case 'free': + throw new WorkspacePlanMismatchError() + default: + throwUncoveredError(workspacePlan) + } + + if (workspacePlan.status === 'canceled') return false + + const editorsCount = await countSeatsByTypeInWorkspace({ + workspaceId, + type: 'editor' + }) + + const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData) + + mutateSubscriptionDataWithNewValidSeatNumbers({ + seatCount: editorsCount, + workspacePlan: workspacePlan.name, + getWorkspacePlanProductId, + subscriptionData + }) + + if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) { + await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' }) + return true + } + return false + } + +export const manageSubscriptionDownscaleFactoryOld = + ({ + getWorkspaceSubscriptions, + downscaleWorkspaceSubscription, + updateWorkspaceSubscription + }: { + getWorkspaceSubscriptions: GetWorkspaceSubscriptions + downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription + updateWorkspaceSubscription: UpsertWorkspaceSubscription + }) => + async (context: { logger: Logger }) => { + const { logger } = context + const subscriptions = await getWorkspaceSubscriptions() + for (const workspaceSubscription of subscriptions) { + const log = logger.child({ workspaceId: workspaceSubscription.workspaceId }) + try { + const subDownscaled = await downscaleWorkspaceSubscription({ + workspaceSubscription + }) + if (subDownscaled) { + log.info( + 'Downscaled workspace subscription to match the current workspace team' + ) + } else { + log.info('Did not need to downscale the workspace subscription') + } + } catch (err) { + log.error({ err }, 'Failed to downscale workspace subscription') + } + const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription }) + const updatedWorkspaceSubscription = { + ...workspaceSubscription, + currentBillingCycleEnd: newBillingCycleEnd + } + await updateWorkspaceSubscription({ + workspaceSubscription: updatedWorkspaceSubscription + }) + log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') + } + } + +export const manageSubscriptionDownscaleFactoryNew = + ({ + getWorkspaceSubscriptions, + downscaleWorkspaceSubscription, + updateWorkspaceSubscription, + getSubscriptionData + }: { + getWorkspaceSubscriptions: GetWorkspaceSubscriptions + downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription + updateWorkspaceSubscription: UpsertWorkspaceSubscription + getSubscriptionData: GetSubscriptionData + }) => + async (context: { logger: Logger }) => { + const { logger } = context + const subscriptions = await getWorkspaceSubscriptions() + for (const workspaceSubscription of subscriptions) { + const log = logger.child({ workspaceId: workspaceSubscription.workspaceId }) + try { + //TODO: + const subDownscaled = await downscaleWorkspaceSubscription({ + workspaceSubscription + }) + if (subDownscaled) { + log.info( + 'Downscaled workspace subscription to match the current workspace team' + ) + } else { + log.info('Did not need to downscale the workspace subscription') + } + } catch (err) { + log.error({ err }, 'Failed to downscale workspace subscription') + } + const subscriptionData = await getSubscriptionData( + workspaceSubscription.subscriptionData + ) + const updatedWorkspaceSubscription = { + ...workspaceSubscription, + currentBillingCycleEnd: subscriptionData.currentPeriodEnd + } + await updateWorkspaceSubscription({ + workspaceSubscription: updatedWorkspaceSubscription + }) + log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end') + } + } diff --git a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts index 10ed8c67e..e4793515f 100644 --- a/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts +++ b/packages/server/modules/gatekeeper/tests/intergration/billingRepositories.spec.ts @@ -10,10 +10,11 @@ import { upsertPaidWorkspacePlanFactory, getWorkspaceSubscriptionFactory, getWorkspaceSubscriptionBySubscriptionIdFactory, - getWorkspaceSubscriptionsPastBillingCycleEndFactory, changeExpiredTrialWorkspacePlanStatusesFactory, upsertTrialWorkspacePlanFactory, - getWorkspacesByPlanAgeFactory + getWorkspacesByPlanAgeFactory, + getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans, + upsertWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { createTestSubscriptionData, @@ -43,8 +44,8 @@ const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db }) const getWorkspaceSubscriptionBySubscriptionId = getWorkspaceSubscriptionBySubscriptionIdFactory({ db }) -const getSubscriptionsAboutToEndBillingCycle = - getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db }) +const getSubscriptionsAboutToEndBillingCycleOld = + getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({ db }) const changeExpiredTrialWorkspacePlanStatuses = changeExpiredTrialWorkspacePlanStatusesFactory({ db }) @@ -526,10 +527,18 @@ describe('billing repositories @gatekeeper', () => { const workspace2Subscription = createTestWorkspaceSubscription({ workspaceId: workspace2Id }) + await upsertWorkspacePlanFactory({ db })({ + workspacePlan: { + workspaceId: workspace2Subscription.workspaceId, + name: 'plus', + status: 'valid', + createdAt: new Date() + } + }) await upsertWorkspaceSubscription({ workspaceSubscription: workspace2Subscription }) - const subscriptions = await getSubscriptionsAboutToEndBillingCycle() + const subscriptions = await getSubscriptionsAboutToEndBillingCycleOld() expect(subscriptions).deep.equalInAnyOrder([workspace2Subscription]) }) }) diff --git a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts index e3f08d820..a630ea3f4 100644 --- a/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/subscriptions.spec.ts @@ -15,10 +15,14 @@ import { import { addWorkspaceSubscriptionSeatIfNeededFactoryNew, addWorkspaceSubscriptionSeatIfNeededFactoryOld, - downscaleWorkspaceSubscriptionFactory, - handleSubscriptionUpdateFactory, - manageSubscriptionDownscaleFactory + handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' +import { + downscaleWorkspaceSubscriptionFactoryNew, + downscaleWorkspaceSubscriptionFactoryOld, + manageSubscriptionDownscaleFactoryOld +} from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale' + import { createTestSubscriptionData, createTestWorkspaceSubscription @@ -1014,13 +1018,13 @@ describe('subscriptions @gatekeeper', () => { }) }) - describe('downscaleWorkspaceSubscriptionFactory creates a function, that', () => { + describe('downscaleWorkspaceSubscriptionFactoryOld creates a function, that', () => { it('throws an error if the workspace has no plan attached to it', async () => { const subscriptionData = createTestSubscriptionData() const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData }) - const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({ getWorkspacePlan: async () => null, countWorkspaceRole: async () => { expect.fail() @@ -1044,7 +1048,7 @@ describe('subscriptions @gatekeeper', () => { subscriptionData, workspaceId }) - const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({ getWorkspacePlan: async () => ({ name: 'unlimited', workspaceId, @@ -1073,7 +1077,7 @@ describe('subscriptions @gatekeeper', () => { subscriptionData, workspaceId }) - const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({ getWorkspacePlan: async () => ({ name: 'plus', workspaceId, @@ -1109,7 +1113,7 @@ describe('subscriptions @gatekeeper', () => { workspaceId }) const workspacePlanName = 'plus' - const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({ getWorkspacePlan: async () => ({ name: workspacePlanName, workspaceId, @@ -1164,7 +1168,7 @@ describe('subscriptions @gatekeeper', () => { const workspacePlanName = 'plus' let reconciledSub: SubscriptionDataInput | undefined = undefined - const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({ + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({ getWorkspacePlan: async () => ({ name: workspacePlanName, workspaceId, @@ -1193,14 +1197,178 @@ describe('subscriptions @gatekeeper', () => { ).to.be.equal(guestQuantity / 2) }) }) - describe('manageSubscriptionDownscaleFactory, creates a function, that', () => { + describe('downscaleWorkspaceSubscriptionFactoryNew creates a function, that', () => { + it('throws an error if the workspace has no plan attached to it', async () => { + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({ + getWorkspacePlan: async () => null, + countSeatsByTypeInWorkspace: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await downscaleSubscription({ workspaceSubscription }) + }) + expect(err.message).to.equal(new WorkspacePlanNotFoundError().message) + }) + it('throws an error if workspacePlan is not a paid plan', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({ + getWorkspacePlan: async () => ({ + name: 'unlimited', + workspaceId, + createdAt: new Date(), + status: 'valid' + }), + countSeatsByTypeInWorkspace: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const err = await expectToThrow(async () => { + await downscaleSubscription({ workspaceSubscription }) + }) + expect(err.message).to.equal(new WorkspacePlanMismatchError().message) + }) + it('returns if the subscription is canceled', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData() + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({ + getWorkspacePlan: async () => ({ + name: 'pro', + workspaceId, + createdAt: new Date(), + status: 'canceled' + }), + countSeatsByTypeInWorkspace: async () => { + expect.fail() + }, + getWorkspacePlanProductId: () => { + expect.fail() + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + const hasDownscaled = await downscaleSubscription({ workspaceSubscription }) + expect(hasDownscaled).to.be.false + }) + it('does not reconcile the subscription seats did not change', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const priceId = cryptoRandomString({ length: 10 }) + const productId = cryptoRandomString({ length: 10 }) + const quantity = 10 + const subscriptionItemId = cryptoRandomString({ length: 10 }) + const subscriptionData = createTestSubscriptionData({ + products: [{ priceId, productId, quantity, subscriptionItemId }] + }) + const workspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + billingInterval: 'monthly', + currentBillingCycleEnd: new Date(2034, 11, 5), + workspaceId + }) + const workspacePlanName = 'pro' + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({ + getWorkspacePlan: async () => ({ + name: workspacePlanName, + workspaceId, + createdAt: new Date(), + status: 'valid' + }), + countSeatsByTypeInWorkspace: async () => { + return 10 + }, + getWorkspacePlanProductId: ({ workspacePlan }) => { + return workspacePlan === workspacePlanName + ? productId + : cryptoRandomString({ length: 10 }) + }, + reconcileSubscriptionData: async () => { + expect.fail() + } + }) + await downscaleSubscription({ workspaceSubscription }) + }) + it('reconciles the subscription to the new seat values', async () => { + const workspaceId = cryptoRandomString({ length: 10 }) + const proPriceId = cryptoRandomString({ length: 10 }) + const proProductId = cryptoRandomString({ length: 10 }) + const proQuantity = 10 + const proSubscriptionItemId = cryptoRandomString({ length: 10 }) + + const subscriptionData = createTestSubscriptionData({ + products: [ + { + priceId: proPriceId, + productId: proProductId, + quantity: proQuantity, + subscriptionItemId: proSubscriptionItemId + } + ] + }) + const testWorkspaceSubscription = createTestWorkspaceSubscription({ + subscriptionData, + workspaceId + }) + const workspacePlanName = 'pro' + + let reconciledSub: SubscriptionDataInput | undefined = undefined + const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({ + getWorkspacePlan: async () => ({ + name: workspacePlanName, + workspaceId, + createdAt: new Date(), + status: 'valid' + }), + countSeatsByTypeInWorkspace: async () => { + return 5 + }, + getWorkspacePlanProductId: () => { + return proProductId + }, + reconcileSubscriptionData: async ({ subscriptionData }) => { + reconciledSub = subscriptionData + } + }) + await downscaleSubscription({ workspaceSubscription: testWorkspaceSubscription }) + + expect( + reconciledSub!.products.find((p) => p.productId === proProductId)?.quantity + ).to.be.equal(5) + }) + }) + describe('manageSubscriptionDownscaleFactoryOld, creates a function, that', () => { it('still updates the monthly billing cycle end, even if subscription reconciliation fails', async () => { const testWorkspaceSubscription = createTestWorkspaceSubscription({ billingInterval: 'monthly', currentBillingCycleEnd: new Date(2034, 11, 5) }) let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined - await manageSubscriptionDownscaleFactory({ + await manageSubscriptionDownscaleFactoryOld({ getWorkspaceSubscriptions: async () => [testWorkspaceSubscription], downscaleWorkspaceSubscription: async () => { throw new Error('kabumm') @@ -1222,7 +1390,7 @@ describe('subscriptions @gatekeeper', () => { currentBillingCycleEnd: new Date(2034, 11, 5) }) let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined - await manageSubscriptionDownscaleFactory({ + await manageSubscriptionDownscaleFactoryOld({ getWorkspaceSubscriptions: async () => [testWorkspaceSubscription], downscaleWorkspaceSubscription: async () => { throw new Error('kabumm') @@ -1593,7 +1761,8 @@ describe('subscriptions @gatekeeper', () => { customerId: cryptoRandomString({ length: 10 }), subscriptionId: cryptoRandomString({ length: 10 }), status: 'active', - products: [] + products: [], + currentPeriodEnd: new Date() } const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData @@ -1657,7 +1826,8 @@ describe('subscriptions @gatekeeper', () => { quantity: 20, subscriptionItemId: cryptoRandomString({ length: 10 }) } - ] + ], + currentPeriodEnd: new Date() } const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData, @@ -2044,7 +2214,8 @@ describe('subscriptions @gatekeeper', () => { customerId: cryptoRandomString({ length: 10 }), subscriptionId: cryptoRandomString({ length: 10 }), status: 'active', - products: [] + products: [], + currentPeriodEnd: new Date() } const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData @@ -2102,7 +2273,8 @@ describe('subscriptions @gatekeeper', () => { quantity: 10, subscriptionItemId: cryptoRandomString({ length: 10 }) } - ] + ], + currentPeriodEnd: new Date() } const workspaceSubscription = createTestWorkspaceSubscription({ subscriptionData, From 38fd761fe32ad458ab54fef56195b34fbdf1f385 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Thu, 20 Mar 2025 18:57:52 +0100 Subject: [PATCH 3/6] fix(gatekeeper): fix date format in subscription parse --- packages/server/modules/gatekeeper/clients/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index c73127c89..5b6a256a2 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -66,7 +66,7 @@ export const parseSubscriptionData = ( cancelAt: stripeSubscription.cancel_at ? new Date(stripeSubscription.cancel_at * 1000) : null, - currentPeriodEnd: stripeSubscription.current_period_end, + currentPeriodEnd: stripeSubscription.current_period_end * 1000, // this value arrives as a UNIX timestamp products: stripeSubscription.items.data.map((subscriptionItem) => { const productId = typeof subscriptionItem.price.product === 'string' From b1c9d8b2d451feda42749b0bf53ec8eb8bdfa7f2 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Fri, 21 Mar 2025 11:14:34 +0100 Subject: [PATCH 4/6] feat(gatekeeper): on invoice created trigger downscale --- .../server/modules/gatekeeper/rest/billing.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index 5aefe102e..1b681cd53 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -22,6 +22,7 @@ import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { getStripeClient } from '@/modules/gatekeeper/stripe' import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions' import { getEventBus } from '@/modules/shared/services/eventBus' +import { SubscriptionData } from '@/modules/gatekeeper/domain/billing' export const getBillingRouter = (): Router => { const router = Router() @@ -144,6 +145,19 @@ export const getBillingRouter = (): Router => { })({ subscriptionData: parseSubscriptionData(event.data.object) }) break + case 'invoice.created': + const subscriptionData = await getSubscriptionFromEventFactory({ stripe })( + event + ) + if (!subscriptionData) break + await handleSubscriptionUpdateFactory({ + getWorkspacePlan: getWorkspacePlanFactory({ db }), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }), + getWorkspaceSubscriptionBySubscriptionId: + getWorkspaceSubscriptionBySubscriptionIdFactory({ db }), + upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }) + })({ subscriptionData }) + break default: break @@ -154,3 +168,18 @@ export const getBillingRouter = (): Router => { return router } + +const getSubscriptionFromEventFactory = + ({ stripe }: { stripe: Stripe }) => + async (event: Stripe.InvoiceCreatedEvent): Promise => { + const subscription = event.data.object.subscription + if (!subscription) { + return null + } + if (typeof subscription === 'string') { + return await getSubscriptionDataFactory({ stripe })({ + subscriptionId: subscription + }) + } + return parseSubscriptionData(subscription) + } From cd39e18d9baeffab35c9c29fd600e61524daeb9a Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Mon, 24 Mar 2025 15:31:02 +0100 Subject: [PATCH 5/6] chore(workspaces): fix linter --- packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts index 2efe6312a..d08988ea2 100644 --- a/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/checkout.spec.ts @@ -586,7 +586,8 @@ describe('checkout @gatekeeper', () => { } ], status: 'active', - cancelAt: null + cancelAt: null, + currentPeriodEnd: new Date() } let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined = From 800547309a12fd10e77eb08d523456317603c49b Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Mon, 24 Mar 2025 15:42:54 +0100 Subject: [PATCH 6/6] chore(workspaces): create table helper for subscriptions table --- .../gatekeeper/repositories/billing.ts | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index c7663eee1..4e5e749b4 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -38,6 +38,14 @@ const WorkspacePlans = buildTableHelper('workspace_plans', [ 'createdAt', 'updatedAt' ]) +const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [ + 'workspaceId', + 'createdAt', + 'updatedAt', + 'currentBillingCycleEnd', + 'billingInterval', + 'subscriptionData' +]) const tables = { workspaces: (db: Knex) => db('workspaces'), @@ -230,14 +238,7 @@ export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans = ) .whereIn(WorkspacePlans.col.name, oldPlans) .where('currentBillingCycleEnd', '<', cycleEnd) - .select([ - 'workspace_subscriptions.workspaceId', - 'workspace_subscriptions.createdAt', - 'workspace_subscriptions.updatedAt', - 'workspace_subscriptions.currentBillingCycleEnd', - 'workspace_subscriptions.billingInterval', - 'workspace_subscriptions.subscriptionData' - ]) + .select(WorkspaceSubscriptions.cols) } export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans = @@ -254,14 +255,7 @@ export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans = ) .whereIn(WorkspacePlans.col.name, newPlans) .where('currentBillingCycleEnd', '<', cycleEnd) - .select([ - 'workspace_subscriptions.workspaceId', - 'workspace_subscriptions.createdAt', - 'workspace_subscriptions.updatedAt', - 'workspace_subscriptions.currentBillingCycleEnd', - 'workspace_subscriptions.billingInterval', - 'workspace_subscriptions.subscriptionData' - ]) + .select(WorkspaceSubscriptions.cols) } export const getWorkspacePlanByProjectIdFactory =