Merge pull request #4236 from specklesystems/alessandro/web-2803-downscale-workspace-subscription

Alessandro/web 2803 downscale workspace subscription
This commit is contained in:
Alessandro Magionami
2025-03-26 09:14:41 +01:00
committed by GitHub
13 changed files with 565 additions and 177 deletions
@@ -66,6 +66,7 @@ export const parseSubscriptionData = (
cancelAt: stripeSubscription.cancel_at
? new Date(stripeSubscription.cancel_at * 1000)
: null,
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'
@@ -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
@@ -113,7 +113,7 @@ const subscriptionProduct = z.object({
export type SubscriptionProduct = z.infer<typeof subscriptionProduct>
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<typeof SubscriptionData>
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<typeof subscriptionData>
export type UpsertWorkspaceSubscription = (args: {
workspaceSubscription: WorkspaceSubscription
}) => Promise<void>
+36 -11
View File
@@ -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
])
}
)
}
@@ -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'
@@ -37,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<Workspace>('workspaces'),
@@ -212,15 +221,41 @@ 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(WorkspaceSubscriptions.cols)
}
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(WorkspaceSubscriptions.cols)
}
export const getWorkspacePlanByProjectIdFactory =
@@ -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<SubscriptionData | null> => {
const subscription = event.data.object.subscription
if (!subscription) {
return null
}
if (typeof subscription === 'string') {
return await getSubscriptionDataFactory({ stripe })({
subscriptionId: subscription
})
}
return parseSubscriptionData(subscription)
}
@@ -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(),
@@ -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<boolean>
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')
}
}
@@ -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<boolean>
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')
}
}
@@ -8,7 +8,9 @@ import { assign } from 'lodash'
export const createTestSubscriptionData = (
overrides: Partial<SubscriptionData> = {}
): 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)
}
@@ -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])
})
})
@@ -586,7 +586,8 @@ describe('checkout @gatekeeper', () => {
}
],
status: 'active',
cancelAt: null
cancelAt: null,
currentPeriodEnd: new Date()
}
let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined =
@@ -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,
@@ -195,6 +195,8 @@ export const createTestWorkspace = async (
}
if (addSubscription) {
const aMonthFromNow = new Date()
aMonthFromNow.setMonth(new Date().getMonth() + 1)
await upsertSubscription({
workspaceSubscription: {
workspaceId: newWorkspace.id,
@@ -207,7 +209,8 @@ export const createTestWorkspace = async (
customerId: cryptoRandomString({ length: 10 }),
cancelAt: null,
status: 'active',
products: []
products: [],
currentPeriodEnd: aMonthFromNow
}
}
})