6982023dca
* feat(gatekeeper): add per workspace feature flags * feat(workspaces): add admin api for granting and removing access to workspace features * fix(workspaces): use the correct constant name * fix(workspaces): more test type fixes * fix(shared): fix tests and types * fix(workspaces): properly use exhaustive switch statement * fix(workspaces): add new workspace plan feature to switch * fix(workspaces): use regular integer, its fine for now... * fix(workspaces): feature flag retention post checkout * fix(gatekeeper): fix upsert plan tests
1678 lines
60 KiB
TypeScript
1678 lines
60 KiB
TypeScript
import type {
|
|
SubscriptionData,
|
|
SubscriptionDataInput,
|
|
SubscriptionUpdateIntent,
|
|
WorkspaceSubscription
|
|
} from '@/modules/gatekeeper/domain/billing'
|
|
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
|
|
import {
|
|
WorkspaceNotPaidPlanError,
|
|
WorkspacePlanMismatchError,
|
|
WorkspacePlanNotFoundError,
|
|
WorkspaceSubscriptionNotFoundError
|
|
} from '@/modules/gatekeeper/errors/billing'
|
|
import {
|
|
addWorkspaceSubscriptionSeatIfNeededFactory,
|
|
getTotalSeatsCountByPlanFactory,
|
|
handleSubscriptionUpdateFactory
|
|
} from '@/modules/gatekeeper/services/subscriptions'
|
|
import { downscaleWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale'
|
|
|
|
import {
|
|
createTestSubscriptionData,
|
|
createTestWorkspaceSubscription
|
|
} from '@/modules/gatekeeper/tests/helpers'
|
|
import { expectToThrow } from '@/test/assertionHelper'
|
|
import type { WorkspacePlan } from '@speckle/shared'
|
|
import { PaidWorkspacePlans, throwUncoveredError } from '@speckle/shared'
|
|
import { expect } from 'chai'
|
|
import cryptoRandomString from 'crypto-random-string'
|
|
import { omit } from 'lodash-es'
|
|
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
|
|
import type { EventBusEmit } from '@/modules/shared/services/eventBus'
|
|
import { testLogger } from '@/observability/logging'
|
|
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
|
|
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
|
|
|
|
describe('subscriptions @gatekeeper', () => {
|
|
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
|
|
it('throws if subscription is not found and status is not incomplete', async () => {
|
|
const subscriptionData = createTestSubscriptionData()
|
|
const err = await expectToThrow(async () => {
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => null,
|
|
getWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertPaidWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
emitEvent: async () => {
|
|
expect.fail()
|
|
}
|
|
})({ subscriptionData, logger: testLogger })
|
|
})
|
|
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
|
|
})
|
|
it('returns if subscription is not found and status is incomplete', async () => {
|
|
const subscriptionData = createTestSubscriptionData()
|
|
subscriptionData.status = 'incomplete'
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => null,
|
|
getWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertPaidWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
emitEvent: async () => {
|
|
expect.fail()
|
|
}
|
|
})({ subscriptionData, logger: testLogger })
|
|
})
|
|
it('throws if workspacePlan is not found', async () => {
|
|
const subscriptionData = createTestSubscriptionData()
|
|
const err = await expectToThrow(async () => {
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () =>
|
|
createTestWorkspaceSubscription({ subscriptionData }),
|
|
getWorkspacePlan: async () => null,
|
|
upsertWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertPaidWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
emitEvent: async () => {
|
|
expect.fail()
|
|
}
|
|
})({ subscriptionData, logger: testLogger })
|
|
})
|
|
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
|
|
})
|
|
;(['unlimited', 'academia'] as const).forEach((name) =>
|
|
it(`throws for non paid workspace plan: ${name}`, async () => {
|
|
const subscriptionData = createTestSubscriptionData()
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const err = await expectToThrow(async () => {
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () =>
|
|
createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
workspaceId
|
|
}),
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
upsertWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertPaidWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
emitEvent: async () => {
|
|
expect.fail()
|
|
}
|
|
})({ subscriptionData, logger: testLogger })
|
|
})
|
|
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
|
})
|
|
)
|
|
it('sets the state to cancelationScheduled', async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status: 'active',
|
|
cancelAt: new Date(2099, 12, 31)
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
workspaceId
|
|
})
|
|
|
|
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
|
let updatedPlan: WorkspacePlan | undefined = undefined
|
|
let emittedEventName: string | undefined = undefined
|
|
let emittedEventPayload: unknown = undefined
|
|
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
|
emittedEventName = eventName
|
|
emittedEventPayload = payload
|
|
}
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedSubscription = workspaceSubscription
|
|
},
|
|
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
|
updatedPlan = workspacePlan
|
|
},
|
|
emitEvent
|
|
})({ subscriptionData, logger: testLogger })
|
|
expect(updatedPlan!.status).to.be.equal('cancelationScheduled')
|
|
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
|
workspaceSubscription.updatedAt
|
|
)
|
|
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
|
|
omit(workspaceSubscription, 'updatedAt')
|
|
)
|
|
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'workspacePlan.status': 'cancelationScheduled'
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'subscription.totalEditorSeats': 3
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'previousSubscription.totalEditorSeats': 3
|
|
})
|
|
})
|
|
it('sets the status to valid', async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status: 'active',
|
|
cancelAt: null
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = {
|
|
subscriptionData,
|
|
billingInterval: 'monthly' as const,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
updateIntent: null,
|
|
currency: 'usd' as const,
|
|
currentBillingCycleEnd: new Date(),
|
|
workspaceId
|
|
}
|
|
|
|
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
|
let updatedPlan: WorkspacePlan | undefined = undefined
|
|
let emittedEventName: string | undefined = undefined
|
|
let emittedEventPayload: unknown = undefined
|
|
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
|
emittedEventName = eventName
|
|
emittedEventPayload = payload
|
|
}
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'paymentFailed'
|
|
}),
|
|
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedSubscription = workspaceSubscription
|
|
},
|
|
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
|
updatedPlan = workspacePlan
|
|
},
|
|
emitEvent
|
|
})({ subscriptionData, logger: testLogger })
|
|
expect(updatedPlan!.status).to.be.equal('valid')
|
|
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
|
workspaceSubscription.updatedAt
|
|
)
|
|
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
|
|
omit(workspaceSubscription, 'updatedAt')
|
|
)
|
|
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'workspacePlan.status': 'valid'
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'subscription.totalEditorSeats': 3
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'previousSubscription.totalEditorSeats': 3
|
|
})
|
|
})
|
|
it('updates the plan with the subscription update intent', async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status: 'active',
|
|
cancelAt: null
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const now = new Date()
|
|
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
const inOneYear = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000)
|
|
|
|
const workspaceSubscription = {
|
|
subscriptionData,
|
|
billingInterval: 'monthly' as const,
|
|
createdAt: oneMonthAgo,
|
|
updatedAt: oneMonthAgo,
|
|
updateIntent: {
|
|
userId,
|
|
planName: 'proUnlimited' as const,
|
|
billingInterval: 'yearly' as const,
|
|
currentBillingCycleEnd: inOneYear,
|
|
updatedAt: now,
|
|
currency: 'usd' as const,
|
|
products: [
|
|
{
|
|
priceId: subscriptionData.products[0].priceId,
|
|
productId: subscriptionData.products[0].productId,
|
|
quantity: subscriptionData.products[0].quantity
|
|
}
|
|
]
|
|
},
|
|
currency: 'usd' as const,
|
|
currentBillingCycleEnd: new Date(),
|
|
workspaceId
|
|
}
|
|
|
|
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
|
let updatedPlan: WorkspacePlan | undefined = undefined
|
|
let emittedEventName: string | undefined = undefined
|
|
let emittedEventPayload: unknown = undefined
|
|
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
|
emittedEventName = eventName
|
|
emittedEventPayload = payload
|
|
}
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'paymentFailed'
|
|
}),
|
|
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedSubscription = workspaceSubscription
|
|
},
|
|
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
|
updatedPlan = workspacePlan
|
|
},
|
|
emitEvent
|
|
})({ subscriptionData, logger: testLogger })
|
|
expect(updatedPlan!.name).to.be.equal('proUnlimited')
|
|
expect(updatedPlan!.status).to.be.equal('valid')
|
|
expect(updatedSubscription).to.be.deep.equal({
|
|
workspaceId,
|
|
billingInterval: 'yearly',
|
|
currentBillingCycleEnd: inOneYear,
|
|
updateIntent: null,
|
|
currency: 'usd',
|
|
updatedAt: now,
|
|
createdAt: oneMonthAgo,
|
|
subscriptionData
|
|
})
|
|
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'workspacePlan.status': 'valid'
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'subscription.totalEditorSeats': 3
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'previousSubscription.totalEditorSeats': 3
|
|
})
|
|
})
|
|
it('sets the state to paymentFailed', async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status: 'past_due'
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
workspaceId
|
|
})
|
|
|
|
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
|
let updatedPlan: WorkspacePlan | undefined = undefined
|
|
let emittedEventName: string | undefined = undefined
|
|
let emittedEventPayload: unknown = undefined
|
|
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
|
emittedEventName = eventName
|
|
emittedEventPayload = payload
|
|
}
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedSubscription = workspaceSubscription
|
|
},
|
|
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
|
updatedPlan = workspacePlan
|
|
},
|
|
emitEvent
|
|
})({ subscriptionData, logger: testLogger })
|
|
expect(updatedPlan!.status).to.be.equal('paymentFailed')
|
|
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
|
workspaceSubscription.updatedAt
|
|
)
|
|
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
|
|
omit(workspaceSubscription, 'updatedAt')
|
|
)
|
|
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'workspacePlan.status': 'paymentFailed'
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'subscription.totalEditorSeats': 3
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'previousSubscription.totalEditorSeats': 3
|
|
})
|
|
})
|
|
it('sets the state to canceled', async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status: 'canceled'
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = {
|
|
subscriptionData,
|
|
billingInterval: 'monthly' as const,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
updateIntent: null,
|
|
currency: 'usd' as const,
|
|
currentBillingCycleEnd: new Date(),
|
|
workspaceId
|
|
}
|
|
|
|
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
|
let updatedPlan: WorkspacePlan | undefined = undefined
|
|
let emittedEventName: string | undefined = undefined
|
|
let emittedEventPayload: unknown = undefined
|
|
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
|
emittedEventName = eventName
|
|
emittedEventPayload = payload
|
|
}
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedSubscription = workspaceSubscription
|
|
},
|
|
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
|
updatedPlan = workspacePlan
|
|
},
|
|
emitEvent
|
|
})({ subscriptionData, logger: testLogger })
|
|
expect(updatedPlan!.status).to.be.equal('canceled')
|
|
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
|
workspaceSubscription.updatedAt
|
|
)
|
|
expect(omit(updatedSubscription!, 'updatedAt')).deep.equal(
|
|
omit(workspaceSubscription, 'updatedAt')
|
|
)
|
|
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'workspacePlan.status': 'canceled'
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'subscription.totalEditorSeats': 3
|
|
})
|
|
expect(emittedEventPayload).to.have.nested.include({
|
|
'previousSubscription.totalEditorSeats': 3
|
|
})
|
|
})
|
|
;(
|
|
['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const
|
|
).forEach((status) => {
|
|
it(`does not update the plan or the subscription in case of an unhandled status: ${status}`, async () => {
|
|
const subscriptionData = createTestSubscriptionData({
|
|
status
|
|
})
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
workspaceId
|
|
})
|
|
|
|
await handleSubscriptionUpdateFactory({
|
|
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
upsertWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
upsertPaidWorkspacePlan: async () => {
|
|
expect.fail()
|
|
},
|
|
emitEvent: async () => {
|
|
expect.fail()
|
|
}
|
|
})({ subscriptionData, logger: testLogger })
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('addWorkspaceSubscriptionSeatIfNeededFactory returns a function, that', () => {
|
|
it('just returns if the workspacePlan is not found', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () => null,
|
|
getWorkspaceSubscription: async () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: async () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => 0,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
expect(true).to.be.true
|
|
})
|
|
it('returns if the workspaceSubscription is not found', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'free',
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
getWorkspaceSubscription: async () => null,
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: async () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => 0,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
})
|
|
it('throws if a non paid plan, has a subscription', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({ products: [] })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
workspaceId,
|
|
subscriptionData
|
|
})
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'free',
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
getWorkspaceSubscription: async () => workspaceSubscription,
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: async () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => 0,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
})
|
|
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
|
})
|
|
it('returns without reconciliation if the subscription is canceled', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({ products: [] })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
workspaceId,
|
|
subscriptionData
|
|
})
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'team',
|
|
workspaceId,
|
|
status: 'canceled'
|
|
}),
|
|
getWorkspaceSubscription: async () => workspaceSubscription,
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: async () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => 0,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
})
|
|
it('uses the relevant seat count, product and price id', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({ products: [] })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
workspaceId,
|
|
subscriptionData
|
|
})
|
|
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
|
name: 'team',
|
|
workspaceId,
|
|
status: 'valid'
|
|
})
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const roleCount = 10
|
|
|
|
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () => workspacePlan,
|
|
getWorkspaceSubscription: async () => workspaceSubscription,
|
|
getWorkspacePlanPriceId: ({ workspacePlan, billingInterval }) => {
|
|
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
|
switch (workspacePlan) {
|
|
case 'pro':
|
|
case 'teamUnlimited':
|
|
case 'proUnlimited':
|
|
expect.fail()
|
|
case 'team':
|
|
return priceId
|
|
default:
|
|
throwUncoveredError(workspacePlan)
|
|
}
|
|
},
|
|
getWorkspacePlanProductId: (args) => {
|
|
if (args.workspacePlan !== 'team') expect.fail()
|
|
return productId
|
|
},
|
|
reconcileSubscriptionData: async ({
|
|
prorationBehavior,
|
|
subscriptionData
|
|
}) => {
|
|
if (prorationBehavior !== 'always_invoice') expect.fail()
|
|
reconciledSubscriptionData = subscriptionData
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => roleCount,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
|
|
expect(reconciledSubscriptionData).to.be.ok
|
|
expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([
|
|
{ productId, priceId, quantity: roleCount }
|
|
])
|
|
})
|
|
|
|
it('updates the sub existing product quantity if the one matching the new seat type, does not have enough quantities', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{
|
|
priceId,
|
|
productId,
|
|
quantity: 5,
|
|
subscriptionItemId
|
|
}
|
|
]
|
|
})
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
workspaceId,
|
|
subscriptionData
|
|
})
|
|
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
|
name: 'team',
|
|
workspaceId,
|
|
status: 'valid'
|
|
})
|
|
const roleCount = 10
|
|
|
|
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () => workspacePlan,
|
|
getWorkspaceSubscription: async () => workspaceSubscription,
|
|
getWorkspacePlanPriceId: ({ workspacePlan, billingInterval }) => {
|
|
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
|
switch (workspacePlan) {
|
|
case 'pro':
|
|
case 'teamUnlimited':
|
|
case 'proUnlimited':
|
|
expect.fail()
|
|
case 'team':
|
|
return priceId
|
|
default:
|
|
throwUncoveredError(workspacePlan)
|
|
}
|
|
},
|
|
getWorkspacePlanProductId: (args) => {
|
|
if (args.workspacePlan !== workspacePlan.name) expect.fail()
|
|
return productId
|
|
},
|
|
reconcileSubscriptionData: async ({
|
|
prorationBehavior,
|
|
subscriptionData
|
|
}) => {
|
|
if (prorationBehavior !== 'always_invoice') expect.fail()
|
|
reconciledSubscriptionData = subscriptionData
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => roleCount,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
expect(reconciledSubscriptionData!.products).deep.equalInAnyOrder([
|
|
{ productId, priceId, quantity: roleCount, subscriptionItemId }
|
|
])
|
|
})
|
|
it('does not update the subscription if the product matching the new role, has enough quantities', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const updatedByUserId = cryptoRandomString({ length: 10 })
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{
|
|
priceId,
|
|
productId,
|
|
quantity: 2,
|
|
subscriptionItemId
|
|
}
|
|
]
|
|
})
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
workspaceId,
|
|
subscriptionData
|
|
})
|
|
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
|
name: 'team',
|
|
workspaceId,
|
|
status: 'valid'
|
|
})
|
|
const count = 1
|
|
|
|
const addWorkspaceSubscriptionSeatIfNeeded =
|
|
addWorkspaceSubscriptionSeatIfNeededFactory({
|
|
getWorkspacePlan: async () => workspacePlan,
|
|
getWorkspaceSubscription: async () => workspaceSubscription,
|
|
getWorkspacePlanPriceId: ({ workspacePlan, billingInterval }) => {
|
|
if (billingInterval !== workspaceSubscription.billingInterval) expect.fail()
|
|
switch (workspacePlan) {
|
|
case 'pro':
|
|
case 'teamUnlimited':
|
|
case 'proUnlimited':
|
|
expect.fail()
|
|
case 'team':
|
|
return priceId
|
|
default:
|
|
throwUncoveredError(workspacePlan)
|
|
}
|
|
},
|
|
getWorkspacePlanProductId: (args) => {
|
|
if (args.workspacePlan !== workspacePlan.name) expect.fail()
|
|
return productId
|
|
},
|
|
reconcileSubscriptionData: async () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => count,
|
|
upsertWorkspaceSubscription: async () => {}
|
|
})
|
|
await addWorkspaceSubscriptionSeatIfNeeded({
|
|
updatedByUserId,
|
|
workspaceId,
|
|
seatType: WorkspaceSeatType.Editor
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('mutateSubscriptionDataWithNewValidSeats', () => {
|
|
it('can totally downscale the subscription', () => {
|
|
const desiredSeatCount = 0
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const currentQuantity = 2
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{ priceId, productId, quantity: currentQuantity, subscriptionItemId }
|
|
]
|
|
})
|
|
|
|
mutateSubscriptionDataWithNewValidSeatNumbers({
|
|
seatCount: desiredSeatCount,
|
|
workspacePlan: PaidWorkspacePlans.Team,
|
|
getWorkspacePlanProductId: () => productId,
|
|
subscriptionData
|
|
})
|
|
|
|
expect(subscriptionData.products).to.has.lengthOf(0)
|
|
})
|
|
|
|
it('mutates the quantity when the count is less than the one given', () => {
|
|
const desiredSeatCount = 5
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const currentQuantity = 10
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{ priceId, productId, quantity: currentQuantity, subscriptionItemId }
|
|
]
|
|
})
|
|
|
|
mutateSubscriptionDataWithNewValidSeatNumbers({
|
|
seatCount: desiredSeatCount,
|
|
workspacePlan: PaidWorkspacePlans.Team,
|
|
getWorkspacePlanProductId: () => productId,
|
|
subscriptionData
|
|
})
|
|
|
|
expect(subscriptionData.products[0].quantity).to.be.equal(desiredSeatCount)
|
|
})
|
|
|
|
it('throws an exception to notify that instead of downscale, a subscription is broken and an upscale is required', () => {
|
|
const desiredSeatCount = 10
|
|
const currentQuantity = 5
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const priceId = cryptoRandomString({ length: 10 })
|
|
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{ priceId, productId, quantity: currentQuantity, subscriptionItemId }
|
|
]
|
|
})
|
|
|
|
const mutation = () =>
|
|
mutateSubscriptionDataWithNewValidSeatNumbers({
|
|
seatCount: desiredSeatCount,
|
|
workspacePlan: PaidWorkspacePlans.Team,
|
|
getWorkspacePlanProductId: () => productId,
|
|
subscriptionData
|
|
})
|
|
|
|
expect(mutation).to.throw('Subscription missing an upscale')
|
|
})
|
|
|
|
it('throws an exception when the subscription is malformed', () => {
|
|
const desiredSeatCount = 10
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: []
|
|
})
|
|
|
|
const mutation = () =>
|
|
mutateSubscriptionDataWithNewValidSeatNumbers({
|
|
seatCount: desiredSeatCount,
|
|
workspacePlan: PaidWorkspacePlans.Team,
|
|
getWorkspacePlanProductId: () => productId,
|
|
subscriptionData
|
|
})
|
|
|
|
expect(mutation).to.throw('Product not found at mutation')
|
|
})
|
|
})
|
|
|
|
describe('downscaleWorkspaceSubscriptionFactory 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({
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'unlimited',
|
|
workspaceId,
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: PaidWorkspacePlans.Team,
|
|
workspaceId,
|
|
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 if 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 = PaidWorkspacePlans.Team
|
|
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: workspacePlanName,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
countSeatsByTypeInWorkspace: async ({ type }) => {
|
|
return type === WorkspaceSeatType.Viewer ? 0 : quantity
|
|
},
|
|
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 priceId = cryptoRandomString({ length: 10 })
|
|
const productId = cryptoRandomString({ length: 10 })
|
|
const editorQty = 10
|
|
const proSubscriptionItemId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData = createTestSubscriptionData({
|
|
products: [
|
|
{
|
|
priceId,
|
|
productId,
|
|
quantity: editorQty,
|
|
subscriptionItemId: proSubscriptionItemId
|
|
}
|
|
]
|
|
})
|
|
const testWorkspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
workspaceId
|
|
})
|
|
const workspacePlanName = PaidWorkspacePlans.Team
|
|
|
|
let reconciledSub: SubscriptionDataInput | undefined = undefined
|
|
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: workspacePlanName,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
countSeatsByTypeInWorkspace: async ({ type }) => {
|
|
return type === WorkspaceSeatType.Viewer ? 0 : editorQty / 2
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
return productId
|
|
},
|
|
reconcileSubscriptionData: async ({ subscriptionData }) => {
|
|
reconciledSub = subscriptionData
|
|
}
|
|
})
|
|
await downscaleSubscription({ workspaceSubscription: testWorkspaceSubscription })
|
|
|
|
expect(
|
|
reconciledSub!.products.find((p) => p.productId === productId)?.quantity
|
|
).to.be.equal(editorQty / 2)
|
|
})
|
|
})
|
|
describe('downscaleWorkspaceSubscriptionFactory 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({
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'unlimited',
|
|
workspaceId,
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: 'pro',
|
|
workspaceId,
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: workspacePlanName,
|
|
workspaceId,
|
|
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 = downscaleWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: workspacePlanName,
|
|
workspaceId,
|
|
status: 'valid'
|
|
}),
|
|
countSeatsByTypeInWorkspace: async () => {
|
|
return 5
|
|
},
|
|
getWorkspacePlanProductId: () => {
|
|
return proProductId
|
|
},
|
|
reconcileSubscriptionData: async ({ subscriptionData }) => {
|
|
reconciledSub = subscriptionData
|
|
}
|
|
})
|
|
const hasDownscaled = await downscaleSubscription({
|
|
workspaceSubscription: testWorkspaceSubscription
|
|
})
|
|
|
|
expect(hasDownscaled).to.be.true
|
|
expect(
|
|
reconciledSub!.products.find((p) => p.productId === proProductId)?.quantity
|
|
).to.be.equal(5)
|
|
})
|
|
})
|
|
|
|
describe('upgradeWorkspaceSubscriptionFactory creates a function, that', () => {
|
|
it('throws WorkspacePlanNotFound if no plan can be found', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () => null,
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
|
|
})
|
|
;(['unlimited', 'academia'] as const).forEach((plan) => {
|
|
it(`throws WorkspaceNotPaidPlan for ${plan}`, async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
name: plan,
|
|
status: 'valid',
|
|
workspaceId
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal(new WorkspaceNotPaidPlanError().message)
|
|
})
|
|
})
|
|
;(['team', 'pro'] as const).forEach((plan) => {
|
|
;(['canceled', 'cancelationScheduled', 'paymentFailed'] as const).forEach(
|
|
(status) => {
|
|
it(`throws WorkspaceNotPaidPlan for ${plan} on a non valid status: ${status}`, async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: plan,
|
|
status
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'pro',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal(new WorkspaceNotPaidPlanError().message)
|
|
})
|
|
}
|
|
)
|
|
})
|
|
it('throws WorkspaceSubscriptionNotFound', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'team',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return null
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
|
|
})
|
|
|
|
it('throws WorkspacePlanUpgradeError for downgrading the plan', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = createTestWorkspaceSubscription()
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'pro',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return workspaceSubscription
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'yearly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal("Can't upgrade to a less expensive plan")
|
|
})
|
|
|
|
it('throws WorkspacePlanUpgradeError for downgrading the billing interval', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
billingInterval: 'yearly'
|
|
})
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'team',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return workspaceSubscription
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal("Can't upgrade from yearly to monthly billing cycle")
|
|
})
|
|
it('throws WorkspacePlanDowngradeError for noop requests', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
billingInterval: 'monthly'
|
|
})
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'team',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return workspaceSubscription
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'team',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal("Can't upgrade to the same plan")
|
|
})
|
|
it('throws WorkspacePlanMismatchError if subscription has no seats for the current plan', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData: SubscriptionData = {
|
|
cancelAt: null,
|
|
customerId: cryptoRandomString({ length: 10 }),
|
|
subscriptionId: cryptoRandomString({ length: 10 }),
|
|
status: 'active',
|
|
products: [],
|
|
currentPeriodEnd: new Date()
|
|
}
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData
|
|
})
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'team',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: () => {
|
|
return cryptoRandomString({ length: 10 })
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
expect.fail()
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return workspaceSubscription
|
|
},
|
|
reconcileSubscriptionData: () => {
|
|
expect.fail()
|
|
},
|
|
updateWorkspaceSubscription: () => {
|
|
expect.fail()
|
|
},
|
|
countSeatsByTypeInWorkspace: () => {
|
|
expect.fail()
|
|
}
|
|
})
|
|
const err = await expectToThrow(async () => {
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'pro',
|
|
billingInterval: 'monthly'
|
|
})
|
|
})
|
|
|
|
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
|
})
|
|
it('replaces current products with new product', async () => {
|
|
const workspaceId = cryptoRandomString({ length: 10 })
|
|
const userId = cryptoRandomString({ length: 10 })
|
|
const subscriptionData: SubscriptionData = {
|
|
cancelAt: null,
|
|
customerId: cryptoRandomString({ length: 10 }),
|
|
subscriptionId: cryptoRandomString({ length: 10 }),
|
|
status: 'active',
|
|
products: [
|
|
{
|
|
priceId: cryptoRandomString({ length: 10 }),
|
|
productId: 'teamProduct',
|
|
quantity: 10,
|
|
subscriptionItemId: cryptoRandomString({ length: 10 })
|
|
}
|
|
],
|
|
currentPeriodEnd: new Date()
|
|
}
|
|
const workspaceSubscription = createTestWorkspaceSubscription({
|
|
subscriptionData,
|
|
billingInterval: 'monthly'
|
|
})
|
|
|
|
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
|
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
|
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
|
getWorkspacePlan: async () =>
|
|
buildTestWorkspacePlan({
|
|
workspaceId,
|
|
name: 'team',
|
|
status: 'valid'
|
|
}),
|
|
getWorkspacePlanProductId: ({ workspacePlan }) => {
|
|
switch (workspacePlan) {
|
|
case 'team':
|
|
return 'teamProduct'
|
|
case 'teamUnlimited':
|
|
return 'teamUnlimitedProduct'
|
|
case 'pro':
|
|
return 'proProduct'
|
|
case 'proUnlimited':
|
|
return 'proUnlimitedProduct'
|
|
}
|
|
},
|
|
getWorkspacePlanPriceId: () => {
|
|
return 'newPlanPrice'
|
|
},
|
|
getWorkspaceSubscription: async () => {
|
|
return workspaceSubscription
|
|
},
|
|
reconcileSubscriptionData: async ({ subscriptionData }) => {
|
|
reconciledSubscriptionData = subscriptionData
|
|
},
|
|
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
|
updatedWorkspaceSubscription = workspaceSubscription
|
|
},
|
|
countSeatsByTypeInWorkspace: async () => {
|
|
return 4
|
|
}
|
|
})
|
|
await upgradeWorkspaceSubscription({
|
|
userId,
|
|
workspaceId,
|
|
targetPlan: 'pro',
|
|
billingInterval: 'yearly'
|
|
})
|
|
|
|
const updateIntent = updatedWorkspaceSubscription!
|
|
.updateIntent as SubscriptionUpdateIntent
|
|
expect(updateIntent).to.deep.contain({
|
|
billingInterval: 'yearly',
|
|
planName: 'pro',
|
|
products: [
|
|
{
|
|
productId: 'proProduct',
|
|
priceId: 'newPlanPrice',
|
|
quantity: 4
|
|
}
|
|
]
|
|
})
|
|
expect(reconciledSubscriptionData!.products.length).to.equal(1)
|
|
expect(
|
|
reconciledSubscriptionData!.products.find((p) => p.productId === 'proProduct')!
|
|
.quantity
|
|
).to.equal(4)
|
|
})
|
|
})
|
|
describe('getTotalSeatsCountByPlanFactory returns a function that, ', () => {
|
|
it('should return 0 if subscription data has no product', () => {
|
|
const getWorkspacePlanProductId = () => 'any'
|
|
expect(
|
|
getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({
|
|
workspacePlan: 'pro',
|
|
subscriptionData: { products: [] }
|
|
})
|
|
).to.eq(0)
|
|
})
|
|
it('should return the number of purchased seats in the current billing period for the subscription', () => {
|
|
const getWorkspacePlanProductId = () => 'productId'
|
|
expect(
|
|
getTotalSeatsCountByPlanFactory({ getWorkspacePlanProductId })({
|
|
workspacePlan: 'pro',
|
|
subscriptionData: {
|
|
products: [
|
|
{
|
|
productId: 'productId',
|
|
quantity: 4
|
|
} as SubscriptionData['products'][number]
|
|
]
|
|
}
|
|
})
|
|
).to.eq(4)
|
|
})
|
|
})
|
|
})
|